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.2
  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. log("Manifest found. Analyzing for problems..");
  99. let fixed = 0;
  100. const manifestURL = $`link[rel="manifest"]`.href;
  101. const manifest = await cacheInto("site_manifest:" + location.origin, async () => JSON.parse(await (await grabURL(manifestURL)).text()));
  102. // fix manifests with missing start_url
  103. if (!manifest.start_url) {
  104. manifest.start_url = location.origin;
  105. fixed++;
  106. }
  107. // fix manifests with display values Chromium doesn't like anymore
  108. if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) {
  109. manifest.display = "minimal-ui";
  110. fixed++;
  111. }
  112. if (fixed) {
  113. // since we're loading the manifest from a data: URI, fix all the relative URIs (TODO: some relative URIs may linger)
  114. manifest.icons.forEach(img => img.src= resolveURI(img.src, manifestURL));
  115. ["start_url", "scope"].forEach(k => manifest[k] = resolveURI(manifest[k], manifestURL));
  116. $`link[rel="manifest"]`.remove();
  117. log(`Fixed ${fixed} issue${fixed>1?'s':''} in site manifest.`);
  118. verb = 'repaired';
  119. return manifest;
  120. }
  121. // nothing to do, let the original manifest stand.nothing.
  122. verb = 'validated';
  123. return null;
  124. }
  125.  
  126. async function generateManifest() {
  127. // Remember how there's this universal way to get a web site's name? Yeah, me neither.
  128. const goodNames = [
  129. // plausible places to find one
  130. $`meta[name="application-name"]`?.content,
  131. $`meta[name="apple-mobile-web-app-title"]`?.content,
  132. $`meta[name="al:android:app_name"]`?.content,
  133. $`meta[name="al:ios:app_name"]`?.content,
  134. $`meta[property="og:site_name"]`?.content,
  135. $`meta[property="og:title"]`?.content,
  136. ].filter(v=>!!v).sort((a,b)=>a.length-b.length); // short names first.
  137. const badNames = [
  138. // various bad ideas
  139. $`link[rel="search]"`?.title.replace(/ search/i,''),
  140. document.title,
  141. $`h1`?.textContent,
  142. [...location.hostname.replace(/^www\./,'')].map((c,i)=>i?c:c.toUpperCase()).join('') // capitalized domain name. If everything else fails, there's at least this.
  143. ].filter(v=>!!v);
  144. const short_name = goodNames[0] ?? badNames[0];
  145. const app_name = goodNames.at(-1) ?? badNames[0];
  146.  
  147. const descriptions = [
  148. $`meta[property="og:description"]`?.content,
  149. $`meta[name="description"]`?.content,
  150. $`meta[name="description"]`?.getAttribute("value"),
  151. $`meta[name="twitter:description"]`?.content,
  152. ].filter(v=>!!v);
  153. const app_description = descriptions[0];
  154.  
  155. const colors = [
  156. $`meta[name="theme-color"]`?.content,
  157. getComputedStyle(document.body).backgroundColor
  158. ].filter(v=>!!v);
  159. const theme_color = colors[0];
  160. const background_color = colors.at(-1);
  161.  
  162. // focus on caching only the bits with network requests
  163. const images = await cacheInto("images:"+location.origin, async () => {
  164. const icons = [
  165. ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href),
  166. resolveURI($`meta[itemprop="image"]`?.content),
  167. ].filter(v=>!!v);
  168. // fetch all the icons, so we know what we're working with.
  169. const images = (await Promise.all(icons.map(getUntaintedImage))).filter(v=>!!v);
  170. images.sort((a,b)=>b.height - a.height); // largest image first.
  171. if (!images.length) {
  172. const fallback = await getUntaintedImage("/favicon.ico"); // last resort. well known location for tiny site icons.
  173. if (fallback) images.unshift(fallback);
  174. }
  175. if (!images.length) {
  176. verb = 'could not be generated because no app icons were found';
  177. return; // just give up. we can't install an app without an icon.
  178. }
  179. // grab the biggest one.
  180. const biggestImage = images[0];
  181. if (biggestImage.width < 144 || biggestImage.height < 144 || biggestImage.type !== 'image/png') {
  182. 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.`);
  183. // welp, we're gonna scale it.
  184. const img = await makeBigPNG(biggestImage.img);
  185. images.unshift(img);
  186. }
  187. images.forEach(img=>delete img.img);
  188. verb = '';
  189. return images;
  190. });
  191. if (!images) {
  192. return;
  193. }
  194.  
  195. verb += 'generated';
  196. // There it is, our glorious Web Manifest.
  197. return {
  198. name: app_name,
  199. short_name: short_name,
  200. description: app_description,
  201. start_url: location.href,
  202. display: "standalone",
  203. theme_color: theme_color,
  204. background_color: background_color,
  205. icons: images.map(img => ({
  206. src: img.src,
  207. sizes: `${img.width}x${img.height}`,
  208. type: img.type
  209. }))
  210. };
  211. }
  212.  
  213. let adjective;
  214. let verb = 'grabbed from cache and ';
  215.  
  216. async function main() {
  217. const start = Date.now();
  218. let manifest;
  219.  
  220. if ($`link[rel="manifest"]`) {
  221. adjective = 'Site';
  222. manifest = await repairManifest();
  223. } else {
  224. adjective = 'Custom';
  225. manifest = await generateManifest();
  226. }
  227.  
  228. if (manifest) {
  229. // Use GM_addElement to inject the manifest.
  230. // It doesn't succeed in bypassing Content Security Policy rules today, but maybe userscript extensions will make this work someday.
  231. GM_addElement(document.head, 'link', {
  232. rel: "manifest",
  233. href: 'data:application/manifest+json,'+encodeURIComponent(JSON.stringify(manifest))
  234. });
  235. }
  236. log(`${adjective} manifest ${verb} in ${Date.now()-start}ms.`, manifest ? JSON.stringify(manifest,null,2).replace(/"data:.*?"/g,`"data: URI removed"`) : '');
  237. }
  238.  
  239. withLogs(main);