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.
Files changed (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. 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
+ }