您需要先安装一个扩展,例如 篡改猴、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.8 // @noframes // @author itsnotlupus // @license MIT // @require https://gf.zukizuki.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, observeDOM */ /** * Wishlist: * - too many rules. can't deploy a worker, etc. * - how far could a userscript go in emulating an offline worker tho? * - fetch/xhr can be intercepted. * - image loading could be polyfilled too. * - page navigation is iffier. if the first page doesn't load, the userscript won't either. * - but if we can get pasted that, network-first offline policy would probably cause the least damage (https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Caching) * - [...new Set([...$$`[href],[src]`].map(a=>a.href??a.src).filter(url=>url.startsWith(location.origin)).map(url=>url.split('#')[0]))] // things one might precache in an install event handler * - maskable icons (which involves finding the smallest rect that captures all non-transparent pixels, and shrinking them to fit within safe area.) * - take the silliness further: * - detecting main site navigation entrypoints and generating shortcuts would kinda kick ass. * - look for more weird PWA features, and see if there's a generic way to leverage them. */ // Bits of code that might be useful later: // 1. holding a user's hand to get installed. in case the script ever exposes a more visible mean to install an app, I guess. //early: // const installer = await new Promise(r => addEventListener('beforeinstallprompt', r)); //later: // installer.prompt(); // const { outcome } = await installer.userChoice; // const installed = await new Promise(r => addEventListener('appinstalled', r); // 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(); /** * 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(); }); } /** test if the URL given loads correctly (not 404, etc.) */ function workingURL(src) { return new Promise(resolve => { const url = resolveURL(src); GM_xmlhttpRequest({ url, method: "HEAD", onload(res) { resolve(res.status<300); }, onerror() { resolve(false); } }); }); } function cachedWorkingURL(src) { return cacheInto('working-url:'+src, () => workingURL(src)); } /** 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 getIconMaxSize(icon) { // "any" is technically infinite, but 512x512 is close enough const sizes = icon.sizes.split(/\s+/).map(size=>size=='any'?[512,512]:size.split(/x/i).map(v=>+v)).sort((a,b)=>b[0]-a[0]); return sizes[0]; // [ width, height ] } function appIconsValid(icons) { return icons.some(icon => { const [ width, height ] = getIconMaxSize(icon); return width >= 512 && height >= 512 && icon.type == 'image/png'; }); } async function fixAppIcons(icons) { icons.sort((a,b)=>getIconMaxSize(b)[0] - getIconMaxSize(a)[0]); // largest image first. suboptimal // grab the biggest one. const biggestImage = icons[0]; const [ width, height ] = getIconMaxSize(biggestImage); 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) { const url = `https://play.google.com/store/apps/details?id=${android_id}`; if (await cachedWorkingURL(url)) { apps.push({ platform: "play", // XXX "chromeos_play"? id: android_id, url }); } } 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'; const url = `https://apps.apple.com/app/${app_name}/${ios_id}`; if (await cachedWorkingURL(url)) { apps.push({ platform: "itunes", id: ios_id, url }); } } // 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 []; } }); if (Array.isArray(assetLinksJson)) { await Promise.all(assetLinksJson.filter(i=>i.relation.includes("delegate_permission/common.handle_all_urls")).map(async ({target}) => { switch (target.namespace) { case "android_app": { const url = `https://play.google.com/store/apps/details?id=${target.package_name}` if (await cachedWorkingURL(url)) { apps.push({ platform: "play", id: target.package_name, url }); } break; } case "ios_app": { // the definition of unbridled optimism const url = `https://apps.apple.com/app/app/${target.id}`; if (await cachedWorkingURL(url)) { if (target.appid) apps.push({ platform: "itunes", id: target.appid, url }); } break; } } })); } // dedup apps right quick const urls = new Set; for (let i=apps.length-1;i>=0;i--) { if (urls.has(apps[i].url)) { apps.splice(i,1); } else { urls.add(apps[i].url); } } 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 }; // How to use a 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()); }); // since we're loading the manifest from a data: URL, get rid of all relative URLs fixManifestURLs(manifest, manifestURL); // 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) { log("Obsolete prefer_related_applications field found."); delete manifest.prefer_related_applications; fixed++; } if (manifest.launch_handler) { if (manifest.launch_handler.route_to) { log("Obsolete launch_handler.route_to field found, renaming to client_mode"); manifest.launch_handler.client_mode = manifest.launch_handler.route_to; delete manifest.launch_handler.route_to; fixed++; } if (manifest.launch_handler.navigate_existing_client) { log ("Obsolete launch_handler.navigate_existing_client field found."); delete manifest.launch_handler.navigate_existing_client; fixed++; } } if (fixed) { $$`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; } // return an array of CSP sources acceptable for a manifest URL and usable by this script. may be empty. async function inspectCSP() { 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),{}):{}; function checkCSP(csp, sources = []) { if (!Object.keys(csp).length) return sources; const allowedSources = csp['manifest-src'] ?? csp['default-src']; if (!allowedSources) return sources; return sources.filter(source=>allowedSources.includes(source)); } const cspHeader = parseCSP((await fetch('', {method:'HEAD'})).headers.get(CSP_HEADER)); const cspMeta = parseCSP($(`meta[http-equiv="${CSP_HEADER}"]`)?.content); const sources = checkCSP(cspMeta, checkCSP(cspHeader, ["data:", "blob:"])); if (sources.length) { // log("Acceptable manifest sources are ", sources); } else { log("CSP rules will probably prevent us from setting a manifest."); } return sources; } async function generateManifest(sources) { 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", display_override: [ "window-controls-overlay" ], theme_color, background_color, icons, related_applications }; } let adjective; let verb = 'grabbed from cache and '; async function getManifest(sources) { const start = Date.now(); let manifest; let wasGenerated = false; if ($`link[rel="manifest"]`) { adjective = 'Site'; manifest = await repairManifest(); } else { adjective = 'Custom'; manifest = await generateManifest(); wasGenerated = true; } 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.) let manifestLink; if (sources.includes('data:')) { manifestLink = 'data:application/manifest+json;charset=utf-8,'+encodeURIComponent(JSON.stringify(manifest)); } else { const blob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json;charset=utf-8'}); manifestLink = URL.createObjectURL(blob); // NOTE: no good way to revoke that URL. stick to page lifetime. } GM_addElement('link', { rel: "manifest", href: manifestLink }); // This sets the color of the app title bar on desktop. if (!$`meta[name="theme-color"]`) GM_addElement('meta', { name: "theme-color", content: manifest.theme_color }); } // 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 ?? '' ); return [manifest, wasGenerated]; } // make a custom title bar from whatever header-like content we can find. function customTitleBarJustAddWater(manifest) { let outerDisconnect; function findAndAdjustTitleBar(query) { if (query.matches) { let header = null; let disconnect; // 1. find a header. make it fit roughly as a titelbar, and make it draggable. function findHeader() { if (header && document.body.contains(header)) return; const nodes = [...$$`body *`]; // header nodes are mostly top-most level nodes that cover the width of the page, are flush against the top of the page, and not too tall. // (all broad generalizations are faulty, etc.) const header_nodes = nodes.filter(n=> { const { width, height, top } = n.getBoundingClientRect(); return document.body.clientWidth - width < 2 && top < 5 && height > 10 && height < 200; }).filter((n,i,a)=>a.every(p=>p==n||!p.contains(n))); if (!header_nodes.length) return; // ok, plausible header found. Yer a titlebar, Header! header = header_nodes[0]; Object.assign(header.style, { // fixed or sticky position would be great here, but it's too likely to break pages that weren't expecting it. // settle for trying not to be drawn under native titlebar elements. WebkitAppRegion: 'drag', appRegion: 'drag', paddingLeft: 'env(titlebar-area-x, 0)', paddingRight: 'calc(100% - env(titlebar-area-width, 100%))', minHeight: 'env(titlebar-area-height, initial)', backgroundColor: manifest.theme_color }); // 2. look for interactive elements within the header. make those not draggable. function findInteractiveHeaderElements() { // this won't catch clickable divs. $$('a,input,button,select,label,textarea', header).forEach(n=>Object.assign(n.style, { WebkitAppRegion: 'no-drag', appRegion: 'no-drag' })); } try { disconnect?.(); } catch (e) { log(e) } findInteractiveHeaderElements(); disconnect = observeDOM(findInteractiveHeaderElements, header); } try { outerDisconnect?.(); } catch(e) { log(e) } findHeader(); outerDisconnect = observeDOM(findHeader); } else { try { outerDisconnect?.(); } catch(e) { log(e) } } } const query = matchMedia('(display-mode: window-controls-overlay)'); findAndAdjustTitleBar(query); query.addListener(e => findAndAdjustTitleBar(e)); } async function main() { const sources = await inspectCSP(); const [manifest, wasGenerated] = await getManifest(sources); // if there was a site manifest, then trust that someone knew what they were doing, and don't try weird shenanigans. if (wasGenerated) { // but if we instead foisted installability upon an unuspecting web page... await customTitleBarJustAddWater(manifest); } } main();