// ==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.3.1
// @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 */
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() {
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 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"]`.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() {
// 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))
});
}
// explain 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);