Installability

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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
  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.8
  7. // @noframes
  8. // @author itsnotlupus
  9. // @license MIT
  10. // @require https://greatest.deepsurf.us/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, fetchJSON, observeDOM */
  21.  
  22. /**
  23. * Wishlist:
  24. * - too many rules. can't deploy a worker, etc.
  25. * - how far could a userscript go in emulating an offline worker tho?
  26. * - fetch/xhr can be intercepted.
  27. * - image loading could be polyfilled too.
  28. * - page navigation is iffier. if the first page doesn't load, the userscript won't either.
  29. * - but if we can get pasted that, network-first offline policy would probably cause the least damage (https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Caching)
  30. * - [...new Set([...$$`[href],[src]`].map(a=>a.href??a.src).filter(url=>url.startsWith(location.origin)).map(url=>url.split('#')[0]))] // things one might precache in an install event handler
  31. * - maskable icons (which involves finding the smallest rect that captures all non-transparent pixels, and shrinking them to fit within safe area.)
  32. * - take the silliness further:
  33. * - detecting main site navigation entrypoints and generating shortcuts would kinda kick ass.
  34. * - look for more weird PWA features, and see if there's a generic way to leverage them.
  35. */
  36.  
  37. // Bits of code that might be useful later:
  38.  
  39. // 1. holding a user's hand to get installed. in case the script ever exposes a more visible mean to install an app, I guess.
  40. //early:
  41. // const installer = await new Promise(r => addEventListener('beforeinstallprompt', r));
  42. //later:
  43. // installer.prompt();
  44. // const { outcome } = await installer.userChoice;
  45. // const installed = await new Promise(r => addEventListener('appinstalled', r);
  46.  
  47. // a default app icon to use if no suitable icons are found in the site
  48. 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>`;
  49.  
  50. // keep cached bits of manifests on any given site for 24 hours before fetching/generating new ones.
  51. const CACHE_MANIFEST_EXPIRATION = 24*3600*1000;
  52.  
  53. /** cache the result of work() into GM storage for a day. */
  54. async function cacheInto(key, work) {
  55. const cached = GM_getValue(key);
  56. if (cached && cached.expires > Date.now()) return cached.data;
  57. const data = await work();
  58. if (data != null) GM_setValue(key, { expires: Date.now() + CACHE_MANIFEST_EXPIRATION, data });
  59. return data;
  60. }
  61.  
  62. /** Resolve a relative URL into an absolute URL */
  63. const resolveURL = (url, base=location.href) => url && new URL(url, base).toString();
  64.  
  65. /**
  66. * load an image without CSP restrictions.
  67. */
  68. function getImage(src) {1
  69. return new Promise((resolve) => {
  70. const img = GM_addElement('img', {
  71. src: resolveURL(src),
  72. crossOrigin: "anonymous"
  73. });
  74. img.onload = () => resolve(img);
  75. img.onerror = () => resolve(null);
  76. img.remove();
  77. });
  78. }
  79.  
  80. /** test if the URL given loads correctly (not 404, etc.) */
  81. function workingURL(src) {
  82. return new Promise(resolve => {
  83. const url = resolveURL(src);
  84. GM_xmlhttpRequest({
  85. url,
  86. method: "HEAD",
  87. onload(res) {
  88. resolve(res.status<300);
  89. },
  90. onerror() {
  91. resolve(false);
  92. }
  93. });
  94. });
  95. }
  96.  
  97. function cachedWorkingURL(src) {
  98. return cacheInto('working-url:'+src, () => workingURL(src));
  99. }
  100.  
  101. /** fetch an arbitrary URL using current browser cookies. no restrictions. */
  102. function grabURL(src) {
  103. return new Promise(resolve => {
  104. const url = resolveURL(src);
  105. GM_xmlhttpRequest({
  106. url,
  107. responseType: 'blob',
  108. async onload(res) {
  109. resolve(res.response);
  110. },
  111. onerror() {
  112. log("Couldn't grab URL " + src);
  113. resolve(null);
  114. }
  115. });
  116. });
  117. }
  118.  
  119. /**
  120. * Grab an image and its mime-type regardless of browser sandbox limitations.
  121. */
  122. async function getUntaintedImage(src) {
  123. const blob = await grabURL(src);
  124. const blobURL = URL.createObjectURL(blob);
  125. const img = await getImage(blobURL);
  126. if (!img) return null;
  127. URL.revokeObjectURL(blobURL);
  128. return {
  129. src: resolveURL(src),
  130. img,
  131. width: img.naturalWidth,
  132. height: img.naturalHeight,
  133. type: blob.type
  134. };
  135. }
  136.  
  137. function makeBigPNG({ img }) {
  138. // scale to at least 512x512, but keep the pixels if there are more.
  139. const width = Math.max(512, img.width);
  140. const height = Math.max(512, img.height);
  141. const canvas = crel('canvas', { width, height });
  142. const ctx = canvas.getContext('2d');
  143. ctx.drawImage(img, 0, 0, width, height);
  144. const url = canvas.toDataURL({ type: "image/png" });
  145. return {
  146. src: url,
  147. width,
  148. height,
  149. type: "image/png"
  150. };
  151. }
  152.  
  153. function guessAppName() {
  154. // Remember how there's this universal way to get a web site's name? Yeah, me neither.
  155. const goodNames = [
  156. // plausible places to find one
  157. $`meta[name="application-name"]`?.content,
  158. $`meta[name="apple-mobile-web-app-title"]`?.content,
  159. $`meta[name="al:android:app_name"]`?.content,
  160. $`meta[name="al:ios:app_name"]`?.content,
  161. $`meta[property="og:site_name"]`?.content,
  162. $`meta[property="og:title"]`?.content,
  163. ].filter(v=>!!v).sort((a,b)=>a.length-b.length); // short names first.
  164. const badNames = [
  165. // various bad ideas
  166. $`link[rel="search]"`?.title.replace(/ search/i,''),
  167. document.title,
  168. $`h1`?.textContent,
  169. [...location.hostname.replace(/^www\./,'')].map((c,i)=>i?c:c.toUpperCase()).join('') // capitalized domain name. If everything else fails, there's at least this.
  170. ].filter(v=>!!v);
  171. const short_name = goodNames[0] ?? badNames[0];
  172. //const app_name = goodNames.at(-1) ?? badNames[0];
  173. return short_name;
  174. }
  175.  
  176. function guessAppDescription() {
  177. const descriptions = [
  178. $`meta[property="og:description"]`?.content,
  179. $`meta[name="description"]`?.content,
  180. $`meta[name="description"]`?.getAttribute("value"),
  181. $`meta[name="twitter:description"]`?.content,
  182. ].filter(v=>!!v);
  183. return descriptions[0];
  184. }
  185.  
  186. function guessAppColors() {
  187. const colors = [
  188. $`meta[name="theme-color"]`?.content,
  189. getComputedStyle(document.body).backgroundColor
  190. ].filter(v=>!!v);
  191. return {
  192. theme_color: colors[0],
  193. background_color: colors.at(-1)
  194. };
  195. }
  196.  
  197. async function gatherAppIcons() {
  198. // focus on caching only the bits with network requests
  199. return cacheInto("images:"+location.origin, async () => {
  200. const iconURLs = [
  201. ...Array.from($$`link[rel*="icon"]`).filter(link=>link.rel!="mask-icon").map(link=>link.href),
  202. resolveURL($`meta[itemprop="image"]`?.content),
  203. ].filter(v=>!!v);
  204. // fetch all the icons, so we know what we're working with.
  205. const images = (await Promise.all(iconURLs.map(getUntaintedImage))).filter(v=>!!v);
  206. if (!images.length) {
  207. const fallback = await getUntaintedImage("/favicon.ico"); // last resort. well known location for tiny site icons.
  208. if (fallback) images.unshift(fallback);
  209. }
  210. if (!images.length) {
  211. images.unshift(await getUntaintedImage(FALLBACK_ICON));
  212. verb = 'generated with a fallback icon';
  213. }
  214. const icons = images.map(img => ({
  215. src: img.src,
  216. sizes: `${img.width}x${img.height}`,
  217. type: img.type
  218. }));
  219. await fixAppIcons(icons);
  220. verb = '';
  221. return icons;
  222. });
  223. }
  224.  
  225. function getIconMaxSize(icon) {
  226. // "any" is technically infinite, but 512x512 is close enough
  227. const sizes = icon.sizes.split(/\s+/).map(size=>size=='any'?[512,512]:size.split(/x/i).map(v=>+v)).sort((a,b)=>b[0]-a[0]);
  228. return sizes[0]; // [ width, height ]
  229. }
  230.  
  231. function appIconsValid(icons) {
  232. return icons.some(icon => {
  233. const [ width, height ] = getIconMaxSize(icon);
  234. return width >= 512 && height >= 512 && icon.type == 'image/png';
  235. });
  236. }
  237.  
  238. async function fixAppIcons(icons) {
  239. icons.sort((a,b)=>getIconMaxSize(b)[0] - getIconMaxSize(a)[0]); // largest image first. suboptimal
  240. // grab the biggest one.
  241. const biggestImage = icons[0];
  242. const [ width, height ] = getIconMaxSize(biggestImage);
  243. if (width < 512 || height < 512 || biggestImage.type !== 'image/png') {
  244. log(`We may not have a valid icon yet, scaling an image of type ${biggestImage.type} and size (${width}x${height}) into a big enough PNG.`);
  245. // welp, we're gonna scale it.
  246. const img = await makeBigPNG(await getUntaintedImage(biggestImage.src));
  247. icons.unshift({
  248. src: img.src,
  249. sizes: `${img.width}x${img.height}`,
  250. type: img.type
  251. });
  252. }
  253. return icons;
  254. }
  255.  
  256. async function guessRelatedApplications() {
  257.  
  258. // 1. "app links", a weird decade old half-baked half-supported spec that has the data we'd need for this.
  259. // seen on threads.net, and probably not much elsewhere. but hey, we can parse synchronously and cheaply.
  260. const apps = [];
  261. const android_id = $`meta[property="al:android:package"]`?.content
  262. if (android_id) {
  263. const url = `https://play.google.com/store/apps/details?id=${android_id}`;
  264. if (await cachedWorkingURL(url)) {
  265. apps.push({
  266. platform: "play", // XXX "chromeos_play"?
  267. id: android_id,
  268. url
  269. });
  270. }
  271. }
  272. const ios_id = $`meta[property="al:ios:app_store_id"]`?.content;
  273. if (ios_id) {
  274. const app_name = $`meta[property="al:ios:app-name"]`?.content ?? 'app';
  275. const url = `https://apps.apple.com/app/${app_name}/${ios_id}`;
  276. if (await cachedWorkingURL(url)) {
  277. apps.push({
  278. platform: "itunes",
  279. id: ios_id,
  280. url
  281. });
  282. }
  283. }
  284. // theoretically, there could be more here, like windows app and stuff.
  285. // see https://developers.facebook.com/docs/applinks/metadata-reference
  286.  
  287. // 2. .well-known/assetlinks.json
  288. // see https://github.com/google/digitalassetlinks/blob/master/well-known/details.md
  289. const assetLinksJson = await cacheInto("assetLinksJson:"+location.origin, async () => {
  290. try {
  291. return await fetchJSON(resolveURL("/.well-known/assetlinks.json"));
  292. } catch {
  293. return [];
  294. }
  295. });
  296. if (Array.isArray(assetLinksJson)) {
  297. await Promise.all(assetLinksJson.filter(i=>i.relation.includes("delegate_permission/common.handle_all_urls")).map(async ({target}) => {
  298. switch (target.namespace) {
  299. case "android_app": {
  300. const url = `https://play.google.com/store/apps/details?id=${target.package_name}`
  301. if (await cachedWorkingURL(url)) {
  302. apps.push({
  303. platform: "play",
  304. id: target.package_name,
  305. url
  306. });
  307. }
  308. break;
  309. }
  310. case "ios_app": { // the definition of unbridled optimism
  311. const url = `https://apps.apple.com/app/app/${target.id}`;
  312. if (await cachedWorkingURL(url)) {
  313. if (target.appid) apps.push({
  314. platform: "itunes",
  315. id: target.appid,
  316. url
  317. });
  318. }
  319. break;
  320. }
  321. }
  322. }));
  323. }
  324. // dedup apps right quick
  325. const urls = new Set;
  326. for (let i=apps.length-1;i>=0;i--) {
  327. if (urls.has(apps[i].url)) {
  328. apps.splice(i,1);
  329. } else {
  330. urls.add(apps[i].url);
  331. }
  332. }
  333.  
  334. return apps.length ? apps : undefined;
  335. }
  336.  
  337. /** modify manifest in place, turn all known relative URLs into absolute URLs */
  338. function fixManifestURLs(manifest, manifestURL) {
  339.  
  340. // a map of URLs in the manifest structure
  341. const URL_IN_MANIFEST = {
  342. file_handlers: [ { action: true } ],
  343. icons: [ { src: true } ],
  344. protocol_handlers: [ { url: true } ],
  345. scope: true,
  346. screenshots: [ { src: true } ],
  347. serviceworker: { url: true },
  348. share_target: { action: true },
  349. shortcuts: [ {
  350. url: true,
  351. icons: [ { src: true } ]
  352. } ],
  353. start_url: true
  354. };
  355. // How to use a map to traverse a manifest
  356. function recurse(obj, schema, transform) {
  357. if (Array.isArray(schema)) return obj.forEach(item => recurse(item, schema[0], transform));
  358. Object.keys(schema).forEach(key => { switch (true) {
  359. case !obj[key]: return;
  360. case typeof obj[key] == 'object': recurse(obj[key], schema[key], transform); break;
  361. default: obj[key] = transform(obj[key]);
  362. }});
  363. }
  364.  
  365. recurse(manifest, URL_IN_MANIFEST, url => resolveURL(url, manifestURL));
  366. }
  367.  
  368. async function repairManifest() {
  369. let fixed = 0;
  370. const manifestURL = $`link[rel="manifest"]`.href;
  371. const manifest = await cacheInto("site_manifest:" + location.origin, async () => {
  372. verb = '';
  373. return JSON.parse(await (await grabURL(manifestURL)).text());
  374. });
  375. // since we're loading the manifest from a data: URL, get rid of all relative URLs
  376. fixManifestURLs(manifest, manifestURL);
  377. // fix: missing short_name
  378. if (!manifest.short_name) {
  379. log("Missing short_name field.");
  380. manifest.short_name = manifest.name || guessAppName();
  381. fixed++;
  382. }
  383. // fix: missing name
  384. if (!manifest.name) {
  385. log("Missing name field.");
  386. manifest.name = manifest.short_name || guessAppName();
  387. fixed++;
  388. }
  389. // fix: missing or insufficient icons
  390. if (!manifest.icons) {
  391. log("Missing icons field.");
  392. manifest.icons = await gatherAppIcons();
  393. fixed++;
  394. } else if (!appIconsValid(manifest.icons)) {
  395. log("Invalid icons field.");
  396. await fixAppIcons(manifest.icons);
  397. fixed++;
  398. }
  399. // fix: missing start_url
  400. if (!manifest.start_url) {
  401. log("Missing start_url field.");
  402. manifest.start_url = location.origin;
  403. fixed++;
  404. }
  405. // fix: invalid display value (typically "browser")
  406. if (!["standalone", "fullscreen", "minimal-ui"].includes(manifest.display)) {
  407. log("Missing or invalid display field.");
  408. manifest.display = "minimal-ui";
  409. fixed++;
  410. }
  411. if (manifest.prefer_related_applications) {
  412. log("Obsolete prefer_related_applications field found.");
  413. delete manifest.prefer_related_applications;
  414. fixed++;
  415. }
  416. if (manifest.launch_handler) {
  417. if (manifest.launch_handler.route_to) {
  418. log("Obsolete launch_handler.route_to field found, renaming to client_mode");
  419. manifest.launch_handler.client_mode = manifest.launch_handler.route_to;
  420. delete manifest.launch_handler.route_to;
  421. fixed++;
  422. }
  423. if (manifest.launch_handler.navigate_existing_client) {
  424. log ("Obsolete launch_handler.navigate_existing_client field found.");
  425. delete manifest.launch_handler.navigate_existing_client;
  426. fixed++;
  427. }
  428. }
  429. if (fixed) {
  430. $$`link[rel="manifest"]`.forEach(link=>link.remove());
  431. verb += `repaired ${fixed} issue${fixed>1?'s':''}`;
  432. return manifest;
  433. }
  434. // nothing to do, let the original manifest stand.nothing.
  435. verb += 'validated';
  436. return null;
  437. }
  438.  
  439. // return an array of CSP sources acceptable for a manifest URL and usable by this script. may be empty.
  440. async function inspectCSP() {
  441. const CSP_HEADER = 'Content-Security-Policy';
  442. 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),{}):{};
  443. function checkCSP(csp, sources = []) {
  444. if (!Object.keys(csp).length) return sources;
  445. const allowedSources = csp['manifest-src'] ?? csp['default-src'];
  446. if (!allowedSources) return sources;
  447. return sources.filter(source=>allowedSources.includes(source));
  448. }
  449.  
  450. const cspHeader = parseCSP((await fetch('', {method:'HEAD'})).headers.get(CSP_HEADER));
  451. const cspMeta = parseCSP($(`meta[http-equiv="${CSP_HEADER}"]`)?.content);
  452. const sources = checkCSP(cspMeta, checkCSP(cspHeader, ["data:", "blob:"]));
  453. if (sources.length) {
  454. // log("Acceptable manifest sources are ", sources);
  455. } else {
  456. log("CSP rules will probably prevent us from setting a manifest.");
  457. }
  458. return sources;
  459. }
  460.  
  461. async function generateManifest(sources) {
  462.  
  463. const short_name = guessAppName();
  464. const description = guessAppDescription();
  465. const { theme_color, background_color } = guessAppColors();
  466.  
  467. const icons = await gatherAppIcons();
  468.  
  469. const related_applications = await guessRelatedApplications();
  470.  
  471. verb += 'generated';
  472. // There it is, our glorious Web Manifest.
  473. return {
  474. name: short_name,
  475. short_name,
  476. description,
  477. start_url: location.href,
  478. scope: resolveURL("/"),
  479. display: "standalone",
  480. display_override: [ "window-controls-overlay" ],
  481. theme_color,
  482. background_color,
  483. icons,
  484. related_applications
  485. };
  486. }
  487.  
  488. let adjective;
  489. let verb = 'grabbed from cache and ';
  490.  
  491. async function getManifest(sources) {
  492. const start = Date.now();
  493. let manifest;
  494. let wasGenerated = false;
  495.  
  496. if ($`link[rel="manifest"]`) {
  497. adjective = 'Site';
  498. manifest = await repairManifest();
  499. } else {
  500. adjective = 'Custom';
  501. manifest = await generateManifest();
  502. wasGenerated = true;
  503. }
  504.  
  505. if (manifest) {
  506. // Use GM_addElement to inject the manifest.
  507. // It doesn't succeed in bypassing Content Security Policy rules today, but maybe userscript extensions will make this work someday.
  508. // (Note: TamperMonkey Beta has a setting to disable CSP altogether in their Advanced Settings.)
  509. let manifestLink;
  510. if (sources.includes('data:')) {
  511. manifestLink = 'data:application/manifest+json;charset=utf-8,'+encodeURIComponent(JSON.stringify(manifest));
  512. } else {
  513. const blob = new Blob([JSON.stringify(manifest)], {type: 'application/manifest+json;charset=utf-8'});
  514. manifestLink = URL.createObjectURL(blob);
  515. // NOTE: no good way to revoke that URL. stick to page lifetime.
  516. }
  517.  
  518. GM_addElement('link', {
  519. rel: "manifest",
  520. href: manifestLink
  521. });
  522. // This sets the color of the app title bar on desktop.
  523. if (!$`meta[name="theme-color"]`) GM_addElement('meta', {
  524. name: "theme-color",
  525. content: manifest.theme_color
  526. });
  527. }
  528. // summarize what we did.
  529. logGroup(`${adjective} manifest ${verb} in ${Date.now()-start}ms.`,
  530. manifest ?
  531. JSON.stringify(manifest,null,2).replace(/"data:.{70,}?"/g, url=>`"${url.slice(0,35)}…[${url.length-45}_more_bytes]…${url.slice(-10,-1)}"`)
  532. : $`link[rel="manifest"]`?.href ?? ''
  533. );
  534. return [manifest, wasGenerated];
  535. }
  536.  
  537. // make a custom title bar from whatever header-like content we can find.
  538. function customTitleBarJustAddWater(manifest) {
  539.  
  540. let outerDisconnect;
  541. function findAndAdjustTitleBar(query) {
  542. if (query.matches) {
  543.  
  544. let header = null;
  545. let disconnect;
  546.  
  547. // 1. find a header. make it fit roughly as a titelbar, and make it draggable.
  548. function findHeader() {
  549. if (header && document.body.contains(header)) return;
  550. const nodes = [...$$`body *`];
  551. // header nodes are mostly top-most level nodes that cover the width of the page, are flush against the top of the page, and not too tall.
  552. // (all broad generalizations are faulty, etc.)
  553. const header_nodes = nodes.filter(n=> {
  554. const { width, height, top } = n.getBoundingClientRect();
  555. return document.body.clientWidth - width < 2 && top < 5 && height > 10 && height < 200;
  556. }).filter((n,i,a)=>a.every(p=>p==n||!p.contains(n)));
  557. if (!header_nodes.length) return;
  558. // ok, plausible header found. Yer a titlebar, Header!
  559. header = header_nodes[0];
  560. Object.assign(header.style, {
  561. // fixed or sticky position would be great here, but it's too likely to break pages that weren't expecting it.
  562. // settle for trying not to be drawn under native titlebar elements.
  563. WebkitAppRegion: 'drag',
  564. appRegion: 'drag',
  565. paddingLeft: 'env(titlebar-area-x, 0)',
  566. paddingRight: 'calc(100% - env(titlebar-area-width, 100%))',
  567. minHeight: 'env(titlebar-area-height, initial)',
  568. backgroundColor: manifest.theme_color
  569. });
  570. // 2. look for interactive elements within the header. make those not draggable.
  571. function findInteractiveHeaderElements() {
  572. // this won't catch clickable divs.
  573. $$('a,input,button,select,label,textarea', header).forEach(n=>Object.assign(n.style, {
  574. WebkitAppRegion: 'no-drag',
  575. appRegion: 'no-drag'
  576. }));
  577. }
  578.  
  579. try { disconnect?.(); } catch (e) { log(e) }
  580. findInteractiveHeaderElements();
  581. disconnect = observeDOM(findInteractiveHeaderElements, header);
  582. }
  583.  
  584. try { outerDisconnect?.(); } catch(e) { log(e) }
  585. findHeader();
  586. outerDisconnect = observeDOM(findHeader);
  587. } else {
  588. try { outerDisconnect?.(); } catch(e) { log(e) }
  589. }
  590. }
  591.  
  592. const query = matchMedia('(display-mode: window-controls-overlay)');
  593. findAndAdjustTitleBar(query);
  594. query.addListener(e => findAndAdjustTitleBar(e));
  595. }
  596.  
  597. async function main() {
  598. const sources = await inspectCSP();
  599. const [manifest, wasGenerated] = await getManifest(sources);
  600. // if there was a site manifest, then trust that someone knew what they were doing, and don't try weird shenanigans.
  601. if (wasGenerated) {
  602. // but if we instead foisted installability upon an unuspecting web page...
  603. await customTitleBarJustAddWater(manifest);
  604. }
  605. }
  606.  
  607. main();