-
-
Save KennFatt/b94329520a15e8c909773813f429971f to your computer and use it in GitHub Desktop.
enhanced-pwa-everything.user.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Enhanced PWA Everything | |
| // @description Preserve the site's manifest when possible, patch it into a more installable form, otherwise inject a fallback manifest. | |
| // @author KennFatt | |
| // @version 0.0.5 | |
| // @match *://*/* | |
| // @grant none | |
| // @run-at document-idle | |
| // @noframes | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const INJECTED_ATTR = 'data-pwa-everything'; | |
| const DEFAULT_THEME_COLOR = '#000000'; | |
| const DEFAULT_BG_COLOR = '#000000'; | |
| function getAppTitle() { | |
| return (document.title || location.hostname || 'App').trim(); | |
| } | |
| function absoluteUrl(url) { | |
| try { | |
| return new URL(url, document.baseURI).href; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function toBase64Utf8(str) { | |
| return btoa(unescape(encodeURIComponent(str))); | |
| } | |
| function toManifestDataUrl(manifest) { | |
| return 'data:application/manifest+json;base64,' + toBase64Utf8(JSON.stringify(manifest)); | |
| } | |
| function guessScopeFromPath(pathname) { | |
| if (!pathname || pathname === '/') return '/'; | |
| const idx = pathname.lastIndexOf('/'); | |
| return idx <= 0 ? '/' : pathname.slice(0, idx + 1); | |
| } | |
| function makeSvgIcon(size, label) { | |
| const safeLabel = String(label || 'A').slice(0, 2).toUpperCase(); | |
| const svg = | |
| `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">` + | |
| `<rect width="100%" height="100%" rx="${Math.round(size * 0.18)}" ry="${Math.round(size * 0.18)}" fill="#000000"/>` + | |
| `<text x="50%" y="54%" text-anchor="middle" dominant-baseline="middle" ` + | |
| `font-family="Arial, sans-serif" font-size="${Math.round(size * 0.42)}" fill="#ffffff">${safeLabel}</text>` + | |
| `</svg>`; | |
| return 'data:image/svg+xml;base64,' + toBase64Utf8(svg); | |
| } | |
| function buildFallbackIcons() { | |
| const label = getAppTitle().charAt(0) || 'A'; | |
| return [ | |
| { | |
| src: makeSvgIcon(192, label), | |
| sizes: '192x192', | |
| type: 'image/svg+xml', | |
| purpose: 'any' | |
| }, | |
| { | |
| src: makeSvgIcon(512, label), | |
| sizes: '512x512', | |
| type: 'image/svg+xml', | |
| purpose: 'any' | |
| } | |
| ]; | |
| } | |
| function getExistingManifestLink() { | |
| return document.querySelector('link[rel~="manifest"][href]'); | |
| } | |
| function removeOldInjectedManifest() { | |
| document.querySelectorAll(`link[rel~="manifest"][${INJECTED_ATTR}="true"]`).forEach(el => el.remove()); | |
| } | |
| function injectManifest(manifest) { | |
| if (!document.head) return; | |
| removeOldInjectedManifest(); | |
| const link = document.createElement('link'); | |
| link.rel = 'manifest'; | |
| link.href = toManifestDataUrl(manifest); | |
| link.setAttribute(INJECTED_ATTR, 'true'); | |
| document.head.prepend(link); | |
| } | |
| function buildFallbackManifest() { | |
| const title = getAppTitle(); | |
| return { | |
| name: title, | |
| short_name: title.slice(0, 12) || title, | |
| start_url: location.href, | |
| scope: guessScopeFromPath(location.pathname), | |
| display: 'standalone', | |
| theme_color: DEFAULT_THEME_COLOR, | |
| background_color: DEFAULT_BG_COLOR, | |
| icons: buildFallbackIcons() | |
| }; | |
| } | |
| function normalizeIcons(icons, manifestUrl) { | |
| if (!Array.isArray(icons) || icons.length === 0) { | |
| return buildFallbackIcons(); | |
| } | |
| const normalized = icons | |
| .filter(icon => icon && typeof icon === 'object' && typeof icon.src === 'string' && icon.src.trim()) | |
| .map(icon => { | |
| const out = { ...icon }; | |
| const src = manifestUrl ? absoluteUrl(new URL(icon.src, manifestUrl).href) : absoluteUrl(icon.src); | |
| if (src) out.src = src; | |
| if (!out.type) { | |
| const lower = String(out.src).toLowerCase(); | |
| if (lower.endsWith('.png')) out.type = 'image/png'; | |
| else if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) out.type = 'image/jpeg'; | |
| else if (lower.endsWith('.webp')) out.type = 'image/webp'; | |
| else if (lower.endsWith('.svg')) out.type = 'image/svg+xml'; | |
| } | |
| return out; | |
| }); | |
| return normalized.length ? normalized : buildFallbackIcons(); | |
| } | |
| function normalizeShortName(name) { | |
| return String(name || '').trim().slice(0, 12); | |
| } | |
| function patchManifest(originalManifest, manifestUrl) { | |
| const fallback = buildFallbackManifest(); | |
| const patched = (originalManifest && typeof originalManifest === 'object') | |
| ? JSON.parse(JSON.stringify(originalManifest)) | |
| : {}; | |
| const title = getAppTitle(); | |
| if (!patched.name && !patched.short_name) { | |
| patched.name = title; | |
| patched.short_name = normalizeShortName(title); | |
| } else { | |
| if (!patched.name && patched.short_name) patched.name = String(patched.short_name); | |
| if (!patched.short_name && patched.name) patched.short_name = normalizeShortName(patched.name); | |
| } | |
| if (!patched.start_url || typeof patched.start_url !== 'string' || !patched.start_url.trim()) { | |
| patched.start_url = fallback.start_url; | |
| } | |
| if (!patched.scope || typeof patched.scope !== 'string' || !patched.scope.trim()) { | |
| patched.scope = guessScopeFromPath(location.pathname); | |
| } | |
| patched.display = 'standalone'; | |
| if (!patched.theme_color) { | |
| patched.theme_color = DEFAULT_THEME_COLOR; | |
| } | |
| if (!patched.background_color) { | |
| patched.background_color = DEFAULT_BG_COLOR; | |
| } | |
| patched.icons = normalizeIcons(patched.icons, manifestUrl); | |
| return patched; | |
| } | |
| async function fetchManifestJson(manifestUrl) { | |
| const response = await fetch(manifestUrl, { | |
| credentials: 'include', | |
| cache: 'no-store' | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`); | |
| } | |
| return await response.json(); | |
| } | |
| async function patchOrFallback() { | |
| const existingLink = getExistingManifestLink(); | |
| if (!existingLink) { | |
| injectManifest(buildFallbackManifest()); | |
| console.debug('[PWA Everything] No existing manifest found. Injected fallback.'); | |
| return; | |
| } | |
| const href = existingLink.getAttribute('href'); | |
| const manifestUrl = absoluteUrl(href); | |
| if (!manifestUrl) { | |
| injectManifest(buildFallbackManifest()); | |
| console.warn('[PWA Everything] Existing manifest href was invalid. Injected fallback.'); | |
| return; | |
| } | |
| try { | |
| const manifestJson = await fetchManifestJson(manifestUrl); | |
| const patched = patchManifest(manifestJson, manifestUrl); | |
| injectManifest(patched); | |
| console.debug('[PWA Everything] Existing manifest preserved and patched.'); | |
| } catch (error) { | |
| console.warn('[PWA Everything] Could not read existing manifest. Injecting fallback.', error); | |
| injectManifest(buildFallbackManifest()); | |
| } | |
| } | |
| patchOrFallback(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment