Installability

Every web page is an installable app! Generate or repair a Web Manifest for any web page.

当前为 2023-08-09 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Installability
// @description Every web page is an installable app! Generate or repair a Web Manifest for any web page.
// @namespace   Itsnotlupus Industries
// @match       https://*/*
// @version     1.6
// @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
// @connect     *
// ==/UserScript==

/* jshint esversion:11 */
/* eslint curly: 0 no-return-assign: 0, no-loop-func: 0 */
/* global $, $$, crel, log, logGroup, withLogs, fetchJSON */

// a default app icon to use if no suitable icons are found in the site
const FALLBACK_ICON = 'data:image/svg+xml;base64,'+btoa`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><defs><linearGradient id="a" x1="-44" x2="-4" y1="-24" y2="-24" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#009467"/><stop offset="1" stop-color="#87d770"/></linearGradient></defs><rect width="40" height="40" x="-44" y="-44" fill="url(#a)" rx="20" transform="matrix(0 -1 -1 0 0 0)"/><path d="M4 23.5v.5a20 20 0 1 0 40 0v-.5a20 20 0 0 1-40 0z" opacity=".1"/><path fill="#fff" d="M24.5 23a1.5 1.5 0 0 0 0 3 1.5 1.5 0 0 0 0-3z"/><g fill="none" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"><path d="M33.5 27.5s3-1 3-3c0-3.5-9.2-5-12.5-5-7-.1-12.3 1.4-12.5 4s3 3 3 3"/><path d="M30.5 17.5s1.1-3.8-.6-4.7c-3-1.7-8.9 5.7-10.5 8.4-3.7 6-5 11.4-2.8 12.9 2.2 1.4 3.9-.6 3.9-.6"/><path d="M21.5 14.5s-2.2-2.4-3.8-1.4c-3 1.8.3 10.5 2 13.4 3.3 6.2 7.3 10 9.6 8.8 5.2-2-.8-12.8-.8-12.8"/></g></svg>`;

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

/** cache the result of work() into GM storage for a day. */
async function cacheInto(key, work) {
  const cached = GM_getValue(key);
  if (cached && cached.expires > Date.now()) return cached.data;
  const data = await work();
  if (data != null) GM_setValue(key, { expires: Date.now() + CACHE_MANIFEST_EXPIRATION, data });
  return data;
}

/** Resolve a relative URL into an absolute URL */
const resolveURL = (url, base=location.href) => url && new URL(url, base).toString();

// CSP stuff. mostly useless.
// const CSP_HEADER = 'Content-Security-Policy';
// const parseCSP = csp => csp?csp.split(';').map(line=>line.trim().split(/\s+/)).reduce((o,a)=>(a.length>1&&(o[a[0]]=a.slice(1)),o),{}):{};
// async function inspectCSP() {
//   const csp1 = parseCSP((await fetch('')).headers.get(CSP_HEADER));
//   const csp2 = parseCSP($(`meta[http-equiv="${CSP_HEADER}"]`)?.content);
//   console.log(csp1);
//   console.log(csp2);
//   // limited usefulness, but if CSP allows for blob: and not data:, or data: and not blob:,
//   // a userscript could tweak the URL scheme they use to make things work.
// }
// inspectCSP();

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

/** fetch an arbitrary URL using current browser cookies. no restrictions. */
function grabURL(src) {
  return new Promise(resolve => {
    const url = resolveURL(src);
    GM_xmlhttpRequest({
      url,
      responseType: 'blob',
      async onload(res) {
        resolve(res.response);
      },
      onerror() {
        log("Couldn't grab URL " + src);
        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;
  URL.revokeObjectURL(blobURL);
  return {
    src: resolveURL(src),
    img,
    width: img.naturalWidth,
    height: img.naturalHeight,
    type: blob.type
  };
}

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

function guessAppName() {
  // 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.at(-1) ?? badNames[0];
  return short_name;
}

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

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

async function gatherAppIcons() {
  // focus on caching only the bits with network requests
  return cacheInto("images:"+location.origin, async () => {
     const iconURLs = [
      ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href),
      resolveURL($`meta[itemprop="image"]`?.content),
    ].filter(v=>!!v);
    // fetch all the icons, so we know what we're working with.
    const images = (await Promise.all(iconURLs.map(getUntaintedImage))).filter(v=>!!v);
    if (!images.length) {
      const fallback = await getUntaintedImage("/favicon.ico"); // last resort. well known location for tiny site icons.
      if (fallback) images.unshift(fallback);
    }
    if (!images.length) {
      images.unshift(await getUntaintedImage(FALLBACK_ICON));
      verb = 'generated with a fallback icon';
    }
    const icons = images.map(img => ({
      src: img.src,
      sizes: `${img.width}x${img.height}`,
      type: img.type
    }));
    await fixAppIcons(icons);
    verb = '';
    return icons;
  });
}

function appIconsValid(icons) {
  return icons.some(icon => {
    const [ width, height ] = icon.sizes.split('x');
    return width >= 512 && height >= 512 && icon.type == 'image/png';
  });
}

async function fixAppIcons(icons) {
  icons.sort((a,b)=>parseInt(b.sizes) - parseInt(a.sizes)); // largest image first.
  // grab the biggest one.
  const biggestImage = icons[0];
  const [ width, height ] = biggestImage.sizes.split('x');
  if (width < 512 || height < 512 || biggestImage.type !== 'image/png') {
    log(`We may not have a valid icon yet, scaling an image of type ${biggestImage.type} and size (${width}x${height}) into a big enough PNG.`);
    // welp, we're gonna scale it.
    const img = await makeBigPNG(await getUntaintedImage(biggestImage.src));
    icons.unshift({
      src: img.src,
      sizes: `${img.width}x${img.height}`,
      type: img.type
    });
  }
  return icons;
}

async function guessRelatedApplications() {
  // 1. "app links", a weird decade old half-baked half-supported spec that has the data we'd need for this.
  // seen on threads.net, and probably not much elsewhere. but hey, we can parse synchronously and cheaply.
  const apps = [];
  const android_id = $`meta[property="al:android:package"]`?.content
  if (android_id) {
    apps.push({
      platform: "play", // XXX "chromeos_play"?
      id: android_id,
      url: `https://play.google.com/store/apps/details?id=${android_id}`
    });
  }
  const ios_id = $`meta[property="al:ios:app_store_id"]`?.content;
  if (ios_id) {
    const app_name = $`meta[property="al:ios:app-name"]`?.content ?? 'app';
    apps.push({
      platform: "itunes",
      id: ios_id,
      url: `https://apps.apple.com/app/${app_name}/${ios_id}`
    });
  }
  // theoretically, there could be more here, like windows app and stuff.
  // see https://developers.facebook.com/docs/applinks/metadata-reference

  // 2. .well-known/assetlinks.json
  // see https://github.com/google/digitalassetlinks/blob/master/well-known/details.md
  const assetLinksJson = await cacheInto("assetLinksJson:"+location.origin, async () => {
    try {
      return await fetchJSON(resolveURL("/.well-known/assetlinks.json"));
    } catch {
      return [];
    }
  });
  assetLinksJson.filter(i=>i.relation.includes("delegate_permission/common.handle_all_urls")).forEach(({target}) => {
    switch (target.namespace) {
      case "android_app":
        apps.push({
          platform: "play",
          id: target.package_name,
          url: `https://play.google.com/store/apps/details?id=${target.package_name}`
        });
        break;
      case "ios_app": // the definition of unbridled optimism
        if (target.appid) apps.push({
          platform: "itunes",
          id: target.appid,
          url: `https://apps.apple.com/app/app/${target.id}`
        });
        break;
    }
  });

  return apps.length ? apps : undefined;
}

/** modify manifest in place, turn all known relative URLs into absolute URLs */
function fixManifestURLs(manifest, manifestURL) {

  // a map of URLs in the manifest structure
  const URL_IN_MANIFEST = {
    file_handlers: [ { action: true } ],
    icons: [ { src: true } ],
    protocol_handlers: [ { url: true } ],
    scope: true,
    screenshots: [ { src: true } ],
    serviceworker: { url: true },
    share_target: { action: true },
    shortcuts: [ {
      url: true,
      icons: [ { src: true } ]
    } ],
    start_url: true
  };
  // follow the map to traverse a manifest
  function recurse(obj, schema, transform) {
    if (Array.isArray(schema)) return obj.forEach(item => recurse(item, schema[0], transform));
    Object.keys(schema).forEach(key => { switch (true) {
      case !obj[key]: return;
      case typeof obj[key] == 'object': recurse(obj[key], schema[key], transform); break;
      default: obj[key] = transform(obj[key]);
    }});
  }

  recurse(manifest, URL_IN_MANIFEST, url => resolveURL(url, manifestURL));
}

async function repairManifest() {
  let fixed = 0;
  const manifestURL = $`link[rel="manifest"]`.href;
  const manifest = await cacheInto("site_manifest:" + location.origin, async () => {
    verb = '';
    return JSON.parse(await (await grabURL(manifestURL)).text());
  });
  // fix: missing short_name
  if (!manifest.short_name) {
    log("Missing short_name field.");
    manifest.short_name = manifest.name ?? guessAppName();
    fixed++;
  }
  // fix: missing name
  if (!manifest.name) {
    log("Missing name field.");
    manifest.name = manifest.short_name ?? guessAppName();
    fixed++;
  }
  // fix: missing or insufficient icons
  if (!manifest.icons) {
    log("Missing icons field.");
    manifest.icons = await gatherAppIcons();
    fixed++;
  } else if (!appIconsValid(manifest.icons)) {
    log("Invalid icons field.");
    await fixAppIcons(manifest.icons);
    fixed++;
  }
  // fix: missing start_url
  if (!manifest.start_url) {
    log("Missing start_url field.");
    manifest.start_url = location.origin;
    fixed++;
  }
  // fix: invalid display value (typically "browser")
  if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) {
    log("Missing or invalid display field.");
    manifest.display = "minimal-ui";
    fixed++;
  }
  if (manifest.prefer_related_applications) {
    delete manifest.prefer_related_applications;
    fixed++;
  }
  if (fixed) {
    // since we're loading the manifest from a data: URL, get rid of all relative URLs
    fixManifestURLs(manifest, manifestURL);
    $$`link[rel="manifest"]`.forEach(link=>link.remove());
    verb += `repaired ${fixed} issue${fixed>1?'s':''}`;
    return manifest;
  }
  // nothing to do, let the original manifest stand.nothing.
  verb += 'validated';
  return null;
}

async function generateManifest() {

  const short_name = guessAppName();
  const description = guessAppDescription();
  const { theme_color, background_color } = guessAppColors();

  const icons = await gatherAppIcons();

  const related_applications = await guessRelatedApplications();

  verb += 'generated';
  // There it is, our glorious Web Manifest.
  return {
    name: short_name,
    short_name,
    description,
    start_url: location.href,
    scope: resolveURL("/"),
    display: "standalone",
    theme_color,
    background_color,
    icons,
    related_applications
  };
}

let adjective;
let verb = 'grabbed from cache and ';

async function main() {
  const start = Date.now();
  let manifest;

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

  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.
    // (Note: TamperMonkey Beta has a setting to disable CSP altogether in their Advanced Settings.)
    GM_addElement(document.head, 'link', {
      rel: "manifest",
      href: 'data:application/manifest+json;charset=utf-8,'+encodeURIComponent(JSON.stringify(manifest))
    });
  }
  // summarize what we did.
  logGroup(`${adjective} manifest ${verb} in ${Date.now()-start}ms.`,
    manifest ?
      JSON.stringify(manifest,null,2).replace(/"data:.{70,}?"/g, url=>`"${url.slice(0,35)}…[${url.length-45}_more_bytes]…${url.slice(-10,-1)}"`)
      : $`link[rel="manifest"]`?.href ?? ''
  );
}

withLogs(main);

//early:
//  let installer = await new Promise(r => addEventListener('beforeinstallprompt', r));
//later:
//  installer.prompt();
//  const { outcome } = await installer.userChoice;