Installability

Every web page is an installable app! Generate or repair a Web Manifest for any web page.

当前为 2023-08-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Installability
  3. // @description Every web page is an installable app! Generate or repair a Web Manifest for any web page.
  4. // @namespace Itsnotlupus Industries
  5. // @match https://*/*
  6. // @version 1.5
  7. // @noframes
  8. // @author itsnotlupus
  9. // @license MIT
  10. // @require https://greasyfork.org/scripts/468394-itsnotlupus-tiny-utilities/code/utils.js
  11. // @grant GM_xmlhttpRequest
  12. // @grant GM_addElement
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @connect *
  16. // ==/UserScript==
  17.  
  18. /* jshint esversion:11 */
  19. /* eslint curly: 0 no-return-assign: 0, no-loop-func: 0 */
  20. /* global $, $$, crel, log, logGroup, withLogs */
  21.  
  22. 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>`;
  23.  
  24. const CACHE_MANIFEST_EXPIRATION = 24*3600*1000; // keep cached bits of manifests on any given site for 24 hours before fetching/generating new ones.
  25.  
  26. async function cacheInto(key, work) {
  27. const cached = GM_getValue(key);
  28. if (cached && cached.expires > Date.now()) return cached.data;
  29. const data = await work();
  30. if (data != null) GM_setValue(key, { expires: Date.now() + CACHE_MANIFEST_EXPIRATION, data });
  31. return data;
  32. }
  33.  
  34. const resolveURI = (uri, base=location.href) => uri && new URL(uri, base).toString();
  35.  
  36. // CSP stuff. mostly useless.
  37. // const CSP_HEADER = 'Content-Security-Policy';
  38. // 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),{}):{};
  39. // async function inspectCSP() {
  40. // const csp1 = parseCSP((await fetch('')).headers.get(CSP_HEADER));
  41. // const csp2 = parseCSP($(`meta[http-equiv="${CSP_HEADER}"]`)?.content);
  42. // console.log(csp1);
  43. // console.log(csp2);
  44. // // limited usefulness, but if CSP allows for blob: and not data:, or data: and not blob:,
  45. // // a userscript could tweak the URI scheme they use to make things work.
  46. // }
  47. // inspectCSP();
  48.  
  49. /**
  50. * load an image without CSP restrictions.
  51. */
  52. function getImage(src) {1
  53. return new Promise((resolve) => {
  54. const img = GM_addElement('img', {
  55. src: resolveURI(src),
  56. crossOrigin: "anonymous"
  57. });
  58. img.onload = () => resolve(img);
  59. img.onerror = () => resolve(null);
  60. img.remove();
  61. });
  62. }
  63.  
  64. function grabURL(src) {
  65. return new Promise(resolve => {
  66. const url = resolveURI(src);
  67. GM_xmlhttpRequest({
  68. url,
  69. responseType: 'blob',
  70. async onload(res) {
  71. resolve(res.response);
  72. },
  73. onerror() {
  74. log("Couldn't grab URL " + src);
  75. resolve(null);
  76. }
  77. });
  78. });
  79. }
  80.  
  81. /**
  82. * Grab an image and its mime-type regardless of browser sandbox limitations.
  83. */
  84. async function getUntaintedImage(src) {
  85. const blob = await grabURL(src);
  86. const blobURL = URL.createObjectURL(blob);
  87. const img = await getImage(blobURL);
  88. if (!img) return null;
  89. URL.revokeObjectURL(blobURL);
  90. return {
  91. src: resolveURI(src),
  92. img,
  93. width: img.naturalWidth,
  94. height: img.naturalHeight,
  95. type: blob.type
  96. };
  97. }
  98.  
  99. function makeBigPNG(fromImg) {
  100. // scale to at least 144x144, but keep the pixels if there are more.
  101. const width = Math.max(144, fromImg.width);
  102. const height = Math.max(144, fromImg.height);
  103. const canvas = crel('canvas', { width, height });
  104. const ctx = canvas.getContext('2d');
  105. ctx.drawImage(fromImg, 0, 0, width, height);
  106. const url = canvas.toDataURL({ type: "image/png" });
  107. return {
  108. src: url,
  109. width,
  110. height,
  111. type: "image/png"
  112. };
  113. }
  114.  
  115. async function repairManifest() {
  116. let fixed = 0;
  117. const manifestURL = $`link[rel="manifest"]`.href;
  118. const manifest = await cacheInto("site_manifest:" + location.origin, async () => {
  119. verb = '';
  120. return JSON.parse(await (await grabURL(manifestURL)).text());
  121. });
  122. // fix manifests with missing start_url
  123. if (!manifest.start_url) {
  124. manifest.start_url = location.origin;
  125. fixed++;
  126. }
  127. // fix manifests with display values Chromium doesn't like anymore
  128. if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) {
  129. manifest.display = "minimal-ui";
  130. fixed++;
  131. }
  132. // TODO: add repair steps from these lists:
  133. // https://developer.chrome.com/en/docs/lighthouse/pwa/installable-manifest/
  134. // https://web.dev/install-criteria/
  135. // https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Making_PWAs_installable#the_web_app_manifest
  136. if (fixed) {
  137. // since we're loading the manifest from a data: URI, fix all the relative URIs (TODO: some relative URIs may linger)
  138. manifest.icons.forEach(img => img.src= resolveURI(img.src, manifestURL));
  139. ["start_url", "scope"].forEach(k => manifest[k] = resolveURI(manifest[k], manifestURL));
  140. $$`link[rel="manifest"]`.forEach(link=>link.remove());
  141. verb += `repaired ${fixed} issue${fixed>1?'s':''}`;
  142. return manifest;
  143. }
  144. // nothing to do, let the original manifest stand.nothing.
  145. verb += 'validated';
  146. return null;
  147. }
  148.  
  149. async function generateManifest() {
  150. // Remember how there's this universal way to get a web site's name? Yeah, me neither.
  151. const goodNames = [
  152. // plausible places to find one
  153. $`meta[name="application-name"]`?.content,
  154. $`meta[name="apple-mobile-web-app-title"]`?.content,
  155. $`meta[name="al:android:app_name"]`?.content,
  156. $`meta[name="al:ios:app_name"]`?.content,
  157. $`meta[property="og:site_name"]`?.content,
  158. $`meta[property="og:title"]`?.content,
  159. ].filter(v=>!!v).sort((a,b)=>a.length-b.length); // short names first.
  160. const badNames = [
  161. // various bad ideas
  162. $`link[rel="search]"`?.title.replace(/ search/i,''),
  163. document.title,
  164. $`h1`?.textContent,
  165. [...location.hostname.replace(/^www\./,'')].map((c,i)=>i?c:c.toUpperCase()).join('') // capitalized domain name. If everything else fails, there's at least this.
  166. ].filter(v=>!!v);
  167. const short_name = goodNames[0] ?? badNames[0];
  168. const app_name = goodNames.at(-1) ?? badNames[0];
  169.  
  170. const descriptions = [
  171. $`meta[property="og:description"]`?.content,
  172. $`meta[name="description"]`?.content,
  173. $`meta[name="description"]`?.getAttribute("value"),
  174. $`meta[name="twitter:description"]`?.content,
  175. ].filter(v=>!!v);
  176. const app_description = descriptions[0];
  177.  
  178. const colors = [
  179. $`meta[name="theme-color"]`?.content,
  180. getComputedStyle(document.body).backgroundColor
  181. ].filter(v=>!!v);
  182. const theme_color = colors[0];
  183. const background_color = colors.at(-1);
  184.  
  185. // focus on caching only the bits with network requests
  186. const images = await cacheInto("images:"+location.origin, async () => {
  187. const icons = [
  188. ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href),
  189. resolveURI($`meta[itemprop="image"]`?.content),
  190. ].filter(v=>!!v);
  191. // fetch all the icons, so we know what we're working with.
  192. const images = (await Promise.all(icons.map(getUntaintedImage))).filter(v=>!!v);
  193. images.sort((a,b)=>b.height - a.height); // largest image first.
  194. if (!images.length) {
  195. const fallback = await getUntaintedImage("/favicon.ico"); // last resort. well known location for tiny site icons.
  196. if (fallback) images.unshift(fallback);
  197. }
  198. if (!images.length) {
  199. images.unshift(await getUntaintedImage(FALLBACK_ICON));
  200. verb = 'generated with a fallback icon';
  201. }
  202. // grab the biggest one.
  203. const biggestImage = images[0];
  204. if (biggestImage.width < 144 || biggestImage.height < 144 || biggestImage.type !== 'image/png') {
  205. 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.`);
  206. // welp, we're gonna scale it.
  207. const img = await makeBigPNG(biggestImage.img);
  208. images.unshift(img);
  209. }
  210. images.forEach(img=>delete img.img);
  211. verb = '';
  212. return images;
  213. });
  214. if (!images) {
  215. return;
  216. }
  217.  
  218. verb += 'generated';
  219. // There it is, our glorious Web Manifest.
  220. return {
  221. name: app_name,
  222. short_name: short_name,
  223. description: app_description,
  224. start_url: location.href,
  225. scope: resolveURI("/"),
  226. display: "standalone",
  227. theme_color: theme_color,
  228. background_color: background_color,
  229. icons: images.map(img => ({
  230. src: img.src,
  231. sizes: `${img.width}x${img.height}`,
  232. type: img.type
  233. }))
  234. };
  235. }
  236.  
  237. let adjective;
  238. let verb = 'grabbed from cache and ';
  239.  
  240. async function main() {
  241. const start = Date.now();
  242. let manifest;
  243.  
  244. if ($`link[rel="manifest"]`) {
  245. adjective = 'Site';
  246. manifest = await repairManifest();
  247. } else {
  248. adjective = 'Custom';
  249. manifest = await generateManifest();
  250. }
  251.  
  252. if (manifest) {
  253. // Use GM_addElement to inject the manifest.
  254. // It doesn't succeed in bypassing Content Security Policy rules today, but maybe userscript extensions will make this work someday.
  255. // (Note: TamperMonkey lets you disable CSP altogether in their Advanced Settings.)
  256. GM_addElement(document.head, 'link', {
  257. rel: "manifest",
  258. href: 'data:application/manifest+json,'+encodeURIComponent(JSON.stringify(manifest))
  259. });
  260. }
  261. // explain what we did.
  262. logGroup(`${adjective} manifest ${verb} in ${Date.now()-start}ms.`,
  263. manifest ?
  264. JSON.stringify(manifest,null,2).replace(/"data:.{70,}?"/g, url=>`"${url.slice(0,35)}…[${url.length-45}_more_bytes]…${url.slice(-10,-1)}"`)
  265. : $`link[rel="manifest"]`?.href ?? ''
  266. );
  267. }
  268.  
  269. withLogs(main);