您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Every web page is an installable app! Generate or repair a Web Manifest for any web page.
当前为
- // ==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);