// ==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();