Installability: Every web page is an installable app!

Generate or repair a Web Manifest for any web page

目前为 2023-08-06 提交的版本。查看 最新版本

// ==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://gf.qytechs.cn/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();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址