Installability: Every web page is an installable app!

Generate or repair a Web Manifest for any web page

目前為 2023-08-06 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name        Installability: Every web page is an installable app!
// @description Generate or repair a Web Manifest for any web page
// @namespace   Itsnotlupus Industries
// @match       https://*/*
// @grant       none
// @version     1.0
// @noframes
// @author      itsnotlupus
// @license     MIT
// @require     https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
// @grant       GM_xmlhttpRequest
// @grant       GM_addElement
// @grant       GM_getValue
// @grant       GM_setValue
// ==/UserScript==

/*jshint esversion:11 */

/**
 * A simple premise: Make every web page installable as an app on your local device.
 *
 * WHY?
 *
 * Your "app" will be present in your list of apps.
 * It will load without the usual browser chrome, and if you squint the right way, you might come to think of it as an app rather than a web page.
 *
 * HOW?
 *
 * By generating a Web Manifest automatically, using what scraps of data we can find hidden within the page.
 *
 * THAT.. THAT SOUNDS IFFY.
 *
 * That's not a question. And also, yes.
 * If we can't find a big enough app icon, we'll upscale whatever we find. It might be ugly.
 * If the web page has security rules preventing us from injecting our own manifest in the page, this won't work.
 *
 * Also, browsers may not be in a rush to notice we set/updated the page's manifest. patience.
 *
 */

const CACHE_MANIFEST_EXPIRATION = 24*3600*1000; // keep generated manifests on any given site for 24 hours before generating new ones.

const resolveURI = (uri, base=location.href) => uri && new URL(uri, base).toString();

/**
 * load an image without CSP restrictions.
 */
function getImage(src) {1
  return new Promise((resolve) => {
    const img = GM_addElement('img', {
      src: resolveURI(src),
      crossOrigin: "anonymous"
    });
    img.onload = () => resolve(img);
    img.onerror = () => resolve(null);
    img.remove();
  });
}

function grabURL(src) {
  return new Promise(resolve => {
    const url = resolveURI(src);
    GM_xmlhttpRequest({
      url,
      responseType: 'blob',
      anonymous: true,
      async onload(res) {
        resolve(res.response);
      },
      onerror() {
        console.error("Couldn't grab URL " + s);
        resolve(null);
      }
    });
  });
}

/**
 * Grab an image and its mime-type regardless of browser sandbox limitations.
 */
async function getUntaintedImage(src) {
  const blob = await grabURL(src);
  const blobURL = URL.createObjectURL(blob);
  const img = await getImage(blobURL);
  if (!img) return null; // the URL returned a non-image.
  URL.revokeObjectURL(blobURL);
  return {
    src: resolveURI(src),
    img,
    width: img.naturalWidth,
    height: img.naturalHeight,
    type: blob.type
  };
}

function makeBigPNG(fromImg) {
  // scale to at least 144x144, but keep the pixels if there are more.
  const width = Math.max(144, fromImg.width);
  const height = Math.max(144, fromImg.height);
  const canvas = crel('canvas', { width, height });
  const ctx = canvas.getContext('2d');
  ctx.drawImage(fromImg, 0, 0, width, height);
  const url = canvas.toDataURL({ type: "image/png" });
  return {
    src: url,
    width,
    height,
    type: "image/png"
  };
}

async function repairManifest() {
  console.log("Manifest found. Analyzing for problems..");
  let fixed = false;
  const manifestURL = $`link[rel="manifest"]`.href;
  const manifestBlob = await grabURL(manifestURL);
  const manifest = JSON.parse(await manifestBlob.text());
  // fix manifests with missing start_url
  if (!manifest.start_url) {
    manifest.start_url = location.origin;
    fixed = true;
  }
  if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) {
    manifest.display = "minimal-ui";
    fixed = true;
  }
  if (fixed) {
    // since we're loading the manifest from a data: URI, fix all the relative URIs
    manifest.icons.forEach(img => img.src= resolveURI(img.src, manifestURL));
    ["start_url", "scope"].forEach(k => manifest[k] = resolveURI(manifest[k], manifestURL));
    $`link[rel="manifest"]`.remove();
    return manifest;
  }
  // nothing to do, let the original manifest stand.nothing.
  console.log("Manifest seems valid. Good to go.");
  return null;
}

async function generateManifest() {
  // Remember how there's this universal way to get a web site's name? Yeah, me neither.
  const goodNames = [
    // plausible places to find one
    $`meta[name="application-name"]`?.content,
    $`meta[name="apple-mobile-web-app-title"]`?.content,
    $`meta[name="al:android:app_name"]`?.content,
    $`meta[name="al:ios:app_name"]`?.content,
    $`meta[property="og:site_name"]`?.content,
    $`meta[property="og:title"]`?.content,
  ].filter(v=>!!v).sort((a,b)=>a.length-b.length); // short names first.
  const badNames = [
    // various bad ideas
    $`link[rel="search]"`?.title.replace(/ search/i,''),
    document.title,
    $`h1`?.textContent,
    [...location.hostname.replace(/^www\./,'')].map((c,i)=>i?c:c.toUpperCase()).join('') // capitalized domain name. If everything else fails, there's at least this.
  ].filter(v=>!!v);
  const short_name = goodNames[0] ?? badNames[0];
  const app_name = goodNames[-1] ?? badNames[0];

  const descriptions = [
    $`meta[property="og:description"]`?.content,
    $`meta[name="description"]`?.content,
    $`meta[name="description"]`?.getAttribute("value"),
    $`meta[name="twitter:description"]`?.content,
  ].filter(v=>!!v);
  const app_description = descriptions[0];

  const icons = [
    ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href),
    resolveURI($`meta[itemprop="image"]`?.content),
    "/favicon.ico", // well known location for tiny site icons.
  ].filter(v=>!!v);
  // fetch all the icons, so we know what we're working with.
  const images = (await Promise.all(icons.map(getUntaintedImage))).filter(v=>!!v);
  images.sort((a,b)=>b.height - a.height); // largest image first.
  if (!images.length) {
    console.error("Could not find any app icon here. Giving up on creating a manifest.")
    return;  // just give up. we can't install an app without an icon.
  }
  // grab the biggest one.
  const biggestImage = images[0];
  if (biggestImage.width < 144 || biggestImage.height < 144 || biggestImage.type !== 'image/png') {
    console.log(`We may not have a valid icon yet, scaling an image of type ${biggestImage.type} and size (${biggestImage.width}x${biggestImage.height}) into a big enough PNG.`);
    // welp, we're gonna scale it.
    const img = await makeBigPNG(biggestImage.img);
    images.unshift(img);
  }

  const colors = [
    $`meta[name="theme-color"]`?.content,
    getComputedStyle(document.body).backgroundColor
  ].filter(v=>!!v);
  const theme_color = colors[0];
  const background_color = colors.at(-1);

  // There it is, our glorious Web Manifest.
  return {
    name: app_name,
    short_name: short_name,
    description: app_description,
    start_url: location.href,
    display: "standalone",
    theme_color: theme_color,
    background_color: background_color,
    icons: images.map(img => ({
      src: img.src,
      sizes: `${img.width}x${img.height}`,
      type: img.type
    }))
  };
}

async function main() {

  const start = Date.now();
  let manifest;

  const cached_item = GM_getValue("manifest:"+location.origin);
  if (cached_item && cached_item.expires > Date.now()) {
    // shortcut everything
    manifest = cached_item.manifest;
    manifest.start_url = location.href;
    // don't forget to blow up any pre-existing manifest
    $`link[rel="manifest"]`?.remove();
  } else {

    if ($`link[rel="manifest"]`) {
      manifest = await repairManifest();
    } else {
      manifest = await generateManifest();
    }

    if (manifest) {
      GM_setValue("manifest:"+location.origin, {
        expires: Date.now() + CACHE_MANIFEST_EXPIRATION,
        manifest
      });
      console.log("New manifest generated.", JSON.stringify(manifest,null,2).replace(/"data:.*?"/g,`"data: URI removed"`));
    }
  }

  if (manifest) {
    // Use GM_addElement to inject the manifest.
    // It doesn't succeed in bypassing Content Security Policy rules today, but maybe userscript extensions will make this work someday.
    GM_addElement(document.head, 'link', {
      rel: "manifest",
      href: 'data:application/manifest+json,'+encodeURIComponent(JSON.stringify(manifest))
    });
  }
}

main();