Installability

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

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

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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.2
// @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 */

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

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;
}

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',
      async onload(res) {
        resolve(res.response);
      },
      onerror() {
        log("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;
  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() {
  log("Manifest found. Analyzing for problems..");
  let fixed = 0;
  const manifestURL = $`link[rel="manifest"]`.href;
  const manifest = await cacheInto("site_manifest:" + location.origin, async () => JSON.parse(await (await grabURL(manifestURL)).text()));
  // fix manifests with missing start_url
  if (!manifest.start_url) {
    manifest.start_url = location.origin;
    fixed++;
  }
  // fix manifests with display values Chromium doesn't like anymore
  if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) {
    manifest.display = "minimal-ui";
    fixed++;
  }
  if (fixed) {
    // since we're loading the manifest from a data: URI, fix all the relative URIs (TODO: some relative URIs may linger)
    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();
    log(`Fixed ${fixed} issue${fixed>1?'s':''} in site manifest.`);
    verb = 'repaired';
    return manifest;
  }
  // nothing to do, let the original manifest stand.nothing.
  verb = 'validated';
  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.at(-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 colors = [
    $`meta[name="theme-color"]`?.content,
    getComputedStyle(document.body).backgroundColor
  ].filter(v=>!!v);
  const theme_color = colors[0];
  const background_color = colors.at(-1);

  // focus on caching only the bits with network requests
  const images = await cacheInto("images:"+location.origin, async () => {
     const icons = [
      ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href),
      resolveURI($`meta[itemprop="image"]`?.content),
    ].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) {
      const fallback = await getUntaintedImage("/favicon.ico"); // last resort. well known location for tiny site icons.
      if (fallback) images.unshift(fallback);
    }
    if (!images.length) {
      verb = 'could not be generated because no app icons were found';
      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') {
      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);
    }
    images.forEach(img=>delete img.img);
    verb = '';
    return images;
  });
  if (!images) {
    return;
  }

  verb += 'generated';
  // 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
    }))
  };
}

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.
    GM_addElement(document.head, 'link', {
      rel: "manifest",
      href: 'data:application/manifest+json,'+encodeURIComponent(JSON.stringify(manifest))
    });
  }
  log(`${adjective} manifest ${verb} in ${Date.now()-start}ms.`, manifest ? JSON.stringify(manifest,null,2).replace(/"data:.*?"/g,`"data: URI removed"`) : '');
}

withLogs(main);