sourcemonkey 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/bytes.js +37 -0
- package/dist/channel-placeholder.js +5 -0
- package/dist/collection-scan.js +17 -0
- package/dist/collections.html +353 -0
- package/dist/compare.html +358 -0
- package/dist/composed-source.js +205 -0
- package/dist/connect-prompt.html +47 -0
- package/dist/debug/icons.html +211 -0
- package/dist/debug/storage.html +163 -0
- package/dist/debug-log.js +75 -0
- package/dist/diagnostic-location.js +64 -0
- package/dist/eval-hook.js +308 -0
- package/dist/extension/background.js +5263 -0
- package/dist/extension/channel.js +133 -0
- package/dist/extension/code-links.js +187 -0
- package/dist/extension/code-tabs.js +460 -0
- package/dist/extension/collection-dialog.js +619 -0
- package/dist/extension/collection-display.js +322 -0
- package/dist/extension/collections.js +2905 -0
- package/dist/extension/compare.js +867 -0
- package/dist/extension/concurrency.js +186 -0
- package/dist/extension/connect-grants.js +109 -0
- package/dist/extension/connect-prompt-page.js +70 -0
- package/dist/extension/connect-prompt.js +266 -0
- package/dist/extension/copy-button.js +63 -0
- package/dist/extension/copy-dialog.js +502 -0
- package/dist/extension/copy-preference.js +66 -0
- package/dist/extension/debug/icons.js +203 -0
- package/dist/extension/debug/storage.js +365 -0
- package/dist/extension/deep-link.js +77 -0
- package/dist/extension/delete-dialog.js +432 -0
- package/dist/extension/delete-icon.js +40 -0
- package/dist/extension/details-tooltips.js +66 -0
- package/dist/extension/directories.js +679 -0
- package/dist/extension/export-backup.js +407 -0
- package/dist/extension/export-dialog.js +142 -0
- package/dist/extension/file-access.js +74 -0
- package/dist/extension/file-changes.js +346 -0
- package/dist/extension/frame-info.js +85 -0
- package/dist/extension/github-provider.js +336 -0
- package/dist/extension/glyph.js +84 -0
- package/dist/extension/gm-bindings.js +601 -0
- package/dist/extension/gm-download.js +419 -0
- package/dist/extension/gm-menu.js +228 -0
- package/dist/extension/gm-notification.js +141 -0
- package/dist/extension/gm-originator-map.js +23 -0
- package/dist/extension/gm-tab-data.js +121 -0
- package/dist/extension/gm-tab.js +160 -0
- package/dist/extension/gm-url-change.js +111 -0
- package/dist/extension/gm-url-scheme.js +27 -0
- package/dist/extension/gm-values.js +176 -0
- package/dist/extension/gm-window-control.js +104 -0
- package/dist/extension/gm-xhr-headers.js +333 -0
- package/dist/extension/gm-xhr.js +584 -0
- package/dist/extension/injector.js +3145 -0
- package/dist/extension/install-capture.js +277 -0
- package/dist/extension/line-diff.js +233 -0
- package/dist/extension/local-copy.js +560 -0
- package/dist/extension/local-delete.js +276 -0
- package/dist/extension/location-kinds.js +108 -0
- package/dist/extension/log-format.js +158 -0
- package/dist/extension/managed-changes.js +361 -0
- package/dist/extension/managed-items.js +57 -0
- package/dist/extension/match-index.js +375 -0
- package/dist/extension/missing-scripts.js +66 -0
- package/dist/extension/more-menu.js +109 -0
- package/dist/extension/move-dialog.js +197 -0
- package/dist/extension/offscreen.js +37 -0
- package/dist/extension/options.js +504 -0
- package/dist/extension/page-heading.js +170 -0
- package/dist/extension/page-links.js +199 -0
- package/dist/extension/parse-check-client.js +70 -0
- package/dist/extension/parse-check.js +161 -0
- package/dist/extension/parse-sandbox-host.js +172 -0
- package/dist/extension/parse-sandbox.js +175 -0
- package/dist/extension/popup-commands-open.js +41 -0
- package/dist/extension/popup-view.js +387 -0
- package/dist/extension/popup.js +703 -0
- package/dist/extension/prism-userscript.js +53 -0
- package/dist/extension/refresh-error.js +136 -0
- package/dist/extension/refresh-targets.js +160 -0
- package/dist/extension/related-versions.js +1144 -0
- package/dist/extension/resize-observer-loop.js +19 -0
- package/dist/extension/row-cache.js +387 -0
- package/dist/extension/script-blobs.js +413 -0
- package/dist/extension/script-details.js +658 -0
- package/dist/extension/script-enablement.js +285 -0
- package/dist/extension/script-identity.js +310 -0
- package/dist/extension/script-keys.js +54 -0
- package/dist/extension/script-log-id.js +155 -0
- package/dist/extension/script-log.js +290 -0
- package/dist/extension/script-rejection.js +104 -0
- package/dist/extension/script-sites.js +158 -0
- package/dist/extension/script-store.js +3507 -0
- package/dist/extension/script-updater.js +991 -0
- package/dist/extension/script-url-hash.js +61 -0
- package/dist/extension/select-all.js +37 -0
- package/dist/extension/select-open-guard.js +90 -0
- package/dist/extension/site-icon.js +1261 -0
- package/dist/extension/sm-dev-link.js +302 -0
- package/dist/extension/solo-click.js +58 -0
- package/dist/extension/sticky-numbers.js +188 -0
- package/dist/extension/storage-breakdown.js +253 -0
- package/dist/extension/storage-scan.js +86 -0
- package/dist/extension/storage-usage.js +194 -0
- package/dist/extension/structural-keys.js +29 -0
- package/dist/extension/sw-teardown.js +36 -0
- package/dist/extension/tab-status.js +403 -0
- package/dist/extension/toolbar-summary.js +105 -0
- package/dist/extension/user-scripts-access.js +43 -0
- package/dist/extension/userscript.js +3672 -0
- package/dist/extension/value-store.js +204 -0
- package/dist/extension/version-probe-fetch.js +68 -0
- package/dist/extension/worker-client.js +109 -0
- package/dist/extension/worker-ops.js +171 -0
- package/dist/extension/zip-writer.js +24 -0
- package/dist/external-files.js +878 -0
- package/dist/file-loader.js +436 -0
- package/dist/file-locations.js +439 -0
- package/dist/generated-wrappers.js +5 -0
- package/dist/github-auth.js +148 -0
- package/dist/github-urls.js +46 -0
- package/dist/gm-api-catalog.js +217 -0
- package/dist/gm-connect.js +273 -0
- package/dist/gm-xhr-types.js +5 -0
- package/dist/http-read.js +142 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/local-file-overrides.js +558 -0
- package/dist/manifest.json +38 -0
- package/dist/markup-response.js +126 -0
- package/dist/match-targets.js +305 -0
- package/dist/menu-captions.js +22 -0
- package/dist/offscreen.html +39 -0
- package/dist/options.html +197 -0
- package/dist/parse-check-protocol.js +51 -0
- package/dist/parse-location.js +171 -0
- package/dist/parse-sandbox.html +56 -0
- package/dist/phase-timing.js +155 -0
- package/dist/popup.html +197 -0
- package/dist/public-suffixes.js +6957 -0
- package/dist/run-context.js +31 -0
- package/dist/runtime-messages.js +34 -0
- package/dist/sandboxed-page.js +35 -0
- package/dist/script-build.js +787 -0
- package/dist/script-display.js +383 -0
- package/dist/script-icon.js +43 -0
- package/dist/script-links.js +204 -0
- package/dist/script-source-url.js +214 -0
- package/dist/script-world.js +65 -0
- package/dist/sm-dev-protocol.js +84 -0
- package/dist/source-map.js +177 -0
- package/dist/stack-link.js +138 -0
- package/dist/styles.css +825 -0
- package/dist/unsafe-window-use.js +229 -0
- package/dist/url-match.js +734 -0
- package/dist/userscript-header.js +248 -0
- package/dist/userscript.html +1165 -0
- package/dist/vendor/fflate.LICENSE +21 -0
- package/dist/vendor/fflate.js +2692 -0
- package/dist/vendor/prism/prism-line-numbers.css +1 -0
- package/dist/vendor/prism/prism-line-numbers.js +1 -0
- package/dist/vendor/prism/prism.css +1 -0
- package/dist/vendor/prism/prism.js +1946 -0
- package/dist/version-compare.js +141 -0
- package/dist/version-probe.js +120 -0
- package/lib/build-info.json +1 -0
- package/lib/bytes.d.ts +3 -0
- package/lib/bytes.js +37 -0
- package/lib/channel-placeholder.d.ts +1 -0
- package/lib/channel-placeholder.js +5 -0
- package/lib/collection-scan.d.ts +8 -0
- package/lib/collection-scan.js +17 -0
- package/lib/composed-source.d.ts +28 -0
- package/lib/composed-source.js +205 -0
- package/lib/debug-log.d.ts +9 -0
- package/lib/debug-log.js +75 -0
- package/lib/diagnostic-location.d.ts +8 -0
- package/lib/diagnostic-location.js +64 -0
- package/lib/eval-hook.d.ts +34 -0
- package/lib/eval-hook.js +308 -0
- package/lib/external-files.d.ts +111 -0
- package/lib/external-files.js +878 -0
- package/lib/file-loader.d.ts +48 -0
- package/lib/file-loader.js +436 -0
- package/lib/file-locations.d.ts +44 -0
- package/lib/file-locations.js +439 -0
- package/lib/generated-wrappers.d.ts +11 -0
- package/lib/generated-wrappers.js +5 -0
- package/lib/github-auth.d.ts +12 -0
- package/lib/github-auth.js +148 -0
- package/lib/github-urls.d.ts +1 -0
- package/lib/github-urls.js +46 -0
- package/lib/gm-api-catalog.d.ts +24 -0
- package/lib/gm-api-catalog.js +217 -0
- package/lib/gm-connect.d.ts +34 -0
- package/lib/gm-connect.js +273 -0
- package/lib/gm-xhr-types.d.ts +68 -0
- package/lib/gm-xhr-types.js +5 -0
- package/lib/harness/browser-extensions.d.ts +73 -0
- package/lib/harness/browser-extensions.js +285 -0
- package/lib/harness/browser-lang.d.ts +20 -0
- package/lib/harness/browser-lang.js +86 -0
- package/lib/harness/browser-launch.d.ts +14 -0
- package/lib/harness/browser-launch.js +184 -0
- package/lib/harness/checks.d.ts +110 -0
- package/lib/harness/checks.js +321 -0
- package/lib/harness/cli.d.ts +2 -0
- package/lib/harness/cli.js +2259 -0
- package/lib/harness/command-args.d.ts +91 -0
- package/lib/harness/command-args.js +369 -0
- package/lib/harness/command-lines.d.ts +80 -0
- package/lib/harness/command-lines.js +180 -0
- package/lib/harness/command-list.d.ts +206 -0
- package/lib/harness/command-list.js +1475 -0
- package/lib/harness/console-commands.d.ts +463 -0
- package/lib/harness/console-commands.js +3216 -0
- package/lib/harness/download-watch.d.ts +21 -0
- package/lib/harness/download-watch.js +156 -0
- package/lib/harness/extension-backend.d.ts +41 -0
- package/lib/harness/extension-backend.js +100 -0
- package/lib/harness/extension-host.d.ts +112 -0
- package/lib/harness/extension-host.js +552 -0
- package/lib/harness/extension-launch.d.ts +67 -0
- package/lib/harness/extension-launch.js +285 -0
- package/lib/harness/extension-link.d.ts +315 -0
- package/lib/harness/extension-link.js +607 -0
- package/lib/harness/external-manager.d.ts +146 -0
- package/lib/harness/external-manager.js +414 -0
- package/lib/harness/fixture.d.ts +165 -0
- package/lib/harness/fixture.js +461 -0
- package/lib/harness/frame-sessions.d.ts +71 -0
- package/lib/harness/frame-sessions.js +248 -0
- package/lib/harness/frames.d.ts +68 -0
- package/lib/harness/frames.js +216 -0
- package/lib/harness/help-format.d.ts +17 -0
- package/lib/harness/help-format.js +127 -0
- package/lib/harness/host.d.ts +352 -0
- package/lib/harness/host.js +423 -0
- package/lib/harness/index.d.ts +8 -0
- package/lib/harness/index.js +10 -0
- package/lib/harness/input-file.d.ts +18 -0
- package/lib/harness/input-file.js +61 -0
- package/lib/harness/json-output.d.ts +565 -0
- package/lib/harness/json-output.js +71 -0
- package/lib/harness/manager-actions.d.ts +21 -0
- package/lib/harness/manager-actions.js +129 -0
- package/lib/harness/manager-commands.d.ts +142 -0
- package/lib/harness/manager-commands.js +1196 -0
- package/lib/harness/manager-fetch.d.ts +33 -0
- package/lib/harness/manager-fetch.js +239 -0
- package/lib/harness/managers.d.ts +24 -0
- package/lib/harness/managers.js +27 -0
- package/lib/harness/output-style.d.ts +125 -0
- package/lib/harness/output-style.js +322 -0
- package/lib/harness/page-commands.d.ts +134 -0
- package/lib/harness/page-commands.js +1141 -0
- package/lib/harness/page-output.d.ts +67 -0
- package/lib/harness/page-output.js +139 -0
- package/lib/harness/page-watch.d.ts +171 -0
- package/lib/harness/page-watch.js +607 -0
- package/lib/harness/parse-check-node.d.ts +3 -0
- package/lib/harness/parse-check-node.js +132 -0
- package/lib/harness/path-args.d.ts +22 -0
- package/lib/harness/path-args.js +148 -0
- package/lib/harness/playwright-chromium.d.ts +16 -0
- package/lib/harness/playwright-chromium.js +86 -0
- package/lib/harness/prepare.d.ts +168 -0
- package/lib/harness/prepare.js +364 -0
- package/lib/harness/prompt-editor.d.ts +17 -0
- package/lib/harness/prompt-editor.js +354 -0
- package/lib/harness/prompt-history.d.ts +13 -0
- package/lib/harness/prompt-history.js +89 -0
- package/lib/harness/rebuild-advice.d.ts +6 -0
- package/lib/harness/rebuild-advice.js +39 -0
- package/lib/harness/run-log.d.ts +47 -0
- package/lib/harness/run-log.js +140 -0
- package/lib/harness/run-logs.d.ts +132 -0
- package/lib/harness/run-logs.js +241 -0
- package/lib/harness/run.d.ts +194 -0
- package/lib/harness/run.js +1973 -0
- package/lib/harness/runs.d.ts +229 -0
- package/lib/harness/runs.js +726 -0
- package/lib/harness/save-html.d.ts +39 -0
- package/lib/harness/save-html.js +89 -0
- package/lib/harness/saved-pages.d.ts +120 -0
- package/lib/harness/saved-pages.js +275 -0
- package/lib/harness/script-mark.d.ts +17 -0
- package/lib/harness/script-mark.js +56 -0
- package/lib/harness/snapshot.d.ts +143 -0
- package/lib/harness/snapshot.js +474 -0
- package/lib/harness/stuck-tabs.d.ts +19 -0
- package/lib/harness/stuck-tabs.js +131 -0
- package/lib/harness/tab-refs.d.ts +20 -0
- package/lib/harness/tab-refs.js +80 -0
- package/lib/harness/tampermonkey.d.ts +71 -0
- package/lib/harness/tampermonkey.js +356 -0
- package/lib/harness/tm-ids.d.ts +18 -0
- package/lib/harness/tm-ids.js +88 -0
- package/lib/harness/violentmonkey.d.ts +34 -0
- package/lib/harness/violentmonkey.js +162 -0
- package/lib/harness/wait.d.ts +77 -0
- package/lib/harness/wait.js +458 -0
- package/lib/http-read.d.ts +30 -0
- package/lib/http-read.js +142 -0
- package/lib/local-file-overrides.d.ts +35 -0
- package/lib/local-file-overrides.js +558 -0
- package/lib/manifest.json +38 -0
- package/lib/markup-response.d.ts +11 -0
- package/lib/markup-response.js +126 -0
- package/lib/match-targets.d.ts +18 -0
- package/lib/match-targets.js +305 -0
- package/lib/menu-captions.d.ts +1 -0
- package/lib/menu-captions.js +22 -0
- package/lib/parse-check-protocol.d.ts +27 -0
- package/lib/parse-check-protocol.js +51 -0
- package/lib/parse-location.d.ts +12 -0
- package/lib/parse-location.js +171 -0
- package/lib/phase-timing.d.ts +12 -0
- package/lib/phase-timing.js +155 -0
- package/lib/public-suffixes.d.ts +1 -0
- package/lib/public-suffixes.js +6957 -0
- package/lib/run-context.d.ts +2 -0
- package/lib/run-context.js +31 -0
- package/lib/runtime-messages.d.ts +10 -0
- package/lib/runtime-messages.js +34 -0
- package/lib/sandboxed-page.d.ts +5 -0
- package/lib/sandboxed-page.js +35 -0
- package/lib/script-build.d.ts +111 -0
- package/lib/script-build.js +787 -0
- package/lib/script-display.d.ts +51 -0
- package/lib/script-display.js +383 -0
- package/lib/script-icon.d.ts +16 -0
- package/lib/script-icon.js +43 -0
- package/lib/script-links.d.ts +24 -0
- package/lib/script-links.js +204 -0
- package/lib/script-source-url.d.ts +8 -0
- package/lib/script-source-url.js +214 -0
- package/lib/script-world.d.ts +6 -0
- package/lib/script-world.js +65 -0
- package/lib/sm-dev-protocol.d.ts +251 -0
- package/lib/sm-dev-protocol.js +84 -0
- package/lib/source-map.d.ts +32 -0
- package/lib/source-map.js +177 -0
- package/lib/stack-link.d.ts +15 -0
- package/lib/stack-link.js +138 -0
- package/lib/unsafe-window-use.d.ts +1 -0
- package/lib/unsafe-window-use.js +229 -0
- package/lib/url-match.d.ts +40 -0
- package/lib/url-match.js +734 -0
- package/lib/userscript-header.d.ts +33 -0
- package/lib/userscript-header.js +248 -0
- package/lib/version-compare.d.ts +6 -0
- package/lib/version-compare.js +141 -0
- package/lib/version-probe.d.ts +17 -0
- package/lib/version-probe.js +120 -0
- package/package.json +55 -3
- package/skills/SourceMonkey-dev/SKILL.md +868 -0
- package/skills/SourceMonkey-dev/references/cdp.md +293 -0
- package/skills/SourceMonkey-dev/references/commands.md +297 -0
- package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
- package/skills/SourceMonkey-dev/references/specs.md +46 -0
- package/skills/SourceMonkey-friction/SKILL.md +103 -0
- package/skills/SourceMonkey-friction/references/template.md +59 -0
|
@@ -0,0 +1,1261 @@
|
|
|
1
|
+
import { base64ToBytes, bytesToBase64 } from '../bytes.js';
|
|
2
|
+
import { fetchResourceBytes, isNotModified, resolveScriptResource } from '../file-loader.js';
|
|
3
|
+
import { presentLocalFileCandidates } from '../local-file-overrides.js';
|
|
4
|
+
import { MAX_SITE_ICONS, representativeMatches } from '../match-targets.js';
|
|
5
|
+
import { renderGlyph } from './glyph.js';
|
|
6
|
+
import { getValue } from '../userscript-header.js';
|
|
7
|
+
import { iconRefFromHeader, isIconContentType, MAX_ICON_BYTES } from '../script-icon.js';
|
|
8
|
+
import { assertWorkerContext, isPageContext } from '../run-context.js';
|
|
9
|
+
import { callWorker } from './worker-client.js';
|
|
10
|
+
// Site-icon (favicon) extraction + cache. Used by the collections page
|
|
11
|
+
// and userscript viewer to show a 16x16 icon per targeted site (up to
|
|
12
|
+
// MAX_SITE_ICONS distinct sites) next to each script.
|
|
13
|
+
//
|
|
14
|
+
// Two-tier lookup, so deep-targeted scripts (e.g. one userscript per
|
|
15
|
+
// section of a big site) each get their own icon instead of sharing
|
|
16
|
+
// the first one discovered for the origin:
|
|
17
|
+
// 1. page URL → Chrome `/_favicon/` DB → HTML discovery there
|
|
18
|
+
// (cached per page URL).
|
|
19
|
+
// 2. origin fallback → Chrome `/_favicon/` DB → origin-root HTML
|
|
20
|
+
// → /favicon.ico → Google S2 (cached per origin, shared across
|
|
21
|
+
// every script on that host).
|
|
22
|
+
// The page URL is each site's `linkUrl` from `representativeMatches` —
|
|
23
|
+
// the same URL that site's favicon click-through navigates to.
|
|
24
|
+
//
|
|
25
|
+
// Caches in chrome.storage.local:
|
|
26
|
+
// `pathIcon:<pageUrl>` — page-specific lookup result for that
|
|
27
|
+
// page. Null means "no icon on this page" — we fall back to
|
|
28
|
+
// the origin cache, not refetch the page.
|
|
29
|
+
// `siteIcon:<origin>` — origin-level fallback result (Chrome DB
|
|
30
|
+
// + root HTML + /favicon.ico + Google S2). Shared across all
|
|
31
|
+
// scripts on the origin.
|
|
32
|
+
// Success entries live 7d, null entries 24h so a transient outage
|
|
33
|
+
// doesn't permanently blank the row. The page-URL cache layer is
|
|
34
|
+
// skipped entirely when the page URL is just the origin root — the
|
|
35
|
+
// two would otherwise duplicate the same lookup.
|
|
36
|
+
//
|
|
37
|
+
// Reads go through `usableCacheHit`, which counts a row we wouldn't
|
|
38
|
+
// paint (or one written under an older discovery schema version) as a
|
|
39
|
+
// miss — so a row written by an older, laxer build heals on the next
|
|
40
|
+
// render instead of waiting out its TTL.
|
|
41
|
+
// Schema version for discovered icons (`siteIcon:` / `pathIcon:`).
|
|
42
|
+
// Bumped when a discovery bug (like accepting cross-host SSO — single
|
|
43
|
+
// sign-on — redirects)
|
|
44
|
+
// could have poisoned cached rows with another host's icon.
|
|
45
|
+
const DISCOVERED_ICON_CACHE_VERSION = 2;
|
|
46
|
+
const KEY_PREFIX = 'siteIcon:';
|
|
47
|
+
const siteIconKey = (origin) => `${KEY_PREFIX}${origin}`;
|
|
48
|
+
// Path-scoped cache for icons discovered by probing a non-root URL.
|
|
49
|
+
// Key is the full page URL (origin + `linkUrl` path) — each deep
|
|
50
|
+
// section gets its own entry so e.g. nytimes.com/puzzles/spelling-bee/
|
|
51
|
+
// can have a different icon from nytimes.com/athletic/. Hits here are
|
|
52
|
+
// strictly the HTML-discovery result; origin-level fallbacks live in
|
|
53
|
+
// `siteIcon:` so the /favicon.ico + S2 fetches happen at most once
|
|
54
|
+
// per host.
|
|
55
|
+
const PATH_ICON_KEY_PREFIX = 'pathIcon:';
|
|
56
|
+
const pathIconKey = (pageUrl) => `${PATH_ICON_KEY_PREFIX}${pageUrl}`;
|
|
57
|
+
// Separate cache prefix for icons declared via `@icon`/`@iconURL`/etc.
|
|
58
|
+
// Same `SiteIconEntry` shape and TTLs as the origin-discovered path; the
|
|
59
|
+
// prefix split keeps URL strings (which can be long) from colliding
|
|
60
|
+
// with origin strings and lets either path be invalidated independently.
|
|
61
|
+
const URL_ICON_KEY_PREFIX = 'iconUrl:';
|
|
62
|
+
const urlIconKey = (url) => `${URL_ICON_KEY_PREFIX}${url}`;
|
|
63
|
+
const SUCCESS_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
64
|
+
const NULL_TTL_MS = 24 * 60 * 60 * 1000;
|
|
65
|
+
const MAX_HTML_BYTES = 1024 * 1024;
|
|
66
|
+
// Cap on inline-`data:`-URL icons declared in `<link href="data:...">`.
|
|
67
|
+
// Base64 inflates by ~4/3 and the `data:image/...;base64,` prefix adds
|
|
68
|
+
// a few dozen chars; round up and add slack rather than guess.
|
|
69
|
+
const MAX_INLINE_DATA_URL_LEN = Math.ceil((MAX_ICON_BYTES * 4) / 3) + 64;
|
|
70
|
+
function isDataUrl(url) {
|
|
71
|
+
return /^data:/i.test(url);
|
|
72
|
+
}
|
|
73
|
+
// Does a `data:` URL actually carry bytes? `data:,` (empty) is the
|
|
74
|
+
// standard "this site has no favicon, stop asking" marker —
|
|
75
|
+
// example.com serves exactly that — and painting it yields a blank
|
|
76
|
+
// broken `<img>` where the globe placeholder belongs. A missing comma
|
|
77
|
+
// counts as empty too: that's not a well-formed `data:` URL at all.
|
|
78
|
+
// The one rule; every place an icon can enter or leave applies it.
|
|
79
|
+
function dataUrlHasPayload(url) {
|
|
80
|
+
const comma = url.indexOf(',');
|
|
81
|
+
return comma >= 0 && url.slice(comma + 1).trim() !== '';
|
|
82
|
+
}
|
|
83
|
+
// A `data:` URL is usable as an icon only when it's within the inline
|
|
84
|
+
// cap AND carries a payload. Shared by both entry points for an inline
|
|
85
|
+
// icon: the `<link href="data:...">` discovery path and a script's own
|
|
86
|
+
// `@icon data:...`. Both check the scheme before calling.
|
|
87
|
+
function usableDataUrl(url) {
|
|
88
|
+
if (url.length > MAX_INLINE_DATA_URL_LEN)
|
|
89
|
+
return null;
|
|
90
|
+
return dataUrlHasPayload(url) ? url : null;
|
|
91
|
+
}
|
|
92
|
+
// Would we put this stored string in an `<img src>`? Every icon we
|
|
93
|
+
// store is a payload-bearing `data:` URL, so anything else is a row
|
|
94
|
+
// this build didn't write — and the point of asking is to distrust
|
|
95
|
+
// rows written by other builds. Defaulting closed keeps an unknown
|
|
96
|
+
// shape (say a bare `https:` URL, which an extension page would fetch
|
|
97
|
+
// remotely and most likely have blocked) out of the DOM.
|
|
98
|
+
function paintableIcon(dataUrl) {
|
|
99
|
+
return isDataUrl(dataUrl) && dataUrlHasPayload(dataUrl);
|
|
100
|
+
}
|
|
101
|
+
// An `href="data:,"` is the page declaring it HAS no icon, not a
|
|
102
|
+
// candidate. Drop it before scoring, so a page offering both that
|
|
103
|
+
// marker and a real icon doesn't hand the win to the marker — `icon`
|
|
104
|
+
// outscores `apple-touch-icon`, so the marker would take it. Unlike
|
|
105
|
+
// `paintableIcon` this defaults open: most hrefs are ordinary paths,
|
|
106
|
+
// and only the marker shape is disqualifying.
|
|
107
|
+
function emptyDataHref(href) {
|
|
108
|
+
const h = href.trim();
|
|
109
|
+
return isDataUrl(h) && !dataUrlHasPayload(h);
|
|
110
|
+
}
|
|
111
|
+
// ---------- Storage I/O ----------
|
|
112
|
+
export async function loadSiteIcons(origins) {
|
|
113
|
+
const unique = Array.from(new Set(origins));
|
|
114
|
+
if (unique.length === 0)
|
|
115
|
+
return new Map();
|
|
116
|
+
const keys = unique.map(siteIconKey);
|
|
117
|
+
const got = await chrome.storage.local.get(keys);
|
|
118
|
+
const out = new Map();
|
|
119
|
+
for (const o of unique) {
|
|
120
|
+
const entry = got[siteIconKey(o)];
|
|
121
|
+
if (entry)
|
|
122
|
+
out.set(o, entry);
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
async function storeSiteIcon(origin, entry) {
|
|
127
|
+
assertWorkerContext('write storage');
|
|
128
|
+
await chrome.storage.local.set({
|
|
129
|
+
[siteIconKey(origin)]: { ...entry, v: DISCOVERED_ICON_CACHE_VERSION },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
async function loadPathIcon(pageUrl) {
|
|
133
|
+
const got = await chrome.storage.local.get(pathIconKey(pageUrl));
|
|
134
|
+
return got[pathIconKey(pageUrl)];
|
|
135
|
+
}
|
|
136
|
+
async function storePathIcon(pageUrl, entry) {
|
|
137
|
+
assertWorkerContext('write storage');
|
|
138
|
+
await chrome.storage.local.set({
|
|
139
|
+
[pathIconKey(pageUrl)]: { ...entry, v: DISCOVERED_ICON_CACHE_VERSION },
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
async function loadUrlIcon(url) {
|
|
143
|
+
const got = await chrome.storage.local.get(urlIconKey(url));
|
|
144
|
+
return got[urlIconKey(url)];
|
|
145
|
+
}
|
|
146
|
+
async function storeUrlIcon(url, entry) {
|
|
147
|
+
assertWorkerContext('write storage');
|
|
148
|
+
await chrome.storage.local.set({ [urlIconKey(url)]: entry });
|
|
149
|
+
}
|
|
150
|
+
export function isCacheFresh(entry, now = Date.now()) {
|
|
151
|
+
const ttl = entry.dataUrl !== null ? SUCCESS_TTL_MS : NULL_TTL_MS;
|
|
152
|
+
return now - entry.fetchedAt < ttl;
|
|
153
|
+
}
|
|
154
|
+
// Read a cache row as a settled answer, or null meaning "go fetch".
|
|
155
|
+
// Stale is a miss, and so is a row holding a dataUrl we would refuse
|
|
156
|
+
// to paint — a build predating the empty-`data:` check stored
|
|
157
|
+
// example.com's `data:,` marker as though it were an icon, and nothing
|
|
158
|
+
// but the 7-day success TTL would ever have cleared it. Treating it as
|
|
159
|
+
// a miss both hides it now and overwrites the row with the correct
|
|
160
|
+
// "no icon" on the refetch. `requireVersion` extends the same rule to
|
|
161
|
+
// rows written before a discovery-logic fix (e.g. rejecting cross-host
|
|
162
|
+
// SSO redirects).
|
|
163
|
+
export function usableCacheHit(entry, requireVersion, now = Date.now()) {
|
|
164
|
+
if (!entry || !isCacheFresh(entry, now))
|
|
165
|
+
return null;
|
|
166
|
+
if (requireVersion !== undefined && entry.v !== requireVersion)
|
|
167
|
+
return null;
|
|
168
|
+
if (entry.dataUrl !== null && !paintableIcon(entry.dataUrl))
|
|
169
|
+
return null;
|
|
170
|
+
return entry;
|
|
171
|
+
}
|
|
172
|
+
// ---------- Favicon fetch ----------
|
|
173
|
+
// Bumped by `resetIconFetchesForTests`. A fetch started before the bump
|
|
174
|
+
// returns its icon but doesn't store it: a test's reset would otherwise
|
|
175
|
+
// be followed by an icon an earlier test asked for, landing in storage
|
|
176
|
+
// partway through the next test.
|
|
177
|
+
let fetchEpoch = 0;
|
|
178
|
+
// For the e2e reset (`resetAllState` in background.ts): fetches already
|
|
179
|
+
// running won't store what they find, and later lookups start their own.
|
|
180
|
+
export function resetIconFetchesForTests() {
|
|
181
|
+
fetchEpoch++;
|
|
182
|
+
inflight.clear();
|
|
183
|
+
}
|
|
184
|
+
// Discover an icon for `pageUrl` specifically (Chrome's `/_favicon/` DB
|
|
185
|
+
// first, then HTML discovery at `pageUrl`). Cached under
|
|
186
|
+
// `pathIcon:<pageUrl>` so each deep-targeted script gets its own entry.
|
|
187
|
+
// A null result means "no usable icon on this page"; callers fall back
|
|
188
|
+
// to the origin-level cache rather than retrying.
|
|
189
|
+
async function fetchAndCachePathIcon(pageUrl) {
|
|
190
|
+
const epoch = fetchEpoch;
|
|
191
|
+
const found = from('favicon-store', await fetchChromeFavicon(pageUrl, { exactPage: true })) ??
|
|
192
|
+
from('page', await tryDiscoverIconViaHtml(pageUrl));
|
|
193
|
+
if (epoch === fetchEpoch)
|
|
194
|
+
await storePathIcon(pageUrl, foundEntry(found));
|
|
195
|
+
return found?.dataUrl ?? null;
|
|
196
|
+
}
|
|
197
|
+
function from(source, dataUrl) {
|
|
198
|
+
return dataUrl ? { dataUrl, source } : null;
|
|
199
|
+
}
|
|
200
|
+
function foundEntry(found) {
|
|
201
|
+
return found ? { dataUrl: found.dataUrl, fetchedAt: Date.now(), source: found.source } : { dataUrl: null, fetchedAt: Date.now() };
|
|
202
|
+
}
|
|
203
|
+
// Discover an icon for `origin` via the shared fallback chain
|
|
204
|
+
// (Chrome's `/_favicon/` DB → origin-root HTML → /favicon.ico → Google S2).
|
|
205
|
+
// Cached under `siteIcon:<origin>` and shared across every script on
|
|
206
|
+
// the host — the path-scoped lookup hits this when its own probe came
|
|
207
|
+
// up empty.
|
|
208
|
+
async function fetchAndCacheOriginIcon(origin) {
|
|
209
|
+
const epoch = fetchEpoch;
|
|
210
|
+
const found = await fetchOriginFallback(origin);
|
|
211
|
+
if (epoch === fetchEpoch)
|
|
212
|
+
await storeSiteIcon(origin, foundEntry(found));
|
|
213
|
+
return found?.dataUrl ?? null;
|
|
214
|
+
}
|
|
215
|
+
// Fetch a script-declared (`@icon`) URL and cache it under
|
|
216
|
+
// `iconUrl:<url>`. No discovery, no redirect policy: the script author
|
|
217
|
+
// named this URL, so wherever it lands is what they asked for.
|
|
218
|
+
async function fetchAndCacheUrlIcon(url) {
|
|
219
|
+
const epoch = fetchEpoch;
|
|
220
|
+
const dataUrl = await fetchIconAt(url);
|
|
221
|
+
if (epoch === fetchEpoch)
|
|
222
|
+
await storeUrlIcon(url, { dataUrl, fetchedAt: Date.now() });
|
|
223
|
+
return dataUrl;
|
|
224
|
+
}
|
|
225
|
+
async function fetchOriginFallback(origin) {
|
|
226
|
+
const fromChrome = await fetchChromeFavicon(origin + '/');
|
|
227
|
+
if (fromChrome)
|
|
228
|
+
return from('favicon-store', fromChrome);
|
|
229
|
+
const fromRoot = await tryDiscoverIconViaHtml(origin + '/');
|
|
230
|
+
if (fromRoot)
|
|
231
|
+
return from('page', fromRoot);
|
|
232
|
+
const fromIco = await fetchIconAt(`${origin}/favicon.ico`, { rejectCrossHostRedirect: true });
|
|
233
|
+
if (fromIco)
|
|
234
|
+
return from('favicon.ico', fromIco);
|
|
235
|
+
// Google S2 is the only leg that touches a third party. The host
|
|
236
|
+
// name is already public via the @match; the request still leaks
|
|
237
|
+
// "we couldn't find this site's icon ourselves" to Google. Reached
|
|
238
|
+
// only when every direct leg has failed.
|
|
239
|
+
return from('google', await fetchGoogleS2Icon(origin));
|
|
240
|
+
}
|
|
241
|
+
async function fetchGoogleS2Icon(origin) {
|
|
242
|
+
let host;
|
|
243
|
+
try {
|
|
244
|
+
host = new URL(origin).hostname;
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
if (!host)
|
|
250
|
+
return null;
|
|
251
|
+
// sz=64 is sharp at 16x16 even on 2x dpi displays.
|
|
252
|
+
const url = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(host)}&sz=64`;
|
|
253
|
+
return await fetchIconAt(url);
|
|
254
|
+
}
|
|
255
|
+
async function tryDiscoverIconViaHtml(pageUrl) {
|
|
256
|
+
const got = await fetchHtml(pageUrl);
|
|
257
|
+
if (!got)
|
|
258
|
+
return null;
|
|
259
|
+
const best = pickBestIconCandidate(parseIconLinks(got.text));
|
|
260
|
+
if (!best)
|
|
261
|
+
return null;
|
|
262
|
+
let absUrl;
|
|
263
|
+
try {
|
|
264
|
+
// Resolve against the post-redirect URL so same-host path / scheme
|
|
265
|
+
// / `www.` redirects resolve relative hrefs against where the page
|
|
266
|
+
// actually lives (`fetchHtml` already rejected cross-host redirects).
|
|
267
|
+
absUrl = new URL(best.href, got.finalUrl).toString();
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
if (absUrl.startsWith('data:'))
|
|
273
|
+
return usableDataUrl(absUrl);
|
|
274
|
+
return await fetchIconAt(absUrl, { rejectCrossHostRedirect: true });
|
|
275
|
+
}
|
|
276
|
+
// Pull out every `<link rel="...icon...">` tag from an HTML string.
|
|
277
|
+
// Scans only the `<head>` segment when one is delimited; otherwise the
|
|
278
|
+
// whole document. Tolerant of attribute ordering, unquoted values,
|
|
279
|
+
// and self-closing slashes.
|
|
280
|
+
export function parseIconLinks(html) {
|
|
281
|
+
const headEnd = html.search(/<\/head\s*>/i);
|
|
282
|
+
const region = headEnd >= 0 ? html.slice(0, headEnd) : html;
|
|
283
|
+
const out = [];
|
|
284
|
+
const linkRe = /<link\b([^>]*)>/gi;
|
|
285
|
+
let m;
|
|
286
|
+
while ((m = linkRe.exec(region)) !== null) {
|
|
287
|
+
const attrs = parseAttrs(m[1]);
|
|
288
|
+
const rel = (attrs.rel ?? '').toLowerCase();
|
|
289
|
+
if (!rel.includes('icon'))
|
|
290
|
+
continue;
|
|
291
|
+
if (!attrs.href)
|
|
292
|
+
continue;
|
|
293
|
+
out.push({
|
|
294
|
+
href: attrs.href,
|
|
295
|
+
rel,
|
|
296
|
+
sizes: attrs.sizes ?? null,
|
|
297
|
+
type: attrs.type ?? null,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
302
|
+
function parseAttrs(s) {
|
|
303
|
+
const out = {};
|
|
304
|
+
const re = /([a-zA-Z-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g;
|
|
305
|
+
let m;
|
|
306
|
+
while ((m = re.exec(s)) !== null) {
|
|
307
|
+
const value = m[2] ?? m[3] ?? m[4] ?? '';
|
|
308
|
+
out[m[1].toLowerCase()] = value;
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
// Pick the candidate most likely to look good at 16x16. Two kinds are
|
|
313
|
+
// dropped before scoring rather than ranked low: mask-icon (a Safari
|
|
314
|
+
// pinned-tab monochrome SVG mask, which renders as a black silhouette
|
|
315
|
+
// in <img>) and an empty `data:` href (see `emptyDataHref`).
|
|
316
|
+
export function pickBestIconCandidate(candidates) {
|
|
317
|
+
if (candidates.length === 0)
|
|
318
|
+
return null;
|
|
319
|
+
const viable = candidates.filter((c) => !c.rel.includes('mask-icon') && !emptyDataHref(c.href));
|
|
320
|
+
if (viable.length === 0)
|
|
321
|
+
return null;
|
|
322
|
+
return viable
|
|
323
|
+
.map((c) => ({ c, score: scoreIcon(c) }))
|
|
324
|
+
.sort((a, b) => b.score - a.score)[0].c;
|
|
325
|
+
}
|
|
326
|
+
function scoreIcon(c) {
|
|
327
|
+
let s = 0;
|
|
328
|
+
// Apple touch icons are usually 180px and padded with background
|
|
329
|
+
// colour — usable but a worse fit at 16x16 than a plain "icon".
|
|
330
|
+
s += c.rel.includes('apple-touch') ? 20 : 60;
|
|
331
|
+
const t = (c.type ?? '').toLowerCase();
|
|
332
|
+
if (t === 'image/png')
|
|
333
|
+
s += 20;
|
|
334
|
+
else if (t === 'image/svg+xml')
|
|
335
|
+
s += 15;
|
|
336
|
+
else if (t === 'image/x-icon' || t === 'image/vnd.microsoft.icon')
|
|
337
|
+
s += 10;
|
|
338
|
+
else if (t === 'image/jpeg' || t === 'image/webp')
|
|
339
|
+
s += 5;
|
|
340
|
+
const size = maxDeclaredSize(c.sizes);
|
|
341
|
+
if (size === null)
|
|
342
|
+
s += 3;
|
|
343
|
+
else if (size === Infinity)
|
|
344
|
+
s += 25; // sizes="any" → vector
|
|
345
|
+
else if (size >= 32 && size <= 64)
|
|
346
|
+
s += 30; // 16px @ 2x dpi target
|
|
347
|
+
else if (size >= 16 && size < 32)
|
|
348
|
+
s += 18;
|
|
349
|
+
else if (size > 64 && size <= 192)
|
|
350
|
+
s += 12;
|
|
351
|
+
else
|
|
352
|
+
s += 1;
|
|
353
|
+
return s;
|
|
354
|
+
}
|
|
355
|
+
// Read the largest `<width>x<height>` pair out of a `sizes` attribute,
|
|
356
|
+
// returning Infinity for the literal `any` (used by vector icons) and
|
|
357
|
+
// null when nothing parses.
|
|
358
|
+
export function maxDeclaredSize(sizes) {
|
|
359
|
+
if (!sizes)
|
|
360
|
+
return null;
|
|
361
|
+
const lc = sizes.toLowerCase().trim();
|
|
362
|
+
if (lc === 'any')
|
|
363
|
+
return Infinity;
|
|
364
|
+
let max = null;
|
|
365
|
+
for (const m of lc.matchAll(/(\d+)x(\d+)/g)) {
|
|
366
|
+
const n = Math.max(Number(m[1]), Number(m[2]));
|
|
367
|
+
if (max === null || n > max)
|
|
368
|
+
max = n;
|
|
369
|
+
}
|
|
370
|
+
return max;
|
|
371
|
+
}
|
|
372
|
+
// ---------- Icon + HTML retrieval ----------
|
|
373
|
+
//
|
|
374
|
+
// Both legs delegate to file-loader's `fetchResourceBytes`. The icon
|
|
375
|
+
// path supports http(s) and file:// uniformly (the latter via the
|
|
376
|
+
// page-context XHR fallback). HTML probing is http-only with a more
|
|
377
|
+
// permissive content-type gate. Size caps are enforced inside
|
|
378
|
+
// fetchResourceBytes against both Content-Length (when the server
|
|
379
|
+
// supplies it) and the buffered body.
|
|
380
|
+
function normalizeSiteHost(url) {
|
|
381
|
+
try {
|
|
382
|
+
return new URL(url).host.toLowerCase().replace(/^www\./, '');
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// True when a fetch that started at `requestedUrl` and ended at
|
|
389
|
+
// `finalUrl` stayed on the same host (allowing a leading `www.`
|
|
390
|
+
// difference; scheme may change, port may not). False when it bounced
|
|
391
|
+
// across hosts — e.g. to an SSO (single sign-on) login page
|
|
392
|
+
// (`login.corp.google.com`, `accounts.google.com`) or a marketing
|
|
393
|
+
// portal (`workspace.google.com`), whose `<link rel="icon">` belongs
|
|
394
|
+
// to that other host rather than the site we asked about.
|
|
395
|
+
//
|
|
396
|
+
// Deliberately host-level, not registrable-domain (eTLD+1): a looser
|
|
397
|
+
// check would re-admit `app.corp.google.com` → `login.corp.google.com`,
|
|
398
|
+
// which is the exact case this exists to reject. The cost is that a
|
|
399
|
+
// legitimate `example.com` → `app.example.com` bounce also misses,
|
|
400
|
+
// and that layer then falls through to the next strategy.
|
|
401
|
+
export function isSameHostRedirect(requestedUrl, finalUrl) {
|
|
402
|
+
const reqHost = normalizeSiteHost(requestedUrl);
|
|
403
|
+
const finHost = normalizeSiteHost(finalUrl);
|
|
404
|
+
return reqHost !== null && reqHost === finHost;
|
|
405
|
+
}
|
|
406
|
+
// ---------- Chrome `_favicon` database lookup ----------
|
|
407
|
+
//
|
|
408
|
+
// Chrome's Manifest V3 `favicon` permission exposes
|
|
409
|
+
// `chrome-extension://<id>/_favicon/`, backed by Chrome's internal
|
|
410
|
+
// favicon database (populated when the user visits pages in real tabs —
|
|
411
|
+
// after SSO authentication and after client-side JS updates
|
|
412
|
+
// `<link rel="icon">`).
|
|
413
|
+
//
|
|
414
|
+
// Quirk: `/_favicon/` never 404s when a URL is unknown to Chrome's DB;
|
|
415
|
+
// it returns 200 with Chrome's default globe PNG. We fetch a known-miss
|
|
416
|
+
// sentinel URL once per context to learn those exact bytes and reject
|
|
417
|
+
// any response that matches them. (The handler ignores
|
|
418
|
+
// `forceEmptyDefaultFavicon`, so there is no cleaner miss signal.)
|
|
419
|
+
//
|
|
420
|
+
// Chrome's `fallbackToHost` parameter defaults to on: an unvisited page
|
|
421
|
+
// URL then answers with the host's icon. The path-scoped tier turns it
|
|
422
|
+
// off so it only ever reports a page-specific hit — otherwise every
|
|
423
|
+
// deep URL on a host would cache the host icon and HTML discovery at
|
|
424
|
+
// that page would never run.
|
|
425
|
+
//
|
|
426
|
+
// Known gap: Chrome maps an icon onto every URL of the redirect chain
|
|
427
|
+
// that led to it, so a tab that bounced to SSO and never signed in
|
|
428
|
+
// leaves the SSO icon on the original URL. The DB tier then returns it
|
|
429
|
+
// and the 7-day TTL is the only way out (see TODO.md).
|
|
430
|
+
const CHROME_FAVICON_SIZE = 32;
|
|
431
|
+
const CHROME_FAVICON_MISS_SENTINEL = 'http://sm-favicon-miss-sentinel.invalid/';
|
|
432
|
+
let chromeFallbackGlobePromise = null;
|
|
433
|
+
export function chromeFaviconUrl(pageUrl, opts, size = CHROME_FAVICON_SIZE) {
|
|
434
|
+
const u = new URL(chrome.runtime.getURL('/_favicon/'));
|
|
435
|
+
u.searchParams.set('pageUrl', pageUrl);
|
|
436
|
+
u.searchParams.set('size', String(size));
|
|
437
|
+
if (opts?.exactPage)
|
|
438
|
+
u.searchParams.set('fallbackToHost', '0');
|
|
439
|
+
return u.toString();
|
|
440
|
+
}
|
|
441
|
+
async function fetchChromeFaviconRaw(pageUrl, opts) {
|
|
442
|
+
if (typeof chrome === 'undefined' || !chrome.runtime?.getURL)
|
|
443
|
+
return null;
|
|
444
|
+
let url;
|
|
445
|
+
try {
|
|
446
|
+
url = chromeFaviconUrl(pageUrl, opts);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
let res;
|
|
452
|
+
try {
|
|
453
|
+
res = await fetch(url);
|
|
454
|
+
}
|
|
455
|
+
catch {
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
if (!res.ok)
|
|
459
|
+
return null;
|
|
460
|
+
const mime = (res.headers.get('content-type') ?? '').split(';')[0].trim() || 'image/png';
|
|
461
|
+
let body;
|
|
462
|
+
try {
|
|
463
|
+
body = await res.arrayBuffer();
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
if (body.byteLength === 0 || body.byteLength > MAX_ICON_BYTES)
|
|
469
|
+
return null;
|
|
470
|
+
const dataUrl = arrayBufferToDataUrl(body, mime);
|
|
471
|
+
return paintableIcon(dataUrl) ? dataUrl : null;
|
|
472
|
+
}
|
|
473
|
+
function getChromeFallbackGlobe() {
|
|
474
|
+
if (!chromeFallbackGlobePromise) {
|
|
475
|
+
chromeFallbackGlobePromise = fetchChromeFaviconRaw(CHROME_FAVICON_MISS_SENTINEL).then((res) => {
|
|
476
|
+
if (!res)
|
|
477
|
+
chromeFallbackGlobePromise = null;
|
|
478
|
+
return res;
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return chromeFallbackGlobePromise;
|
|
482
|
+
}
|
|
483
|
+
export async function fetchChromeFavicon(pageUrl, opts) {
|
|
484
|
+
const [candidate, fallbackGlobe] = await Promise.all([
|
|
485
|
+
fetchChromeFaviconRaw(pageUrl, opts),
|
|
486
|
+
getChromeFallbackGlobe(),
|
|
487
|
+
]);
|
|
488
|
+
if (!candidate)
|
|
489
|
+
return null;
|
|
490
|
+
if (!fallbackGlobe || candidate === fallbackGlobe)
|
|
491
|
+
return null;
|
|
492
|
+
return candidate;
|
|
493
|
+
}
|
|
494
|
+
async function fetchHtml(url) {
|
|
495
|
+
const res = await fetchResourceBytes(url, {
|
|
496
|
+
maxBytes: MAX_HTML_BYTES,
|
|
497
|
+
acceptContentType: (ct) => ct.includes('html'),
|
|
498
|
+
});
|
|
499
|
+
// `isNotModified` can't fire — neither icon fetch passes `conditional`
|
|
500
|
+
// — but it is in the return type, and narrowing it here is cheaper
|
|
501
|
+
// than a cast that would go stale if one ever did.
|
|
502
|
+
if ('failed' in res || isNotModified(res))
|
|
503
|
+
return null;
|
|
504
|
+
if (!isSameHostRedirect(url, res.finalUrl))
|
|
505
|
+
return null;
|
|
506
|
+
// Decode as UTF-8. Real-world HTML in non-UTF-8 charsets is rare
|
|
507
|
+
// enough today that we accept the occasional mojibake — and even
|
|
508
|
+
// then the icon link tags are ASCII attributes that decode fine.
|
|
509
|
+
const text = new TextDecoder('utf-8', { fatal: false }).decode(res.body);
|
|
510
|
+
return { text, finalUrl: res.finalUrl };
|
|
511
|
+
}
|
|
512
|
+
// Scheme-aware icon fetch. Used by both the origin-discovery path
|
|
513
|
+
// (always http(s)) and the `@icon`-header path (any of http(s)/file://).
|
|
514
|
+
// Goes through file-loader's bounded byte fetcher so the page-context
|
|
515
|
+
// chrome-extension → file:// opaque-response trap is sidestepped.
|
|
516
|
+
async function fetchIconAt(url, opts) {
|
|
517
|
+
const res = await fetchResourceBytes(url, {
|
|
518
|
+
maxBytes: MAX_ICON_BYTES,
|
|
519
|
+
acceptContentType: isIconContentType,
|
|
520
|
+
});
|
|
521
|
+
// Icons fail quietly — a missing favicon is cosmetic, and there is
|
|
522
|
+
// no surface that would show a reason.
|
|
523
|
+
if ('failed' in res || isNotModified(res))
|
|
524
|
+
return null;
|
|
525
|
+
if (opts?.rejectCrossHostRedirect && !isSameHostRedirect(url, res.finalUrl)) {
|
|
526
|
+
return null;
|
|
527
|
+
}
|
|
528
|
+
// Strip any `; charset=…` parameter; default to image/x-icon when
|
|
529
|
+
// the server omitted Content-Type so the data URL still renders.
|
|
530
|
+
const mime = res.contentType.split(';')[0].trim() || 'image/x-icon';
|
|
531
|
+
const dataUrl = arrayBufferToDataUrl(res.body, mime);
|
|
532
|
+
// Keep the stored-icons-are-paintable invariant local. A zero-byte
|
|
533
|
+
// 200 would encode to a payload-less `data:<mime>;base64,`, which
|
|
534
|
+
// the read side rejects — so storing it would mean refetching on
|
|
535
|
+
// every single paint, forever, and silently. `fetchResourceBytes`
|
|
536
|
+
// already refuses empty bodies, but that lives in another module.
|
|
537
|
+
return paintableIcon(dataUrl) ? dataUrl : null;
|
|
538
|
+
}
|
|
539
|
+
function arrayBufferToDataUrl(buffer, mime) {
|
|
540
|
+
const bytes = new Uint8Array(buffer);
|
|
541
|
+
// Build the binary string in chunks; passing the whole array to
|
|
542
|
+
// String.fromCharCode blows the call stack for large icons.
|
|
543
|
+
let binary = '';
|
|
544
|
+
const CHUNK = 0x8000;
|
|
545
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
546
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
|
547
|
+
}
|
|
548
|
+
return `data:${mime};base64,${btoa(binary)}`;
|
|
549
|
+
}
|
|
550
|
+
// ---------- Higher-level helpers ----------
|
|
551
|
+
// In-flight de-dup: simultaneous calls for the same cache key share one
|
|
552
|
+
// Promise. Cache rows that go stale and trigger a refetch land here
|
|
553
|
+
// too — the second caller waits on the first. The map is keyed by the
|
|
554
|
+
// full storage key (e.g. `siteIcon:<origin>` vs `iconUrl:<url>`) so the
|
|
555
|
+
// two icon-collection paths don't collide.
|
|
556
|
+
const inflight = new Map();
|
|
557
|
+
// Answers already known on this page, by the same storage keys: a
|
|
558
|
+
// usable cache row read here, or a fetch finished here. Lets a slot
|
|
559
|
+
// whose icons are all known be drawn finished, with no globe first;
|
|
560
|
+
// see `renderSiteIconInto`. Page-lifetime, like the cache rows it
|
|
561
|
+
// mirrors, which last days.
|
|
562
|
+
const settled = new Map();
|
|
563
|
+
function remember(key, pending) {
|
|
564
|
+
return pending.then((dataUrl) => {
|
|
565
|
+
settled.set(key, dataUrl);
|
|
566
|
+
return dataUrl;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
// The page URL `ensureSiteIcon` probes before falling back to the
|
|
570
|
+
// origin, or null when it goes straight to the origin. The path-scoped
|
|
571
|
+
// layer only earns its keep when the probe is deeper than the origin
|
|
572
|
+
// root — otherwise it would just duplicate the origin-root HTML fetch
|
|
573
|
+
// the fallback chain already does.
|
|
574
|
+
function pathProbeUrl(origin, pageUrl) {
|
|
575
|
+
if (pageUrl === undefined || pageUrl === origin + '/' || pageUrl === origin)
|
|
576
|
+
return null;
|
|
577
|
+
return pageUrl;
|
|
578
|
+
}
|
|
579
|
+
export async function ensureSiteIcon(origin, pageUrl) {
|
|
580
|
+
const probe = pathProbeUrl(origin, pageUrl);
|
|
581
|
+
if (probe) {
|
|
582
|
+
const pathHit = await ensurePathIcon(probe);
|
|
583
|
+
if (pathHit !== null)
|
|
584
|
+
return pathHit;
|
|
585
|
+
}
|
|
586
|
+
return ensureOriginIcon(origin);
|
|
587
|
+
}
|
|
588
|
+
async function ensurePathIcon(pageUrl) {
|
|
589
|
+
const key = pathIconKey(pageUrl);
|
|
590
|
+
const pending = inflight.get(key);
|
|
591
|
+
if (pending)
|
|
592
|
+
return pending;
|
|
593
|
+
const cached = usableCacheHit(await loadPathIcon(pageUrl), DISCOVERED_ICON_CACHE_VERSION);
|
|
594
|
+
if (cached) {
|
|
595
|
+
settled.set(key, cached.dataUrl);
|
|
596
|
+
return cached.dataUrl;
|
|
597
|
+
}
|
|
598
|
+
// Re-check after the storage await: another caller may have started
|
|
599
|
+
// a fetch while we were reading.
|
|
600
|
+
const racing = inflight.get(key);
|
|
601
|
+
if (racing)
|
|
602
|
+
return racing;
|
|
603
|
+
const p = remember(key, fetchToCache(key)).finally(() => {
|
|
604
|
+
inflight.delete(key);
|
|
605
|
+
});
|
|
606
|
+
inflight.set(key, p);
|
|
607
|
+
return p;
|
|
608
|
+
}
|
|
609
|
+
async function ensureOriginIcon(origin) {
|
|
610
|
+
const key = siteIconKey(origin);
|
|
611
|
+
const pending = inflight.get(key);
|
|
612
|
+
if (pending)
|
|
613
|
+
return pending;
|
|
614
|
+
const cached = usableCacheHit((await loadSiteIcons([origin])).get(origin), DISCOVERED_ICON_CACHE_VERSION);
|
|
615
|
+
if (cached) {
|
|
616
|
+
settled.set(key, cached.dataUrl);
|
|
617
|
+
return cached.dataUrl;
|
|
618
|
+
}
|
|
619
|
+
const racing = inflight.get(key);
|
|
620
|
+
if (racing)
|
|
621
|
+
return racing;
|
|
622
|
+
const p = remember(key, fetchToCache(key)).finally(() => {
|
|
623
|
+
inflight.delete(key);
|
|
624
|
+
});
|
|
625
|
+
inflight.set(key, p);
|
|
626
|
+
return p;
|
|
627
|
+
}
|
|
628
|
+
// Resolve a script-declared icon URL (`@icon` / `@iconURL` / etc.).
|
|
629
|
+
// Inline `data:` URLs are returned directly when within the size cap;
|
|
630
|
+
// http(s) and file:// URLs go through the same bounded fetch + storage
|
|
631
|
+
// cache the origin-discovery path uses, keyed by the URL itself so
|
|
632
|
+
// multiple scripts sharing a URL share one fetch.
|
|
633
|
+
//
|
|
634
|
+
// `localRoots` opts this into the same local-override rule `@require`
|
|
635
|
+
// and `@resource` use — see docs/local-file-overrides.md. Without it
|
|
636
|
+
// (the default) nothing changes and no local reads happen. A script
|
|
637
|
+
// that declares its icon by absolute URL while developing locally would
|
|
638
|
+
// otherwise be the one file reference that ignored the copy on disk,
|
|
639
|
+
// which reads as a gap rather than a policy.
|
|
640
|
+
export async function ensureIconForUrl(url, localRoots = []) {
|
|
641
|
+
if (url.startsWith('data:'))
|
|
642
|
+
return usableDataUrl(url);
|
|
643
|
+
for (const candidate of await presentLocalFileCandidates(url, localRoots, iconRootListings)) {
|
|
644
|
+
const local = await localIconCandidate(candidate);
|
|
645
|
+
if (local)
|
|
646
|
+
return local;
|
|
647
|
+
}
|
|
648
|
+
return ensureIconAt(url);
|
|
649
|
+
}
|
|
650
|
+
// A local stand-in candidate, memoized for the life of the page rather
|
|
651
|
+
// than written to the storage cache.
|
|
652
|
+
//
|
|
653
|
+
// Deliberately NOT the storage cache: a miss there is remembered for 24
|
|
654
|
+
// hours, so an icon file created after the first paint wouldn't appear
|
|
655
|
+
// until the next day — the same "why isn't my edit showing up" trap the
|
|
656
|
+
// require path exists to avoid. A page-lifetime memo instead means the
|
|
657
|
+
// cost is one local read per icon per page load (repaints are free) and
|
|
658
|
+
// a reload always sees the current state of the disk.
|
|
659
|
+
//
|
|
660
|
+
// A candidate that isn't there, or isn't an image, just returns null and
|
|
661
|
+
// the caller falls through to the declared URL.
|
|
662
|
+
const localIconMemo = new Map();
|
|
663
|
+
// Root listings for the candidate gate, memoized for the life of the
|
|
664
|
+
// page for the same reason as `localIconMemo`: the page-lifetime memo
|
|
665
|
+
// already means a file created after first paint waits for a reload,
|
|
666
|
+
// so a listing of the same age hides nothing the memo didn't.
|
|
667
|
+
const iconRootListings = new Map();
|
|
668
|
+
function localIconCandidate(url) {
|
|
669
|
+
const memo = localIconMemo.get(url);
|
|
670
|
+
if (memo)
|
|
671
|
+
return memo;
|
|
672
|
+
const p = fetchIconAt(url);
|
|
673
|
+
localIconMemo.set(url, p);
|
|
674
|
+
return p;
|
|
675
|
+
}
|
|
676
|
+
async function ensureIconAt(url) {
|
|
677
|
+
const key = urlIconKey(url);
|
|
678
|
+
const pending = inflight.get(key);
|
|
679
|
+
if (pending)
|
|
680
|
+
return pending;
|
|
681
|
+
const cached = usableCacheHit(await loadUrlIcon(url));
|
|
682
|
+
if (cached) {
|
|
683
|
+
settled.set(key, cached.dataUrl);
|
|
684
|
+
return cached.dataUrl;
|
|
685
|
+
}
|
|
686
|
+
const racing = inflight.get(key);
|
|
687
|
+
if (racing)
|
|
688
|
+
return racing;
|
|
689
|
+
const p = remember(key, fetchToCache(key)).finally(() => {
|
|
690
|
+
inflight.delete(key);
|
|
691
|
+
});
|
|
692
|
+
inflight.set(key, p);
|
|
693
|
+
return p;
|
|
694
|
+
}
|
|
695
|
+
// ---------- Known answers ----------
|
|
696
|
+
// The icon `ensureSiteIcon` would give for a site, when it is already
|
|
697
|
+
// known on this page; undefined when it has to be looked up.
|
|
698
|
+
function settledSiteIcon(t) {
|
|
699
|
+
const probe = pathProbeUrl(t.origin, t.linkUrl);
|
|
700
|
+
if (probe) {
|
|
701
|
+
const pathHit = settled.get(pathIconKey(probe));
|
|
702
|
+
if (pathHit === undefined)
|
|
703
|
+
return undefined;
|
|
704
|
+
if (pathHit !== null)
|
|
705
|
+
return pathHit;
|
|
706
|
+
}
|
|
707
|
+
return settled.get(siteIconKey(t.origin));
|
|
708
|
+
}
|
|
709
|
+
// Same for `ensureIconForUrl`. Never known up front with local roots:
|
|
710
|
+
// a stand-in on disk is looked for first, and that is a read.
|
|
711
|
+
function settledUrlIcon(url, localRoots) {
|
|
712
|
+
if (url.startsWith('data:'))
|
|
713
|
+
return usableDataUrl(url);
|
|
714
|
+
if (localRoots.length > 0)
|
|
715
|
+
return undefined;
|
|
716
|
+
return settled.get(urlIconKey(url));
|
|
717
|
+
}
|
|
718
|
+
// The script's declared icon, resolved against its own URL, or null.
|
|
719
|
+
// The header value is raw (URL, data:, or relative path). Resolving it
|
|
720
|
+
// against the script's own URL lets `@icon foo.png` reach the sibling
|
|
721
|
+
// file alongside the script. A ref that climbs out of a LOCAL script's
|
|
722
|
+
// folder is rejected and drops back to origin discovery; from a remote
|
|
723
|
+
// script it resolves like any other URL.
|
|
724
|
+
function declaredIconUrl(script) {
|
|
725
|
+
const iconRef = iconRefFromHeader(script.header ?? null);
|
|
726
|
+
return iconRef ? resolveScriptResource(iconRef, script.fileUrl)?.url ?? null : null;
|
|
727
|
+
}
|
|
728
|
+
// Read every cached icon these scripts' slots will ask for, in one
|
|
729
|
+
// storage read, so the slots drawn next can show them straight away
|
|
730
|
+
// instead of a globe that changes a moment later. Only reads: a miss
|
|
731
|
+
// is left for the slot to fetch.
|
|
732
|
+
export async function preloadSiteIcons(scripts, limit = MAX_SITE_ICONS) {
|
|
733
|
+
const discovered = new Set();
|
|
734
|
+
const declared = new Set();
|
|
735
|
+
for (const script of scripts) {
|
|
736
|
+
const url = declaredIconUrl(script);
|
|
737
|
+
if (url) {
|
|
738
|
+
if (!url.startsWith('data:'))
|
|
739
|
+
declared.add(urlIconKey(url));
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
for (const t of representativeMatches(script.matching, limit)) {
|
|
743
|
+
const probe = pathProbeUrl(t.origin, t.linkUrl);
|
|
744
|
+
if (probe)
|
|
745
|
+
discovered.add(pathIconKey(probe));
|
|
746
|
+
discovered.add(siteIconKey(t.origin));
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const keys = [...discovered, ...declared].filter((k) => !settled.has(k));
|
|
750
|
+
if (keys.length === 0)
|
|
751
|
+
return;
|
|
752
|
+
const got = await chrome.storage.local.get(keys);
|
|
753
|
+
for (const key of keys) {
|
|
754
|
+
const entry = got[key];
|
|
755
|
+
const hit = usableCacheHit(entry, discovered.has(key) ? DISCOVERED_ICON_CACHE_VERSION : undefined);
|
|
756
|
+
if (hit)
|
|
757
|
+
settled.set(key, hit.dataUrl);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// How many sites a page heading shows — the viewer's and the compare
|
|
761
|
+
// page's. More than a collections row: the heading is the one place a
|
|
762
|
+
// script's whole target list is worth a glance, and nothing beside it
|
|
763
|
+
// needs to line up.
|
|
764
|
+
export const HEADING_SITE_ICONS = 10;
|
|
765
|
+
// Build the favicon slot for a script row / viewer header. Always
|
|
766
|
+
// renders something synchronously: one clickable `<a>` per distinct
|
|
767
|
+
// site the script's @match set names (opening that site's linkUrl in
|
|
768
|
+
// a new tab, at most `limit` of them — a table row has less room than
|
|
769
|
+
// a heading), or a single inert `<span>` when no pattern yields a
|
|
770
|
+
// usable origin (file:// scripts, `<all_urls>`, etc.). Every wrapper
|
|
771
|
+
// starts with a globe placeholder, swapped for the real favicon once
|
|
772
|
+
// it loads. Until every site's icon has loaded, only the first site's
|
|
773
|
+
// wrapper is in the slot. When every icon is already known on this
|
|
774
|
+
// page (`preloadSiteIcons`), the finished slot is drawn at once.
|
|
775
|
+
//
|
|
776
|
+
// Two sites that turn out to share one favicon image (maps.google.com
|
|
777
|
+
// and www.google.com, say) — or that both came up empty and show the
|
|
778
|
+
// globe — collapse to the first icon once both have loaded: the
|
|
779
|
+
// duplicate is removed and the survivor's tooltip lists every site it
|
|
780
|
+
// now stands for. Compared by the resolved data URL, which is the only
|
|
781
|
+
// way to know — the hosts differ.
|
|
782
|
+
//
|
|
783
|
+
// Icon collection priority: a script-declared `@icon` (or its aliases)
|
|
784
|
+
// wins outright over the origin-discovered favicons and collapses the
|
|
785
|
+
// slot to that one icon — a script that pins its icon has said what
|
|
786
|
+
// it wants to look like. Its click-through link is still the first
|
|
787
|
+
// site's linkUrl — the @icon header is only a visual override.
|
|
788
|
+
//
|
|
789
|
+
// Resolves, once every fetch has settled, with the number of icons the
|
|
790
|
+
// slot ended up holding.
|
|
791
|
+
export async function renderSiteIconInto(slot, script,
|
|
792
|
+
// Local override roots for a script-declared `@icon`, from the
|
|
793
|
+
// caller's `StoredCollection`. Passed in rather than derived here
|
|
794
|
+
// because the per-collection opt-out lives on the collection row,
|
|
795
|
+
// which this module has no reason to load. Omitted → no overrides, no
|
|
796
|
+
// local reads.
|
|
797
|
+
localRoots = [], limit = MAX_SITE_ICONS) {
|
|
798
|
+
const targets = representativeMatches(script.matching, limit);
|
|
799
|
+
const iconUrl = declaredIconUrl(script);
|
|
800
|
+
if (targets.length === 0) {
|
|
801
|
+
// No usable target URL for this script's @match set (file://,
|
|
802
|
+
// `<all_urls>`, malformed pattern, …). Surface that explicitly so
|
|
803
|
+
// hovering the inert globe isn't silent.
|
|
804
|
+
const inner = buildInner(slot, null);
|
|
805
|
+
slot.replaceChildren(inner);
|
|
806
|
+
if (iconUrl)
|
|
807
|
+
await paintDeclaredIcon(inner, iconUrl, localRoots);
|
|
808
|
+
return 1;
|
|
809
|
+
}
|
|
810
|
+
if (iconUrl) {
|
|
811
|
+
// This is the script's own icon, not a site's, so the tooltip leads
|
|
812
|
+
// with the script's name; every site it names follows — all of
|
|
813
|
+
// them, not just the `limit` a row would show.
|
|
814
|
+
const inner = buildInner(slot, targets[0]);
|
|
815
|
+
const sites = representativeMatches(script.matching, Infinity).map((t) => t.linkUrl);
|
|
816
|
+
const name = script.header ? getValue(script.header, 'name')?.trim() : undefined;
|
|
817
|
+
inner.title = name ? `${name}\n${sitesTooltip(sites)}` : sitesTooltip(sites);
|
|
818
|
+
slot.replaceChildren(inner);
|
|
819
|
+
await paintDeclaredIcon(inner, iconUrl, localRoots);
|
|
820
|
+
return 1;
|
|
821
|
+
}
|
|
822
|
+
const inners = targets.map((t) => buildInner(slot, t));
|
|
823
|
+
// Every icon already known on this page (`preloadSiteIcons`): draw
|
|
824
|
+
// the finished slot now, so it never changes width after it appears.
|
|
825
|
+
const known = targets.map(settledSiteIcon);
|
|
826
|
+
if (known.every((icon) => icon !== undefined)) {
|
|
827
|
+
const painted = known.map((icon, i) => showIcon(inners[i], icon ?? null));
|
|
828
|
+
return fillMergedSlot(slot, targets, inners, painted);
|
|
829
|
+
}
|
|
830
|
+
// Otherwise only the first site's placeholder is shown while the
|
|
831
|
+
// icons load: the others are globes too until then, and a row of
|
|
832
|
+
// identical globes that later collapses to one makes the layout
|
|
833
|
+
// jump. The slot widens once the icons turn out to differ.
|
|
834
|
+
slot.replaceChildren(inners[0]);
|
|
835
|
+
const painted = await Promise.all(targets.map((t, i) => paintIcon(inners[i], ensureSiteIcon(t.origin, t.linkUrl))));
|
|
836
|
+
// Skipped when a newer render has replaced the slot's contents, so a
|
|
837
|
+
// stale pass can't put its icons back.
|
|
838
|
+
if (inners[0].parentNode !== slot)
|
|
839
|
+
return slot.childElementCount;
|
|
840
|
+
return fillMergedSlot(slot, targets, inners, painted);
|
|
841
|
+
}
|
|
842
|
+
// Paint a declared `@icon` into `inner`: at once when this page already
|
|
843
|
+
// knows it, otherwise once it loads.
|
|
844
|
+
async function paintDeclaredIcon(inner, url, localRoots) {
|
|
845
|
+
const known = settledUrlIcon(url, localRoots);
|
|
846
|
+
if (known !== undefined)
|
|
847
|
+
showIcon(inner, known);
|
|
848
|
+
else
|
|
849
|
+
await paintIcon(inner, ensureIconForUrl(url, localRoots));
|
|
850
|
+
}
|
|
851
|
+
// Fill `slot` with one icon per distinct picture among the sites'
|
|
852
|
+
// painted icons, and return how many that is.
|
|
853
|
+
//
|
|
854
|
+
// Collapses sites whose favicon came back byte-identical — and sites
|
|
855
|
+
// that all ended on the globe, which look identical too; a row of
|
|
856
|
+
// three globes says nothing three times.
|
|
857
|
+
function fillMergedSlot(slot, targets, inners, painted) {
|
|
858
|
+
const merged = mergeSlot(painted);
|
|
859
|
+
for (const { sites } of merged) {
|
|
860
|
+
if (sites.length > 1)
|
|
861
|
+
inners[sites[0]].title = sitesTooltip(sites.map((i) => targets[i].linkUrl));
|
|
862
|
+
}
|
|
863
|
+
slot.replaceChildren(...merged.map(({ sites }) => inners[sites[0]]));
|
|
864
|
+
return merged.length;
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* The icons a slot shows for its sites' icons (`painted`, null for the
|
|
868
|
+
* globe), in the order shown: each as the indexes of the sites it
|
|
869
|
+
* stands for, its own first. Byte-identical icons are one, and so are
|
|
870
|
+
* globes.
|
|
871
|
+
*/
|
|
872
|
+
export function mergeSlot(painted) {
|
|
873
|
+
const byIcon = new Map();
|
|
874
|
+
painted.forEach((icon, i) => {
|
|
875
|
+
const sites = byIcon.get(icon ?? '');
|
|
876
|
+
if (sites)
|
|
877
|
+
sites.push(i);
|
|
878
|
+
else
|
|
879
|
+
byIcon.set(icon ?? '', [i]);
|
|
880
|
+
});
|
|
881
|
+
const kept = [...byIcon.values()];
|
|
882
|
+
// Real favicons before the globe: a globe says only "a site", so it
|
|
883
|
+
// goes last where it isn't in the way. The first site stays first
|
|
884
|
+
// even as a globe — it is the script's primary site, and the header
|
|
885
|
+
// order is what the reader expects to find there.
|
|
886
|
+
const shownFirst = (sites) => sites[0] === 0 || painted[sites[0]] !== null;
|
|
887
|
+
return [...kept.filter(shownFirst), ...kept.filter((sites) => !shownFirst(sites))].map((sites) => ({ sites }));
|
|
888
|
+
}
|
|
889
|
+
// Lines a merged icon's tooltip can show before it turns into a count.
|
|
890
|
+
const TOOLTIP_SITES_MAX = 10;
|
|
891
|
+
// Tooltip for an icon standing for several sites: one URL per line,
|
|
892
|
+
// then `(and N more)` once the list runs long — a fifty-site tooltip
|
|
893
|
+
// is a wall, not a hint.
|
|
894
|
+
export function sitesTooltip(urls) {
|
|
895
|
+
if (urls.length <= TOOLTIP_SITES_MAX)
|
|
896
|
+
return urls.join('\n');
|
|
897
|
+
const shown = urls.slice(0, TOOLTIP_SITES_MAX);
|
|
898
|
+
return `${shown.join('\n')}\n(and ${urls.length - TOOLTIP_SITES_MAX} more)`;
|
|
899
|
+
}
|
|
900
|
+
// The icon for ONE `@match` pattern, as the viewer's Match rows show
|
|
901
|
+
// beside each entry. Linked to `linkUrl` when the pattern gave us one
|
|
902
|
+
// (the site, or the same-directory fallback for a site-less pattern),
|
|
903
|
+
// inert otherwise. The favicon comes from `target`'s site; a linkable
|
|
904
|
+
// pattern that names no site (`*://*/x/page.html`) keeps the globe.
|
|
905
|
+
// `noLinkTitle` replaces the inert globe's default tooltip.
|
|
906
|
+
export function renderPatternIconInto(slot, target, linkUrl, noLinkTitle) {
|
|
907
|
+
const inner = buildInner(slot, linkUrl, noLinkTitle);
|
|
908
|
+
slot.replaceChildren(inner);
|
|
909
|
+
if (target)
|
|
910
|
+
void paintIcon(inner, ensureSiteIcon(target.origin, target.linkUrl));
|
|
911
|
+
}
|
|
912
|
+
// One icon's wrapper: a link when there is a URL to open, an inert
|
|
913
|
+
// span otherwise. Starts out holding the globe placeholder.
|
|
914
|
+
function buildInner(slot, target, noLinkTitle = 'Unable to extract link to target site') {
|
|
915
|
+
const linkUrl = typeof target === 'string' ? target : target?.linkUrl ?? null;
|
|
916
|
+
const inner = slot.ownerDocument.createElement(linkUrl ? 'a' : 'span');
|
|
917
|
+
inner.className = 'site-icon-inner';
|
|
918
|
+
inner.appendChild(buildGlobeSvg(slot.ownerDocument));
|
|
919
|
+
if (linkUrl) {
|
|
920
|
+
const a = inner;
|
|
921
|
+
a.href = linkUrl;
|
|
922
|
+
a.target = '_blank';
|
|
923
|
+
a.rel = 'noopener noreferrer';
|
|
924
|
+
a.title = linkUrl;
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
inner.title = noLinkTitle;
|
|
928
|
+
}
|
|
929
|
+
return inner;
|
|
930
|
+
}
|
|
931
|
+
// Swap the globe inside `inner` for the icon `pending` resolves to,
|
|
932
|
+
// and hand back that icon's data URL (null when there is none, or the
|
|
933
|
+
// fetch failed — the globe stays). No isConnected guard: a re-render
|
|
934
|
+
// replaces the whole slot, so any paint into an orphaned `inner`
|
|
935
|
+
// decorates a subtree the user never sees.
|
|
936
|
+
async function paintIcon(inner, pending) {
|
|
937
|
+
let dataUrl;
|
|
938
|
+
try {
|
|
939
|
+
dataUrl = await pending;
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
return showIcon(inner, dataUrl);
|
|
945
|
+
}
|
|
946
|
+
// Swap the globe inside `inner` for `dataUrl`'s picture, if there is
|
|
947
|
+
// one, and hand `dataUrl` back.
|
|
948
|
+
function showIcon(inner, dataUrl) {
|
|
949
|
+
if (!dataUrl)
|
|
950
|
+
return null;
|
|
951
|
+
const img = inner.ownerDocument.createElement('img');
|
|
952
|
+
img.src = iconImgSrc(dataUrl);
|
|
953
|
+
img.alt = '';
|
|
954
|
+
inner.replaceChildren(img);
|
|
955
|
+
return dataUrl;
|
|
956
|
+
}
|
|
957
|
+
// Per-document registry: one blob: URL per distinct large icon. With
|
|
958
|
+
// dozens of scripts targeting the same site, every row otherwise
|
|
959
|
+
// inlined its own copy of the favicon's base64 `data:` URL — a handful
|
|
960
|
+
// of distinct icons re-inlined across ~every row, which ballooned the
|
|
961
|
+
// collections page past half a megabyte. Pointing every row at a shared
|
|
962
|
+
// ~55-char blob: handle instead drops all that duplication out of the
|
|
963
|
+
// DOM; the bytes live once in the browser's blob table.
|
|
964
|
+
//
|
|
965
|
+
// Document-lifetime, never revoked: the set is bounded by the number of
|
|
966
|
+
// DISTINCT icons on the page (tiny), and not revoking keeps it
|
|
967
|
+
// bfcache-safe — a restored page's <img>s still resolve. This mirrors
|
|
968
|
+
// the GM_getResourceURL policy (see wrapper-gm-runtime.js). Keyed by the
|
|
969
|
+
// data: URL string, so a re-render reuses the existing blob for an
|
|
970
|
+
// unchanged icon and only mints one for genuinely new icons.
|
|
971
|
+
const iconObjectUrls = new Map();
|
|
972
|
+
// Icons whose `data:` URL is at or below this many chars stay inline —
|
|
973
|
+
// below it a blob: URL (plus its table entry) doesn't beat the data URL
|
|
974
|
+
// it would replace, and de-duping a handful of tiny bytes isn't worth
|
|
975
|
+
// it. Sits well above the favicons used in tests and well below
|
|
976
|
+
// real-world ones (smallest observed ≈ 1.2 KB).
|
|
977
|
+
const ICON_INLINE_MAX_LEN = 512;
|
|
978
|
+
// Map a cached icon `data:` URL to what we actually put in `<img src>`:
|
|
979
|
+
// large icons become a shared blob: URL (deduped by the data URL
|
|
980
|
+
// string); small ones pass through unchanged.
|
|
981
|
+
function iconImgSrc(dataUrl) {
|
|
982
|
+
if (dataUrl.length <= ICON_INLINE_MAX_LEN)
|
|
983
|
+
return dataUrl;
|
|
984
|
+
const existing = iconObjectUrls.get(dataUrl);
|
|
985
|
+
if (existing)
|
|
986
|
+
return existing;
|
|
987
|
+
const blob = dataUrlToBlob(dataUrl);
|
|
988
|
+
if (!blob)
|
|
989
|
+
return dataUrl; // unparseable → fall back to inline
|
|
990
|
+
const url = URL.createObjectURL(blob);
|
|
991
|
+
iconObjectUrls.set(dataUrl, url);
|
|
992
|
+
return url;
|
|
993
|
+
}
|
|
994
|
+
// Decode a `data:[<mime>][;base64],<payload>` URL into a Blob
|
|
995
|
+
// synchronously (no fetch round-trip) so the caller can swap `src`
|
|
996
|
+
// without an extra await. Handles base64 and plain (percent-encoded)
|
|
997
|
+
// payloads; returns null on anything it can't parse. The non-base64
|
|
998
|
+
// branch assumes a TEXT payload (it round-trips through a JS string, so
|
|
999
|
+
// raw binary would be UTF-8-mangled) — fine here because real binary
|
|
1000
|
+
// icon data URLs are always `;base64`; the only non-base64 icons we
|
|
1001
|
+
// see are inline SVG/text.
|
|
1002
|
+
export function dataUrlToBlob(dataUrl) {
|
|
1003
|
+
const content = dataUrlContent(dataUrl);
|
|
1004
|
+
return content ? new Blob([base64ToBytes(content.base64)], { type: content.contentType }) : null;
|
|
1005
|
+
}
|
|
1006
|
+
/** A `data:` URL's type and bytes, as base64; null for one that isn't well-formed. */
|
|
1007
|
+
export function dataUrlContent(dataUrl) {
|
|
1008
|
+
const comma = dataUrl.indexOf(',');
|
|
1009
|
+
if (!dataUrl.startsWith('data:') || comma < 0)
|
|
1010
|
+
return null;
|
|
1011
|
+
const meta = dataUrl.slice(5, comma); // e.g. "image/png;base64"
|
|
1012
|
+
const payload = dataUrl.slice(comma + 1);
|
|
1013
|
+
const isBase64 = /;base64$/i.test(meta);
|
|
1014
|
+
const contentType = (isBase64 ? meta.slice(0, -7) : meta).split(';')[0] || 'application/octet-stream';
|
|
1015
|
+
try {
|
|
1016
|
+
// Checked as base64 by decoding it.
|
|
1017
|
+
if (isBase64)
|
|
1018
|
+
return { contentType, base64: bytesToBase64(new Uint8Array(base64ToBytes(payload.trim()))) };
|
|
1019
|
+
return { contentType, base64: bytesToBase64(new TextEncoder().encode(decodeURIComponent(payload))) };
|
|
1020
|
+
}
|
|
1021
|
+
catch {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
// The globe placeholder shown until a favicon loads (and the permanent
|
|
1026
|
+
// icon for scripts with no resolvable origin). Rendered as a tiny
|
|
1027
|
+
// `<use>` of the shared `#sm-globe` <symbol> defined once per page, so
|
|
1028
|
+
// 100+ rows don't each inline the full circle+meridian path data. The
|
|
1029
|
+
// stroke/fill presentation lives in CSS on `.site-icon-globe`.
|
|
1030
|
+
function buildGlobeSvg(doc) {
|
|
1031
|
+
return renderGlyph(doc, '#sm-globe', 'site-icon-globe');
|
|
1032
|
+
}
|
|
1033
|
+
export function isIconCacheKey(key) {
|
|
1034
|
+
return (key.startsWith(KEY_PREFIX) ||
|
|
1035
|
+
key.startsWith(PATH_ICON_KEY_PREFIX) ||
|
|
1036
|
+
key.startsWith(URL_ICON_KEY_PREFIX));
|
|
1037
|
+
}
|
|
1038
|
+
export function parseCachedIconRow(storageKey, value, now = Date.now()) {
|
|
1039
|
+
let kind;
|
|
1040
|
+
let target;
|
|
1041
|
+
let requireVersion;
|
|
1042
|
+
if (storageKey.startsWith(KEY_PREFIX)) {
|
|
1043
|
+
kind = 'origin';
|
|
1044
|
+
target = storageKey.slice(KEY_PREFIX.length);
|
|
1045
|
+
requireVersion = DISCOVERED_ICON_CACHE_VERSION;
|
|
1046
|
+
}
|
|
1047
|
+
else if (storageKey.startsWith(PATH_ICON_KEY_PREFIX)) {
|
|
1048
|
+
kind = 'path';
|
|
1049
|
+
target = storageKey.slice(PATH_ICON_KEY_PREFIX.length);
|
|
1050
|
+
requireVersion = DISCOVERED_ICON_CACHE_VERSION;
|
|
1051
|
+
}
|
|
1052
|
+
else if (storageKey.startsWith(URL_ICON_KEY_PREFIX)) {
|
|
1053
|
+
kind = 'url';
|
|
1054
|
+
target = storageKey.slice(URL_ICON_KEY_PREFIX.length);
|
|
1055
|
+
requireVersion = undefined;
|
|
1056
|
+
}
|
|
1057
|
+
else {
|
|
1058
|
+
return null;
|
|
1059
|
+
}
|
|
1060
|
+
if (!value || typeof value !== 'object')
|
|
1061
|
+
return null;
|
|
1062
|
+
const raw = value;
|
|
1063
|
+
if (typeof raw.fetchedAt !== 'number')
|
|
1064
|
+
return null;
|
|
1065
|
+
const dataUrl = typeof raw.dataUrl === 'string' ? raw.dataUrl : null;
|
|
1066
|
+
const entry = {
|
|
1067
|
+
dataUrl,
|
|
1068
|
+
fetchedAt: raw.fetchedAt,
|
|
1069
|
+
...(typeof raw.v === 'number' ? { v: raw.v } : {}),
|
|
1070
|
+
};
|
|
1071
|
+
const ttl = entry.dataUrl !== null ? SUCCESS_TTL_MS : NULL_TTL_MS;
|
|
1072
|
+
const expiresAt = entry.fetchedAt + ttl;
|
|
1073
|
+
const isFresh = isCacheFresh(entry, now);
|
|
1074
|
+
const isUsable = usableCacheHit(entry, requireVersion, now) !== null;
|
|
1075
|
+
let mimeType = null;
|
|
1076
|
+
let byteLength = null;
|
|
1077
|
+
if (entry.dataUrl) {
|
|
1078
|
+
const blob = dataUrlToBlob(entry.dataUrl);
|
|
1079
|
+
if (blob) {
|
|
1080
|
+
mimeType = blob.type || null;
|
|
1081
|
+
byteLength = blob.size;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return {
|
|
1085
|
+
storageKey,
|
|
1086
|
+
kind,
|
|
1087
|
+
target,
|
|
1088
|
+
entry,
|
|
1089
|
+
expiresAt,
|
|
1090
|
+
isFresh,
|
|
1091
|
+
isUsable,
|
|
1092
|
+
requiredVersion: requireVersion ?? null,
|
|
1093
|
+
mimeType,
|
|
1094
|
+
byteLength,
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
export async function listCachedIcons(now = Date.now()) {
|
|
1098
|
+
const keys = (await chrome.storage.local.getKeys()).filter(isIconCacheKey);
|
|
1099
|
+
if (keys.length === 0)
|
|
1100
|
+
return [];
|
|
1101
|
+
const got = await chrome.storage.local.get(keys);
|
|
1102
|
+
const rows = [];
|
|
1103
|
+
for (const [k, v] of Object.entries(got)) {
|
|
1104
|
+
const row = parseCachedIconRow(k, v, now);
|
|
1105
|
+
if (row)
|
|
1106
|
+
rows.push(row);
|
|
1107
|
+
}
|
|
1108
|
+
rows.sort((a, b) => a.target.localeCompare(b.target) || a.storageKey.localeCompare(b.storageKey));
|
|
1109
|
+
return rows;
|
|
1110
|
+
}
|
|
1111
|
+
export async function deleteCachedIcon(storageKey) {
|
|
1112
|
+
assertWorkerContext('write storage');
|
|
1113
|
+
await chrome.storage.local.remove(storageKey);
|
|
1114
|
+
}
|
|
1115
|
+
export async function deleteAllCachedIcons() {
|
|
1116
|
+
assertWorkerContext('write storage');
|
|
1117
|
+
const keys = (await chrome.storage.local.getKeys()).filter(isIconCacheKey);
|
|
1118
|
+
if (keys.length > 0) {
|
|
1119
|
+
await chrome.storage.local.remove(keys);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
// Fetch the icon a cache key names and store it under that key, in the
|
|
1123
|
+
// worker, which alone writes storage (docs/worker-writes.md). A page
|
|
1124
|
+
// asks it, and keeps its own in-flight and settled maps for painting.
|
|
1125
|
+
function fetchToCache(storageKey) {
|
|
1126
|
+
if (isPageContext())
|
|
1127
|
+
return callWorker('ensureCachedIcon', storageKey);
|
|
1128
|
+
// Not `ensureCachedIcon`: the caller has already put this fetch in
|
|
1129
|
+
// `inflight` under the key, which `refreshCachedIcon` would then wait on.
|
|
1130
|
+
return fetchAndCacheByKey(storageKey) ?? Promise.resolve(null);
|
|
1131
|
+
}
|
|
1132
|
+
function fetchAndCacheByKey(storageKey) {
|
|
1133
|
+
let fetchIt = null;
|
|
1134
|
+
if (storageKey.startsWith(KEY_PREFIX)) {
|
|
1135
|
+
fetchIt = () => fetchAndCacheOriginIcon(storageKey.slice(KEY_PREFIX.length));
|
|
1136
|
+
}
|
|
1137
|
+
else if (storageKey.startsWith(PATH_ICON_KEY_PREFIX)) {
|
|
1138
|
+
fetchIt = () => fetchAndCachePathIcon(storageKey.slice(PATH_ICON_KEY_PREFIX.length));
|
|
1139
|
+
}
|
|
1140
|
+
else if (storageKey.startsWith(URL_ICON_KEY_PREFIX)) {
|
|
1141
|
+
fetchIt = () => fetchAndCacheUrlIcon(storageKey.slice(URL_ICON_KEY_PREFIX.length));
|
|
1142
|
+
}
|
|
1143
|
+
return fetchIt ? withFetchSlot(fetchIt) : null;
|
|
1144
|
+
}
|
|
1145
|
+
// At most this many icon fetches at once. A page on a cold cache asks
|
|
1146
|
+
// for every icon it shows, all at once.
|
|
1147
|
+
const MAX_ICON_FETCHES = 6;
|
|
1148
|
+
let iconFetches = 0;
|
|
1149
|
+
const iconFetchQueue = [];
|
|
1150
|
+
async function withFetchSlot(run) {
|
|
1151
|
+
if (iconFetches >= MAX_ICON_FETCHES)
|
|
1152
|
+
await new Promise((go) => iconFetchQueue.push(go));
|
|
1153
|
+
else
|
|
1154
|
+
iconFetches++;
|
|
1155
|
+
try {
|
|
1156
|
+
return await run();
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
// A waiter takes this slot over; with none, it is freed.
|
|
1160
|
+
const next = iconFetchQueue.shift();
|
|
1161
|
+
if (next)
|
|
1162
|
+
next();
|
|
1163
|
+
else
|
|
1164
|
+
iconFetches--;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
// Worker side of a page's lookup: the cached row when it is still
|
|
1168
|
+
// usable (another page may have just fetched it), else a fetch, shared
|
|
1169
|
+
// with one already in flight for the key.
|
|
1170
|
+
export async function ensureCachedIcon(storageKey) {
|
|
1171
|
+
const version = storageKey.startsWith(URL_ICON_KEY_PREFIX) ? undefined : DISCOVERED_ICON_CACHE_VERSION;
|
|
1172
|
+
const row = (await chrome.storage.local.get(storageKey))[storageKey];
|
|
1173
|
+
const cached = usableCacheHit(row, version);
|
|
1174
|
+
if (cached)
|
|
1175
|
+
return cached.dataUrl;
|
|
1176
|
+
return await refreshCachedIcon(storageKey);
|
|
1177
|
+
}
|
|
1178
|
+
// Worker side: refetch one cache row now, sharing a fetch already in
|
|
1179
|
+
// flight for it. Pages reach it through `fetchToCache`, and the debug
|
|
1180
|
+
// icons page through worker-client.ts.
|
|
1181
|
+
export async function refreshCachedIcon(storageKey) {
|
|
1182
|
+
const pending = inflight.get(storageKey);
|
|
1183
|
+
if (pending)
|
|
1184
|
+
return pending;
|
|
1185
|
+
const p = fetchAndCacheByKey(storageKey);
|
|
1186
|
+
if (!p)
|
|
1187
|
+
return null;
|
|
1188
|
+
const wrapped = p.finally(() => {
|
|
1189
|
+
inflight.delete(storageKey);
|
|
1190
|
+
});
|
|
1191
|
+
inflight.set(storageKey, wrapped);
|
|
1192
|
+
return wrapped;
|
|
1193
|
+
}
|
|
1194
|
+
// A usable cache row for `storageKey`; with `fetch`, looked up first
|
|
1195
|
+
// when there is none.
|
|
1196
|
+
async function iconRow(storageKey, fetch) {
|
|
1197
|
+
const version = storageKey.startsWith(URL_ICON_KEY_PREFIX) ? undefined : DISCOVERED_ICON_CACHE_VERSION;
|
|
1198
|
+
const read = async () => usableCacheHit((await chrome.storage.local.get(storageKey))[storageKey], version);
|
|
1199
|
+
const hit = await read();
|
|
1200
|
+
if (hit || !fetch)
|
|
1201
|
+
return hit;
|
|
1202
|
+
await refreshCachedIcon(storageKey);
|
|
1203
|
+
return read();
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Worker: the icons a page heading's slot shows for these `@match`
|
|
1207
|
+
* patterns, merged and in order, ignoring a declared `@icon`. Without
|
|
1208
|
+
* `fetch`, only from the cache: null when any isn't there.
|
|
1209
|
+
*/
|
|
1210
|
+
export async function siteIconsFor(matches, fetch) {
|
|
1211
|
+
const targets = representativeMatches({ matches }, HEADING_SITE_ICONS);
|
|
1212
|
+
const found = await Promise.all(targets.map(async (t) => {
|
|
1213
|
+
const probe = pathProbeUrl(t.origin, t.linkUrl);
|
|
1214
|
+
if (probe) {
|
|
1215
|
+
const page = await iconRow(pathIconKey(probe), fetch);
|
|
1216
|
+
if (!page)
|
|
1217
|
+
return null;
|
|
1218
|
+
if (page.dataUrl !== null)
|
|
1219
|
+
return { entry: page, fromPage: true };
|
|
1220
|
+
}
|
|
1221
|
+
const origin = await iconRow(siteIconKey(t.origin), fetch);
|
|
1222
|
+
return origin ? { entry: origin, fromPage: false } : null;
|
|
1223
|
+
}));
|
|
1224
|
+
if (found.some((f) => f === null))
|
|
1225
|
+
return null;
|
|
1226
|
+
const rows = found;
|
|
1227
|
+
return mergeSlot(rows.map((r) => r.entry.dataUrl)).map(({ sites }) => {
|
|
1228
|
+
const { entry, fromPage } = rows[sites[0]];
|
|
1229
|
+
return {
|
|
1230
|
+
sites: sites.map((i) => targets[i]),
|
|
1231
|
+
dataUrl: entry.dataUrl,
|
|
1232
|
+
...(entry.dataUrl !== null && entry.source ? { source: entry.source } : {}),
|
|
1233
|
+
fromPage,
|
|
1234
|
+
};
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
/**
|
|
1238
|
+
* Worker: the local stand-in for a declared http(s) icon, searched for
|
|
1239
|
+
* in `localRoots` as a page does (`ensureIconForUrl`), but read fresh
|
|
1240
|
+
* each time rather than kept for the page's life. Null for none.
|
|
1241
|
+
*/
|
|
1242
|
+
export async function localIconFor(url, localRoots) {
|
|
1243
|
+
for (const candidate of await presentLocalFileCandidates(url, localRoots, new Map())) {
|
|
1244
|
+
const dataUrl = await fetchIconAt(candidate);
|
|
1245
|
+
if (dataUrl)
|
|
1246
|
+
return { dataUrl, local: candidate };
|
|
1247
|
+
}
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
/**
|
|
1251
|
+
* Worker: a script's declared icon at `url`, as a `data:` URL; null
|
|
1252
|
+
* when it can't be read. `file:` URLs are read fresh, uncached, as the
|
|
1253
|
+
* file may have just changed. Leaves local stand-ins to `localIconFor`.
|
|
1254
|
+
*/
|
|
1255
|
+
export async function declaredIconFor(url) {
|
|
1256
|
+
if (isDataUrl(url))
|
|
1257
|
+
return usableDataUrl(url);
|
|
1258
|
+
if (url.startsWith('file:'))
|
|
1259
|
+
return fetchIconAt(url);
|
|
1260
|
+
return (await iconRow(urlIconKey(url), true))?.dataUrl ?? null;
|
|
1261
|
+
}
|