Installability

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

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

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