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,419 @@
1
+ // SW-side `GM_download` dispatcher.
2
+ //
3
+ // Wraps `chrome.downloads` so a userscript can save a file to the
4
+ // user's download directory. The page side keeps the caller's
5
+ // `onload` / `onerror` / `onprogress` / `ontimeout` callbacks (they
6
+ // aren't serializable) and we ship terminal + progress events back to
7
+ // the originating tab keyed by the page-generated `requestId`.
8
+ //
9
+ // Wire protocol:
10
+ //
11
+ // Page → SW: `sm:gm-download-start` { type, scriptId, requestId, ... }
12
+ // Page → SW: `sm:gm-download-abort` { type, requestId }
13
+ // SW → page: `sm:gm-download-event` { type, requestId, event }
14
+ //
15
+ // Only `http(s)` URLs reach this module. `blob:` / `data:` downloads
16
+ // are handled entirely page-side by the runtime (an `<a download>`
17
+ // click), because a page-minted `blob:` URL is not resolvable from the
18
+ // extension's own context and a `data:` URL of arbitrary size has no
19
+ // business crossing the message boundary. See docs/GM-APIs.md.
20
+ //
21
+ // Why `chrome.downloads` and not "fetch in the SW, then hand the bytes
22
+ // to an offscreen document": `chrome.downloads.download` already does
23
+ // the fetching, the Content-Disposition handling, the resume/retry, the
24
+ // progress accounting, the Save-As dialog and the Safe-Browsing danger
25
+ // prompt. Buffering a multi-GB video through the SW to rebuild a Blob
26
+ // elsewhere would burn memory for a strictly worse result.
27
+ import { dropEntriesForOriginator } from './gm-originator-map.js';
28
+ import { hasAllowedScheme, WEB_SCHEMES } from './gm-url-scheme.js';
29
+ const byRequestId = new Map();
30
+ const byDownloadId = new Map();
31
+ // How often we ask the download manager about an in-flight download.
32
+ // `chrome.downloads.onChanged` does NOT carry `bytesReceived` (the
33
+ // delta only reports state/filename/error/totalBytes changes), so
34
+ // progress has to be polled — and the same tick doubles as the
35
+ // terminal-state backstop and the service worker's keep-alive. 500 ms
36
+ // is fine for a progress bar and cheap enough to leave running.
37
+ const PROGRESS_POLL_MS = 500;
38
+ // Schemes we hand to the download manager. The URL comes from a
39
+ // page-supplied message, so without this gate a script could ask the
40
+ // privileged SW to pull `file:///etc/passwd` (or a `chrome-extension://`
41
+ // resource) onto disk under a name of its choosing. `blob:` / `data:`
42
+ // never reach here — the page-side runtime handles those itself.
43
+ export function isAllowedDownloadUrl(url) {
44
+ return hasAllowedScheme(url, WEB_SCHEMES);
45
+ }
46
+ // The `details.message` a script sees for a URL `isAllowedDownloadUrl`
47
+ // rejects.
48
+ export function downloadUrlRefusedMessage(url) {
49
+ return `refusing to download non-http(s) URL: ${url}`;
50
+ }
51
+ // Validate the caller's `name` and turn it into the `filename` we pass
52
+ // to `chrome.downloads.download`.
53
+ //
54
+ // Chrome already rejects absolute paths and `..` traversal, but it
55
+ // does so by throwing a generic error; validating here lets the script
56
+ // see a clean `not_permitted` instead of a `not_succeeded` with an
57
+ // opaque message, and keeps the rule in one testable place.
58
+ //
59
+ // Relative subdirectories ARE allowed (`pics/cat.png`) — Chrome
60
+ // resolves them under the user's download directory and TM allows the
61
+ // same. Backslashes are normalized to `/` first so a Windows-style
62
+ // name doesn't slip a segment past the `..` check.
63
+ export function validateDownloadName(name) {
64
+ const raw = (name ?? '').trim();
65
+ if (raw === '')
66
+ return { ok: true };
67
+ const normalized = raw.replace(/\\/g, '/');
68
+ if (/[\u0000-\u001f\u007f]/.test(normalized)) {
69
+ return { ok: false, reason: 'name contains control characters' };
70
+ }
71
+ if (normalized.startsWith('/')) {
72
+ return { ok: false, reason: 'name must be a relative path' };
73
+ }
74
+ if (/^[A-Za-z]:\//.test(normalized)) {
75
+ return { ok: false, reason: 'name must be a relative path' };
76
+ }
77
+ if (normalized.endsWith('/')) {
78
+ return { ok: false, reason: 'name must not be a directory' };
79
+ }
80
+ const segments = normalized.split('/');
81
+ if (segments.some((s) => s === '..')) {
82
+ return { ok: false, reason: 'name must not traverse out of the download directory' };
83
+ }
84
+ if (segments.some((s) => s === '')) {
85
+ return { ok: false, reason: 'name has an empty path segment' };
86
+ }
87
+ return { ok: true, filename: normalized };
88
+ }
89
+ // chrome.downloads.download in the Promise form the installed
90
+ // @types/chrome doesn't declare consistently across versions. Wrap the
91
+ // callback form and surface chrome.runtime.lastError as a rejection —
92
+ // same shape as gm-notification.ts's create wrapper.
93
+ function downloadViaChrome(options) {
94
+ return new Promise((resolve, reject) => {
95
+ try {
96
+ chrome.downloads.download(options, (downloadId) => {
97
+ const lastError = chrome.runtime.lastError;
98
+ if (lastError || downloadId === undefined) {
99
+ reject(new Error(lastError?.message ?? 'downloads.download failed'));
100
+ return;
101
+ }
102
+ resolve(downloadId);
103
+ });
104
+ }
105
+ catch (e) {
106
+ reject(e);
107
+ }
108
+ });
109
+ }
110
+ // Kick off a download. Resolves with the assigned download id, or with
111
+ // an error code the caller ships back to the page.
112
+ export async function startGmDownload(payload, originatorTabId, emit) {
113
+ if (!chrome.downloads) {
114
+ // The `downloads` permission is declared in the manifest, so this
115
+ // only happens if a future build makes it optional and the user
116
+ // hasn't granted it. TM uses `not_permitted` for exactly that case.
117
+ return {
118
+ ok: false,
119
+ error: 'not_permitted',
120
+ details: { message: 'chrome.downloads unavailable (missing downloads permission)' },
121
+ };
122
+ }
123
+ if (!isAllowedDownloadUrl(payload.url)) {
124
+ return {
125
+ ok: false,
126
+ error: 'not_permitted',
127
+ details: { message: downloadUrlRefusedMessage(payload.url) },
128
+ };
129
+ }
130
+ const nameCheck = validateDownloadName(payload.name);
131
+ if (!nameCheck.ok) {
132
+ return { ok: false, error: 'not_permitted', details: { message: nameCheck.reason } };
133
+ }
134
+ const entry = {
135
+ originatorTabId,
136
+ url: payload.url,
137
+ downloadId: null,
138
+ cancelRequested: false,
139
+ emit,
140
+ wantsProgress: false,
141
+ timeoutTimer: null,
142
+ progressTimer: null,
143
+ };
144
+ // Register before awaiting so an abort() that arrives while
145
+ // chrome.downloads.download is still resolving finds the entry.
146
+ byRequestId.set(payload.requestId, entry);
147
+ // Arm the timeout BEFORE creating the download, not after. Chrome
148
+ // doesn't hand back a download id until the server has answered, so
149
+ // a timer armed after the await could never fire for the case that
150
+ // needs it most — a host that accepts the connection and then sits
151
+ // there. `timeoutFired` below is what keeps it from double-settling.
152
+ if (payload.timeout > 0) {
153
+ entry.timeoutTimer = setTimeout(() => {
154
+ const live = byRequestId.get(payload.requestId);
155
+ if (!live)
156
+ return;
157
+ if (live.downloadId == null) {
158
+ // No id yet: tell the page now and leave the entry for the
159
+ // start path, which cancels the download the moment it exists
160
+ // (same handoff as `cancelRequested`).
161
+ live.cancelRequested = true;
162
+ live.emit({ kind: 'timeout' });
163
+ return;
164
+ }
165
+ // Drop the entry before cancelling so the `interrupted` delta
166
+ // that follows finds nothing — the script sees exactly one
167
+ // terminal event.
168
+ releaseEntry(payload.requestId);
169
+ void cancelViaChrome(live.downloadId);
170
+ live.emit({ kind: 'timeout' });
171
+ }, payload.timeout);
172
+ }
173
+ const headers = headerPairs(payload.headers);
174
+ let downloadId;
175
+ try {
176
+ downloadId = await downloadViaChrome({
177
+ url: payload.url,
178
+ ...(nameCheck.filename ? { filename: nameCheck.filename } : {}),
179
+ saveAs: payload.saveAs,
180
+ // Chrome's default anyway, but pin it: a script that downloads
181
+ // 50 images named by the page shouldn't fail on the first
182
+ // collision.
183
+ conflictAction: 'uniquify',
184
+ ...(headers ? { headers } : {}),
185
+ });
186
+ }
187
+ catch (err) {
188
+ releaseEntry(payload.requestId);
189
+ return {
190
+ ok: false,
191
+ error: 'not_succeeded',
192
+ details: { message: err instanceof Error ? err.message : String(err) },
193
+ };
194
+ }
195
+ // The request was abandoned while we were starting — the page called
196
+ // abort() (it settled its own callbacks locally) or our timeout fired
197
+ // (it emitted `timeout` already). Either way: cancel the download we
198
+ // just created and report nothing more.
199
+ if (entry.cancelRequested) {
200
+ releaseEntry(payload.requestId);
201
+ await cancelViaChrome(downloadId);
202
+ return { ok: false, error: 'download_canceled', details: {} };
203
+ }
204
+ // The originating tab closed while we were starting: `dropOriginator`
205
+ // already swept the entry, so re-registering it here would leave a
206
+ // poll timer nobody can ever clear. The transfer itself keeps going
207
+ // (a download the user started is theirs to keep) — we simply stop
208
+ // tracking it. Same window `gm-tab.ts` guards after `tabs.create`.
209
+ if (byRequestId.get(payload.requestId) !== entry) {
210
+ return { ok: true, downloadId };
211
+ }
212
+ entry.downloadId = downloadId;
213
+ byDownloadId.set(downloadId, payload.requestId);
214
+ // Poll for as long as the download is in flight — unconditionally,
215
+ // not just when the caller wants progress. Two jobs beyond progress:
216
+ //
217
+ // - It is the backstop for the terminal event. A delta can be
218
+ // missed (it fires against the download id, which we only learn
219
+ // when `download()` resolves — a small or cached file CAN settle
220
+ // inside that window) and a script left without a terminal event
221
+ // stalls forever.
222
+ // - Each `chrome.downloads.search` is an API call, which keeps the
223
+ // service worker from idle-terminating (~30 s) out from under a
224
+ // minutes-long download. If the SW died, its in-memory maps would
225
+ // go with it and the completion delta would arrive with nothing
226
+ // to route it to.
227
+ //
228
+ // `wantsProgress` only gates whether progress EVENTS are emitted, so
229
+ // the failure modes don't differ between callers who passed an
230
+ // `onprogress` and callers who didn't.
231
+ entry.wantsProgress = payload.wantsProgress;
232
+ entry.progressTimer = setInterval(() => {
233
+ void pollDownload(payload.requestId);
234
+ }, PROGRESS_POLL_MS);
235
+ // First poll immediately rather than one interval later, so a
236
+ // download that already settled reports without a 500 ms delay.
237
+ void pollDownload(payload.requestId);
238
+ return { ok: true, downloadId };
239
+ }
240
+ // Convert the caller's `{name: value}` header object into the
241
+ // `[{name, value}]` array chrome.downloads.download wants. Returns
242
+ // null when there's nothing to send.
243
+ function headerPairs(headers) {
244
+ if (!headers || typeof headers !== 'object')
245
+ return null;
246
+ const pairs = [];
247
+ for (const [name, value] of Object.entries(headers)) {
248
+ if (typeof name !== 'string' || name === '')
249
+ continue;
250
+ pairs.push({ name, value: String(value) });
251
+ }
252
+ return pairs.length > 0 ? pairs : null;
253
+ }
254
+ async function cancelViaChrome(downloadId) {
255
+ try {
256
+ await new Promise((resolve) => {
257
+ chrome.downloads.cancel(downloadId, () => {
258
+ // Reading lastError keeps Chrome from logging "Unchecked
259
+ // runtime.lastError" when the download already finished.
260
+ void chrome.runtime.lastError;
261
+ resolve();
262
+ });
263
+ });
264
+ }
265
+ catch { /* already gone */ }
266
+ }
267
+ // One download item by id, or undefined if the download manager
268
+ // doesn't know it (already erased, or a stubbed API in tests). Shared
269
+ // by the progress poll, the completion path, and the catch-up probe so
270
+ // the lastError handling can't drift.
271
+ async function searchOne(downloadId) {
272
+ try {
273
+ return await new Promise((resolve) => {
274
+ chrome.downloads.search({ id: downloadId }, (found) => {
275
+ void chrome.runtime.lastError;
276
+ resolve(found?.[0]);
277
+ });
278
+ });
279
+ }
280
+ catch {
281
+ return undefined;
282
+ }
283
+ }
284
+ // One poll tick for an in-flight download: settle it if the download
285
+ // manager says it's already done, otherwise report progress. See the
286
+ // call site in `startGmDownload` for why this runs unconditionally.
287
+ async function pollDownload(requestId) {
288
+ const entry = byRequestId.get(requestId);
289
+ if (!entry || entry.downloadId == null)
290
+ return;
291
+ const downloadId = entry.downloadId;
292
+ const item = await searchOne(downloadId);
293
+ // Re-check liveness: the search is async and the download may have
294
+ // settled through the delta (releasing the entry) while it was in
295
+ // flight. Whichever path gets here first wins; the loser finds
296
+ // nothing and emits nothing, so the script sees one terminal event.
297
+ if (byRequestId.get(requestId) !== entry)
298
+ return;
299
+ if (!item)
300
+ return;
301
+ if (item.state === 'complete' || item.state === 'interrupted') {
302
+ releaseEntry(requestId);
303
+ entry.emit(item.state === 'complete'
304
+ ? loadEventFor(entry, downloadId, item)
305
+ : {
306
+ kind: 'error',
307
+ error: 'not_succeeded',
308
+ details: item.error ? { current: item.error } : {},
309
+ });
310
+ return;
311
+ }
312
+ if (!entry.wantsProgress)
313
+ return;
314
+ const total = typeof item.totalBytes === 'number' ? item.totalBytes : 0;
315
+ entry.emit({
316
+ kind: 'progress',
317
+ loaded: typeof item.bytesReceived === 'number' ? item.bytesReceived : 0,
318
+ total,
319
+ lengthComputable: total > 0,
320
+ });
321
+ }
322
+ // Handle one `chrome.downloads.onChanged` delta. Terminal states emit
323
+ // the matching page event and free the entry; everything else (paused,
324
+ // filename determined, danger prompt) is ignored.
325
+ export function handleDownloadChanged(delta) {
326
+ const requestId = byDownloadId.get(delta.id);
327
+ if (!requestId)
328
+ return;
329
+ const entry = byRequestId.get(requestId);
330
+ if (!entry)
331
+ return;
332
+ const state = delta.state?.current;
333
+ if (state === 'complete') {
334
+ releaseEntry(requestId);
335
+ void emitComplete(entry, delta.id);
336
+ return;
337
+ }
338
+ if (state === 'interrupted') {
339
+ releaseEntry(requestId);
340
+ entry.emit({
341
+ kind: 'error',
342
+ error: 'not_succeeded',
343
+ details: {
344
+ // `delta.error` is Chrome's InterruptReason (NETWORK_FAILED,
345
+ // USER_CANCELED, FILE_ACCESS_DENIED, …). Passing it through as
346
+ // `{current, previous}` is what TM does and what real scripts
347
+ // branch on.
348
+ ...(delta.error?.current != null ? { current: delta.error.current } : {}),
349
+ ...(delta.error?.previous != null ? { previous: delta.error.previous } : {}),
350
+ },
351
+ });
352
+ }
353
+ }
354
+ // Fill in the completed download's filename / size before emitting
355
+ // `load`. The onChanged delta doesn't carry them reliably, so we ask
356
+ // the download manager. A failed lookup still emits `load` — the
357
+ // download DID complete and the script shouldn't be told otherwise.
358
+ async function emitComplete(entry, downloadId) {
359
+ const item = await searchOne(downloadId);
360
+ entry.emit(loadEventFor(entry, downloadId, item));
361
+ }
362
+ // The `load` event for a completed download. `item` is optional
363
+ // because the lookup can come back empty (download erased, stubbed
364
+ // API); the request URL is the fallback for `finalUrl`.
365
+ function loadEventFor(entry, downloadId, item) {
366
+ return {
367
+ kind: 'load',
368
+ downloadId,
369
+ filename: item?.filename ?? '',
370
+ finalUrl: item?.finalUrl ?? item?.url ?? entry.url,
371
+ totalBytes: typeof item?.totalBytes === 'number' ? item.totalBytes : 0,
372
+ };
373
+ }
374
+ // Cancel an in-flight download on the script's behalf. The page has
375
+ // already settled its own callbacks (matching `GM_xmlhttpRequest`'s
376
+ // synchronous abort), so we only tear down SW-side state here.
377
+ export async function abortGmDownload(requestId) {
378
+ const entry = byRequestId.get(requestId);
379
+ if (!entry)
380
+ return;
381
+ if (entry.downloadId == null) {
382
+ // The download hasn't been created yet; flag it so the start path
383
+ // cancels as soon as it has an id.
384
+ entry.cancelRequested = true;
385
+ return;
386
+ }
387
+ const downloadId = entry.downloadId;
388
+ releaseEntry(requestId);
389
+ await cancelViaChrome(downloadId);
390
+ }
391
+ // Timers + secondary index teardown for one entry. Shared by the
392
+ // single-request release path and the shed-on-tab-close sweep so the
393
+ // two can't drift.
394
+ function cleanupEntry(entry) {
395
+ if (entry.timeoutTimer != null)
396
+ clearTimeout(entry.timeoutTimer);
397
+ if (entry.progressTimer != null)
398
+ clearInterval(entry.progressTimer);
399
+ if (entry.downloadId != null)
400
+ byDownloadId.delete(entry.downloadId);
401
+ }
402
+ // Drop an entry and clear its timers. Terminal-event handlers call
403
+ // this BEFORE emitting so a callback that immediately starts another
404
+ // download can't collide with the entry it just settled.
405
+ function releaseEntry(requestId) {
406
+ const entry = byRequestId.get(requestId);
407
+ if (!entry)
408
+ return;
409
+ cleanupEntry(entry);
410
+ byRequestId.delete(requestId);
411
+ }
412
+ // Drop every request that belongs to a tab that just closed. Unlike
413
+ // GM_openInTab we do NOT cancel the transfers: a download the user
414
+ // started is theirs to keep even if the page that asked for it is
415
+ // gone. We just stop tracking it (nobody is left to route events to).
416
+ // Returns the dropped requestIds for logging.
417
+ export function dropOriginator(originatorTabId) {
418
+ return dropEntriesForOriginator(byRequestId, originatorTabId, cleanupEntry);
419
+ }
@@ -0,0 +1,228 @@
1
+ // SW-side `GM_registerMenuCommand` / `GM_unregisterMenuCommand` state.
2
+ //
3
+ // Per-tab, per-document, per-script registry of menu commands a
4
+ // userscript has asked us to expose. The wrapper holds the actual
5
+ // callback locally (callbacks aren't serializable across worlds); we
6
+ // only track enough to render the toolbar popup's command rows and
7
+ // route a click back to the document that registered it.
8
+ //
9
+ // Lifecycle:
10
+ // - Registered by the wrapper after `sm:start` via `sm:gm-menu-register`.
11
+ // - Cleared per-tab on a fresh-document navigation of the top frame
12
+ // (the new wrappers re-register from scratch) and on tab close.
13
+ // - A frame's commands go with its document (`pruneDocuments`).
14
+ // - SW restart wipes this state. The wrapper's per-document callbacks
15
+ // also vanish on document teardown, so the symmetric drop is
16
+ // correct: the SW would have no way to route a fire event back to a
17
+ // callback that no longer exists.
18
+ //
19
+ // Every registration is kept. A script's caption shows once per tab, and
20
+ // picking it runs all its registrations (`visibleCommands`,
21
+ // `menuCommandGroup`).
22
+ //
23
+ // Storage is in-memory only — same shape as `gm-tab.ts` / `gm-notification.ts`.
24
+ //
25
+ // A tab holds commands from several documents when scripts run in its
26
+ // iframes: the top frame's and each frame's. The popup merges them per
27
+ // script (docs/frames.md).
28
+ import { visibleCommands } from '../menu-captions.js';
29
+ // tabId → documentId → its commands, top frame first.
30
+ const byTab = new Map();
31
+ // Commands are per-page, so a register under a different top document
32
+ // than the one we're holding replaces the tab's state rather than
33
+ // adding to it — whichever of the register and the navigation clear
34
+ // arrives first, the loser is a no-op.
35
+ //
36
+ // Known window, same accepted class as the `chrome.tabs.onRemoved` one
37
+ // noted at the register call site: a message from a document that has
38
+ // already been torn down can in principle land after its successor has
39
+ // registered, and would replace the live page's commands. Nothing
40
+ // local can tell that apart from the ordinary case (both are "a
41
+ // different page"), and it needs a message to outlive a whole
42
+ // navigation plus the next document's `document-end`.
43
+ function ensureDoc(tabId, docId, topDocId) {
44
+ let docs = byTab.get(tabId);
45
+ if (docs && [...docs.values()].some((d) => d.topDocId !== topDocId))
46
+ docs = undefined;
47
+ if (!docs) {
48
+ docs = new Map();
49
+ byTab.set(tabId, docs);
50
+ }
51
+ let doc = docs.get(docId);
52
+ if (!doc) {
53
+ doc = { topDocId, scripts: new Map() };
54
+ // The top frame's commands list first.
55
+ if (docId === topDocId)
56
+ byTab.set(tabId, new Map([[docId, doc], ...docs]));
57
+ else
58
+ docs.set(docId, doc);
59
+ }
60
+ return doc.scripts;
61
+ }
62
+ // Register or update a menu command. TM/VM both treat `GM_registerMenuCommand`
63
+ // with an existing `id` (passed via the options object) as an update, so
64
+ // we mirror that by overwriting any entry with the same commandId.
65
+ //
66
+ // `topDocId` is the tab's top document; omitted, `docId` is the top
67
+ // frame's own.
68
+ //
69
+ // Returns true iff the visible state changed (new entry, or an update
70
+ // that changed `name`). Callers gate menu rebuilds on this so a noisy
71
+ // no-op register doesn't send an open popup a redraw.
72
+ export function registerMenuCommand(tabId, docId, scriptId, commandId, name, topDocId = docId) {
73
+ const scripts = ensureDoc(tabId, docId, topDocId);
74
+ const entry = { commandId, name, documentId: docId };
75
+ const list = scripts.get(scriptId);
76
+ if (!list) {
77
+ scripts.set(scriptId, [entry]);
78
+ return true;
79
+ }
80
+ const existing = list.findIndex((c) => c.commandId === commandId);
81
+ if (existing >= 0) {
82
+ if (list[existing].name === name)
83
+ return false;
84
+ list[existing] = entry;
85
+ return true;
86
+ }
87
+ list.push(entry);
88
+ return true;
89
+ }
90
+ // Remove a menu command. No-op if it isn't registered (matches TM —
91
+ // `GM_unregisterMenuCommand(<unknown id>)` is silent). Returns true
92
+ // iff a registration was removed, so callers can skip a wasted rebuild
93
+ // on unknown ids. (Removing a hidden older registration of a repeated
94
+ // caption also returns true: a redraw that changes nothing.)
95
+ //
96
+ // Scoped like the register side: an unregister reaches only its own
97
+ // document's entries. The same commandId can legitimately exist in two
98
+ // documents (an explicit `{id: '...'}`).
99
+ export function unregisterMenuCommand(tabId, docId, scriptId, commandId) {
100
+ const docs = byTab.get(tabId);
101
+ const scripts = docs?.get(docId)?.scripts;
102
+ if (!docs || !scripts)
103
+ return false;
104
+ const list = scripts.get(scriptId);
105
+ if (!list)
106
+ return false;
107
+ if (!list.some((c) => c.commandId === commandId))
108
+ return false;
109
+ const next = list.filter((c) => c.commandId !== commandId);
110
+ if (next.length === 0) {
111
+ scripts.delete(scriptId);
112
+ if (scripts.size === 0)
113
+ docs.delete(docId);
114
+ if (docs.size === 0)
115
+ byTab.delete(tabId);
116
+ }
117
+ else {
118
+ scripts.set(scriptId, next);
119
+ }
120
+ return true;
121
+ }
122
+ // Every registration in a tab, keyed by scriptId, in registration
123
+ // order: the top frame's first, then each frame's.
124
+ function allCommandsForTab(tabId) {
125
+ const merged = new Map();
126
+ for (const doc of byTab.get(tabId)?.values() ?? []) {
127
+ for (const [scriptId, list] of doc.scripts) {
128
+ const into = merged.get(scriptId);
129
+ if (into)
130
+ into.push(...list);
131
+ else
132
+ merged.set(scriptId, [...list]);
133
+ }
134
+ }
135
+ return merged;
136
+ }
137
+ // The commands a tab shows, keyed by scriptId: one per caption across
138
+ // all the tab's documents (see `visibleCommands`). Returns an empty map
139
+ // when nothing is registered (callers iterate it either way).
140
+ export function getMenuCommandsForTab(tabId) {
141
+ const shown = new Map();
142
+ for (const [scriptId, list] of allCommandsForTab(tabId)) {
143
+ shown.set(scriptId, visibleCommands(list, (c) => c.name));
144
+ }
145
+ return shown;
146
+ }
147
+ // The registrations picking `entry` runs: all of the script's commands
148
+ // in the tab with its caption, in any document, in registration order.
149
+ export function menuCommandGroup(tabId, scriptId, entry) {
150
+ return (allCommandsForTab(tabId).get(scriptId) ?? []).filter((c) => c.name === entry.name);
151
+ }
152
+ // Look up a single command — used by the popup's command handler to
153
+ // resolve a clicked item back to the originating document. Without
154
+ // `documentId`, the first document holding it, top frame first.
155
+ export function findMenuCommand(tabId, scriptId, commandId, documentId) {
156
+ for (const [docId, doc] of byTab.get(tabId) ?? []) {
157
+ if (documentId !== undefined && docId !== documentId)
158
+ continue;
159
+ const found = doc.scripts.get(scriptId)?.find((c) => c.commandId === commandId);
160
+ if (found)
161
+ return found;
162
+ }
163
+ return null;
164
+ }
165
+ // Drop every command for a tab. Called on `chrome.tabs.onRemoved`, on
166
+ // navigation to a page we don't inject into, and on fresh-document
167
+ // navigation (when `updateTabMatches` resets `actual` to `{}`). The
168
+ // next document's wrappers will re-register.
169
+ //
170
+ // `keepDocumentId` is for the navigation caller: it runs after several
171
+ // awaits, by which time the new page's wrappers may already have
172
+ // registered. Passing the top document being navigated TO makes the
173
+ // clear drop only state left over from an earlier page, so a wrapper
174
+ // that registered early (in the top frame or a frame of it) keeps its
175
+ // commands. Omit it to drop the tab's state unconditionally.
176
+ //
177
+ // It does NOT make the clear safe in general: leaving a document drops
178
+ // that document's commands, and a bfcache restore back to it does not
179
+ // re-register them (Chrome doesn't re-inject). See the known gap in
180
+ // GM-APIs.md.
181
+ //
182
+ // An empty `keepDocumentId` means the caller doesn't know which
183
+ // document it is navigating to, and an unknown document must not
184
+ // protect state: fall back to the unconditional clear rather than
185
+ // keeping commands we can't tie to anything.
186
+ //
187
+ // Mirror of the late-register window above: a clear naming an
188
+ // intermediate document can still delete a newer document's
189
+ // registrations if two navigations land inside one await window.
190
+ // Neither side can order documents from local state alone.
191
+ export function clearTab(tabId, keepDocumentId) {
192
+ const docs = byTab.get(tabId);
193
+ if (keepDocumentId && docs && [...docs.values()].every((d) => d.topDocId === keepDocumentId))
194
+ return;
195
+ byTab.delete(tabId);
196
+ }
197
+ // Drop the commands of a tab's frame documents that aren't in
198
+ // `liveDocIds` (the tab's frames now): the frame navigated or was
199
+ // removed. The top frame's are left to `clearTab`. Returns true iff
200
+ // something went.
201
+ export function pruneDocuments(tabId, liveDocIds) {
202
+ const docs = byTab.get(tabId);
203
+ if (!docs)
204
+ return false;
205
+ let changed = false;
206
+ for (const [docId, doc] of docs) {
207
+ if (docId === doc.topDocId || liveDocIds.has(docId))
208
+ continue;
209
+ docs.delete(docId);
210
+ changed = true;
211
+ }
212
+ if (docs.size === 0)
213
+ byTab.delete(tabId);
214
+ return changed;
215
+ }
216
+ // The top document the tab's commands belong to, or undefined when it
217
+ // holds none.
218
+ export function topDocument(tabId) {
219
+ return byTab.get(tabId)?.values().next().value?.topDocId;
220
+ }
221
+ // Whether the tab holds commands from any frame's document (as opposed
222
+ // to only the top frame's): `pruneDocuments` has something to check.
223
+ export function hasFrameDocuments(tabId) {
224
+ for (const [docId, doc] of byTab.get(tabId) ?? [])
225
+ if (docId !== doc.topDocId)
226
+ return true;
227
+ return false;
228
+ }