sourcemonkey 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/bytes.js +37 -0
- package/dist/channel-placeholder.js +5 -0
- package/dist/collection-scan.js +17 -0
- package/dist/collections.html +353 -0
- package/dist/compare.html +358 -0
- package/dist/composed-source.js +205 -0
- package/dist/connect-prompt.html +47 -0
- package/dist/debug/icons.html +211 -0
- package/dist/debug/storage.html +163 -0
- package/dist/debug-log.js +75 -0
- package/dist/diagnostic-location.js +64 -0
- package/dist/eval-hook.js +308 -0
- package/dist/extension/background.js +5263 -0
- package/dist/extension/channel.js +133 -0
- package/dist/extension/code-links.js +187 -0
- package/dist/extension/code-tabs.js +460 -0
- package/dist/extension/collection-dialog.js +619 -0
- package/dist/extension/collection-display.js +322 -0
- package/dist/extension/collections.js +2905 -0
- package/dist/extension/compare.js +867 -0
- package/dist/extension/concurrency.js +186 -0
- package/dist/extension/connect-grants.js +109 -0
- package/dist/extension/connect-prompt-page.js +70 -0
- package/dist/extension/connect-prompt.js +266 -0
- package/dist/extension/copy-button.js +63 -0
- package/dist/extension/copy-dialog.js +502 -0
- package/dist/extension/copy-preference.js +66 -0
- package/dist/extension/debug/icons.js +203 -0
- package/dist/extension/debug/storage.js +365 -0
- package/dist/extension/deep-link.js +77 -0
- package/dist/extension/delete-dialog.js +432 -0
- package/dist/extension/delete-icon.js +40 -0
- package/dist/extension/details-tooltips.js +66 -0
- package/dist/extension/directories.js +679 -0
- package/dist/extension/export-backup.js +407 -0
- package/dist/extension/export-dialog.js +142 -0
- package/dist/extension/file-access.js +74 -0
- package/dist/extension/file-changes.js +346 -0
- package/dist/extension/frame-info.js +85 -0
- package/dist/extension/github-provider.js +336 -0
- package/dist/extension/glyph.js +84 -0
- package/dist/extension/gm-bindings.js +601 -0
- package/dist/extension/gm-download.js +419 -0
- package/dist/extension/gm-menu.js +228 -0
- package/dist/extension/gm-notification.js +141 -0
- package/dist/extension/gm-originator-map.js +23 -0
- package/dist/extension/gm-tab-data.js +121 -0
- package/dist/extension/gm-tab.js +160 -0
- package/dist/extension/gm-url-change.js +111 -0
- package/dist/extension/gm-url-scheme.js +27 -0
- package/dist/extension/gm-values.js +176 -0
- package/dist/extension/gm-window-control.js +104 -0
- package/dist/extension/gm-xhr-headers.js +333 -0
- package/dist/extension/gm-xhr.js +584 -0
- package/dist/extension/injector.js +3145 -0
- package/dist/extension/install-capture.js +277 -0
- package/dist/extension/line-diff.js +233 -0
- package/dist/extension/local-copy.js +560 -0
- package/dist/extension/local-delete.js +276 -0
- package/dist/extension/location-kinds.js +108 -0
- package/dist/extension/log-format.js +158 -0
- package/dist/extension/managed-changes.js +361 -0
- package/dist/extension/managed-items.js +57 -0
- package/dist/extension/match-index.js +375 -0
- package/dist/extension/missing-scripts.js +66 -0
- package/dist/extension/more-menu.js +109 -0
- package/dist/extension/move-dialog.js +197 -0
- package/dist/extension/offscreen.js +37 -0
- package/dist/extension/options.js +504 -0
- package/dist/extension/page-heading.js +170 -0
- package/dist/extension/page-links.js +199 -0
- package/dist/extension/parse-check-client.js +70 -0
- package/dist/extension/parse-check.js +161 -0
- package/dist/extension/parse-sandbox-host.js +172 -0
- package/dist/extension/parse-sandbox.js +175 -0
- package/dist/extension/popup-commands-open.js +41 -0
- package/dist/extension/popup-view.js +387 -0
- package/dist/extension/popup.js +703 -0
- package/dist/extension/prism-userscript.js +53 -0
- package/dist/extension/refresh-error.js +136 -0
- package/dist/extension/refresh-targets.js +160 -0
- package/dist/extension/related-versions.js +1144 -0
- package/dist/extension/resize-observer-loop.js +19 -0
- package/dist/extension/row-cache.js +387 -0
- package/dist/extension/script-blobs.js +413 -0
- package/dist/extension/script-details.js +658 -0
- package/dist/extension/script-enablement.js +285 -0
- package/dist/extension/script-identity.js +310 -0
- package/dist/extension/script-keys.js +54 -0
- package/dist/extension/script-log-id.js +155 -0
- package/dist/extension/script-log.js +290 -0
- package/dist/extension/script-rejection.js +104 -0
- package/dist/extension/script-sites.js +158 -0
- package/dist/extension/script-store.js +3507 -0
- package/dist/extension/script-updater.js +991 -0
- package/dist/extension/script-url-hash.js +61 -0
- package/dist/extension/select-all.js +37 -0
- package/dist/extension/select-open-guard.js +90 -0
- package/dist/extension/site-icon.js +1261 -0
- package/dist/extension/sm-dev-link.js +302 -0
- package/dist/extension/solo-click.js +58 -0
- package/dist/extension/sticky-numbers.js +188 -0
- package/dist/extension/storage-breakdown.js +253 -0
- package/dist/extension/storage-scan.js +86 -0
- package/dist/extension/storage-usage.js +194 -0
- package/dist/extension/structural-keys.js +29 -0
- package/dist/extension/sw-teardown.js +36 -0
- package/dist/extension/tab-status.js +403 -0
- package/dist/extension/toolbar-summary.js +105 -0
- package/dist/extension/user-scripts-access.js +43 -0
- package/dist/extension/userscript.js +3672 -0
- package/dist/extension/value-store.js +204 -0
- package/dist/extension/version-probe-fetch.js +68 -0
- package/dist/extension/worker-client.js +109 -0
- package/dist/extension/worker-ops.js +171 -0
- package/dist/extension/zip-writer.js +24 -0
- package/dist/external-files.js +878 -0
- package/dist/file-loader.js +436 -0
- package/dist/file-locations.js +439 -0
- package/dist/generated-wrappers.js +5 -0
- package/dist/github-auth.js +148 -0
- package/dist/github-urls.js +46 -0
- package/dist/gm-api-catalog.js +217 -0
- package/dist/gm-connect.js +273 -0
- package/dist/gm-xhr-types.js +5 -0
- package/dist/http-read.js +142 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/local-file-overrides.js +558 -0
- package/dist/manifest.json +38 -0
- package/dist/markup-response.js +126 -0
- package/dist/match-targets.js +305 -0
- package/dist/menu-captions.js +22 -0
- package/dist/offscreen.html +39 -0
- package/dist/options.html +197 -0
- package/dist/parse-check-protocol.js +51 -0
- package/dist/parse-location.js +171 -0
- package/dist/parse-sandbox.html +56 -0
- package/dist/phase-timing.js +155 -0
- package/dist/popup.html +197 -0
- package/dist/public-suffixes.js +6957 -0
- package/dist/run-context.js +31 -0
- package/dist/runtime-messages.js +34 -0
- package/dist/sandboxed-page.js +35 -0
- package/dist/script-build.js +787 -0
- package/dist/script-display.js +383 -0
- package/dist/script-icon.js +43 -0
- package/dist/script-links.js +204 -0
- package/dist/script-source-url.js +214 -0
- package/dist/script-world.js +65 -0
- package/dist/sm-dev-protocol.js +84 -0
- package/dist/source-map.js +177 -0
- package/dist/stack-link.js +138 -0
- package/dist/styles.css +825 -0
- package/dist/unsafe-window-use.js +229 -0
- package/dist/url-match.js +734 -0
- package/dist/userscript-header.js +248 -0
- package/dist/userscript.html +1165 -0
- package/dist/vendor/fflate.LICENSE +21 -0
- package/dist/vendor/fflate.js +2692 -0
- package/dist/vendor/prism/prism-line-numbers.css +1 -0
- package/dist/vendor/prism/prism-line-numbers.js +1 -0
- package/dist/vendor/prism/prism.css +1 -0
- package/dist/vendor/prism/prism.js +1946 -0
- package/dist/version-compare.js +141 -0
- package/dist/version-probe.js +120 -0
- package/lib/build-info.json +1 -0
- package/lib/bytes.d.ts +3 -0
- package/lib/bytes.js +37 -0
- package/lib/channel-placeholder.d.ts +1 -0
- package/lib/channel-placeholder.js +5 -0
- package/lib/collection-scan.d.ts +8 -0
- package/lib/collection-scan.js +17 -0
- package/lib/composed-source.d.ts +28 -0
- package/lib/composed-source.js +205 -0
- package/lib/debug-log.d.ts +9 -0
- package/lib/debug-log.js +75 -0
- package/lib/diagnostic-location.d.ts +8 -0
- package/lib/diagnostic-location.js +64 -0
- package/lib/eval-hook.d.ts +34 -0
- package/lib/eval-hook.js +308 -0
- package/lib/external-files.d.ts +111 -0
- package/lib/external-files.js +878 -0
- package/lib/file-loader.d.ts +48 -0
- package/lib/file-loader.js +436 -0
- package/lib/file-locations.d.ts +44 -0
- package/lib/file-locations.js +439 -0
- package/lib/generated-wrappers.d.ts +11 -0
- package/lib/generated-wrappers.js +5 -0
- package/lib/github-auth.d.ts +12 -0
- package/lib/github-auth.js +148 -0
- package/lib/github-urls.d.ts +1 -0
- package/lib/github-urls.js +46 -0
- package/lib/gm-api-catalog.d.ts +24 -0
- package/lib/gm-api-catalog.js +217 -0
- package/lib/gm-connect.d.ts +34 -0
- package/lib/gm-connect.js +273 -0
- package/lib/gm-xhr-types.d.ts +68 -0
- package/lib/gm-xhr-types.js +5 -0
- package/lib/harness/browser-extensions.d.ts +73 -0
- package/lib/harness/browser-extensions.js +285 -0
- package/lib/harness/browser-lang.d.ts +20 -0
- package/lib/harness/browser-lang.js +86 -0
- package/lib/harness/browser-launch.d.ts +14 -0
- package/lib/harness/browser-launch.js +184 -0
- package/lib/harness/checks.d.ts +110 -0
- package/lib/harness/checks.js +321 -0
- package/lib/harness/cli.d.ts +2 -0
- package/lib/harness/cli.js +2259 -0
- package/lib/harness/command-args.d.ts +91 -0
- package/lib/harness/command-args.js +369 -0
- package/lib/harness/command-lines.d.ts +80 -0
- package/lib/harness/command-lines.js +180 -0
- package/lib/harness/command-list.d.ts +206 -0
- package/lib/harness/command-list.js +1475 -0
- package/lib/harness/console-commands.d.ts +463 -0
- package/lib/harness/console-commands.js +3216 -0
- package/lib/harness/download-watch.d.ts +21 -0
- package/lib/harness/download-watch.js +156 -0
- package/lib/harness/extension-backend.d.ts +41 -0
- package/lib/harness/extension-backend.js +100 -0
- package/lib/harness/extension-host.d.ts +112 -0
- package/lib/harness/extension-host.js +552 -0
- package/lib/harness/extension-launch.d.ts +67 -0
- package/lib/harness/extension-launch.js +285 -0
- package/lib/harness/extension-link.d.ts +315 -0
- package/lib/harness/extension-link.js +607 -0
- package/lib/harness/external-manager.d.ts +146 -0
- package/lib/harness/external-manager.js +414 -0
- package/lib/harness/fixture.d.ts +165 -0
- package/lib/harness/fixture.js +461 -0
- package/lib/harness/frame-sessions.d.ts +71 -0
- package/lib/harness/frame-sessions.js +248 -0
- package/lib/harness/frames.d.ts +68 -0
- package/lib/harness/frames.js +216 -0
- package/lib/harness/help-format.d.ts +17 -0
- package/lib/harness/help-format.js +127 -0
- package/lib/harness/host.d.ts +352 -0
- package/lib/harness/host.js +423 -0
- package/lib/harness/index.d.ts +8 -0
- package/lib/harness/index.js +10 -0
- package/lib/harness/input-file.d.ts +18 -0
- package/lib/harness/input-file.js +61 -0
- package/lib/harness/json-output.d.ts +565 -0
- package/lib/harness/json-output.js +71 -0
- package/lib/harness/manager-actions.d.ts +21 -0
- package/lib/harness/manager-actions.js +129 -0
- package/lib/harness/manager-commands.d.ts +142 -0
- package/lib/harness/manager-commands.js +1196 -0
- package/lib/harness/manager-fetch.d.ts +33 -0
- package/lib/harness/manager-fetch.js +239 -0
- package/lib/harness/managers.d.ts +24 -0
- package/lib/harness/managers.js +27 -0
- package/lib/harness/output-style.d.ts +125 -0
- package/lib/harness/output-style.js +322 -0
- package/lib/harness/page-commands.d.ts +134 -0
- package/lib/harness/page-commands.js +1141 -0
- package/lib/harness/page-output.d.ts +67 -0
- package/lib/harness/page-output.js +139 -0
- package/lib/harness/page-watch.d.ts +171 -0
- package/lib/harness/page-watch.js +607 -0
- package/lib/harness/parse-check-node.d.ts +3 -0
- package/lib/harness/parse-check-node.js +132 -0
- package/lib/harness/path-args.d.ts +22 -0
- package/lib/harness/path-args.js +148 -0
- package/lib/harness/playwright-chromium.d.ts +16 -0
- package/lib/harness/playwright-chromium.js +86 -0
- package/lib/harness/prepare.d.ts +168 -0
- package/lib/harness/prepare.js +364 -0
- package/lib/harness/prompt-editor.d.ts +17 -0
- package/lib/harness/prompt-editor.js +354 -0
- package/lib/harness/prompt-history.d.ts +13 -0
- package/lib/harness/prompt-history.js +89 -0
- package/lib/harness/rebuild-advice.d.ts +6 -0
- package/lib/harness/rebuild-advice.js +39 -0
- package/lib/harness/run-log.d.ts +47 -0
- package/lib/harness/run-log.js +140 -0
- package/lib/harness/run-logs.d.ts +132 -0
- package/lib/harness/run-logs.js +241 -0
- package/lib/harness/run.d.ts +194 -0
- package/lib/harness/run.js +1973 -0
- package/lib/harness/runs.d.ts +229 -0
- package/lib/harness/runs.js +726 -0
- package/lib/harness/save-html.d.ts +39 -0
- package/lib/harness/save-html.js +89 -0
- package/lib/harness/saved-pages.d.ts +120 -0
- package/lib/harness/saved-pages.js +275 -0
- package/lib/harness/script-mark.d.ts +17 -0
- package/lib/harness/script-mark.js +56 -0
- package/lib/harness/snapshot.d.ts +143 -0
- package/lib/harness/snapshot.js +474 -0
- package/lib/harness/stuck-tabs.d.ts +19 -0
- package/lib/harness/stuck-tabs.js +131 -0
- package/lib/harness/tab-refs.d.ts +20 -0
- package/lib/harness/tab-refs.js +80 -0
- package/lib/harness/tampermonkey.d.ts +71 -0
- package/lib/harness/tampermonkey.js +356 -0
- package/lib/harness/tm-ids.d.ts +18 -0
- package/lib/harness/tm-ids.js +88 -0
- package/lib/harness/violentmonkey.d.ts +34 -0
- package/lib/harness/violentmonkey.js +162 -0
- package/lib/harness/wait.d.ts +77 -0
- package/lib/harness/wait.js +458 -0
- package/lib/http-read.d.ts +30 -0
- package/lib/http-read.js +142 -0
- package/lib/local-file-overrides.d.ts +35 -0
- package/lib/local-file-overrides.js +558 -0
- package/lib/manifest.json +38 -0
- package/lib/markup-response.d.ts +11 -0
- package/lib/markup-response.js +126 -0
- package/lib/match-targets.d.ts +18 -0
- package/lib/match-targets.js +305 -0
- package/lib/menu-captions.d.ts +1 -0
- package/lib/menu-captions.js +22 -0
- package/lib/parse-check-protocol.d.ts +27 -0
- package/lib/parse-check-protocol.js +51 -0
- package/lib/parse-location.d.ts +12 -0
- package/lib/parse-location.js +171 -0
- package/lib/phase-timing.d.ts +12 -0
- package/lib/phase-timing.js +155 -0
- package/lib/public-suffixes.d.ts +1 -0
- package/lib/public-suffixes.js +6957 -0
- package/lib/run-context.d.ts +2 -0
- package/lib/run-context.js +31 -0
- package/lib/runtime-messages.d.ts +10 -0
- package/lib/runtime-messages.js +34 -0
- package/lib/sandboxed-page.d.ts +5 -0
- package/lib/sandboxed-page.js +35 -0
- package/lib/script-build.d.ts +111 -0
- package/lib/script-build.js +787 -0
- package/lib/script-display.d.ts +51 -0
- package/lib/script-display.js +383 -0
- package/lib/script-icon.d.ts +16 -0
- package/lib/script-icon.js +43 -0
- package/lib/script-links.d.ts +24 -0
- package/lib/script-links.js +204 -0
- package/lib/script-source-url.d.ts +8 -0
- package/lib/script-source-url.js +214 -0
- package/lib/script-world.d.ts +6 -0
- package/lib/script-world.js +65 -0
- package/lib/sm-dev-protocol.d.ts +251 -0
- package/lib/sm-dev-protocol.js +84 -0
- package/lib/source-map.d.ts +32 -0
- package/lib/source-map.js +177 -0
- package/lib/stack-link.d.ts +15 -0
- package/lib/stack-link.js +138 -0
- package/lib/unsafe-window-use.d.ts +1 -0
- package/lib/unsafe-window-use.js +229 -0
- package/lib/url-match.d.ts +40 -0
- package/lib/url-match.js +734 -0
- package/lib/userscript-header.d.ts +33 -0
- package/lib/userscript-header.js +248 -0
- package/lib/version-compare.d.ts +6 -0
- package/lib/version-compare.js +141 -0
- package/lib/version-probe.d.ts +17 -0
- package/lib/version-probe.js +120 -0
- package/package.json +55 -3
- package/skills/SourceMonkey-dev/SKILL.md +868 -0
- package/skills/SourceMonkey-dev/references/cdp.md +293 -0
- package/skills/SourceMonkey-dev/references/commands.md +297 -0
- package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
- package/skills/SourceMonkey-dev/references/specs.md +46 -0
- package/skills/SourceMonkey-friction/SKILL.md +103 -0
- package/skills/SourceMonkey-friction/references/template.md +59 -0
|
@@ -0,0 +1,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
|
+
}
|