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,3145 @@
|
|
|
1
|
+
// Userscript injection via the chrome.userScripts API.
|
|
2
|
+
//
|
|
3
|
+
// Chrome owns the actual matching + injection at navigation time. Our
|
|
4
|
+
// job is to keep its registration set in sync with our "effectively
|
|
5
|
+
// enabled ok scripts" view of the world. Whenever the script-store
|
|
6
|
+
// cache changes we re-sync just the rows that moved, updating their
|
|
7
|
+
// registrations in place; a full rebuild happens only when this worker
|
|
8
|
+
// has no record of what it last registered: at startup, when it can't
|
|
9
|
+
// take the last worker's record from session storage.
|
|
10
|
+
// See docs/injection.md#lifecycle.
|
|
11
|
+
//
|
|
12
|
+
// ---------- Injection shape (concatenated user program) ----------
|
|
13
|
+
//
|
|
14
|
+
// Each registered userscript ships with a js[] array whose entries
|
|
15
|
+
// share an execution world (USER_SCRIPT or MAIN). All entries run in
|
|
16
|
+
// the same realm; they communicate through the world's registry
|
|
17
|
+
// (`reg`, which `document.__smGate` hands out for the channel key; see
|
|
18
|
+
// channel.ts). The layout per script:
|
|
19
|
+
//
|
|
20
|
+
// js[0] — per-script config {code}. Tiny JSON-literal call into
|
|
21
|
+
// the prelude (queues it if the prelude hasn't run yet on
|
|
22
|
+
// this navigation).
|
|
23
|
+
// js[1] — `{code: WRAPPER_PRELUDE}` — the static prelude source,
|
|
24
|
+
// inlined as a {code} blob in every script's registration.
|
|
25
|
+
// Originally tried as `{file: "userscript-prelude.js"}`
|
|
26
|
+
// but `{file}` entries load asynchronously in MAIN world
|
|
27
|
+
// (likely <script src=> under the hood), so subsequent
|
|
28
|
+
// entries would see `reg.run` undefined. The IIFE
|
|
29
|
+
// inside is idempotent — runs once per execution world,
|
|
30
|
+
// drains any queued configs on each entry so subsequent
|
|
31
|
+
// scripts in the same world also get initialized.
|
|
32
|
+
// js[2] — the whole user program: every `@require` concatenated
|
|
33
|
+
// ahead of the script's own body, wrapped in
|
|
34
|
+
// `reg.run(id,function(__smE){<gm bindings>…
|
|
35
|
+
// const __smU=()=>{<source>\n};return …callAs(__smU);})`
|
|
36
|
+
// (the source in a
|
|
37
|
+
// nested scope; see USER_SCOPE_OPEN), so it runs only after
|
|
38
|
+
// the prelude's matcher +
|
|
39
|
+
// check-pending + @run-at gates resolve. Trailing
|
|
40
|
+
// `//# sourceURL=sourcemonkey://<id>/<name>.user.js`, plus
|
|
41
|
+
// an inline source map when the script has requires.
|
|
42
|
+
// js[3] — parse-failure sentinel (`wrapSealEntry`). Its own script,
|
|
43
|
+
// so it still runs when js[2] failed to parse.
|
|
44
|
+
//
|
|
45
|
+
// Why ONE entry for the user program: separate js[] entries are
|
|
46
|
+
// separate V8 scripts with separate top-level scopes, so a library's
|
|
47
|
+
// `const`/`let`/`class` never reached the body. Concatenating gives
|
|
48
|
+
// them one scope, which is what TM/VM do. The cost is line numbers,
|
|
49
|
+
// which the source map pays back. See docs/injection-modes.md for the
|
|
50
|
+
// arrangement this replaced and the one never built.
|
|
51
|
+
//
|
|
52
|
+
// Caveats:
|
|
53
|
+
// - Wrapper-side regex matcher rechecks /regex/ @includes / @excludes
|
|
54
|
+
// that the userScripts API can't express. (Lives in the prelude.)
|
|
55
|
+
// - We register every script at document_start; the prelude in-page
|
|
56
|
+
// gates body execution on the declared @run-at lifecycle event so
|
|
57
|
+
// we don't inherit Chrome's batching of document_end into the
|
|
58
|
+
// post-load burst.
|
|
59
|
+
// - MAIN-world wrappers can't call chrome.runtime directly; the
|
|
60
|
+
// prelude's send() falls back to a DOM event which
|
|
61
|
+
// wrapper-bridge.js relays.
|
|
62
|
+
//
|
|
63
|
+
// Requirements: manifest must include the "userScripts" permission AND
|
|
64
|
+
// the user must enable the "Allow User Scripts" toggle on
|
|
65
|
+
// chrome://extensions. If the toggle is off, chrome.userScripts.register
|
|
66
|
+
// throws. The collections page and the popup
|
|
67
|
+
// prompt for the toggle off their own probe (`probeUserScriptsApi` in
|
|
68
|
+
// user-scripts-access.ts) rather than anything this module records:
|
|
69
|
+
// they need an answer when they draw, and an SW that has been evicted
|
|
70
|
+
// since the last sync would have nothing to report. When the
|
|
71
|
+
// collections page sees the toggle turned on, it asks for a full sync.
|
|
72
|
+
import { fromStorage, MISSING_BLOB_MESSAGE } from './script-blobs.js';
|
|
73
|
+
import { describeError, ensureCollectionKeysMigrated, isScriptEnabled, isCollectionEnabled, recordMissingBlobs, verifyParseChecks, withoutAnnotations, } from './script-store.js';
|
|
74
|
+
import { CONTEXT_MENU_RUN_AT, SUPPORTED_RUN_AT, getValue, getValues, hasTag } from '../userscript-header.js';
|
|
75
|
+
import { isManualScript } from '../script-build.js';
|
|
76
|
+
import { WRAPPER_BRIDGE, WRAPPER_GM_RUNTIME, WRAPPER_PRELUDE, } from '../generated-wrappers.js';
|
|
77
|
+
import { grantedSubsystems, resolveGrants } from '../gm-api-catalog.js';
|
|
78
|
+
import { resolveScriptWorld, runsSandboxed } from '../script-world.js';
|
|
79
|
+
import { scanStorageRows } from './storage-scan.js';
|
|
80
|
+
import { cachedRows, sharedRows } from './row-cache.js';
|
|
81
|
+
import { isScratchUrl, scratchRunOf } from './directories.js';
|
|
82
|
+
import { evalHookKey, withEvalHook } from '../eval-hook.js';
|
|
83
|
+
import { canonicalMatchPatterns, chromeIncludeGlob, hostEndsInTld, matchPatternProblem, pageMatcher, tldCoarseGlob } from '../url-match.js';
|
|
84
|
+
import { dlog } from '../debug-log.js';
|
|
85
|
+
import { startPhaseTimer } from '../phase-timing.js';
|
|
86
|
+
import { emitGmBindings, GM_INFO_PASSTHROUGH_TAGS, windowMethodBinding, } from './gm-bindings.js';
|
|
87
|
+
import { iconRefFromHeader } from '../script-icon.js';
|
|
88
|
+
import { channelKey, loadChannelKey, onlyScript, registryExpr, withChannelKey } from './channel.js';
|
|
89
|
+
import { buildSourceMap, inlineSourceMapComment, } from '../source-map.js';
|
|
90
|
+
import { USER_SOURCE_START_LINE, composeRequiredBody, composeSource, } from '../composed-source.js';
|
|
91
|
+
import { BRIDGE_SOURCE_URL, GM_RUNTIME_SOURCE_URL, PRELUDE_SOURCE_URL, buildConfigSourceUrl, buildRequireSourceUrl, buildSealSourceUrl, buildSourceUrl, } from '../script-source-url.js';
|
|
92
|
+
import { computeLogIds } from './script-log-id.js';
|
|
93
|
+
import { numberMap, numbersFrom, readScriptNumbers, SCRIPT_NUMBERS_KEY } from './sticky-numbers.js';
|
|
94
|
+
import { scriptBasename, scriptDisplayName } from '../script-display.js';
|
|
95
|
+
import { logScriptEvent } from './script-log.js';
|
|
96
|
+
import { assertWorkerContext } from '../run-context.js';
|
|
97
|
+
import { describeRejection, readRejections, writeRejections, } from './script-rejection.js';
|
|
98
|
+
const SCRIPT_PREFIX = 'script:';
|
|
99
|
+
const COLLECTION_PREFIX = 'collection:';
|
|
100
|
+
// Reserved registration ids. Chrome's userScripts API rejects ids
|
|
101
|
+
// that start with `_`, so we use a `sourcemonkey-` prefix; stored
|
|
102
|
+
// script ids are UUIDs and won't collide.
|
|
103
|
+
export const BRIDGE_SCRIPT_ID = 'sourcemonkey-bridge';
|
|
104
|
+
// Reserved registration id for the USER_SCRIPT-world GM_* shared
|
|
105
|
+
// runtime. Registered at document_start so it is in the world's
|
|
106
|
+
// registry before any userscript wrapper runs.
|
|
107
|
+
export const GM_RUNTIME_SCRIPT_ID = 'sourcemonkey-gm-runtime';
|
|
108
|
+
// MAIN-world flavor of the same runtime source. Needed whenever an
|
|
109
|
+
// active MAIN-world script grants a runtime-backed API, which is most
|
|
110
|
+
// granted scripts: MAIN is the default world. Its SW traffic rides
|
|
111
|
+
// `wrapper-bridge.js`.
|
|
112
|
+
export const GM_RUNTIME_MAIN_SCRIPT_ID = 'sourcemonkey-gm-runtime-main';
|
|
113
|
+
// Added to `matches` whenever a spec has `includeGlobs`. Two jobs:
|
|
114
|
+
// - Chrome requires `matches`, and ORs it with `includeGlobs`, so a
|
|
115
|
+
// script whose patterns are all globs needs a stand-in that no
|
|
116
|
+
// real page's path has.
|
|
117
|
+
// - Its host is `*` on purpose. Chrome kills the renderer ("Page
|
|
118
|
+
// crashed") when `chrome.tabs.sendMessage` reaches a USER_SCRIPT-
|
|
119
|
+
// world listener (the bridge, the GM runtime) in a page the spec
|
|
120
|
+
// covers only through `includeGlobs`, on a host none of its
|
|
121
|
+
// `matches` names. A `*` host keeps every page's host covered.
|
|
122
|
+
// Measured; see tests/e2e/include-only.spec.ts.
|
|
123
|
+
// The wrapper's in-page matcher still runs for such a script, so even a
|
|
124
|
+
// page that did have this path wouldn't run it.
|
|
125
|
+
export const NO_PAGE_MATCH = '*://*/__sourcemonkey_no_page__/*';
|
|
126
|
+
// Normalize the script's declared `@run-at` header to one of the four
|
|
127
|
+
// recognized values. The prelude gates body execution on the matching
|
|
128
|
+
// lifecycle event because Chrome's userScripts scheduler batches
|
|
129
|
+
// document_end into the post-load injection burst. See `waitForRunAt`
|
|
130
|
+
// in `userscript-prelude.js`.
|
|
131
|
+
//
|
|
132
|
+
// Anything else (a typo) runs at `document-idle`; `extractStartupLogs`
|
|
133
|
+
// warns about it. `context-menu` passes through: a manual run's config
|
|
134
|
+
// carries it, and the prelude then runs the body at once.
|
|
135
|
+
export function normalizeDeclaredRunAt(value) {
|
|
136
|
+
const v = (value ?? '').trim().toLowerCase();
|
|
137
|
+
if (v === CONTEXT_MENU_RUN_AT)
|
|
138
|
+
return v;
|
|
139
|
+
return SUPPORTED_RUN_AT.includes(v) ? v : 'document-idle';
|
|
140
|
+
}
|
|
141
|
+
// True iff the script needs the shared GM runtime registered in its
|
|
142
|
+
// world. `GM_info` is a baked-in literal and `unsafeWindow` is a plain
|
|
143
|
+
// `window` alias, so a script granting only those (or nothing at all)
|
|
144
|
+
// needs no runtime — which keeps the runtime off the page in the common
|
|
145
|
+
// `@grant none` MAIN-world case.
|
|
146
|
+
export function needsGmRuntime(header) {
|
|
147
|
+
for (const subsystem of grantedSubsystems(header)) {
|
|
148
|
+
if (subsystem !== 'info' && subsystem !== 'unsafe-window')
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function isRegexLiteral(s) {
|
|
154
|
+
return s.length >= 2 && s.startsWith('/') && s.endsWith('/');
|
|
155
|
+
}
|
|
156
|
+
// JSON-encode for embedding in a JS source context. Same `</script>`
|
|
157
|
+
// escape as in gm-bindings.ts — chrome.userScripts takes raw code so
|
|
158
|
+
// the escape is paranoid, but the cost is zero.
|
|
159
|
+
function jsLit(value) {
|
|
160
|
+
return JSON.stringify(value).replace(/<\/script>/gi, '<\\/script>');
|
|
161
|
+
}
|
|
162
|
+
// Build the per-script config blob. Calls `reg.prelude.init(cfg)`
|
|
163
|
+
// directly when the prelude is already loaded in this world, or queues
|
|
164
|
+
// onto `reg.cfgQueue` so the FIRST script's prelude-file load drains
|
|
165
|
+
// it. `reg` is the world's registry (channel.ts).
|
|
166
|
+
//
|
|
167
|
+
// Stays on one physical line so it doesn't perturb anything visual.
|
|
168
|
+
// Trailing `//# sourceURL=` puts the blob under the script's folder
|
|
169
|
+
// in devtools — see "devtools URL layout" below.
|
|
170
|
+
export function buildConfigBlob(cfg) {
|
|
171
|
+
// Drop matching when null so the literal stays compact.
|
|
172
|
+
const wire = {
|
|
173
|
+
id: cfg.id,
|
|
174
|
+
name: cfg.name,
|
|
175
|
+
sourceUrl: cfg.sourceUrl,
|
|
176
|
+
loadedAt: cfg.loadedAt,
|
|
177
|
+
runAt: cfg.runAt,
|
|
178
|
+
needsGmValues: cfg.needsGmValues,
|
|
179
|
+
};
|
|
180
|
+
if (cfg.needsGmRuntime)
|
|
181
|
+
wire.needsGmRuntime = true;
|
|
182
|
+
if (cfg.run !== undefined)
|
|
183
|
+
wire.run = cfg.run;
|
|
184
|
+
if (cfg.manualRun)
|
|
185
|
+
wire.manualRun = cfg.manualRun;
|
|
186
|
+
if (cfg.matching)
|
|
187
|
+
wire.matching = cfg.matching;
|
|
188
|
+
if (cfg.startupLogs && cfg.startupLogs.length > 0) {
|
|
189
|
+
// Level and message only. A `StartupLog` also carries the header
|
|
190
|
+
// `location` the diagnostic is about, and that is for the Log tab
|
|
191
|
+
// — the prelude just echoes these to the page console. Passing it
|
|
192
|
+
// would put the script's path (an absolute local path, for a
|
|
193
|
+
// single-file install) into code the page can read.
|
|
194
|
+
wire.startupLogs = cfg.startupLogs.map((l) => ({ level: l.level, message: l.message }));
|
|
195
|
+
}
|
|
196
|
+
const call = `(function(c,r){` +
|
|
197
|
+
`if(r.prelude){r.prelude.init(c);}else{(r.cfgQueue=r.cfgQueue||[]).push(c);}})(${jsLit(wire)},${registryExpr()});`;
|
|
198
|
+
return call + `\n//# sourceURL=${buildConfigSourceUrl(cfg.logLabel)}\n`;
|
|
199
|
+
}
|
|
200
|
+
// Wrap the static prelude source as a per-script {code} entry.
|
|
201
|
+
// Bytes (WRAPPER_PRELUDE) are byte-identical across every script;
|
|
202
|
+
// the trailing `//# sourceURL=` uses the page-wide
|
|
203
|
+
// `sourcemonkey://SourceMonkey/__prelude.js` URL so devtools'
|
|
204
|
+
// Sources tree groups all N inclusions into a single visible entry
|
|
205
|
+
// (V8 dedups script entries by URL in the tree view). Stack traces
|
|
206
|
+
// from inside the prelude all attribute to that same URL.
|
|
207
|
+
//
|
|
208
|
+
// Why inline (instead of one shared chrome.userScripts registration):
|
|
209
|
+
// per-script entries within one js[] array execute in order;
|
|
210
|
+
// separate registrations DON'T. A separate-prelude design races —
|
|
211
|
+
// the per-script main body's `reg.run(...)` call beats the
|
|
212
|
+
// prelude's helper installation. Inlining eliminates the race at
|
|
213
|
+
// the cost of shipping ~26KB × N times in the registration
|
|
214
|
+
// payload (negligible at our script counts).
|
|
215
|
+
export function wrapPreludeEntry() {
|
|
216
|
+
return withChannelKey(WRAPPER_PRELUDE) + `\n//# sourceURL=${PRELUDE_SOURCE_URL}\n`;
|
|
217
|
+
}
|
|
218
|
+
// The nested scope the user program (requires + body) runs in, inside
|
|
219
|
+
// the scope holding our own declarations: the `__sm*` preamble, the GM
|
|
220
|
+
// bindings (`const GM_info`, granted `GM_*`, `const unsafeWindow =
|
|
221
|
+
// window;`) and the GM prelude. Without it those shared ONE scope with
|
|
222
|
+
// the user program, and real scripts broke:
|
|
223
|
+
//
|
|
224
|
+
// - `const window = unsafeWindow;` put our `const unsafeWindow =
|
|
225
|
+
// window;` in the temporal dead zone (TDZ: a `let`/`const` read
|
|
226
|
+
// before its declaration runs) of the script's `window`, a
|
|
227
|
+
// ReferenceError that killed the script (GreasyFork 531756).
|
|
228
|
+
// - `const unsafeWindow = …`, `let GM_info`, a `var GM_setValue`
|
|
229
|
+
// polyfill or an own `function GM_addStyle` were duplicate-
|
|
230
|
+
// declaration SyntaxErrors, so the script never ran. All of these
|
|
231
|
+
// work under TM and VM, whose GM names live in an outer scope too.
|
|
232
|
+
//
|
|
233
|
+
// A sandboxed page-world script's user program runs in a `with` over its
|
|
234
|
+
// sandbox `window` instead; see `SANDBOX_SCOPE_OPEN` below.
|
|
235
|
+
//
|
|
236
|
+
// Why an arrow FUNCTION, not a block:
|
|
237
|
+
//
|
|
238
|
+
// - A block only scopes `let`/`const`/`class`. A `var` or a sloppy-
|
|
239
|
+
// mode function declaration still lands in the enclosing function
|
|
240
|
+
// scope and clashes with our `const`s there.
|
|
241
|
+
// - An arrow keeps the wrap function's `this` and `arguments`, so
|
|
242
|
+
// top-level `this` is unchanged. A `function` would need
|
|
243
|
+
// `.call(this)`, a lookup a page can tamper with in MAIN world.
|
|
244
|
+
// - Top-level `return` still works (it returns from the arrow), and
|
|
245
|
+
// top-level `await` stays a SyntaxError, as it was.
|
|
246
|
+
//
|
|
247
|
+
// The user program is the arrow's first statement, so a leading
|
|
248
|
+
// `"use strict"` is a real directive and applies to the whole program,
|
|
249
|
+
// as in Tampermonkey and Violentmonkey (tests/examples/use-strict).
|
|
250
|
+
// Like theirs, it's the first file's: a first `@require` that starts
|
|
251
|
+
// with one makes the script strict too, and the script's own after a
|
|
252
|
+
// `@require` does nothing, even when that `@require` is empty (our `;`
|
|
253
|
+
// between files is then the first statement). (Before the nested scope it never applied:
|
|
254
|
+
// our own statements came first.)
|
|
255
|
+
//
|
|
256
|
+
// The opener goes on the head's line and the closer on the line that
|
|
257
|
+
// already holds the wrap's `});`, so no line moves: generated line
|
|
258
|
+
// numbers, the source map and the viewer's stack mapping are unchanged.
|
|
259
|
+
// The close starts with a newline so a body ending in a `//` comment
|
|
260
|
+
// can't swallow it.
|
|
261
|
+
//
|
|
262
|
+
// The registered wrap `return`s the arrow's result, so the wrap
|
|
263
|
+
// function still returns whatever a top-level `return` gave (the
|
|
264
|
+
// prelude ignores it; unit tests read it). The swap eval is global
|
|
265
|
+
// code, where `return` is illegal; its completion value is the arrow's
|
|
266
|
+
// result anyway.
|
|
267
|
+
//
|
|
268
|
+
// The arrow is called through the prelude's `callAs`, which is strict.
|
|
269
|
+
// V8's stack-trace API (`Error.prepareStackTrace` with
|
|
270
|
+
// `CallSite.getFunction`) hands a page every sloppy-mode function on the
|
|
271
|
+
// stack down to the first strict one, so page code the program calls
|
|
272
|
+
// (or an error it throws) could otherwise reach the wrap function: the
|
|
273
|
+
// script's entry in its `arguments`. A strict frame between stops the
|
|
274
|
+
// walk there. The sandboxed form gets the same from `__smC`.
|
|
275
|
+
//
|
|
276
|
+
// The arrow is declared on the head's line and called on the closing
|
|
277
|
+
// one, as before, so its call frame stays past the end of the script's
|
|
278
|
+
// own lines, where the viewer leaves it plain (a frame on line 1 would
|
|
279
|
+
// read as the script's).
|
|
280
|
+
const USER_SCOPE_OPEN = 'const __smU=()=>{';
|
|
281
|
+
const userScopeClose = (ret) => `\n};${ret}__smReg.prelude.callAs(__smU);`;
|
|
282
|
+
// A sandboxed script's user scope (see `runsSandboxed`): the program
|
|
283
|
+
// runs inside `with (__smW)`, the scope over the prelude's sandbox
|
|
284
|
+
// `window` (`__smS`), and as a function called with the sandbox as
|
|
285
|
+
// `this`, as in Tampermonkey and Violentmonkey. See docs/injection.md,
|
|
286
|
+
// "The sandbox window".
|
|
287
|
+
//
|
|
288
|
+
// - `with` is what makes a bare `$` find the copy a `@require` put on
|
|
289
|
+
// `window` before the page's, and a bare `window` read as the
|
|
290
|
+
// sandbox. The wrap and the swap eval are sloppy code, where `with`
|
|
291
|
+
// is allowed; a `"use strict"` program inside the function is fine.
|
|
292
|
+
// - A function rather than the arrow, so top-level `this` can be the
|
|
293
|
+
// sandbox. `callAs` calls it through the prelude's captured
|
|
294
|
+
// `Reflect.apply`, out of the way of a page that patched
|
|
295
|
+
// `Function.prototype.call`.
|
|
296
|
+
// - Top-level `arguments` is then the function's own (empty) list
|
|
297
|
+
// rather than the wrap's.
|
|
298
|
+
// - The `__sm*` names live outside the `with`, and the scope never
|
|
299
|
+
// claims `__sm*` names, so they resolve to these.
|
|
300
|
+
//
|
|
301
|
+
// Same lines as the arrow form: the declaration and opener go on the
|
|
302
|
+
// head line, the closer on the wrap's last line. Like the arrow, the
|
|
303
|
+
// function is defined on the head line (inside the `with`, so the
|
|
304
|
+
// scope is lexically its) and called on the closing one, so its call
|
|
305
|
+
// frame is past the end of the script's own lines.
|
|
306
|
+
const SANDBOX_SCOPE_OPEN = '{const __smU=function(){';
|
|
307
|
+
const sandboxScopeClose = (ret) => `\n};${ret}__smC(__smU,__smS)}`;
|
|
308
|
+
// Declares the sandbox, holding the script's own bindings, ahead of
|
|
309
|
+
// the `with`.
|
|
310
|
+
function sandboxDeclaration(own) {
|
|
311
|
+
// `unsafeWindow` is always on the sandbox, binding or not, as in TM
|
|
312
|
+
// and VM: a script with no `@grant` line that takes it from `window`
|
|
313
|
+
// (`const { unsafeWindow } = window`) still gets the page. This line
|
|
314
|
+
// is outside the `with`, so `window` here is the page's.
|
|
315
|
+
const exposed = own.declared.map((n) => `${n}:${n}`)
|
|
316
|
+
.concat(own.declared.includes('unsafeWindow') ? [] : ['unsafeWindow:window'])
|
|
317
|
+
.concat(own.windowMethods.map((m) => `${m}:${windowMethodBinding(m)}`))
|
|
318
|
+
.join(',');
|
|
319
|
+
return `const __smP=__smReg.prelude,__smS=__smP.sandbox({${exposed}}),__smW=__smP.scopeOf(__smS),__smC=__smP.callAs;`;
|
|
320
|
+
}
|
|
321
|
+
// The user scope's opener, after any `return`, and closer, for a script
|
|
322
|
+
// that is or isn't sandboxed. `declared` is what its bindings declare.
|
|
323
|
+
function userScope(own, withReturn) {
|
|
324
|
+
const ret = withReturn ? 'return' : '';
|
|
325
|
+
if (!own)
|
|
326
|
+
return { open: USER_SCOPE_OPEN, close: userScopeClose(ret ? ret + ' ' : '') };
|
|
327
|
+
return {
|
|
328
|
+
open: `${sandboxDeclaration(own)}with(__smW)${SANDBOX_SCOPE_OPEN}`,
|
|
329
|
+
close: sandboxScopeClose(ret ? ret + ' ' : ''),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
// The eval hook key (src/eval-hook.ts) for a script in an sm-dev run's
|
|
333
|
+
// test collection (`scratch:///<run>/...`): its run's. None for any
|
|
334
|
+
// other script.
|
|
335
|
+
export function scratchEvalHookKey(fileUrl) {
|
|
336
|
+
const run = scratchRunOf(fileUrl);
|
|
337
|
+
return run ? evalHookKey(run) : undefined;
|
|
338
|
+
}
|
|
339
|
+
// The composed user program with the eval hook under `key`, when there
|
|
340
|
+
// is one. `mainStart` is where the script's own source starts in it,
|
|
341
|
+
// after its requires: the IIFE the hook also goes into is looked for
|
|
342
|
+
// there, since a library's own wrapper isn't the script's scope.
|
|
343
|
+
function withScriptEvalHook(program, mainStart, scriptId, key) {
|
|
344
|
+
return key ? withEvalHook(program, key, scriptId, mainStart) : program;
|
|
345
|
+
}
|
|
346
|
+
// Wrap the whole user program — every `@require` plus the script's own
|
|
347
|
+
// body — as ONE js[] entry. Output shape:
|
|
348
|
+
//
|
|
349
|
+
// (function(__smReg){__smReg.run("<id>",function(__smE){<DECL PREAMBLE><GM BINDINGS><GM PRELUDE>const __smU=()=>{
|
|
350
|
+
// // @require <url 1>
|
|
351
|
+
// <require 1 line 1>
|
|
352
|
+
// ...
|
|
353
|
+
// ;
|
|
354
|
+
// <main body line 1>
|
|
355
|
+
// ...
|
|
356
|
+
// };return __smReg.prelude.callAs(__smU);})})(<registry>);
|
|
357
|
+
// //# sourceURL=<main url>
|
|
358
|
+
// //# sourceMappingURL=data:application/json;… (only when requires exist)
|
|
359
|
+
//
|
|
360
|
+
// `__smE` is the per-script entry the prelude passes in. The DECL
|
|
361
|
+
// PREAMBLE pulls `__smDecision` / `__smName` / `__smId` /
|
|
362
|
+
// `__smLoadedAt` / `__smSend` out of it so the GM bindings + GM
|
|
363
|
+
// prelude (generated by gm-bindings.ts, which references those
|
|
364
|
+
// identifiers) work unchanged.
|
|
365
|
+
//
|
|
366
|
+
// Why ONE entry: separate js[] entries are separate V8 scripts with
|
|
367
|
+
// separate top-level scopes, so a require's `const`/`let`/`class` was
|
|
368
|
+
// invisible to the main body and had to be bridged across. One entry
|
|
369
|
+
// means one scope, and the question stops existing — the same
|
|
370
|
+
// arrangement TM/VM get by concatenating into a single IIFE. See
|
|
371
|
+
// docs/injection-modes.md for what that bridge cost.
|
|
372
|
+
//
|
|
373
|
+
// Fidelity wins that fall out of the same change:
|
|
374
|
+
//
|
|
375
|
+
// - `@require` code now runs inside the `@run-at` gate along with
|
|
376
|
+
// the body, instead of firing early in Chrome's document_start
|
|
377
|
+
// injection burst.
|
|
378
|
+
// - It therefore sees the GM prelude (`storage.init`), so a
|
|
379
|
+
// require's top-level `GM_setValue` reads a live cache.
|
|
380
|
+
// - The GM binding block ships once per script instead of once per
|
|
381
|
+
// require.
|
|
382
|
+
//
|
|
383
|
+
// The user program runs in its own nested scope (`USER_SCOPE_OPEN` /
|
|
384
|
+
// `userScopeClose`), so its top-level declarations shadow ours rather
|
|
385
|
+
// than clashing. See the note on those constants.
|
|
386
|
+
//
|
|
387
|
+
// Line numbers:
|
|
388
|
+
//
|
|
389
|
+
// - **No requires** (the overwhelmingly common case) — head,
|
|
390
|
+
// bindings and prelude are minified onto the opening line, so body
|
|
391
|
+
// line N is still physical line N and no map is emitted. Byte for
|
|
392
|
+
// byte the same entry as before Mode A.
|
|
393
|
+
// - **With requires** — the body necessarily moves down by however
|
|
394
|
+
// many lines the requires occupy. An inline source map restores
|
|
395
|
+
// the original file and line in devtools. `Error.stack` strings
|
|
396
|
+
// stay in generated coordinates because V8 doesn't map those; see
|
|
397
|
+
// src/source-map.ts and TODO.md.
|
|
398
|
+
export function wrapCombinedEntry(args) {
|
|
399
|
+
const idLit = jsLit(args.scriptId);
|
|
400
|
+
const hooked = (program, mainStart = 0) => withScriptEvalHook(program, mainStart, args.scriptId, args.evalHookKey);
|
|
401
|
+
// Declaration preamble — pulls per-script values out of the entry
|
|
402
|
+
// the prelude passes in. The names match what gm-bindings.ts's
|
|
403
|
+
// prelude expects (`__smDecision`, `__smName`, `__smId`,
|
|
404
|
+
// `__smLoadedAt`, `__smSend`) so we don't have to rewrite that
|
|
405
|
+
// module for the wrapper shape.
|
|
406
|
+
const declPreamble = `var __smDecision=__smE.decision;` +
|
|
407
|
+
`var __smName=__smE.cfg.name;` +
|
|
408
|
+
`var __smId=__smE.cfg.id;` +
|
|
409
|
+
`var __smLoadedAt=__smE.cfg.loadedAt;` +
|
|
410
|
+
`var __smSend=__smE.send;`;
|
|
411
|
+
// gmBindings and gmPrelude are already single-line strings (or
|
|
412
|
+
// empty), so the head stays on one physical line.
|
|
413
|
+
const scope = userScope(args.sandboxed ?? null, true);
|
|
414
|
+
// The registry comes in as the outer function's parameter, its
|
|
415
|
+
// expression (which holds the channel key) at the very end of the
|
|
416
|
+
// entry: so neither the wrap function's source nor any function the
|
|
417
|
+
// page could reach later holds the key. That outer function runs at
|
|
418
|
+
// document_start, before any page script.
|
|
419
|
+
const head = `(function(__smReg){__smReg.run(${idLit},function(__smE){${declPreamble}${args.gmBindings}${args.gmPrelude}${scope.open}`;
|
|
420
|
+
const tail = `${scope.close}})})(${registryExpr()});`;
|
|
421
|
+
if ((args.script.requires ?? []).length === 0) {
|
|
422
|
+
// Nothing to concatenate, so nothing shifts and no map is needed.
|
|
423
|
+
// No newline after `head`: the body's first line SHARES generated
|
|
424
|
+
// line 0 with the wrap, which is what makes generated line N equal
|
|
425
|
+
// original line N and lets the viewer pass refs through unmapped.
|
|
426
|
+
// `buildSwapEvalBody` mirrors this exactly — see NO_REQUIRE_LAYOUT
|
|
427
|
+
// in composed-source.ts.
|
|
428
|
+
return head + hooked(composeRequiredBody(args.script)) + `${tail}\n//# sourceURL=${args.sourceUrl}\n`;
|
|
429
|
+
}
|
|
430
|
+
// Head occupies generated line 0; user source starts below it.
|
|
431
|
+
const composed = composeSource(args.script, {
|
|
432
|
+
requireUrls: args.requireUrls,
|
|
433
|
+
mainUrl: args.sourceUrl,
|
|
434
|
+
startLine: USER_SOURCE_START_LINE,
|
|
435
|
+
});
|
|
436
|
+
const code = head + '\n' + hooked(composed.text, composed.mainStart) + `${tail}\n`;
|
|
437
|
+
const map = buildSourceMap({
|
|
438
|
+
file: args.sourceUrl,
|
|
439
|
+
segments: composed.segments,
|
|
440
|
+
// head + user source + the closing `});`
|
|
441
|
+
totalLines: 1 + composed.lineCount + 1,
|
|
442
|
+
});
|
|
443
|
+
return code + `//# sourceURL=${args.sourceUrl}\n` + inlineSourceMapComment(map) + '\n';
|
|
444
|
+
}
|
|
445
|
+
// Assembler: Mode A (concatenated user program). See
|
|
446
|
+
// docs/injection-modes.md for the arrangements tried or considered
|
|
447
|
+
// instead (separate entries per require, eval-per-require) and why
|
|
448
|
+
// this one won.
|
|
449
|
+
//
|
|
450
|
+
// The prelude is inlined as the second js[] entry — NOT
|
|
451
|
+
// registered separately. We tried the separate-singleton approach
|
|
452
|
+
// and hit a real race: chrome.userScripts injection order isn't
|
|
453
|
+
// guaranteed BETWEEN registrations, so a per-script main-body
|
|
454
|
+
// entry could call `reg.run(...)` before its world's singleton
|
|
455
|
+
// prelude had defined the helper. The synchronous TypeError killed
|
|
456
|
+
// the body before it ran. Within a SINGLE registration's js[],
|
|
457
|
+
// entries run in order, so inlining the prelude eliminates the
|
|
458
|
+
// race.
|
|
459
|
+
//
|
|
460
|
+
// Cost: the WRAPPER_PRELUDE bytes ship N times in the registration
|
|
461
|
+
// payload (~26KB × N). Devtools tree-dedups by sourceURL though, so
|
|
462
|
+
// the user only sees one `__prelude.js` entry in the Sources tree
|
|
463
|
+
// — see wrapPreludeEntry's URL choice.
|
|
464
|
+
//
|
|
465
|
+
// ---------- devtools URL layout ----------
|
|
466
|
+
//
|
|
467
|
+
// Every `{code}` entry that wants a friendly name in the devtools
|
|
468
|
+
// Sources tree carries its own `//# sourceURL=` directive built by
|
|
469
|
+
// `src/script-source-url.ts`. The shape is
|
|
470
|
+
// `sourcemonkey://SourceMonkey/<folder>/...`:
|
|
471
|
+
//
|
|
472
|
+
// sourcemonkey://SourceMonkey/
|
|
473
|
+
// __bridge.js (one page-wide registration, USER_SCRIPT world)
|
|
474
|
+
// __gm-runtime.js (one page-wide registration, USER_SCRIPT world)
|
|
475
|
+
// __prelude.js (one tree entry — bytes ship per script, devtools dedups by URL)
|
|
476
|
+
// <folder-1>/ (per-script — log label)
|
|
477
|
+
// __config.js (per-script config blob)
|
|
478
|
+
// <name>.user.js (per-script main body)
|
|
479
|
+
// <host>/<path> (one subtree per @require)
|
|
480
|
+
// <folder-2>/
|
|
481
|
+
// ...
|
|
482
|
+
//
|
|
483
|
+
// Why `SourceMonkey` as the authority: devtools strips the URL
|
|
484
|
+
// scheme and uses the host as the first visible folder; without it
|
|
485
|
+
// our scripts would scatter at the top level of the Sources tree.
|
|
486
|
+
// Why the per-script folder is the log label (`foo.user.js#E4`) rather
|
|
487
|
+
// than the script's UUID: the UUID is a debug-grade hash that's useless
|
|
488
|
+
// when scanning the tree. See script-source-url.ts for the URL
|
|
489
|
+
// builders and the `#` → `%23` encoding for the suffix.
|
|
490
|
+
//
|
|
491
|
+
// `@require` entries also get synthetic `sourcemonkey://` URLs so
|
|
492
|
+
// they (a) cluster with their owning script and (b) tell the truth
|
|
493
|
+
// — we're injecting our stored copy, not re-fetching from the CDN.
|
|
494
|
+
// The original host + path become subfolders so provenance is still
|
|
495
|
+
// visible in the tree.
|
|
496
|
+
function assembleEntries(parts) {
|
|
497
|
+
return [
|
|
498
|
+
{ code: parts.configBlob },
|
|
499
|
+
{ code: parts.preludeCode },
|
|
500
|
+
{ code: parts.combinedCode },
|
|
501
|
+
{ code: parts.sealCode },
|
|
502
|
+
];
|
|
503
|
+
}
|
|
504
|
+
// Trailing sentinel entry. Everything the user wrote lives in ONE
|
|
505
|
+
// script now, so a syntax error anywhere in any `@require` — a
|
|
506
|
+
// truncated CDN response, an HTML fragment that squeaked past the
|
|
507
|
+
// content-type gate — makes the whole entry fail to parse. `reg.run`
|
|
508
|
+
// is then never reached, and without this the script would simply
|
|
509
|
+
// never run: no error, no log entry, no badge.
|
|
510
|
+
//
|
|
511
|
+
// This is a separate js[] entry, so it parses and runs even when the
|
|
512
|
+
// one before it didn't; the prelude reports the failure when it sees
|
|
513
|
+
// that `reg.run` never fired.
|
|
514
|
+
export function wrapSealEntry(scriptId, logLabel) {
|
|
515
|
+
return `${registryExpr()}.sealed(${jsLit(scriptId)});\n//# sourceURL=${buildSealSourceUrl(logLabel)}\n`;
|
|
516
|
+
}
|
|
517
|
+
// Pure: assemble the GmInfoInput literal we'll bake into GM_info.
|
|
518
|
+
// `grantedNames` is the script's resolved grant set (empty for
|
|
519
|
+
// `@grant none` / no-grants / unknown-only / MAIN world). It's
|
|
520
|
+
// only used to populate `info.script.grant` — the choice of which
|
|
521
|
+
// real bindings to emit lives in emitGmBindings.
|
|
522
|
+
//
|
|
523
|
+
// Precondition: callers must check `script.header != null` before
|
|
524
|
+
// calling this. The one production caller (`buildGmBindingsForScript`)
|
|
525
|
+
// early-returns for headerless scripts.
|
|
526
|
+
function buildGmInfoInput(script, scriptHandlerVersion, grantedNames) {
|
|
527
|
+
const header = script.header;
|
|
528
|
+
// Deliberately NOT `scriptDisplayName`: this is script-visible API
|
|
529
|
+
// surface (`GM_info.script.name`), so it stays byte-identical to
|
|
530
|
+
// what's stored — no decode-for-display pass — and keeps `??`, so a
|
|
531
|
+
// script that declares an empty `@name` sees an empty name rather
|
|
532
|
+
// than silently getting its URL.
|
|
533
|
+
const name = getValue(header, 'name') ?? script.relativePath;
|
|
534
|
+
const resources = {};
|
|
535
|
+
for (const r of script.resources ?? []) {
|
|
536
|
+
resources[r.name] = { b64: r.bytesBase64, ct: r.contentType };
|
|
537
|
+
}
|
|
538
|
+
// Headers TM copies onto `GM_info.script` by name. Absent → null.
|
|
539
|
+
const passthrough = {};
|
|
540
|
+
for (const tag of GM_INFO_PASSTHROUGH_TAGS) {
|
|
541
|
+
passthrough[tag] = getValue(header, tag) ?? null;
|
|
542
|
+
}
|
|
543
|
+
// TM folds its aliases into the canonical key; do the same so
|
|
544
|
+
// GM_info agrees with what the viewer shows for the icon.
|
|
545
|
+
passthrough.homepage ??= firstValue(header, ['homepageURL', 'website', 'source']);
|
|
546
|
+
passthrough.icon = iconRefFromHeader(header);
|
|
547
|
+
passthrough.icon64 ??= getValue(header, 'icon64URL') ?? null;
|
|
548
|
+
return {
|
|
549
|
+
scriptId: script.id,
|
|
550
|
+
name,
|
|
551
|
+
version: getValue(header, 'version') ?? '',
|
|
552
|
+
namespace: getValue(header, 'namespace') ?? '',
|
|
553
|
+
description: getValue(header, 'description') ?? '',
|
|
554
|
+
matches: script.matching.matches.slice(),
|
|
555
|
+
includes: script.matching.includes.slice(),
|
|
556
|
+
excludes: script.matching.excludes.slice(),
|
|
557
|
+
grants: grantedNames,
|
|
558
|
+
runAt: getValue(header, 'run-at') ?? '',
|
|
559
|
+
scriptMetaStr: script.headerText,
|
|
560
|
+
scriptHandlerVersion,
|
|
561
|
+
resources,
|
|
562
|
+
passthrough,
|
|
563
|
+
// `.ref`, not `.url`: report the ref as written in the header, as
|
|
564
|
+
// TM/VM do, rather than the resolved (possibly local file://) URL.
|
|
565
|
+
// These are the *loaded* lists (deduped; and after a failed
|
|
566
|
+
// refresh, the prior good set — same as the body they go with).
|
|
567
|
+
requires: (script.requires ?? []).map((r) => r.ref),
|
|
568
|
+
resourceList: (script.resources ?? []).map((r) => ({ name: r.name, url: r.ref })),
|
|
569
|
+
connects: getValues(header, 'connect'),
|
|
570
|
+
noframes: hasTag(header, 'noframes'),
|
|
571
|
+
userAgentData: readUserAgentData(),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
function firstValue(header, tags) {
|
|
575
|
+
for (const tag of tags) {
|
|
576
|
+
const v = getValue(header, tag);
|
|
577
|
+
if (v !== undefined)
|
|
578
|
+
return v;
|
|
579
|
+
}
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
// Snapshot `navigator.userAgentData` once per SW lifetime. It
|
|
583
|
+
// describes the browser, not the page, so every script gets the same
|
|
584
|
+
// value. `null` where the API is missing (unit tests under Node).
|
|
585
|
+
let uaDataSnapshot;
|
|
586
|
+
function readUserAgentData() {
|
|
587
|
+
if (uaDataSnapshot !== undefined)
|
|
588
|
+
return uaDataSnapshot;
|
|
589
|
+
const nav = globalThis.navigator;
|
|
590
|
+
const data = nav?.userAgentData;
|
|
591
|
+
uaDataSnapshot = data
|
|
592
|
+
? {
|
|
593
|
+
brands: data.brands.map((b) => ({ brand: b.brand, version: b.version })),
|
|
594
|
+
mobile: data.mobile,
|
|
595
|
+
platform: data.platform,
|
|
596
|
+
}
|
|
597
|
+
: null;
|
|
598
|
+
return uaDataSnapshot;
|
|
599
|
+
}
|
|
600
|
+
// Build the full GM-binding source string for one script: a const
|
|
601
|
+
// declaration per granted API plus the implicit `GM_info`. Async
|
|
602
|
+
// because we may eventually need to read persisted values at register
|
|
603
|
+
// time (not today; the storage snapshot path runs through the
|
|
604
|
+
// check-pending reply).
|
|
605
|
+
//
|
|
606
|
+
// World-independent. The emitted bodies delegate to the shared GM
|
|
607
|
+
// runtime (`__smReg.gm`), which is
|
|
608
|
+
// registered in BOTH worlds now (see `buildGmRuntimeSpecs`), with the
|
|
609
|
+
// MAIN-world copy routing its SW traffic over `wrapper-bridge.js`. So
|
|
610
|
+
// a `@grant unsafeWindow` + `@grant GM_setValue` script gets a real
|
|
611
|
+
// page `window` AND working storage.
|
|
612
|
+
export async function buildGmBindingsForScript(script, scriptHandlerVersion) {
|
|
613
|
+
await loadChannelKey();
|
|
614
|
+
// No header at all → no GM_info either (we'd have nothing to put
|
|
615
|
+
// in it). Production callers never hit this because validateHeader
|
|
616
|
+
// rejects headerless scripts upstream; the guard is for unit tests.
|
|
617
|
+
if (!script.header)
|
|
618
|
+
return { js: '', prelude: '', granted: [], needsValues: false, declared: [], windowMethods: [] };
|
|
619
|
+
const grants = getValues(script.header, 'grant');
|
|
620
|
+
const resolved = resolveGrants(grants);
|
|
621
|
+
// `@grant none` and no-grants both fall through to emitGmBindings
|
|
622
|
+
// with an empty set — emitGmBindings still emits GM_info (TM/VM
|
|
623
|
+
// compat). `resolved.granted` is the set of real bindings (empty
|
|
624
|
+
// for @grant none / no-grants); GM_info is implicit and not
|
|
625
|
+
// reflected here.
|
|
626
|
+
const grantedNames = resolved.hasNone ? [] : [...resolved.granted];
|
|
627
|
+
// A body that names `unsafeWindow` with no grant for it gets the
|
|
628
|
+
// binding anyway: without the `const` the reference is a
|
|
629
|
+
// ReferenceError. TM and VM both expose the name ungranted, so this is
|
|
630
|
+
// what a script written against either already expects.
|
|
631
|
+
//
|
|
632
|
+
// It joins the EMITTED set only. `GM_info.script.grant` keeps
|
|
633
|
+
// reporting the `@grant` lines the file actually declares — a script
|
|
634
|
+
// reading its own metadata must see its own metadata. And the name
|
|
635
|
+
// buys nothing on the SW side: `unsafeWindow` has no subsystem the
|
|
636
|
+
// bridge gate can be asked for (`grantedSubsystems` is header-derived
|
|
637
|
+
// and unchanged), so this adds no capability, only a `window` alias.
|
|
638
|
+
//
|
|
639
|
+
// A sandboxed script reaches `unsafeWindow` without the binding too:
|
|
640
|
+
// the sandbox always carries it (`sandboxDeclaration`). So this
|
|
641
|
+
// matters for `@grant none` and isolated-world scripts.
|
|
642
|
+
//
|
|
643
|
+
// A `@require` that declares the name itself needs no special case:
|
|
644
|
+
// the user program runs in a nested scope (see wrapCombinedEntry), so
|
|
645
|
+
// the library's own `const unsafeWindow = …` shadows ours.
|
|
646
|
+
const emittedNames = script.usesUnsafeWindow === true
|
|
647
|
+
&& !grantedNames.includes('unsafeWindow')
|
|
648
|
+
? [...grantedNames, 'unsafeWindow']
|
|
649
|
+
: grantedNames;
|
|
650
|
+
return emitGmBindings(emittedNames, buildGmInfoInput(script, scriptHandlerVersion, grantedNames), runsSandboxed(script));
|
|
651
|
+
}
|
|
652
|
+
export async function buildSwapEvalBody(script, scriptHandlerVersion, logLabel = scriptBasename(script.relativePath)) {
|
|
653
|
+
await loadChannelKey();
|
|
654
|
+
// World-independent now: the shared runtime is registered in whichever
|
|
655
|
+
// worlds need it, so a MAIN-world script gets the same bindings.
|
|
656
|
+
const gmEmit = await buildGmBindingsForScript(script, scriptHandlerVersion);
|
|
657
|
+
const sourceUrl = buildSourceUrl(logLabel);
|
|
658
|
+
const requires = script.requires ?? [];
|
|
659
|
+
// gmEmit.js is single-line and ends with a space (or is empty), so
|
|
660
|
+
// it occupies generated line 0 exactly as `wrapCombinedEntry`'s wrap
|
|
661
|
+
// head does.
|
|
662
|
+
const scope = userScope(runsSandboxed(script) ? gmEmit : null, false);
|
|
663
|
+
// The registry first: the bindings and the scope reach everything
|
|
664
|
+
// through it. Global code, so the key in it is in no function.
|
|
665
|
+
const head = `const __smReg=${registryExpr()}; ` + gmEmit.js + scope.open;
|
|
666
|
+
// An sm-dev test collection's script keeps its eval hook when swapped.
|
|
667
|
+
const hookKey = scratchEvalHookKey(script.fileUrl);
|
|
668
|
+
const hooked = (program, mainStart = 0) => withScriptEvalHook(program, mainStart, script.id, hookKey);
|
|
669
|
+
if (requires.length === 0) {
|
|
670
|
+
// No newline, matching the registered path's no-require branch:
|
|
671
|
+
// the body's first line shares line 0 with the preamble, so
|
|
672
|
+
// generated line N is original line N and the viewer's
|
|
673
|
+
// pass-through is correct. Adding one here would silently shift
|
|
674
|
+
// every logged stack line for the majority of scripts, which have
|
|
675
|
+
// no `@require` — and with no segments to map by, the viewer
|
|
676
|
+
// could not detect it, let alone correct it.
|
|
677
|
+
return { body: head + hooked(composeRequiredBody(script)) + scope.close, needsValues: gmEmit.needsValues };
|
|
678
|
+
}
|
|
679
|
+
const composed = composeSource(script, {
|
|
680
|
+
requireUrls: requires.map((r) => buildRequireSourceUrl(logLabel, r.url)),
|
|
681
|
+
mainUrl: sourceUrl,
|
|
682
|
+
startLine: USER_SOURCE_START_LINE,
|
|
683
|
+
});
|
|
684
|
+
// The map goes on the body the prelude evals; the prelude appends
|
|
685
|
+
// its own `//# sourceURL=` line after this one. Both are trailing
|
|
686
|
+
// single-line comments, so the order between them doesn't matter to
|
|
687
|
+
// V8 — and devtools needs the map to show the swapped-in requires as
|
|
688
|
+
// their own files, exactly as a fresh registration does.
|
|
689
|
+
const map = buildSourceMap({
|
|
690
|
+
file: sourceUrl,
|
|
691
|
+
segments: composed.segments,
|
|
692
|
+
// head + user source + the scope's closing line
|
|
693
|
+
totalLines: 1 + composed.lineCount + 1,
|
|
694
|
+
});
|
|
695
|
+
return {
|
|
696
|
+
body: head + '\n' + hooked(composed.text, composed.mainStart) + scope.close + '\n' + inlineSourceMapComment(map),
|
|
697
|
+
needsValues: gmEmit.needsValues,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
export async function buildRegistrationSpec(script, scriptHandlerVersion = '0.0.0', logLabel = scriptBasename(script.relativePath), options = {}) {
|
|
701
|
+
await loadChannelKey();
|
|
702
|
+
// What Chrome gets is the canonical spelling of each `@match`, not
|
|
703
|
+
// the author's: a scheme-less `*.example.com/*` or a globbed
|
|
704
|
+
// `http*://example.com/*` is a pattern Chrome's parser refuses but
|
|
705
|
+
// our matcher runs, so we hand it the form that means the same thing
|
|
706
|
+
// in Chrome's own grammar. One `@match` can expand to
|
|
707
|
+
// more than one pattern when its scheme glob names more than one
|
|
708
|
+
// scheme; duplicates across patterns collapse.
|
|
709
|
+
//
|
|
710
|
+
// Patterns Chrome's grammar refuses even then never go in the spec:
|
|
711
|
+
// it rejects the registration as a whole, so one bad `@match` would
|
|
712
|
+
// take the script's good patterns down with it. Dropping them costs
|
|
713
|
+
// nothing — `matchPattern` can't match them either — and the reason
|
|
714
|
+
// is already in the script's Log, on the header line that declared
|
|
715
|
+
// it (see `extractStartupLogs`). A script left with no pattern at all
|
|
716
|
+
// was already stored `invalid` by `validateHeader`.
|
|
717
|
+
const usableMatches = [
|
|
718
|
+
...new Set(script.matching.matches
|
|
719
|
+
.flatMap((m) => canonicalMatchPatterns(m))
|
|
720
|
+
.filter((m) => matchPatternProblem(m) === null)),
|
|
721
|
+
];
|
|
722
|
+
// A `.tld` host (`*://www.google.tld/*`, "any public suffix") is
|
|
723
|
+
// one Chrome would take, but as a literal host named `…tld` that no
|
|
724
|
+
// page has, and a match pattern can't put `*` there instead. So a
|
|
725
|
+
// `.tld` rule registers as a coarse glob (`*://www.google.*/*`, see
|
|
726
|
+
// `tldCoarseGlob`), which Chrome ORs with `matches` (measured; see
|
|
727
|
+
// below), and the wrapper's matcher narrows it to the exact rule.
|
|
728
|
+
const matches = usableMatches.filter((m) => !hostEndsInTld(m));
|
|
729
|
+
const tldMatches = usableMatches.filter((m) => hostEndsInTld(m));
|
|
730
|
+
// Deduplicated like `matches` above, and for a reason beyond tidiness:
|
|
731
|
+
// `needsReplace` compares the glob lists we sent against the ones
|
|
732
|
+
// `getScripts` reports back, and a header repeating an `@exclude`
|
|
733
|
+
// would leave those two disagreeing forever if Chrome collapsed the
|
|
734
|
+
// duplicate — re-registering the script, and costing it an injection
|
|
735
|
+
// gap, on every sync.
|
|
736
|
+
const includeGlobSet = new Set();
|
|
737
|
+
const excludeGlobSet = new Set();
|
|
738
|
+
// A `.tld` `@include` registers its coarse glob, like a `.tld`
|
|
739
|
+
// `@match`. A glob `@include` ignores case, so Chrome gets a glob
|
|
740
|
+
// wide enough for that (`chromeIncludeGlob`).
|
|
741
|
+
//
|
|
742
|
+
// Every `@exclude` is enforced in the page, which ignores case.
|
|
743
|
+
// Chrome also gets the ones it can only ever read narrower than ours,
|
|
744
|
+
// to skip injecting at all: not a regex, not a `.tld` (widened to
|
|
745
|
+
// `.*`, it would exclude pages it shouldn't), and not one with a `?`,
|
|
746
|
+
// which Chrome reads as a one-character wildcard.
|
|
747
|
+
let hasRegexInclude = false;
|
|
748
|
+
let hasTldRule = tldMatches.length > 0;
|
|
749
|
+
for (const m of tldMatches)
|
|
750
|
+
includeGlobSet.add(chromeIncludeGlob(tldCoarseGlob(m, true)));
|
|
751
|
+
for (const inc of script.matching.includes) {
|
|
752
|
+
if (isRegexLiteral(inc))
|
|
753
|
+
hasRegexInclude = true;
|
|
754
|
+
else if (hostEndsInTld(inc)) {
|
|
755
|
+
includeGlobSet.add(chromeIncludeGlob(tldCoarseGlob(inc, false)));
|
|
756
|
+
hasTldRule = true;
|
|
757
|
+
}
|
|
758
|
+
else
|
|
759
|
+
includeGlobSet.add(chromeIncludeGlob(inc));
|
|
760
|
+
}
|
|
761
|
+
const hasExclude = script.matching.excludes.length > 0;
|
|
762
|
+
for (const ex of script.matching.excludes) {
|
|
763
|
+
if (!isRegexLiteral(ex) && !hostEndsInTld(ex) && !ex.includes('?'))
|
|
764
|
+
excludeGlobSet.add(ex);
|
|
765
|
+
}
|
|
766
|
+
const includeGlobs = [...includeGlobSet];
|
|
767
|
+
const excludeGlobs = [...excludeGlobSet];
|
|
768
|
+
// Chrome refuses a registration without `matches` ("must specify
|
|
769
|
+
// 'matches'"), so a script whose header carries only `@include`s
|
|
770
|
+
// needs a stand-in (see `NO_PAGE_MATCH` below), with the wrapper's
|
|
771
|
+
// in-page matcher holding it to its header.
|
|
772
|
+
//
|
|
773
|
+
// How the globs and `matches` combine matters, because Chrome's own
|
|
774
|
+
// reading of `includeGlobs` is not what the docs for
|
|
775
|
+
// content scripts say. Measured rather than assumed — the probe in
|
|
776
|
+
// tests/e2e/include-only.spec.ts re-checks it on whatever Chrome the
|
|
777
|
+
// suite runs: `chrome.userScripts` ORs
|
|
778
|
+
// `includeGlobs` with `matches` — userscript `@include` semantics —
|
|
779
|
+
// rather than filtering within them. So:
|
|
780
|
+
// - `<all_urls>` as the stand-in would run a glob-only script on
|
|
781
|
+
// every page. The stand-in is `NO_PAGE_MATCH` instead (added to
|
|
782
|
+
// any spec with globs, for the reason given there), and the
|
|
783
|
+
// globs say where Chrome injects.
|
|
784
|
+
// - Were it ever the documented AND, that would inject nowhere.
|
|
785
|
+
// The probe would fail first.
|
|
786
|
+
// - A regex `@include` still needs `<all_urls>` (below): no glob
|
|
787
|
+
// can stand for it.
|
|
788
|
+
// `excludeGlobs` is a true AND under both readings, so it needs no
|
|
789
|
+
// such care.
|
|
790
|
+
//
|
|
791
|
+
// A script with no match/include patterns whatsoever shouldn't reach
|
|
792
|
+
// here — validateHeader stores it `invalid`.
|
|
793
|
+
//
|
|
794
|
+
// A script can land here with `@match` patterns that Chrome's
|
|
795
|
+
// grammar refused (dropped above) alongside its includes. Chrome then
|
|
796
|
+
// injects only where the globs reach, so the refused patterns run
|
|
797
|
+
// nowhere, as when a script has valid `@match`es beside them.
|
|
798
|
+
let usedAllUrlsFallback = false;
|
|
799
|
+
let usedNoPageStandIn = false;
|
|
800
|
+
if (hasRegexInclude) {
|
|
801
|
+
// A regex `@include` can cover pages no registered `@match` or
|
|
802
|
+
// glob does, and Chrome can't read it, so registering the rest
|
|
803
|
+
// alone would never inject there. Every page instead, narrowed by
|
|
804
|
+
// the wrapper, which checks the whole rule set.
|
|
805
|
+
matches.splice(0, matches.length, '<all_urls>');
|
|
806
|
+
usedAllUrlsFallback = true;
|
|
807
|
+
}
|
|
808
|
+
else if (includeGlobs.length > 0) {
|
|
809
|
+
matches.push(NO_PAGE_MATCH);
|
|
810
|
+
usedNoPageStandIn = true;
|
|
811
|
+
}
|
|
812
|
+
else if (matches.length === 0) {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
const declaredRunAt = normalizeDeclaredRunAt(script.header ? getValue(script.header, 'run-at') : undefined);
|
|
816
|
+
const name = scriptDisplayName(script);
|
|
817
|
+
// A script with globs keeps the in-page check too: Chrome's glob
|
|
818
|
+
// reading isn't ours (case, `?`), a `.tld` glob is coarse, and
|
|
819
|
+
// `NO_PAGE_MATCH` is a real (if unlikely) path.
|
|
820
|
+
const needsWrapperFilter = hasRegexInclude || hasExclude || usedAllUrlsFallback
|
|
821
|
+
|| usedNoPageStandIn || hasTldRule;
|
|
822
|
+
const sourceUrl = buildSourceUrl(logLabel);
|
|
823
|
+
const world = resolveScriptWorld(script);
|
|
824
|
+
// GM_info is implicit in TM/VM — available in every world, with or
|
|
825
|
+
// without an `@grant` line — and the rest of the granted surface is
|
|
826
|
+
// emitted the same way in both worlds. The shared GM runtime
|
|
827
|
+
// is registered in whichever worlds need it (`buildGmRuntimeSpecs`),
|
|
828
|
+
// and its MAIN-world copy routes SW traffic over `wrapper-bridge.js`.
|
|
829
|
+
const gmEmit = await buildGmBindingsForScript(script, scriptHandlerVersion);
|
|
830
|
+
const requires = script.requires ?? [];
|
|
831
|
+
// Synthetic sourceURLs per require — these are what stack frames
|
|
832
|
+
// will reference, so the async-error filter in the prelude needs
|
|
833
|
+
// to match against these (not the original fetched URLs).
|
|
834
|
+
const requireSourceUrls = requires.map((r) => buildRequireSourceUrl(logLabel, r.url));
|
|
835
|
+
// Per-script config (entry 0).
|
|
836
|
+
const cfgBlob = buildConfigBlob({
|
|
837
|
+
id: script.id,
|
|
838
|
+
logLabel,
|
|
839
|
+
name,
|
|
840
|
+
sourceUrl,
|
|
841
|
+
loadedAt: script.loadedAt,
|
|
842
|
+
runAt: declaredRunAt,
|
|
843
|
+
needsGmValues: gmEmit.needsValues,
|
|
844
|
+
needsGmRuntime: needsGmRuntime(script.header),
|
|
845
|
+
...(isScratchUrl(script.fileUrl) ? { run: scratchRunOf(script.fileUrl) ?? '' } : {}),
|
|
846
|
+
matching: needsWrapperFilter ? pageMatcher(script.matching) : null,
|
|
847
|
+
startupLogs: script.startupLogs ?? [],
|
|
848
|
+
...(options.manualRun ? { manualRun: options.manualRun } : {}),
|
|
849
|
+
});
|
|
850
|
+
// The whole user program — requires then body — as ONE entry
|
|
851
|
+
// (js[2]). Sharing a scope is what makes a library's top-level
|
|
852
|
+
// `const`/`class` visible to the body; see wrapCombinedEntry.
|
|
853
|
+
const combinedCode = wrapCombinedEntry({
|
|
854
|
+
scriptId: script.id,
|
|
855
|
+
sourceUrl,
|
|
856
|
+
script,
|
|
857
|
+
requireUrls: requireSourceUrls,
|
|
858
|
+
gmBindings: gmEmit.js,
|
|
859
|
+
gmPrelude: gmEmit.prelude,
|
|
860
|
+
evalHookKey: options.evalHookKey ?? scratchEvalHookKey(script.fileUrl),
|
|
861
|
+
...(runsSandboxed(script) ? { sandboxed: gmEmit } : {}),
|
|
862
|
+
});
|
|
863
|
+
// Register at document_start regardless of the declared @run-at. The
|
|
864
|
+
// prelude itself awaits the matching lifecycle event (DOMContentLoaded
|
|
865
|
+
// for document-end, load for document-idle, etc.) before invoking
|
|
866
|
+
// the user body. See docs/injection.md.
|
|
867
|
+
const spec = {
|
|
868
|
+
id: script.id,
|
|
869
|
+
js: assembleEntries({
|
|
870
|
+
configBlob: cfgBlob,
|
|
871
|
+
preludeCode: wrapPreludeEntry(),
|
|
872
|
+
combinedCode,
|
|
873
|
+
sealCode: wrapSealEntry(script.id, logLabel),
|
|
874
|
+
}),
|
|
875
|
+
runAt: 'document_start',
|
|
876
|
+
world,
|
|
877
|
+
// Every frame whose own URL matches, as in TM and VM, unless the
|
|
878
|
+
// script opts out. See docs/frames.md.
|
|
879
|
+
allFrames: !(script.header && hasTag(script.header, 'noframes')),
|
|
880
|
+
};
|
|
881
|
+
if (matches.length > 0)
|
|
882
|
+
spec.matches = matches;
|
|
883
|
+
if (includeGlobs.length > 0)
|
|
884
|
+
spec.includeGlobs = includeGlobs;
|
|
885
|
+
if (excludeGlobs.length > 0)
|
|
886
|
+
spec.excludeGlobs = excludeGlobs;
|
|
887
|
+
return spec;
|
|
888
|
+
}
|
|
889
|
+
// Pure: would this script be registered right now? Both the script's
|
|
890
|
+
// own enabled flag and its parent collection's flag must be true, and
|
|
891
|
+
// the script must be `ok` — `invalid` scripts never run. A manual
|
|
892
|
+
// script never registers: it runs only on request (`isManualRunCandidate`).
|
|
893
|
+
//
|
|
894
|
+
// Shared with `runSync`'s parse re-verification, which spends a compile
|
|
895
|
+
// round trip per row and so must ask about exactly the rows that are
|
|
896
|
+
// about to be registered — one predicate, so "eligible" can't come to
|
|
897
|
+
// mean two different things.
|
|
898
|
+
export function isRegistrationCandidate(script, collections) {
|
|
899
|
+
return isRunnable(script, collections) && !isManualScript(script);
|
|
900
|
+
}
|
|
901
|
+
// Pure: could this manual script be run on request right now? The same
|
|
902
|
+
// rules as registering one, apart from being manual.
|
|
903
|
+
export function isManualRunCandidate(script, collections) {
|
|
904
|
+
return isRunnable(script, collections) && isManualScript(script);
|
|
905
|
+
}
|
|
906
|
+
function isRunnable(script, collections) {
|
|
907
|
+
if (script.status !== 'ok')
|
|
908
|
+
return false;
|
|
909
|
+
if (!isScriptEnabled(script))
|
|
910
|
+
return false;
|
|
911
|
+
const collection = collections.get(script.collectionId);
|
|
912
|
+
return !!collection && isCollectionEnabled(collection);
|
|
913
|
+
}
|
|
914
|
+
// Pick the scripts that should be registered right now and build a
|
|
915
|
+
// spec for each. Async because each spec build is async (storage
|
|
916
|
+
// pre-load). We parallelize the per-script builds with Promise.all
|
|
917
|
+
// rather than awaiting sequentially.
|
|
918
|
+
//
|
|
919
|
+
// `logIds` is the {script id → label} map. Every sync passes one built
|
|
920
|
+
// with the script numbers; the default, from `scripts` alone and with
|
|
921
|
+
// no numbers, is for tests. An incremental sync passes a subset of rows,
|
|
922
|
+
// so its map comes from the whole library, because an unnumbered
|
|
923
|
+
// script's `#N` that depends on which rows this sync happened to read
|
|
924
|
+
// would disagree with every other surface that shows the label.
|
|
925
|
+
//
|
|
926
|
+
// `gmRuntime` names the scripts that need the shared GM
|
|
927
|
+
// runtime registered in their world, each carrying the patterns Chrome
|
|
928
|
+
// will inject IT on. Derived from grants, not from the specs, because a
|
|
929
|
+
// `RegistrationSpec` is a plain Chrome type and an extra field on it
|
|
930
|
+
// would be handed to `chrome.userScripts.register`. Kept per script
|
|
931
|
+
// rather than pre-unioned per world so an incremental sync can keep the
|
|
932
|
+
// answer for the rows it isn't rebuilding.
|
|
933
|
+
//
|
|
934
|
+
// Two things fall out of scoping the runtime this way:
|
|
935
|
+
//
|
|
936
|
+
// - A `@grant none` script never drags the runtime onto its page.
|
|
937
|
+
// - A script that does need it puts the runtime on the pages IT runs
|
|
938
|
+
// on, not on every page the user visits.
|
|
939
|
+
//
|
|
940
|
+
// See `buildGmRuntimeSpecs`.
|
|
941
|
+
export async function collectActiveSpecs(scripts, collections, scriptHandlerVersion = '0.0.0', logIds = computeLogIds(scripts.map((s) => ({
|
|
942
|
+
id: s.id, relativePath: s.relativePath,
|
|
943
|
+
})))) {
|
|
944
|
+
await loadChannelKey();
|
|
945
|
+
const eligible = [];
|
|
946
|
+
const promises = [];
|
|
947
|
+
for (const s of scripts) {
|
|
948
|
+
if (!isRegistrationCandidate(s, collections))
|
|
949
|
+
continue;
|
|
950
|
+
const label = logIds.get(s.id) ?? scriptBasename(s.relativePath);
|
|
951
|
+
eligible.push(s);
|
|
952
|
+
promises.push(buildRegistrationSpec(s, scriptHandlerVersion, label));
|
|
953
|
+
}
|
|
954
|
+
const results = await Promise.all(promises);
|
|
955
|
+
const specs = [];
|
|
956
|
+
const gmRuntime = new Map();
|
|
957
|
+
for (let i = 0; i < results.length; i++) {
|
|
958
|
+
const r = results[i];
|
|
959
|
+
// A null spec means the script has nothing Chrome can match on, so
|
|
960
|
+
// it isn't registered — and a script that never runs must not drag
|
|
961
|
+
// the GM runtime onto the page either.
|
|
962
|
+
if (!r)
|
|
963
|
+
continue;
|
|
964
|
+
specs.push(r);
|
|
965
|
+
if (!needsGmRuntime(eligible[i].header))
|
|
966
|
+
continue;
|
|
967
|
+
// Take the patterns off the SPEC, not the header: that's what
|
|
968
|
+
// Chrome will actually inject the script on, including the
|
|
969
|
+
// `<all_urls>` fallback a regex-only `@include` gets. So the
|
|
970
|
+
// runtime is present wherever its scripts can run, by
|
|
971
|
+
// construction.
|
|
972
|
+
gmRuntime.set(eligible[i].id, {
|
|
973
|
+
matches: [...(r.matches ?? [])],
|
|
974
|
+
includeGlobs: [...(r.includeGlobs ?? [])],
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
return { specs, gmRuntime, gmRuntimeScope: gmRuntimeScopeOfSpecs(specs, gmRuntime) };
|
|
978
|
+
}
|
|
979
|
+
// Pure: build the registration spec for the MAIN-world bridge, or
|
|
980
|
+
// return null when no MAIN-world script is active.
|
|
981
|
+
//
|
|
982
|
+
// Why we need this: MAIN-world wrappers can't call
|
|
983
|
+
// chrome.runtime.sendMessage (chrome.runtime is undefined there), so
|
|
984
|
+
// the prelude's send() falls back to dispatching a
|
|
985
|
+
// "sourcemonkey-sync-<key>" DOM event. The bridge runs in USER_SCRIPT
|
|
986
|
+
// world on the same page, hears the event in the same task, and
|
|
987
|
+
// forwards the payload to the SW.
|
|
988
|
+
//
|
|
989
|
+
// Ordering matters here. The request path is a synchronous dispatch,
|
|
990
|
+
// not a queued message, so an event dispatched before this spec's
|
|
991
|
+
// listener exists is dropped rather than delivered later. We emit our
|
|
992
|
+
// own specs ahead of the user scripts' for that reason (see
|
|
993
|
+
// `orderWithOwnSpecs`). When it still races (a sync that widened the
|
|
994
|
+
// bridge re-registered it behind them), the senders hold what the
|
|
995
|
+
// bridge didn't take and send it when the bridge announces itself.
|
|
996
|
+
//
|
|
997
|
+
// Scoping. The bridge registers on the union of every MAIN-world
|
|
998
|
+
// script's patterns — the same rule as the GM runtime, and for the
|
|
999
|
+
// same reason in a different currency: the runtime's cost on an
|
|
1000
|
+
// unrelated page is a page-visible global, the bridge's is a whole
|
|
1001
|
+
// USER_SCRIPT world created for that document (a V8 context per
|
|
1002
|
+
// navigation) plus its listeners. Neither belongs on pages where no
|
|
1003
|
+
// MAIN-world script can run. Unlike the runtime, every MAIN-world
|
|
1004
|
+
// script counts, granted or not: telemetry and the reload handshake
|
|
1005
|
+
// ride the bridge too.
|
|
1006
|
+
export function buildBridgeSpec(activeSpecs) {
|
|
1007
|
+
const scope = bridgeScopeOf(activeSpecs.map((spec) => ({
|
|
1008
|
+
world: (spec.world ?? 'MAIN'),
|
|
1009
|
+
patterns: patternsOf(spec),
|
|
1010
|
+
injectsOn: injectsOnOf(spec),
|
|
1011
|
+
gmRuntime: null,
|
|
1012
|
+
allFrames: spec.allFrames === true,
|
|
1013
|
+
})));
|
|
1014
|
+
return scope ? bridgeSpec(scope) : null;
|
|
1015
|
+
}
|
|
1016
|
+
// The helpers (the bridge, the GM runtime) go into frames whatever
|
|
1017
|
+
// their scripts' `@noframes`: they're scoped by a pattern union, not
|
|
1018
|
+
// per script, and one missing from a frame where a script runs breaks
|
|
1019
|
+
// it, while one in a frame where only `@noframes` scripts match just
|
|
1020
|
+
// sits idle. See docs/frames.md.
|
|
1021
|
+
const HELPER_ALL_FRAMES = true;
|
|
1022
|
+
// The spec itself, with no "is it wanted?" question attached — the
|
|
1023
|
+
// incremental sync answers that from the whole registration set rather
|
|
1024
|
+
// than from one sync's spec list. `scope` is never empty: a MAIN-world
|
|
1025
|
+
// script that reached registration had at least one pattern.
|
|
1026
|
+
function bridgeSpec(scope) {
|
|
1027
|
+
const code = scope.mainInFrames ? withChannelKey(WRAPPER_BRIDGE) : noMainInFrames(withChannelKey(WRAPPER_BRIDGE));
|
|
1028
|
+
const spec = {
|
|
1029
|
+
id: BRIDGE_SCRIPT_ID,
|
|
1030
|
+
js: [{ code: code + `\n//# sourceURL=${BRIDGE_SOURCE_URL}\n` }],
|
|
1031
|
+
runAt: 'document_start',
|
|
1032
|
+
world: 'USER_SCRIPT',
|
|
1033
|
+
// In frames too, whether or not its scripts are: see `HELPER_ALL_FRAMES`.
|
|
1034
|
+
allFrames: HELPER_ALL_FRAMES,
|
|
1035
|
+
};
|
|
1036
|
+
applyScope(spec, scope);
|
|
1037
|
+
return spec;
|
|
1038
|
+
}
|
|
1039
|
+
// The bridge's code for a registration set whose MAIN-world scripts are
|
|
1040
|
+
// all `@noframes`: in a child frame it then doesn't ask the SW about a
|
|
1041
|
+
// sandboxed page (`sm:main-blocked`), since no MAIN-world script would
|
|
1042
|
+
// run there anyway, and the ask would wake the worker for each frame.
|
|
1043
|
+
// As the minified bridge has it (scripts/build.mjs keeps the name).
|
|
1044
|
+
const MAIN_IN_FRAMES = 'var SM_MAIN_IN_FRAMES=true;';
|
|
1045
|
+
const NO_MAIN_IN_FRAMES = 'var SM_MAIN_IN_FRAMES=false;';
|
|
1046
|
+
function noMainInFrames(bridgeCode) {
|
|
1047
|
+
if (!bridgeCode.includes(MAIN_IN_FRAMES))
|
|
1048
|
+
throw new Error('Internal error: The bridge has no frames flag');
|
|
1049
|
+
return bridgeCode.replace(MAIN_IN_FRAMES, NO_MAIN_IN_FRAMES);
|
|
1050
|
+
}
|
|
1051
|
+
// Put a pattern union on a spec. Chrome refuses a registration with
|
|
1052
|
+
// neither field; `<all_urls>` is the unreachable defensive answer (a
|
|
1053
|
+
// script that reached registration always had at least one pattern),
|
|
1054
|
+
// kept because a silent refusal here takes every GM_* call down.
|
|
1055
|
+
function applyScope(spec, scope) {
|
|
1056
|
+
if (scope.matches.size > 0)
|
|
1057
|
+
spec.matches = [...scope.matches];
|
|
1058
|
+
if (scope.includeGlobs.size > 0)
|
|
1059
|
+
spec.includeGlobs = [...scope.includeGlobs];
|
|
1060
|
+
if (!spec.matches && !spec.includeGlobs)
|
|
1061
|
+
spec.matches = ['<all_urls>'];
|
|
1062
|
+
}
|
|
1063
|
+
// Pure: build the registration specs for the shared GM_*
|
|
1064
|
+
// runtime — one per world in `scope`. See the module-level comment in
|
|
1065
|
+
// src/wrapper-gm-runtime.js for the runtime's design.
|
|
1066
|
+
//
|
|
1067
|
+
// The SAME source is registered in both worlds; the runtime detects at
|
|
1068
|
+
// init time whether `chrome.runtime` is reachable and picks its
|
|
1069
|
+
// transport accordingly (direct `sendMessage` in USER_SCRIPT, the
|
|
1070
|
+
// DOM-event bridge in MAIN). One implementation, two transports —
|
|
1071
|
+
// rather than a second MAIN-only copy that would drift.
|
|
1072
|
+
//
|
|
1073
|
+
// Registration ids differ per world because `chrome.userScripts`
|
|
1074
|
+
// namespaces ids globally, not per world.
|
|
1075
|
+
//
|
|
1076
|
+
// Patterns: the union of the owning scripts' own, NOT `<all_urls>` —
|
|
1077
|
+
// the runtime's cost on an unrelated page is its load time, so it
|
|
1078
|
+
// goes only where its scripts go (the bridge is scoped the same
|
|
1079
|
+
// way; see `buildBridgeSpec`). Excludes are deliberately NOT unioned:
|
|
1080
|
+
// `@exclude` is per-script, and a runtime present on a page where no
|
|
1081
|
+
// script runs is harmless, whereas one missing from a page where a
|
|
1082
|
+
// script DOES run breaks every GM_* call it makes.
|
|
1083
|
+
export function buildGmRuntimeSpecs(scope) {
|
|
1084
|
+
const out = [];
|
|
1085
|
+
for (const world of GM_RUNTIME_WORLDS) {
|
|
1086
|
+
const s = scope.get(world);
|
|
1087
|
+
if (!s)
|
|
1088
|
+
continue;
|
|
1089
|
+
const spec = {
|
|
1090
|
+
id: gmRuntimeIdFor(world),
|
|
1091
|
+
js: [{ code: withChannelKey(WRAPPER_GM_RUNTIME) + `\n//# sourceURL=${GM_RUNTIME_SOURCE_URL}\n` }],
|
|
1092
|
+
runAt: 'document_start',
|
|
1093
|
+
world,
|
|
1094
|
+
allFrames: HELPER_ALL_FRAMES,
|
|
1095
|
+
};
|
|
1096
|
+
applyScope(spec, s);
|
|
1097
|
+
out.push(spec);
|
|
1098
|
+
}
|
|
1099
|
+
return out;
|
|
1100
|
+
}
|
|
1101
|
+
// The worlds the runtime can be registered in, in registration order.
|
|
1102
|
+
const GM_RUNTIME_WORLDS = ['USER_SCRIPT', 'MAIN'];
|
|
1103
|
+
// The id the runtime carries in a given world. `chrome.userScripts`
|
|
1104
|
+
// namespaces ids globally rather than per world, so the two
|
|
1105
|
+
// registrations can't share one.
|
|
1106
|
+
function gmRuntimeIdFor(world) {
|
|
1107
|
+
return world === 'MAIN' ? GM_RUNTIME_MAIN_SCRIPT_ID : GM_RUNTIME_SCRIPT_ID;
|
|
1108
|
+
}
|
|
1109
|
+
// What one run injects, in order. The script's world may not have our
|
|
1110
|
+
// own pieces yet, since they're registered only where registered
|
|
1111
|
+
// scripts run, so they go first. Both are no-ops where already present.
|
|
1112
|
+
// - The bridge, for a MAIN-world script (its reports and GM calls
|
|
1113
|
+
// reach the worker through it).
|
|
1114
|
+
// - The GM runtime, for a script that needs one.
|
|
1115
|
+
// `isolated` runs a MAIN-world script in USER_SCRIPT world instead, for
|
|
1116
|
+
// a page that turns off its own script (see `asIsolated`).
|
|
1117
|
+
// Null when the script has nothing Chrome could match on.
|
|
1118
|
+
export async function buildManualRunInjections(script, scriptHandlerVersion, logLabel, gmSnapshot, isolated = false) {
|
|
1119
|
+
const spec = await buildRegistrationSpec(isolated ? asIsolated(script) : script, scriptHandlerVersion, logLabel, {
|
|
1120
|
+
manualRun: gmSnapshot !== undefined ? { gmValues: gmSnapshot.values, gmSeq: gmSnapshot.seq } : {},
|
|
1121
|
+
});
|
|
1122
|
+
if (!spec)
|
|
1123
|
+
return null;
|
|
1124
|
+
const world = (spec.world ?? 'USER_SCRIPT');
|
|
1125
|
+
const out = [];
|
|
1126
|
+
if (world === 'MAIN') {
|
|
1127
|
+
// Relaying only this script's traffic: see `documentKey`.
|
|
1128
|
+
out.push({ world: 'USER_SCRIPT', js: [{ code: onlyScript(withChannelKey(WRAPPER_BRIDGE), script.id) + `\n//# sourceURL=${BRIDGE_SOURCE_URL}\n` }] });
|
|
1129
|
+
}
|
|
1130
|
+
if (needsGmRuntime(script.header)) {
|
|
1131
|
+
out.push({ world, js: [{ code: withChannelKey(WRAPPER_GM_RUNTIME) + `\n//# sourceURL=${GM_RUNTIME_SOURCE_URL}\n` }] });
|
|
1132
|
+
}
|
|
1133
|
+
out.push({ world, js: spec.js.map((j) => ({ code: j.code })) });
|
|
1134
|
+
return out;
|
|
1135
|
+
}
|
|
1136
|
+
// `script` as it would be with `@inject-into content` ahead of its own
|
|
1137
|
+
// headers, so it builds as an isolated script would. For a page that
|
|
1138
|
+
// turns off script in its own world (a CSP `sandbox` without
|
|
1139
|
+
// `allow-scripts`), where Chrome injects nothing in MAIN.
|
|
1140
|
+
function asIsolated(script) {
|
|
1141
|
+
if (!script.header)
|
|
1142
|
+
return script;
|
|
1143
|
+
return {
|
|
1144
|
+
...script,
|
|
1145
|
+
header: { ...script.header, tags: [{ tag: 'inject-into', value: 'content' }, ...script.header.tags] },
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
// What to inject to run MAIN-world script `script` in USER_SCRIPT world
|
|
1149
|
+
// instead, on a page that turns off script in its own world
|
|
1150
|
+
// (`asIsolated`), with the runtime first (a no-op where present). Null
|
|
1151
|
+
// when the script has nothing Chrome could match on. See
|
|
1152
|
+
// `runBlockedMainScripts` in background.ts.
|
|
1153
|
+
export async function buildIsolatedFallbackInjections(script, scriptHandlerVersion, logLabel) {
|
|
1154
|
+
if (!script.header)
|
|
1155
|
+
return null;
|
|
1156
|
+
const spec = await buildRegistrationSpec(asIsolated(script), scriptHandlerVersion, logLabel);
|
|
1157
|
+
if (!spec)
|
|
1158
|
+
return null;
|
|
1159
|
+
const out = [];
|
|
1160
|
+
if (needsGmRuntime(script.header)) {
|
|
1161
|
+
out.push({ world: 'USER_SCRIPT', js: [{ code: withChannelKey(WRAPPER_GM_RUNTIME) + `\n//# sourceURL=${GM_RUNTIME_SOURCE_URL}\n` }] });
|
|
1162
|
+
}
|
|
1163
|
+
out.push({ world: 'USER_SCRIPT', js: spec.js.map((j) => ({ code: j.code })) });
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
// Whether document `documentId` of `tabId` runs script in its own
|
|
1167
|
+
// (MAIN) world. Chrome drops a MAIN-world injection into a page whose
|
|
1168
|
+
// sandbox turns that off, with no error, so this asks for a result.
|
|
1169
|
+
export async function pageWorldRuns(tabId, documentId) {
|
|
1170
|
+
await ensureWorldConfigured();
|
|
1171
|
+
const api = chrome.userScripts;
|
|
1172
|
+
const results = await api.execute({
|
|
1173
|
+
target: { tabId, documentIds: [documentId] },
|
|
1174
|
+
js: [{ code: 'true' }],
|
|
1175
|
+
world: 'MAIN',
|
|
1176
|
+
injectImmediately: true,
|
|
1177
|
+
});
|
|
1178
|
+
return results?.[0]?.result === true;
|
|
1179
|
+
}
|
|
1180
|
+
// Inject a manual run (or a sandboxed page's fallback) into document
|
|
1181
|
+
// `documentId` of `tabId`. Throws what Chrome says when it can't: a
|
|
1182
|
+
// page it won't inject into, or one that's gone (the tab closed or
|
|
1183
|
+
// navigated).
|
|
1184
|
+
export async function executeManualRun(tabId, documentId, injections) {
|
|
1185
|
+
await ensureWorldConfigured();
|
|
1186
|
+
const api = chrome.userScripts;
|
|
1187
|
+
for (const injection of injections) {
|
|
1188
|
+
await api.execute({
|
|
1189
|
+
target: { tabId, documentIds: [documentId] },
|
|
1190
|
+
js: injection.js,
|
|
1191
|
+
world: injection.world,
|
|
1192
|
+
injectImmediately: true,
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// ---------- Runtime sync (uses chrome.userScripts) ----------
|
|
1197
|
+
// Guards the "userScripts is unavailable" console line so it appears
|
|
1198
|
+
// once per service-worker lifetime rather than once per sync.
|
|
1199
|
+
let loggedUserScriptsUnavailable = false;
|
|
1200
|
+
// True if the API surface is present at all. The permission is required
|
|
1201
|
+
// for chrome.userScripts to exist; if it's missing the sync no-ops.
|
|
1202
|
+
export function isUserScriptsAvailable() {
|
|
1203
|
+
return typeof chrome !== 'undefined' && chrome.userScripts != null;
|
|
1204
|
+
}
|
|
1205
|
+
// Whether calls work, which is what "Allow User Scripts" decides. The
|
|
1206
|
+
// namespace can be there with the toggle off: turned off while this
|
|
1207
|
+
// worker runs, every call throws instead. Asks for no scripts by id, so
|
|
1208
|
+
// it's one cheap call.
|
|
1209
|
+
//
|
|
1210
|
+
// When they are, also makes sure Chrome still holds the world's settings
|
|
1211
|
+
// (`repairWorldConfiguration`): this runs at each page load and when a
|
|
1212
|
+
// page comes to the front, which is where a lost setting would show.
|
|
1213
|
+
//
|
|
1214
|
+
// Also syncs when it finds them usable after finding them not: scripts
|
|
1215
|
+
// added or changed meanwhile were never registered, and Chrome says
|
|
1216
|
+
// nothing when the toggle changes.
|
|
1217
|
+
let lastFoundUnusable = false;
|
|
1218
|
+
export async function userScriptsUsable() {
|
|
1219
|
+
let usable = isUserScriptsAvailable();
|
|
1220
|
+
if (usable) {
|
|
1221
|
+
try {
|
|
1222
|
+
await chrome.userScripts.getScripts({ ids: [] });
|
|
1223
|
+
}
|
|
1224
|
+
catch {
|
|
1225
|
+
usable = false;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
if (!usable) {
|
|
1229
|
+
lastFoundUnusable = true;
|
|
1230
|
+
return false;
|
|
1231
|
+
}
|
|
1232
|
+
await repairWorldConfiguration().catch((err) => {
|
|
1233
|
+
console.info('[SourceMonkey] could not check the user script world settings:', describeError(err));
|
|
1234
|
+
});
|
|
1235
|
+
if (lastFoundUnusable) {
|
|
1236
|
+
lastFoundUnusable = false;
|
|
1237
|
+
void syncRegistrations();
|
|
1238
|
+
}
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
// Every script and collection row, without the scripts' bodies: those
|
|
1242
|
+
// are read by `withBodies`, for the scripts that get a spec.
|
|
1243
|
+
async function readAll() {
|
|
1244
|
+
// See match-index's loadAll: the pre-rename key spelling has to be
|
|
1245
|
+
// moved before anything reads the structural rows directly.
|
|
1246
|
+
await ensureCollectionKeysMigrated();
|
|
1247
|
+
// From the worker's row cache (frozen rows, read only), with the
|
|
1248
|
+
// numbers row, which it doesn't keep, read beside it.
|
|
1249
|
+
const isRow = (k) => k.startsWith(SCRIPT_PREFIX) || k.startsWith(COLLECTION_PREFIX);
|
|
1250
|
+
const cached = await sharedRows(isRow);
|
|
1251
|
+
const all = cached
|
|
1252
|
+
? { ...cached, ...await chrome.storage.local.get(SCRIPT_NUMBERS_KEY) }
|
|
1253
|
+
: await scanStorageRows('registration sync', (k) => isRow(k) || k === SCRIPT_NUMBERS_KEY);
|
|
1254
|
+
const scripts = [];
|
|
1255
|
+
const collections = new Map();
|
|
1256
|
+
for (const [k, v] of Object.entries(all)) {
|
|
1257
|
+
if (k.startsWith(SCRIPT_PREFIX))
|
|
1258
|
+
scripts.push(v);
|
|
1259
|
+
else if (k.startsWith(COLLECTION_PREFIX)) {
|
|
1260
|
+
const s = v;
|
|
1261
|
+
collections.set(s.id, s);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
return { scripts, collections, numbers: numberMap(numbersFrom(all[SCRIPT_NUMBERS_KEY])) };
|
|
1265
|
+
}
|
|
1266
|
+
// The rows among `rows` that would be registered, with their bodies:
|
|
1267
|
+
// a spec carries the code, and nothing else in a sync needs it. A row
|
|
1268
|
+
// that isn't a candidate (disabled, invalid, manual, or under a
|
|
1269
|
+
// disabled collection) is left out without reading its bodies, so a
|
|
1270
|
+
// sync that only removes registrations reads none.
|
|
1271
|
+
//
|
|
1272
|
+
// A candidate whose blob is missing comes back `invalid`
|
|
1273
|
+
// (`MISSING_BLOB_MESSAGE`), so `collectActiveSpecs` skips it, and its
|
|
1274
|
+
// row is saved that way (`recordMissingBlobs`) for the readers that
|
|
1275
|
+
// don't load bodies. That write's change event then syncs it again,
|
|
1276
|
+
// as a row that's no longer a candidate.
|
|
1277
|
+
async function withBodies(rows, collections) {
|
|
1278
|
+
const scripts = await fromStorage(rows.filter((s) => isRegistrationCandidate(s, collections)));
|
|
1279
|
+
const missing = scripts.filter((s) => s.status === 'invalid' && s.errorMessage === MISSING_BLOB_MESSAGE);
|
|
1280
|
+
if (missing.length > 0) {
|
|
1281
|
+
console.info(`[SourceMonkey] not registering ${missing.map((s) => s.relativePath).join(', ')}: ${MISSING_BLOB_MESSAGE}`);
|
|
1282
|
+
try {
|
|
1283
|
+
await recordMissingBlobs(missing.map((s) => s.id));
|
|
1284
|
+
}
|
|
1285
|
+
catch (err) {
|
|
1286
|
+
console.info('[SourceMonkey] could not save the missing-file state:', describeError(err));
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return scripts;
|
|
1290
|
+
}
|
|
1291
|
+
let syncState = null;
|
|
1292
|
+
// Forget what we last registered, so the next sync is a full reconcile.
|
|
1293
|
+
//
|
|
1294
|
+
// Callers are anything that falsifies what the record encodes — not
|
|
1295
|
+
// just "these ids are registered" but "everything in here was built
|
|
1296
|
+
// from current rows and passed the parse gate". Turning the parse
|
|
1297
|
+
// checker back on is one: rows registered while it was off were never
|
|
1298
|
+
// gated, and only a full reconcile revisits them.
|
|
1299
|
+
//
|
|
1300
|
+
// Unit tests use it between cases too — the state is module-level and
|
|
1301
|
+
// outlives a storage wipe, which is exactly the cross-test bleed the
|
|
1302
|
+
// retry gate beside it also has to be reset for.
|
|
1303
|
+
// Bumped by `forgetRegistrationState`, which a sync in flight checks.
|
|
1304
|
+
let stateGeneration = 0;
|
|
1305
|
+
export function forgetRegistrationState() {
|
|
1306
|
+
stateGeneration++;
|
|
1307
|
+
syncState = null;
|
|
1308
|
+
lastSyncKind = null;
|
|
1309
|
+
// The next sync reconciles in full, in this worker or the next: the
|
|
1310
|
+
// session record goes too.
|
|
1311
|
+
dropRecord();
|
|
1312
|
+
}
|
|
1313
|
+
// Test-only: as a worker just started, which has no record in memory
|
|
1314
|
+
// and may take the one in session storage. Waits for a pending save.
|
|
1315
|
+
export async function forgetRegistrationStateAsNewWorkerForTests() {
|
|
1316
|
+
if (recordSaveTimer !== null) {
|
|
1317
|
+
clearTimeout(recordSaveTimer);
|
|
1318
|
+
recordSaveTimer = null;
|
|
1319
|
+
await withRegistrationLock(saveRecord);
|
|
1320
|
+
}
|
|
1321
|
+
syncState = null;
|
|
1322
|
+
lastSyncKind = null;
|
|
1323
|
+
mayRestoreRecord = true;
|
|
1324
|
+
recordMarkedStale = false;
|
|
1325
|
+
recordUnsaved = false;
|
|
1326
|
+
}
|
|
1327
|
+
// Which path the last sync took. Test-only, and the only way to tell
|
|
1328
|
+
// from outside: an incremental sync and a full one leave the same
|
|
1329
|
+
// registration set behind, which is the point — so a test that means to
|
|
1330
|
+
// pin the incremental path has to be able to say it took it.
|
|
1331
|
+
let lastSyncKind = null;
|
|
1332
|
+
export function lastSyncKindForTests() {
|
|
1333
|
+
return lastSyncKind;
|
|
1334
|
+
}
|
|
1335
|
+
// Forget ONE id from the record while leaving Chrome's set alone. Unit
|
|
1336
|
+
// tests use it to reproduce a record that is wrong about what Chrome
|
|
1337
|
+
// holds (a reset, a record lost with a stopped worker).
|
|
1338
|
+
export function _forgetRegistrationForTests(id) {
|
|
1339
|
+
syncState?.registered.delete(id);
|
|
1340
|
+
}
|
|
1341
|
+
function patternsOf(spec) {
|
|
1342
|
+
return {
|
|
1343
|
+
matches: (spec.matches?.length ?? 0) > 0,
|
|
1344
|
+
// Sorted, deduplicated copies — the comparison below is about the
|
|
1345
|
+
// pattern SET. Re-registering a script only because its globs came
|
|
1346
|
+
// back in a different order, or with a duplicate collapsed, would
|
|
1347
|
+
// cost it an injection gap for nothing.
|
|
1348
|
+
includeGlobs: [...new Set(spec.includeGlobs ?? [])].sort(),
|
|
1349
|
+
excludeGlobs: [...new Set(spec.excludeGlobs ?? [])].sort(),
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function sameGlobs(a, b) {
|
|
1353
|
+
return a.length === b.length && a.every((v, i) => v === b[i]);
|
|
1354
|
+
}
|
|
1355
|
+
// Would `update` fail to bring a registration shaped like `prior` to
|
|
1356
|
+
// what `spec` declares? True when the spec drops the `matches` field,
|
|
1357
|
+
// or when its glob sets differ from the placed ones at all.
|
|
1358
|
+
function needsReplace(prior, spec) {
|
|
1359
|
+
const next = patternsOf(spec);
|
|
1360
|
+
return (prior.matches && !next.matches)
|
|
1361
|
+
|| !sameGlobs(prior.includeGlobs, next.includeGlobs)
|
|
1362
|
+
|| !sameGlobs(prior.excludeGlobs, next.excludeGlobs);
|
|
1363
|
+
}
|
|
1364
|
+
function needsReplaceFrom(present, spec) {
|
|
1365
|
+
const prior = present.get(spec.id);
|
|
1366
|
+
return prior !== undefined && needsReplace(prior, spec);
|
|
1367
|
+
}
|
|
1368
|
+
// What Chrome actually holds for one id, or undefined if it holds
|
|
1369
|
+
// nothing. The authority both `placeSpec` branches fall back to when
|
|
1370
|
+
// our belief turns out to be wrong.
|
|
1371
|
+
async function placedPatterns(id) {
|
|
1372
|
+
try {
|
|
1373
|
+
const got = await chrome.userScripts.getScripts({ ids: [id] });
|
|
1374
|
+
return got.length > 0 ? patternsOf(got[0]) : undefined;
|
|
1375
|
+
}
|
|
1376
|
+
catch {
|
|
1377
|
+
// Can't tell. Treated as "nothing there", which routes to the
|
|
1378
|
+
// register/replace branch — the safe direction, since those place
|
|
1379
|
+
// the spec whatever the truth turns out to be.
|
|
1380
|
+
return undefined;
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
// Read just the rows named, rather than the whole store. Script rows
|
|
1384
|
+
// come back without their bodies (see `withBodies`).
|
|
1385
|
+
async function readRows(scriptIds, collectionIds) {
|
|
1386
|
+
await ensureCollectionKeysMigrated();
|
|
1387
|
+
const keys = [
|
|
1388
|
+
...[...scriptIds].map((id) => SCRIPT_PREFIX + id),
|
|
1389
|
+
...[...collectionIds].map((id) => COLLECTION_PREFIX + id),
|
|
1390
|
+
];
|
|
1391
|
+
if (keys.length === 0)
|
|
1392
|
+
return {};
|
|
1393
|
+
const cached = cachedRows(keys);
|
|
1394
|
+
if (cached)
|
|
1395
|
+
return cached.found;
|
|
1396
|
+
return await chrome.storage.local.get(keys);
|
|
1397
|
+
}
|
|
1398
|
+
// The placements for user scripts only — our own registrations are the
|
|
1399
|
+
// thing being decided, so they can't be part of the input. (The bridge
|
|
1400
|
+
// itself runs in USER_SCRIPT world, so counting it would keep the
|
|
1401
|
+
// USER_SCRIPT runtime registered forever once a MAIN-world script had
|
|
1402
|
+
// ever been active.)
|
|
1403
|
+
function userPlacements(registered) {
|
|
1404
|
+
return [...registered].filter(([id]) => isUserScriptId(id)).map(([, p]) => p);
|
|
1405
|
+
}
|
|
1406
|
+
// Where the shared GM runtime has to be registered, given what is (or
|
|
1407
|
+
// is about to be) placed. A world earns an entry only from a script
|
|
1408
|
+
// that actually grants a runtime-backed API — which keeps the runtime
|
|
1409
|
+
// off the very common "MAIN world, `@grant none` only" page — and the
|
|
1410
|
+
// entry carries the union of those scripts' own patterns, so the
|
|
1411
|
+
// runtime goes only where they go.
|
|
1412
|
+
function gmRuntimeScopeOf(placements) {
|
|
1413
|
+
const out = new Map();
|
|
1414
|
+
for (const p of placements) {
|
|
1415
|
+
if (!p.gmRuntime)
|
|
1416
|
+
continue;
|
|
1417
|
+
let scope = out.get(p.world);
|
|
1418
|
+
if (!scope) {
|
|
1419
|
+
scope = { matches: new Set(), includeGlobs: new Set() };
|
|
1420
|
+
out.set(p.world, scope);
|
|
1421
|
+
}
|
|
1422
|
+
addToScope(scope, p.gmRuntime);
|
|
1423
|
+
}
|
|
1424
|
+
return out;
|
|
1425
|
+
}
|
|
1426
|
+
// Where the bridge has to be registered: the union of every MAIN-world
|
|
1427
|
+
// script's patterns, or null when there is no MAIN-world script and so
|
|
1428
|
+
// no bridge. Every such script counts, whatever it grants — the reload
|
|
1429
|
+
// handshake and error reports ride the bridge too.
|
|
1430
|
+
function bridgeScopeOf(placements) {
|
|
1431
|
+
let scope = null;
|
|
1432
|
+
for (const p of placements) {
|
|
1433
|
+
if (p.world !== 'MAIN')
|
|
1434
|
+
continue;
|
|
1435
|
+
if (!scope)
|
|
1436
|
+
scope = { matches: new Set(), includeGlobs: new Set(), mainInFrames: false };
|
|
1437
|
+
addToScope(scope, p.injectsOn);
|
|
1438
|
+
if (p.allFrames !== false)
|
|
1439
|
+
scope.mainInFrames = true;
|
|
1440
|
+
}
|
|
1441
|
+
return scope;
|
|
1442
|
+
}
|
|
1443
|
+
function addToScope(scope, patterns) {
|
|
1444
|
+
for (const m of patterns.matches)
|
|
1445
|
+
scope.matches.add(m);
|
|
1446
|
+
for (const g of patterns.includeGlobs)
|
|
1447
|
+
scope.includeGlobs.add(g);
|
|
1448
|
+
}
|
|
1449
|
+
// The patterns Chrome will inject a spec on, as the runtime and bridge
|
|
1450
|
+
// scopes want them.
|
|
1451
|
+
function injectsOnOf(spec) {
|
|
1452
|
+
return { matches: [...(spec.matches ?? [])], includeGlobs: [...(spec.includeGlobs ?? [])] };
|
|
1453
|
+
}
|
|
1454
|
+
// Our own specs for a registration set: the bridge, then a runtime per
|
|
1455
|
+
// world that wants one. In the order Chrome should receive them.
|
|
1456
|
+
function ownSpecsFor(placements) {
|
|
1457
|
+
const out = buildGmRuntimeSpecs(gmRuntimeScopeOf(placements));
|
|
1458
|
+
const bridge = bridgeScopeOf(placements);
|
|
1459
|
+
if (bridge)
|
|
1460
|
+
out.push(bridgeSpec(bridge));
|
|
1461
|
+
return out;
|
|
1462
|
+
}
|
|
1463
|
+
// Same question against a freshly built spec list, where the answer
|
|
1464
|
+
// lives beside the specs rather than on them.
|
|
1465
|
+
function gmRuntimeScopeOfSpecs(specs, gmRuntime) {
|
|
1466
|
+
return gmRuntimeScopeOf(placementsForSpecs(specs, gmRuntime));
|
|
1467
|
+
}
|
|
1468
|
+
// Placements as a freshly built spec list implies them, before Chrome
|
|
1469
|
+
// has been asked — what the own-spec builders take.
|
|
1470
|
+
function placementsForSpecs(specs, gmRuntime) {
|
|
1471
|
+
return specs.map((spec) => ({
|
|
1472
|
+
world: (spec.world ?? 'MAIN'),
|
|
1473
|
+
patterns: patternsOf(spec),
|
|
1474
|
+
injectsOn: injectsOnOf(spec),
|
|
1475
|
+
gmRuntime: gmRuntime.get(spec.id) ?? null,
|
|
1476
|
+
allFrames: spec.allFrames === true,
|
|
1477
|
+
}));
|
|
1478
|
+
}
|
|
1479
|
+
// What a registration says, hashed: everything Chrome is given for it.
|
|
1480
|
+
// A spec is built by the same code each sync, so equal registrations
|
|
1481
|
+
// serialize alike. Memoized per spec object: a sync asks twice.
|
|
1482
|
+
//
|
|
1483
|
+
// Only an incremental sync computes it, for the few scripts it rebuilds:
|
|
1484
|
+
// it stringifies the whole code, which a full sync of every script
|
|
1485
|
+
// shouldn't pay on a worker start. A script a full sync placed has no
|
|
1486
|
+
// hash, so the first incremental sync of it sends it, as before.
|
|
1487
|
+
const specHashes = new WeakMap();
|
|
1488
|
+
function specHash(spec) {
|
|
1489
|
+
let hash = specHashes.get(spec);
|
|
1490
|
+
if (hash === undefined) {
|
|
1491
|
+
hash = hashText(JSON.stringify(spec));
|
|
1492
|
+
specHashes.set(spec, hash);
|
|
1493
|
+
}
|
|
1494
|
+
return hash;
|
|
1495
|
+
}
|
|
1496
|
+
// A stable identity for one of our registrations' pattern union, so an
|
|
1497
|
+
// incremental sync can tell whether the scope moved. Sorted because the
|
|
1498
|
+
// union is built from a Set, whose order follows insertion. The bridge's
|
|
1499
|
+
// frames flag (`noMainInFrames`) counts too, so a script gaining or
|
|
1500
|
+
// losing `@noframes` re-registers it.
|
|
1501
|
+
function gmRuntimeFingerprint(spec) {
|
|
1502
|
+
const noFrames = spec.js?.some((js) => js.code?.includes(NO_MAIN_IN_FRAMES) ?? false) ?? false;
|
|
1503
|
+
return JSON.stringify([
|
|
1504
|
+
[...(spec.matches ?? [])].sort(),
|
|
1505
|
+
[...(spec.includeGlobs ?? [])].sort(),
|
|
1506
|
+
...(noFrames ? ['noMainInFrames'] : []),
|
|
1507
|
+
]);
|
|
1508
|
+
}
|
|
1509
|
+
// ---------- Serializing registration work ----------
|
|
1510
|
+
//
|
|
1511
|
+
// `chrome.userScripts` is a single mutable set, and every path that
|
|
1512
|
+
// changes it does so with a read-modify-write — decide what is there,
|
|
1513
|
+
// then place or drop — that Chrome does not make atomic. Two of those
|
|
1514
|
+
// interleaving is what produces
|
|
1515
|
+
//
|
|
1516
|
+
// Chrome rejected this script's registration, so it will not run:
|
|
1517
|
+
// Duplicate script ID '<uuid>'
|
|
1518
|
+
//
|
|
1519
|
+
// on a script that is in fact registered and running: one attempt
|
|
1520
|
+
// listed the registrations, a second attempt placed the script, and
|
|
1521
|
+
// then the first one offered the same id again. It shows up after a
|
|
1522
|
+
// restart or an extension update (the SW-startup sync overlapping the
|
|
1523
|
+
// one `onCacheChanged` schedules for whatever boot work writes to
|
|
1524
|
+
// storage) and after a refresh (the collection refresh's targeted retry
|
|
1525
|
+
// overlapping the sync its own storage writes triggered).
|
|
1526
|
+
//
|
|
1527
|
+
// So: one registration operation at a time, in the worker, the only
|
|
1528
|
+
// context that places registrations. The queue is
|
|
1529
|
+
// a promise chain rather than a flag because callers must be able to
|
|
1530
|
+
// await their own work; a rejected operation is isolated so it can't
|
|
1531
|
+
// break the ones behind it.
|
|
1532
|
+
let registrationLock = Promise.resolve();
|
|
1533
|
+
function withRegistrationLock(run) {
|
|
1534
|
+
// Every placement runs under this lock, so this is where a page that
|
|
1535
|
+
// reached one is stopped (docs/worker-writes.md).
|
|
1536
|
+
assertWorkerContext('change script registrations');
|
|
1537
|
+
// Same callback for fulfil and reject: the previous operation's
|
|
1538
|
+
// outcome is not ours to care about, only its completion. Called
|
|
1539
|
+
// through an arrow rather than passed directly so `run` can never be
|
|
1540
|
+
// handed the previous operation's value — or, on the rejection
|
|
1541
|
+
// branch, its Error — as an argument.
|
|
1542
|
+
const result = registrationLock.then(() => run(), () => run());
|
|
1543
|
+
registrationLock = result.catch(() => { });
|
|
1544
|
+
return result;
|
|
1545
|
+
}
|
|
1546
|
+
// A sync that hasn't started yet, if any. Everything `syncRegistrations`
|
|
1547
|
+
// does — including reading storage — happens under the lock, so a sync
|
|
1548
|
+
// still waiting there will see any state a later caller wrote and there
|
|
1549
|
+
// is nothing to gain by queueing a second one behind it. Callers that
|
|
1550
|
+
// arrive while one is *running* do get their own, because that one's
|
|
1551
|
+
// storage read already happened.
|
|
1552
|
+
let pendingSync = null;
|
|
1553
|
+
// The scope the queued sync will run with — the UNION of what everyone
|
|
1554
|
+
// who joined it asked for, since collapsing two syncs must not drop
|
|
1555
|
+
// either one's rows. A caller that asked for a full reconcile makes the
|
|
1556
|
+
// whole queued sync full.
|
|
1557
|
+
let queuedScriptIds = new Set();
|
|
1558
|
+
let queuedCollectionIds = new Set();
|
|
1559
|
+
let queuedFull = false;
|
|
1560
|
+
function queueScope(scope) {
|
|
1561
|
+
if (!scope) {
|
|
1562
|
+
queuedFull = true;
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
for (const id of scope.scriptIds)
|
|
1566
|
+
queuedScriptIds.add(id);
|
|
1567
|
+
for (const id of scope.collectionIds)
|
|
1568
|
+
queuedCollectionIds.add(id);
|
|
1569
|
+
}
|
|
1570
|
+
function takeQueuedScope() {
|
|
1571
|
+
const full = queuedFull;
|
|
1572
|
+
const scope = {
|
|
1573
|
+
scriptIds: [...queuedScriptIds],
|
|
1574
|
+
collectionIds: [...queuedCollectionIds],
|
|
1575
|
+
};
|
|
1576
|
+
queuedFull = false;
|
|
1577
|
+
queuedScriptIds = new Set();
|
|
1578
|
+
queuedCollectionIds = new Set();
|
|
1579
|
+
return full ? undefined : scope;
|
|
1580
|
+
}
|
|
1581
|
+
let worldConfigured = false;
|
|
1582
|
+
async function ensureWorldConfigured() {
|
|
1583
|
+
if (worldConfigured)
|
|
1584
|
+
return;
|
|
1585
|
+
await configureWorld();
|
|
1586
|
+
}
|
|
1587
|
+
// Puts the world's settings back if Chrome doesn't hold them, whatever
|
|
1588
|
+
// `worldConfigured` says. Chrome can be left without them while
|
|
1589
|
+
// registrations stay: seen after user scripts were disabled and enabled
|
|
1590
|
+
// again. Then a MAIN-world script's bridge can't message the worker, so
|
|
1591
|
+
// no script on any page gets its run decision, and none runs. Called
|
|
1592
|
+
// wherever user scripts are found usable (`userScriptsUsable`).
|
|
1593
|
+
async function repairWorldConfiguration() {
|
|
1594
|
+
const api = chrome.userScripts;
|
|
1595
|
+
const held = (await api.getWorldConfigurations()).find((c) => c.worldId === undefined);
|
|
1596
|
+
if (held?.messaging === true) {
|
|
1597
|
+
worldConfigured = true;
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
console.info('[SourceMonkey] the user script world had no messaging set up; setting it up again');
|
|
1601
|
+
await configureWorld();
|
|
1602
|
+
}
|
|
1603
|
+
async function configureWorld() {
|
|
1604
|
+
// configureWorld must be called before register. Settings:
|
|
1605
|
+
// - messaging: true — lets our wrapper call
|
|
1606
|
+
// chrome.runtime.sendMessage from the USER_SCRIPT world.
|
|
1607
|
+
// - csp — relaxes the default USER_SCRIPT-world CSP (which omits
|
|
1608
|
+
// 'unsafe-eval') so the reload-fast-path swap can eval the
|
|
1609
|
+
// SW-supplied body. The CSP only applies to scripts running in
|
|
1610
|
+
// this world; it does NOT change the page's own CSP.
|
|
1611
|
+
// - 'unsafe-inline' and * — Chrome checks <script> elements
|
|
1612
|
+
// inserted from this world against this CSP rather than the
|
|
1613
|
+
// page's. Without these, a userscript's page-code injection
|
|
1614
|
+
// (createElement('script'), GM_addElement) is refused on every
|
|
1615
|
+
// page, CSP or not: inline code, and any `src` not on the
|
|
1616
|
+
// extension's own origin.
|
|
1617
|
+
// - The new capability: <script> elements a userscript inserts
|
|
1618
|
+
// run in the page realm even under a strict page script-src.
|
|
1619
|
+
// Tampermonkey 5.5 sets the same world CSP, so it allows this
|
|
1620
|
+
// too. (A userscript could already run any code in its own
|
|
1621
|
+
// world and change the whole DOM.)
|
|
1622
|
+
// - Only <script> elements: inline event handlers and javascript:
|
|
1623
|
+
// URLs a userscript adds stay under the page CSP (see
|
|
1624
|
+
// user-script-world-csp.spec.ts).
|
|
1625
|
+
// Idempotent — Chrome accepts repeated calls but we still guard so
|
|
1626
|
+
// we don't issue them on every sync.
|
|
1627
|
+
await chrome.userScripts.configureWorld({
|
|
1628
|
+
messaging: true,
|
|
1629
|
+
csp: "script-src 'self' 'unsafe-eval' 'unsafe-inline' *",
|
|
1630
|
+
});
|
|
1631
|
+
worldConfigured = true;
|
|
1632
|
+
}
|
|
1633
|
+
// Drop the named registrations. Caller must already hold the
|
|
1634
|
+
// registration lock.
|
|
1635
|
+
//
|
|
1636
|
+
// Only ever called for a script that really is going away — disabled,
|
|
1637
|
+
// deleted, flipped to INVALID, or one whose fresh spec Chrome refused.
|
|
1638
|
+
// Nothing that is staying registered is dropped, so there is no window
|
|
1639
|
+
// in which a live script has no registration; see `placeSpecs`.
|
|
1640
|
+
//
|
|
1641
|
+
// Chrome fails the WHOLE `unregister` call when any single id in the
|
|
1642
|
+
// list is unknown, and an id can go missing before this call: what this
|
|
1643
|
+
// worker believes Chrome holds can be wrong (a reset, a lost record).
|
|
1644
|
+
// So the batch failing is not evidence that the other ids are gone; it
|
|
1645
|
+
// usually means one of them already was.
|
|
1646
|
+
//
|
|
1647
|
+
// Neither of the obvious answers is safe on its own:
|
|
1648
|
+
//
|
|
1649
|
+
// - Aborting leaves ids registered that no longer should be.
|
|
1650
|
+
// - Swallowing the failure does the same: a script that was disabled,
|
|
1651
|
+
// deleted, or flipped to INVALID would keep injecting until some
|
|
1652
|
+
// later sync's batch happened to succeed.
|
|
1653
|
+
//
|
|
1654
|
+
// So fall back to unregistering one id at a time, where an id that has
|
|
1655
|
+
// already gone takes only itself down.
|
|
1656
|
+
async function unregisterIds(ids) {
|
|
1657
|
+
if (ids.length === 0)
|
|
1658
|
+
return;
|
|
1659
|
+
try {
|
|
1660
|
+
await chrome.userScripts.unregister({ ids: [...ids] });
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
catch (err) {
|
|
1664
|
+
// A one-id call has no batch/per-id distinction to report, and it
|
|
1665
|
+
// fails routinely and harmlessly: a sync asks to drop every
|
|
1666
|
+
// affected script that no longer wants a registration, which
|
|
1667
|
+
// includes editing an already-disabled one. Saying so every time
|
|
1668
|
+
// would be a console line per edit that means nothing.
|
|
1669
|
+
if (ids.length > 1) {
|
|
1670
|
+
// console.info, like the other contained failures in this module:
|
|
1671
|
+
// rare, worth seeing in the SW console, and not something the user
|
|
1672
|
+
// can act on or that any one script is to blame for.
|
|
1673
|
+
console.info('[SourceMonkey] unregister failed as a batch; dropping ids one at a time:', describeError(err));
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
for (const id of ids) {
|
|
1677
|
+
try {
|
|
1678
|
+
await chrome.userScripts.unregister({ ids: [id] });
|
|
1679
|
+
}
|
|
1680
|
+
catch { /* already gone, which is what made the batch fail */ }
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
// Drop every registration Chrome currently holds.
|
|
1684
|
+
async function unregisterAll() {
|
|
1685
|
+
const existing = await chrome.userScripts.getScripts();
|
|
1686
|
+
await unregisterIds(existing.map((e) => e.id));
|
|
1687
|
+
}
|
|
1688
|
+
// Same, for callers outside this module — today only the test surface's
|
|
1689
|
+
// `resetAllState`, which wants a deterministic teardown instead of
|
|
1690
|
+
// waiting for the storage-change sync. Exported so that teardown is
|
|
1691
|
+
// ordered against any sync already in flight rather than racing it.
|
|
1692
|
+
export async function unregisterAllRegistrations() {
|
|
1693
|
+
if (!isUserScriptsAvailable()) {
|
|
1694
|
+
dropRecord();
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
await withRegistrationLock(async () => {
|
|
1698
|
+
await unregisterAll();
|
|
1699
|
+
// Our record of Chrome's set is now a lie, and an incremental sync
|
|
1700
|
+
// built on it would leave every script unregistered. Forget it, so
|
|
1701
|
+
// the next sync is a full reconcile. The session copy too.
|
|
1702
|
+
syncState = null;
|
|
1703
|
+
dropRecord();
|
|
1704
|
+
});
|
|
1705
|
+
}
|
|
1706
|
+
// Compile-check every candidate whose stored verdict is missing or
|
|
1707
|
+
// stale, and return the script set with any re-derived rows swapped in.
|
|
1708
|
+
//
|
|
1709
|
+
// What this is for: a row stored before the parse check existed — or
|
|
1710
|
+
// while it was failing open — carries `status: 'ok'` and no verdict,
|
|
1711
|
+
// and nothing in the load pipeline revisits it. Registering unparsable
|
|
1712
|
+
// source raises an uncaught `SyntaxError` that Chrome files under
|
|
1713
|
+
// `chrome://extensions/?errors`, where it is both uncatchable and
|
|
1714
|
+
// indistinguishable from SourceMonkey itself being broken. See
|
|
1715
|
+
// `verifyParseChecks` in script-store.ts.
|
|
1716
|
+
//
|
|
1717
|
+
// Cost: one compile per unchecked row per extension version. The
|
|
1718
|
+
// verdict is persisted, so the next sync finds nothing stale — but
|
|
1719
|
+
// `parseCheckKey` carries the version, so the first sync after an
|
|
1720
|
+
// update re-checks the whole library (capped fan-out; see
|
|
1721
|
+
// `PARSE_RECHECK_CONCURRENCY`). The write it makes fires
|
|
1722
|
+
// `storage.onChanged` and therefore one more sync, which then has
|
|
1723
|
+
// nothing to do.
|
|
1724
|
+
//
|
|
1725
|
+
// Never throws: the checker fails open on its own, and a failure of
|
|
1726
|
+
// this step must not take the whole registration set down with it.
|
|
1727
|
+
async function revalidateCandidates(scripts, collections) {
|
|
1728
|
+
let changed;
|
|
1729
|
+
try {
|
|
1730
|
+
changed = await verifyParseChecks(scripts.filter((s) => isRegistrationCandidate(s, collections)));
|
|
1731
|
+
}
|
|
1732
|
+
catch (err) {
|
|
1733
|
+
console.info('[SourceMonkey] parse re-verification skipped:', describeError(err));
|
|
1734
|
+
return scripts;
|
|
1735
|
+
}
|
|
1736
|
+
if (changed.size === 0)
|
|
1737
|
+
return scripts;
|
|
1738
|
+
for (const row of changed.values()) {
|
|
1739
|
+
// Says what we're about to DO, so it asks the same question
|
|
1740
|
+
// `collectActiveSpecs` will. `status !== 'ok'` isn't that question:
|
|
1741
|
+
// `verifyParseChecks` also hands back rows it refused to write, and
|
|
1742
|
+
// a disabled row is `ok` without being a candidate — which would
|
|
1743
|
+
// print this line for a row that was never going to register.
|
|
1744
|
+
if (isRegistrationCandidate(row, collections))
|
|
1745
|
+
continue;
|
|
1746
|
+
console.info(`[SourceMonkey] not registering ${row.relativePath}: ${row.errorMessage ?? 'it does not parse'}`);
|
|
1747
|
+
}
|
|
1748
|
+
return scripts.map((s) => changed.get(s.id) ?? s);
|
|
1749
|
+
}
|
|
1750
|
+
// Bring Chrome's registration set in line with storage.
|
|
1751
|
+
//
|
|
1752
|
+
// `scope` names the rows that moved; omit it for a full reconcile
|
|
1753
|
+
// (SW startup, and any sync this context can't safely make incremental).
|
|
1754
|
+
// A scoped sync rebuilds only those scripts' registrations and leaves
|
|
1755
|
+
// every other one exactly as it is — see `runIncrementalSync`.
|
|
1756
|
+
//
|
|
1757
|
+
// Serialized against every other registration operation in this
|
|
1758
|
+
// context, and collapsed with any sync that is queued but hasn't begun
|
|
1759
|
+
// — see the lock above for what the overlap used to cost.
|
|
1760
|
+
// Run at the start of each sync, before anything is read: the service
|
|
1761
|
+
// worker numbers new scripts here (`assignNumbers`), so a script
|
|
1762
|
+
// is registered under its numbered label from the start. Unset in unit
|
|
1763
|
+
// tests that don't number scripts.
|
|
1764
|
+
//
|
|
1765
|
+
// Resolves to the script numbers by id, as just assigned, so the sync
|
|
1766
|
+
// takes them from here rather than reading them again. The worker is the
|
|
1767
|
+
// numbers' only writer, and this runs inside the registration lock, so
|
|
1768
|
+
// nothing renumbers a script while the sync uses them.
|
|
1769
|
+
let beforeSync = null;
|
|
1770
|
+
export function setBeforeSync(hook) {
|
|
1771
|
+
beforeSync = hook;
|
|
1772
|
+
}
|
|
1773
|
+
export function syncRegistrations(scope) {
|
|
1774
|
+
queueScope(scope);
|
|
1775
|
+
if (pendingSync)
|
|
1776
|
+
return pendingSync;
|
|
1777
|
+
const sync = withRegistrationLock(async () => {
|
|
1778
|
+
// We're starting: a caller arriving from here on needs its own sync,
|
|
1779
|
+
// because our storage read is about to happen without whatever they
|
|
1780
|
+
// just wrote. Their scope goes into a fresh queue for that sync,
|
|
1781
|
+
// which is why the scope is taken at the same moment.
|
|
1782
|
+
const scopeNow = takeQueuedScope();
|
|
1783
|
+
pendingSync = null;
|
|
1784
|
+
await runSync(scopeNow);
|
|
1785
|
+
});
|
|
1786
|
+
pendingSync = sync;
|
|
1787
|
+
return sync;
|
|
1788
|
+
}
|
|
1789
|
+
// ---------- The record across worker restarts ----------
|
|
1790
|
+
//
|
|
1791
|
+
// A worker that starts with no record of what Chrome holds has to
|
|
1792
|
+
// reconcile in full: read every row and body, build every spec, and
|
|
1793
|
+
// compare them with Chrome's. Within one browser session the last
|
|
1794
|
+
// worker's record says the same thing, so it is kept in session storage
|
|
1795
|
+
// (which outlives a stopped worker, not a browser or extension restart)
|
|
1796
|
+
// and a new worker picks it up, syncing only the rows that changed since
|
|
1797
|
+
// (docs/injection.md#startup-the-record-or-a-full-reconcile).
|
|
1798
|
+
//
|
|
1799
|
+
// - A small marker (`SYNC_DIRTY_KEY`) is written before anything changes
|
|
1800
|
+
// Chrome's set, if it isn't there already. The record is saved once
|
|
1801
|
+
// syncs have been quiet for a moment (`RECORD_SAVE_DELAY_MS`), and the
|
|
1802
|
+
// marker removed with it. A worker that finds the marker reconciles in
|
|
1803
|
+
// full: the record may not describe what Chrome holds.
|
|
1804
|
+
// - So a burst of syncs costs one marker write and one record write,
|
|
1805
|
+
// not a record write each. Every session write reaches every open
|
|
1806
|
+
// extension page's listener.
|
|
1807
|
+
// - Taken only when made by this extension version, and when Chrome
|
|
1808
|
+
// still holds our helpers as it last placed them (`stillHeld`).
|
|
1809
|
+
// - The changed rows: those whose fingerprint differs from the one the
|
|
1810
|
+
// record kept, or that appeared or went away.
|
|
1811
|
+
const SYNC_RECORD_KEY = 'registrationRecord';
|
|
1812
|
+
const SYNC_DIRTY_KEY = 'registrationRecordStale';
|
|
1813
|
+
const RECORD_SAVE_DELAY_MS = 2_000;
|
|
1814
|
+
// A caller's scope plus the restored record's. No caller scope means the
|
|
1815
|
+
// caller asked for no particular rows (a worker's first sync), so the
|
|
1816
|
+
// record's alone.
|
|
1817
|
+
function mergeScopes(a, b) {
|
|
1818
|
+
if (!a)
|
|
1819
|
+
return b;
|
|
1820
|
+
return {
|
|
1821
|
+
scriptIds: [...new Set([...a.scriptIds, ...b.scriptIds])],
|
|
1822
|
+
collectionIds: [...new Set([...a.collectionIds, ...b.collectionIds])],
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
// False once this worker has synced, or its record was dropped on
|
|
1826
|
+
// purpose: a record is only for a worker's first sync.
|
|
1827
|
+
let mayRestoreRecord = true;
|
|
1828
|
+
// Whether the session marker says the saved record may be stale: set
|
|
1829
|
+
// before this worker changes Chrome's set, cleared when it saves.
|
|
1830
|
+
let recordMarkedStale = false;
|
|
1831
|
+
// Whether this worker's record has something the saved one lacks: it
|
|
1832
|
+
// changed Chrome's set, or it was built by a full sync.
|
|
1833
|
+
let recordUnsaved = false;
|
|
1834
|
+
let recordSaveTimer = null;
|
|
1835
|
+
function noteFingerprint(state, key, fingerprint) {
|
|
1836
|
+
if (fingerprint === undefined)
|
|
1837
|
+
state.fingerprints.delete(key);
|
|
1838
|
+
else
|
|
1839
|
+
state.fingerprints.set(key, fingerprint);
|
|
1840
|
+
}
|
|
1841
|
+
// What a sync reads of a script row: all of it except the annotations
|
|
1842
|
+
// a write can move without a sync (`onlyAnnotationsChanged`).
|
|
1843
|
+
//
|
|
1844
|
+
// Plain JSON, without sorting keys: rows come from the row cache or from
|
|
1845
|
+
// storage, both of which keep an object's keys sorted (they can differ
|
|
1846
|
+
// only for keys mixing unusual characters, none of ours). A row whose
|
|
1847
|
+
// keys came in another order would only read as changed, and be synced
|
|
1848
|
+
// again.
|
|
1849
|
+
function scriptFingerprint(row) {
|
|
1850
|
+
return hashText(JSON.stringify(withoutAnnotations(row)));
|
|
1851
|
+
}
|
|
1852
|
+
// What a sync reads of a collection row: whether it is enabled. A
|
|
1853
|
+
// refresh rewrites the rest without a sync.
|
|
1854
|
+
function collectionFingerprint(row) {
|
|
1855
|
+
return isCollectionEnabled(row) ? 'on' : 'off';
|
|
1856
|
+
}
|
|
1857
|
+
// Two 32-bit FNV-1a-style hashes and the length: not for security, only
|
|
1858
|
+
// to tell a changed row from the same one.
|
|
1859
|
+
function hashText(text) {
|
|
1860
|
+
let a = 0x811c9dc5;
|
|
1861
|
+
let b = 0x01000193;
|
|
1862
|
+
for (let i = 0; i < text.length; i++) {
|
|
1863
|
+
const c = text.charCodeAt(i);
|
|
1864
|
+
a = Math.imul(a ^ c, 0x01000193);
|
|
1865
|
+
b = Math.imul(b ^ c, 0x01000193) ^ (b >>> 13);
|
|
1866
|
+
}
|
|
1867
|
+
return `${text.length}:${(a >>> 0).toString(36)}:${(b >>> 0).toString(36)}`;
|
|
1868
|
+
}
|
|
1869
|
+
// Before this worker changes Chrome's set. A failure is logged and the
|
|
1870
|
+
// change goes ahead: the saved record is removed instead, and if that
|
|
1871
|
+
// fails too, a worker stopped before the next save may take a stale one.
|
|
1872
|
+
async function markRecordStale() {
|
|
1873
|
+
if (recordMarkedStale)
|
|
1874
|
+
return;
|
|
1875
|
+
recordUnsaved = true;
|
|
1876
|
+
try {
|
|
1877
|
+
await chrome.storage.session.set({ [SYNC_DIRTY_KEY]: true });
|
|
1878
|
+
recordMarkedStale = true;
|
|
1879
|
+
}
|
|
1880
|
+
catch (err) {
|
|
1881
|
+
console.info('[SourceMonkey] could not mark the registration record stale:', describeError(err));
|
|
1882
|
+
await chrome.storage.session.remove(SYNC_RECORD_KEY).catch(() => undefined);
|
|
1883
|
+
}
|
|
1884
|
+
}
|
|
1885
|
+
function scheduleRecordSave() {
|
|
1886
|
+
if (recordSaveTimer !== null)
|
|
1887
|
+
clearTimeout(recordSaveTimer);
|
|
1888
|
+
recordSaveTimer = setTimeout(() => {
|
|
1889
|
+
recordSaveTimer = null;
|
|
1890
|
+
// Under the registration lock, so no sync changes the set meanwhile.
|
|
1891
|
+
void withRegistrationLock(saveRecord).catch((err) => {
|
|
1892
|
+
// The marker stays, so the next worker reconciles in full.
|
|
1893
|
+
console.info('[SourceMonkey] could not save the registration record:', describeError(err));
|
|
1894
|
+
});
|
|
1895
|
+
}, RECORD_SAVE_DELAY_MS);
|
|
1896
|
+
}
|
|
1897
|
+
async function saveRecord() {
|
|
1898
|
+
const state = syncState;
|
|
1899
|
+
if (!state || !recordUnsaved)
|
|
1900
|
+
return;
|
|
1901
|
+
const record = {
|
|
1902
|
+
version: chrome.runtime.getManifest().version,
|
|
1903
|
+
channelKey: channelKey(),
|
|
1904
|
+
registered: [...state.registered],
|
|
1905
|
+
gmRuntimeScopes: [...state.gmRuntimeScopes],
|
|
1906
|
+
scriptIndex: [...state.scriptIndex],
|
|
1907
|
+
labels: [...state.labels],
|
|
1908
|
+
collections: [...state.collections].map(([id, c]) => [id, isCollectionEnabled(c)]),
|
|
1909
|
+
fingerprints: [...state.fingerprints],
|
|
1910
|
+
};
|
|
1911
|
+
await chrome.storage.session.set({ [SYNC_RECORD_KEY]: record });
|
|
1912
|
+
if (recordMarkedStale)
|
|
1913
|
+
await chrome.storage.session.remove(SYNC_DIRTY_KEY);
|
|
1914
|
+
recordMarkedStale = false;
|
|
1915
|
+
recordUnsaved = false;
|
|
1916
|
+
}
|
|
1917
|
+
// When this worker can't vouch for the saved record any more.
|
|
1918
|
+
function dropRecord() {
|
|
1919
|
+
mayRestoreRecord = false;
|
|
1920
|
+
// Written again before the next change: the marker may have gone with
|
|
1921
|
+
// the storage it was in (a test reset).
|
|
1922
|
+
recordMarkedStale = false;
|
|
1923
|
+
recordUnsaved = false;
|
|
1924
|
+
if (recordSaveTimer !== null)
|
|
1925
|
+
clearTimeout(recordSaveTimer);
|
|
1926
|
+
recordSaveTimer = null;
|
|
1927
|
+
void chrome.storage.session.remove(SYNC_RECORD_KEY).catch(() => undefined);
|
|
1928
|
+
}
|
|
1929
|
+
// Whether Chrome still holds the record's last registration: our own
|
|
1930
|
+
// helpers, with the patterns the record last gave them (their code ships
|
|
1931
|
+
// with the extension, and the record is from this version). Not every
|
|
1932
|
+
// registration: there is no ids-only call, and each one comes back with
|
|
1933
|
+
// its code. Losing the helpers is what a wipe of the set would show.
|
|
1934
|
+
// With no helpers, one user script stands in; with nothing registered,
|
|
1935
|
+
// Chrome must hold nothing.
|
|
1936
|
+
async function stillHeld(registered, ownScopes) {
|
|
1937
|
+
const ids = [...registered.keys()];
|
|
1938
|
+
if (ids.length === 0)
|
|
1939
|
+
return (await chrome.userScripts.getScripts()).length === 0;
|
|
1940
|
+
const own = ids.filter((id) => !isUserScriptId(id));
|
|
1941
|
+
const asked = own.length > 0 ? own : ids.slice(0, 1);
|
|
1942
|
+
const held = new Map((await chrome.userScripts.getScripts({ ids: asked })).map((s) => [s.id, s]));
|
|
1943
|
+
return asked.every((id) => held.has(id))
|
|
1944
|
+
&& own.every((id) => ownScopes.get(id) === gmRuntimeFingerprint(held.get(id)));
|
|
1945
|
+
}
|
|
1946
|
+
// The last worker's record and the rows that changed since, or null when
|
|
1947
|
+
// there is none this worker can take.
|
|
1948
|
+
async function restoreRecord() {
|
|
1949
|
+
const timer = startPhaseTimer('registration record');
|
|
1950
|
+
const got = await chrome.storage.session.get([SYNC_RECORD_KEY, SYNC_DIRTY_KEY]);
|
|
1951
|
+
const record = got[SYNC_RECORD_KEY];
|
|
1952
|
+
const why = !record || !Array.isArray(record.fingerprints) ? 'none saved'
|
|
1953
|
+
: got[SYNC_DIRTY_KEY] ? 'may be stale'
|
|
1954
|
+
: record.version !== chrome.runtime.getManifest().version ? 'another version'
|
|
1955
|
+
: record.channelKey !== await loadChannelKey() ? 'another channel key'
|
|
1956
|
+
: null;
|
|
1957
|
+
timer.mark('read');
|
|
1958
|
+
if (why || !record) {
|
|
1959
|
+
timer.note(`not taken: ${why}`);
|
|
1960
|
+
timer.done();
|
|
1961
|
+
return null;
|
|
1962
|
+
}
|
|
1963
|
+
const registered = new Map(record.registered);
|
|
1964
|
+
if (!(await stillHeld(registered, new Map(record.gmRuntimeScopes)))) {
|
|
1965
|
+
timer.mark('list');
|
|
1966
|
+
timer.note('not taken: Chrome no longer holds the helpers it names');
|
|
1967
|
+
timer.done();
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
timer.mark('list');
|
|
1971
|
+
await ensureCollectionKeysMigrated();
|
|
1972
|
+
const isRow = (k) => k.startsWith(SCRIPT_PREFIX) || k.startsWith(COLLECTION_PREFIX);
|
|
1973
|
+
const rows = (await sharedRows(isRow)) ?? await scanStorageRows('registration record', isRow);
|
|
1974
|
+
const kept = new Map(record.fingerprints);
|
|
1975
|
+
const scriptIds = new Set();
|
|
1976
|
+
const collectionIds = new Set();
|
|
1977
|
+
const changed = (key) => {
|
|
1978
|
+
if (key.startsWith(SCRIPT_PREFIX))
|
|
1979
|
+
scriptIds.add(key.slice(SCRIPT_PREFIX.length));
|
|
1980
|
+
else
|
|
1981
|
+
collectionIds.add(key.slice(COLLECTION_PREFIX.length));
|
|
1982
|
+
};
|
|
1983
|
+
for (const [key, row] of Object.entries(rows)) {
|
|
1984
|
+
const now = key.startsWith(SCRIPT_PREFIX)
|
|
1985
|
+
? scriptFingerprint(row)
|
|
1986
|
+
: collectionFingerprint(row);
|
|
1987
|
+
if (kept.get(key) !== now)
|
|
1988
|
+
changed(key);
|
|
1989
|
+
}
|
|
1990
|
+
for (const key of kept.keys())
|
|
1991
|
+
if (!(key in rows))
|
|
1992
|
+
changed(key);
|
|
1993
|
+
timer.mark('compare');
|
|
1994
|
+
timer.note(`taken; ${scriptIds.size} script(s) and ${collectionIds.size} collection(s) changed`);
|
|
1995
|
+
timer.done();
|
|
1996
|
+
return {
|
|
1997
|
+
state: {
|
|
1998
|
+
registered,
|
|
1999
|
+
gmRuntimeScopes: new Map(record.gmRuntimeScopes),
|
|
2000
|
+
scriptIndex: new Map(record.scriptIndex),
|
|
2001
|
+
labels: new Map(record.labels),
|
|
2002
|
+
// Only what the sync reads of a collection: `isRunnable`.
|
|
2003
|
+
collections: new Map(record.collections.map(([id, enabled]) => [id, { id, enabled }])),
|
|
2004
|
+
fingerprints: kept,
|
|
2005
|
+
},
|
|
2006
|
+
scope: { scriptIds: [...scriptIds], collectionIds: [...collectionIds] },
|
|
2007
|
+
};
|
|
2008
|
+
}
|
|
2009
|
+
async function runSync(scope) {
|
|
2010
|
+
await loadChannelKey();
|
|
2011
|
+
if (!isUserScriptsAvailable()) {
|
|
2012
|
+
// Nothing to register into. Not stored anywhere — the collections
|
|
2013
|
+
// page runs its own probe for the banner — but a sync that can't do
|
|
2014
|
+
// anything shouldn't be silent in the SW console either.
|
|
2015
|
+
//
|
|
2016
|
+
// Once per SW lifetime: `syncRegistrations` re-runs on every
|
|
2017
|
+
// script-storage change, and with the toggle off that would be a
|
|
2018
|
+
// console line per write, forever, all saying the same thing.
|
|
2019
|
+
if (!loggedUserScriptsUnavailable) {
|
|
2020
|
+
loggedUserScriptsUnavailable = true;
|
|
2021
|
+
console.info('[SourceMonkey] chrome.userScripts is unavailable, so nothing was registered');
|
|
2022
|
+
}
|
|
2023
|
+
// Drop any rejections recorded while the API still worked. The map
|
|
2024
|
+
// describes the current registration set, and "nothing is
|
|
2025
|
+
// registered" is a complete answer — leaving entries would strand
|
|
2026
|
+
// REJECTED badges blaming a `@match` pattern for a turned-off
|
|
2027
|
+
// toggle, which the collections page's own banner already explains.
|
|
2028
|
+
await writeRejections(await readRejections(), {});
|
|
2029
|
+
// And forget any record of a registration set, for the same reason:
|
|
2030
|
+
// if the API comes back, what Chrome holds is not something this
|
|
2031
|
+
// context can still claim to know.
|
|
2032
|
+
syncState = null;
|
|
2033
|
+
dropRecord();
|
|
2034
|
+
// Set the world up again once they're back, and sync then.
|
|
2035
|
+
worldConfigured = false;
|
|
2036
|
+
lastFoundUnusable = true;
|
|
2037
|
+
return;
|
|
2038
|
+
}
|
|
2039
|
+
let state = syncState;
|
|
2040
|
+
// A `forgetRegistrationState` while this runs (it doesn't take the
|
|
2041
|
+
// lock) means its result mustn't be kept.
|
|
2042
|
+
const generation = stateGeneration;
|
|
2043
|
+
try {
|
|
2044
|
+
const numbers = (await beforeSync?.()) ?? null;
|
|
2045
|
+
await ensureWorldConfigured();
|
|
2046
|
+
// A worker's first sync: the last worker's record, if this one can
|
|
2047
|
+
// take it, stands in for a full reconcile.
|
|
2048
|
+
if (!syncState && mayRestoreRecord) {
|
|
2049
|
+
mayRestoreRecord = false;
|
|
2050
|
+
const restored = await restoreRecord().catch((err) => {
|
|
2051
|
+
console.info('[SourceMonkey] could not read the last registration record:', describeError(err));
|
|
2052
|
+
return null;
|
|
2053
|
+
});
|
|
2054
|
+
if (restored) {
|
|
2055
|
+
state = syncState = restored.state;
|
|
2056
|
+
scope = mergeScopes(scope, restored.scope);
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
// A scope is only actionable against a record of what we last left
|
|
2060
|
+
// registered. Without one — a fresh service worker, or a sync that
|
|
2061
|
+
// failed part-way and dropped it — the only safe answer is to
|
|
2062
|
+
// reconcile against Chrome's own list.
|
|
2063
|
+
if (scope && state) {
|
|
2064
|
+
await runIncrementalSync(scope, state, numbers);
|
|
2065
|
+
lastSyncKind = 'incremental';
|
|
2066
|
+
}
|
|
2067
|
+
else {
|
|
2068
|
+
syncState = await runFullSync(numbers);
|
|
2069
|
+
lastSyncKind = 'full';
|
|
2070
|
+
// Built from scratch: saved even if it sent Chrome nothing, or a
|
|
2071
|
+
// worker after a browser start would never find one.
|
|
2072
|
+
recordUnsaved = true;
|
|
2073
|
+
}
|
|
2074
|
+
// Saved when this sync changed something (`applyPlan` marks the
|
|
2075
|
+
// record stale first), built it afresh, or an earlier one did.
|
|
2076
|
+
if (generation !== stateGeneration) {
|
|
2077
|
+
syncState = null;
|
|
2078
|
+
dropRecord();
|
|
2079
|
+
}
|
|
2080
|
+
else if (recordUnsaved)
|
|
2081
|
+
scheduleRecordSave();
|
|
2082
|
+
}
|
|
2083
|
+
catch (err) {
|
|
2084
|
+
console.info('[SourceMonkey] userScripts sync failed:', describeError(err));
|
|
2085
|
+
// We no longer know what Chrome holds, so the next sync starts over
|
|
2086
|
+
// rather than diffing against a record that may be half-applied.
|
|
2087
|
+
// This is also what keeps a scoped sync's ids from being lost: they
|
|
2088
|
+
// were consumed by the attempt, and a full reconcile covers them.
|
|
2089
|
+
syncState = null;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
// Rebuild EVERY registration and reconcile the result against what
|
|
2093
|
+
// Chrome currently holds.
|
|
2094
|
+
//
|
|
2095
|
+
// Runs whenever this context has no trustworthy record of the
|
|
2096
|
+
// registration set: at service-worker startup unless the last worker's
|
|
2097
|
+
// record was taken (`restoreRecord`). Chrome retains
|
|
2098
|
+
// registrations across SW restarts and rows change while the worker is
|
|
2099
|
+
// dead, so the post-startup set has to be proven equal to storage
|
|
2100
|
+
// rather than diffed against nothing.
|
|
2101
|
+
//
|
|
2102
|
+
// It is still not a wipe: everything that should stay registered is
|
|
2103
|
+
// updated in place, and the only unregisters are for ids Chrome holds
|
|
2104
|
+
// that storage no longer wants.
|
|
2105
|
+
async function runFullSync(assigned) {
|
|
2106
|
+
const timer = startPhaseTimer('registration sync');
|
|
2107
|
+
const all = await readAll();
|
|
2108
|
+
const { scripts, collections } = all;
|
|
2109
|
+
// As in `runIncrementalSync`: the numbers just assigned, when there are.
|
|
2110
|
+
const numbers = assigned ?? all.numbers;
|
|
2111
|
+
const candidates = await withBodies(scripts, collections);
|
|
2112
|
+
timer.mark('read');
|
|
2113
|
+
const handlerVersion = chrome.runtime.getManifest().version;
|
|
2114
|
+
const effective = await revalidateCandidates(candidates, collections);
|
|
2115
|
+
const logIds = computeLogIds(scripts.map((s) => ({
|
|
2116
|
+
id: s.id, relativePath: s.relativePath,
|
|
2117
|
+
})), numbers);
|
|
2118
|
+
const active = await collectActiveSpecs(effective, collections, handlerVersion, logIds);
|
|
2119
|
+
const userSpecs = active.specs;
|
|
2120
|
+
// Covers the revalidation too — it is per-script work on the same
|
|
2121
|
+
// critical path, and splitting it out would imply the two can be
|
|
2122
|
+
// acted on separately when the sync always does both.
|
|
2123
|
+
timer.mark('build-specs');
|
|
2124
|
+
// Chrome's own list, patterns included — the authority on both what
|
|
2125
|
+
// is there and what shape it is in.
|
|
2126
|
+
const held = await chrome.userScripts.getScripts();
|
|
2127
|
+
const present = new Map(held.map((s) => [s.id, patternsOf(s)]));
|
|
2128
|
+
timer.mark('list');
|
|
2129
|
+
const specs = orderWithOwnSpecs(userSpecs, ownSpecsFor(placementsForSpecs(userSpecs, active.gmRuntime)),
|
|
2130
|
+
// Empty rather than `present`: the bridge and the GM runtime stay in
|
|
2131
|
+
// the spec list even when Chrome already holds them, since the
|
|
2132
|
+
// removal list is everything Chrome holds that the list doesn't
|
|
2133
|
+
// name. Whether each is sent again is `inPlace`'s call: their code
|
|
2134
|
+
// ships with the extension, so an update changes it.
|
|
2135
|
+
new Set());
|
|
2136
|
+
const wanted = new Set(specs.map((s) => s.id));
|
|
2137
|
+
// Most of a full sync re-places what Chrome already holds, from rows
|
|
2138
|
+
// that haven't changed since. One held exactly as specified gets no
|
|
2139
|
+
// call at all; where only the code is the same, the update leaves it
|
|
2140
|
+
// out (see `withoutCode`).
|
|
2141
|
+
// Unless Chrome's order has a helper behind a user script: then
|
|
2142
|
+
// everything is placed again, helpers first, which restores it (see
|
|
2143
|
+
// `retryRefusedOwnSpecs`).
|
|
2144
|
+
const inPlace = keepHelpersFirst(specs, held);
|
|
2145
|
+
const codeInPlace = sameCode(specs, held);
|
|
2146
|
+
const removeIds = [...present.keys()].filter((id) => !wanted.has(id));
|
|
2147
|
+
timer.mark('compare');
|
|
2148
|
+
const applied = await applyPlan({
|
|
2149
|
+
specs,
|
|
2150
|
+
removeIds,
|
|
2151
|
+
present,
|
|
2152
|
+
codeInPlace,
|
|
2153
|
+
inPlace,
|
|
2154
|
+
// The whole set was rebuilt, so the rejection map is rewritten
|
|
2155
|
+
// wholesale — a script that has since been disabled, deleted or
|
|
2156
|
+
// flipped to INVALID stops being REJECTED instead of stranding a
|
|
2157
|
+
// badge nobody can clear.
|
|
2158
|
+
owned: 'all',
|
|
2159
|
+
}, timer);
|
|
2160
|
+
timer.note(`full; ${specs.length} specs (${inPlace.size} in place, ${codeInPlace.size - inPlace.size} more with code in place), `
|
|
2161
|
+
+ `${removeIds.length} dropped, ${scripts.length} stored scripts`);
|
|
2162
|
+
timer.done();
|
|
2163
|
+
const registered = placementsOf(specs, applied, active.gmRuntime);
|
|
2164
|
+
const gmRuntimeScopes = ownFingerprints(specs);
|
|
2165
|
+
await retryRefusedOwnSpecs(applied, registered, gmRuntimeScopes, present);
|
|
2166
|
+
reportSyncResult(specs, applied.placed, inPlace);
|
|
2167
|
+
return {
|
|
2168
|
+
registered,
|
|
2169
|
+
gmRuntimeScopes,
|
|
2170
|
+
scriptIndex: new Map(scripts.map((s) => [
|
|
2171
|
+
s.id, { relativePath: s.relativePath, collectionId: s.collectionId },
|
|
2172
|
+
])),
|
|
2173
|
+
labels: logIds,
|
|
2174
|
+
collections,
|
|
2175
|
+
fingerprints: new Map([
|
|
2176
|
+
...scripts.map((s) => [SCRIPT_PREFIX + s.id, scriptFingerprint(s)]),
|
|
2177
|
+
...[...collections.values()].map((c) => [COLLECTION_PREFIX + c.id, collectionFingerprint(c)]),
|
|
2178
|
+
]),
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
// Rebuild only the registrations for the rows that moved.
|
|
2182
|
+
//
|
|
2183
|
+
// The rest of the set is not read, not rebuilt, and not touched — which
|
|
2184
|
+
// is the point: the old full wipe left EVERY script unregistered for the
|
|
2185
|
+
// length of a register-all, so a document created in that window ran
|
|
2186
|
+
// nothing at all.
|
|
2187
|
+
//
|
|
2188
|
+
// Mutates `state` in place as it goes; the caller drops the whole record
|
|
2189
|
+
// if this throws.
|
|
2190
|
+
//
|
|
2191
|
+
// `assigned`: the script numbers `beforeSync` just assigned, or null
|
|
2192
|
+
// where nothing assigns them (unit tests), which reads them.
|
|
2193
|
+
async function runIncrementalSync(scope, state, assigned) {
|
|
2194
|
+
const timer = startPhaseTimer('registration sync');
|
|
2195
|
+
// A collection row that appeared, went away, or flipped its enabled
|
|
2196
|
+
// flag moves every script under it.
|
|
2197
|
+
const affected = new Set(scope.scriptIds);
|
|
2198
|
+
if (scope.collectionIds.length > 0) {
|
|
2199
|
+
const moved = new Set(scope.collectionIds);
|
|
2200
|
+
for (const [id, info] of state.scriptIndex) {
|
|
2201
|
+
if (moved.has(info.collectionId))
|
|
2202
|
+
affected.add(id);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
const rows = await readRows(affected, scope.collectionIds);
|
|
2206
|
+
timer.mark('read');
|
|
2207
|
+
for (const id of scope.collectionIds) {
|
|
2208
|
+
const row = rows[COLLECTION_PREFIX + id];
|
|
2209
|
+
if (row)
|
|
2210
|
+
state.collections.set(id, row);
|
|
2211
|
+
else
|
|
2212
|
+
state.collections.delete(id);
|
|
2213
|
+
noteFingerprint(state, COLLECTION_PREFIX + id, row && collectionFingerprint(row));
|
|
2214
|
+
}
|
|
2215
|
+
const scripts = [];
|
|
2216
|
+
for (const id of affected) {
|
|
2217
|
+
const row = rows[SCRIPT_PREFIX + id];
|
|
2218
|
+
noteFingerprint(state, SCRIPT_PREFIX + id, row && scriptFingerprint(row));
|
|
2219
|
+
if (!row) {
|
|
2220
|
+
state.scriptIndex.delete(id);
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
scripts.push(row);
|
|
2224
|
+
state.scriptIndex.set(id, {
|
|
2225
|
+
relativePath: row.relativePath, collectionId: row.collectionId,
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
// A script can name a collection this record has never seen: a
|
|
2229
|
+
// collection row is reported only when its enabled flag moves or it
|
|
2230
|
+
// goes away, so its arrival — which is what an added collection is —
|
|
2231
|
+
// reaches us as the scripts under it. Fetch what's missing rather
|
|
2232
|
+
// than calling those scripts ineligible.
|
|
2233
|
+
const unseen = [...new Set(scripts.map((s) => s.collectionId))]
|
|
2234
|
+
.filter((id) => !state.collections.has(id));
|
|
2235
|
+
if (unseen.length > 0) {
|
|
2236
|
+
const extra = await readRows([], unseen);
|
|
2237
|
+
for (const id of unseen) {
|
|
2238
|
+
const row = extra[COLLECTION_PREFIX + id];
|
|
2239
|
+
if (row)
|
|
2240
|
+
state.collections.set(id, row);
|
|
2241
|
+
noteFingerprint(state, COLLECTION_PREFIX + id, row && collectionFingerprint(row));
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
// Labels come from the whole index rather than from the handful of
|
|
2245
|
+
// rows this sync read, and from the script numbers as `beforeSync`
|
|
2246
|
+
// just assigned them: it may have just numbered a script this sync
|
|
2247
|
+
// didn't read.
|
|
2248
|
+
if (unseen.length > 0)
|
|
2249
|
+
timer.mark('read collections');
|
|
2250
|
+
let numbers = assigned;
|
|
2251
|
+
if (!numbers) {
|
|
2252
|
+
numbers = numberMap(await readScriptNumbers());
|
|
2253
|
+
timer.mark('read numbers');
|
|
2254
|
+
}
|
|
2255
|
+
const logIds = computeLogIds([...state.scriptIndex].map(([id, info]) => ({
|
|
2256
|
+
id, relativePath: info.relativePath,
|
|
2257
|
+
})), numbers);
|
|
2258
|
+
// A script being numbered, or an unnumbered one arriving or leaving
|
|
2259
|
+
// (which can renumber its basename's `#N` group), changes labels —
|
|
2260
|
+
// and the label is baked into the registered `//# sourceURL=`
|
|
2261
|
+
// directives, so every script whose label just moved has to be
|
|
2262
|
+
// rebuilt too, however untouched its own row is.
|
|
2263
|
+
const renamed = [...state.registered.keys()].filter((id) => !affected.has(id) && state.labels.get(id) !== logIds.get(id));
|
|
2264
|
+
// Only the labels we actually place are recorded as placed. A
|
|
2265
|
+
// renamed script whose row we cannot re-read keeps its OLD label in
|
|
2266
|
+
// the record, so the next sync sees the mismatch again and retries —
|
|
2267
|
+
// rather than recording a label the registration never got and never
|
|
2268
|
+
// looking at it again.
|
|
2269
|
+
const nextLabels = new Map(logIds);
|
|
2270
|
+
if (renamed.length > 0) {
|
|
2271
|
+
const extra = await readRows(renamed, []);
|
|
2272
|
+
for (const id of renamed) {
|
|
2273
|
+
const row = extra[SCRIPT_PREFIX + id];
|
|
2274
|
+
if (!row) {
|
|
2275
|
+
const stale = state.labels.get(id);
|
|
2276
|
+
if (stale !== undefined)
|
|
2277
|
+
nextLabels.set(id, stale);
|
|
2278
|
+
continue;
|
|
2279
|
+
}
|
|
2280
|
+
scripts.push(row);
|
|
2281
|
+
affected.add(id);
|
|
2282
|
+
noteFingerprint(state, SCRIPT_PREFIX + id, scriptFingerprint(row));
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
timer.mark('labels');
|
|
2286
|
+
const handlerVersion = chrome.runtime.getManifest().version;
|
|
2287
|
+
const candidates = await withBodies(scripts, state.collections);
|
|
2288
|
+
timer.mark('read bodies');
|
|
2289
|
+
const effective = await revalidateCandidates(candidates, state.collections);
|
|
2290
|
+
const active = await collectActiveSpecs(effective, state.collections, handlerVersion, logIds);
|
|
2291
|
+
const userSpecs = active.specs;
|
|
2292
|
+
timer.mark('build-specs');
|
|
2293
|
+
const wanted = new Set(userSpecs.map((s) => s.id));
|
|
2294
|
+
// Every affected script that no longer wants a registration, whether
|
|
2295
|
+
// or not WE believe it has one. The belief can be wrong (a reset, a
|
|
2296
|
+
// record lost with a stopped worker), and guarding on it would leave
|
|
2297
|
+
// such a script injecting after the user disabled or deleted it.
|
|
2298
|
+
//
|
|
2299
|
+
// A wrong belief about *presence* self-corrects everywhere else,
|
|
2300
|
+
// because `placeSpec` has a second call that disagrees with it (a
|
|
2301
|
+
// wrong belief about glob values doesn't — see `placeSpec`).
|
|
2302
|
+
// Removal has no such fallback, so it does not get to guess: an
|
|
2303
|
+
// unregister for an id nothing holds costs one refused call and is
|
|
2304
|
+
// swallowed (see `unregisterIds`).
|
|
2305
|
+
const removeIds = [...affected].filter((id) => !wanted.has(id));
|
|
2306
|
+
// Project the post-sync world set, so the bridge / GM-runtime
|
|
2307
|
+
// registrations follow the WHOLE registration set rather than the
|
|
2308
|
+
// slice this sync happens to be holding.
|
|
2309
|
+
const projected = new Map(state.registered);
|
|
2310
|
+
for (const id of removeIds)
|
|
2311
|
+
projected.delete(id);
|
|
2312
|
+
for (const [id, placement] of placementsForSpecs(userSpecs, active.gmRuntime).map((p, i) => [userSpecs[i].id, p])) {
|
|
2313
|
+
projected.set(id, placement);
|
|
2314
|
+
}
|
|
2315
|
+
const ownSpecs = ownSpecsFor(userPlacements(projected));
|
|
2316
|
+
// Our own registrations are dropped independently — the bridge when
|
|
2317
|
+
// no MAIN-world script is left, each world's runtime when no script
|
|
2318
|
+
// there needs it; a page can end up wanting the MAIN runtime and not
|
|
2319
|
+
// the USER_SCRIPT one.
|
|
2320
|
+
const ownWanted = new Set(ownSpecs.map((spec) => spec.id));
|
|
2321
|
+
for (const id of OWN_IDS) {
|
|
2322
|
+
if (!ownWanted.has(id) && state.registered.has(id))
|
|
2323
|
+
removeIds.push(id);
|
|
2324
|
+
}
|
|
2325
|
+
const present = new Map([...state.registered].map(([id, p]) => [id, p.patterns]));
|
|
2326
|
+
// Our own registrations are normally not re-offered (see
|
|
2327
|
+
// `orderWithOwnSpecs`), but their patterns follow the scripts: adding
|
|
2328
|
+
// the first script on a new host moves the union, and a skipped
|
|
2329
|
+
// update would leave the bridge or the GM runtime absent on
|
|
2330
|
+
// exactly the page that just started needing it. So one whose
|
|
2331
|
+
// fingerprint moved counts as not-yet-placed.
|
|
2332
|
+
const nextScopes = ownFingerprints(ownSpecs);
|
|
2333
|
+
const alreadyPlaced = {
|
|
2334
|
+
has: (id) => present.has(id) && state.gmRuntimeScopes.get(id) === nextScopes.get(id),
|
|
2335
|
+
};
|
|
2336
|
+
const specs = orderWithOwnSpecs(userSpecs, ownSpecs, alreadyPlaced);
|
|
2337
|
+
state.gmRuntimeScopes = nextScopes;
|
|
2338
|
+
// Rebuilt exactly as placed: nothing to send. A row change the
|
|
2339
|
+
// registration doesn't carry, such as a refresh that only moved the
|
|
2340
|
+
// file's modified time. Trusts the record, as the rest of an
|
|
2341
|
+
// incremental sync does; a full reconcile corrects a wrong one.
|
|
2342
|
+
//
|
|
2343
|
+
// None when one of our own registrations is sent: an updated helper
|
|
2344
|
+
// can move behind the user scripts in Chrome's order, and sending
|
|
2345
|
+
// these again puts them after it, as `keepHelpersFirst` does for a
|
|
2346
|
+
// full sync.
|
|
2347
|
+
const helperSent = specs.some((spec) => !isUserScriptId(spec.id));
|
|
2348
|
+
const inPlace = helperSent ? new Set() : new Set(userSpecs
|
|
2349
|
+
.filter((spec) => state.registered.get(spec.id)?.specHash === specHash(spec))
|
|
2350
|
+
.map((spec) => spec.id));
|
|
2351
|
+
const applied = await applyPlan({
|
|
2352
|
+
specs,
|
|
2353
|
+
removeIds,
|
|
2354
|
+
present,
|
|
2355
|
+
inPlace,
|
|
2356
|
+
// An own spec offered here that Chrome holds is one whose patterns
|
|
2357
|
+
// moved. Its code is fixed when the extension is built, and this
|
|
2358
|
+
// worker's first sync placed it with this code: a full one did, and
|
|
2359
|
+
// a taken record comes only from a worker of the same version, whose
|
|
2360
|
+
// first sync did. Only the specs being offered: the rest aren't sent.
|
|
2361
|
+
codeInPlace: new Set(specs.filter((spec) => !isUserScriptId(spec.id) && present.has(spec.id)).map((spec) => spec.id)),
|
|
2362
|
+
// Only the rows this sync considered. An id it never looked at
|
|
2363
|
+
// keeps whatever the map already says: dropping those would clear a
|
|
2364
|
+
// REJECTED badge for a script Chrome is still refusing, and
|
|
2365
|
+
// rewriting them wholesale would resurrect ones already cleared.
|
|
2366
|
+
owned: affected,
|
|
2367
|
+
}, timer);
|
|
2368
|
+
timer.note(`incremental; ${specs.length} specs (${inPlace.size} in place), ${removeIds.length} dropped`);
|
|
2369
|
+
timer.done();
|
|
2370
|
+
for (const id of removeIds)
|
|
2371
|
+
state.registered.delete(id);
|
|
2372
|
+
const specsById = new Map(specs.map((spec) => [spec.id, spec]));
|
|
2373
|
+
for (const [id, placement] of placementsOf(specs, applied, active.gmRuntime)) {
|
|
2374
|
+
const spec = specsById.get(id);
|
|
2375
|
+
state.registered.set(id, spec && isUserScriptId(id) ? { ...placement, specHash: specHash(spec) } : placement);
|
|
2376
|
+
}
|
|
2377
|
+
for (const id of applied.refused)
|
|
2378
|
+
state.registered.delete(id);
|
|
2379
|
+
await retryRefusedOwnSpecs(applied, state.registered, state.gmRuntimeScopes, present);
|
|
2380
|
+
state.labels = nextLabels;
|
|
2381
|
+
// A sync that sent nothing has nothing to say.
|
|
2382
|
+
if (inPlace.size < specs.length || removeIds.length > 0) {
|
|
2383
|
+
reportSyncResult(specs, applied.placed, inPlace, 'incremental');
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
// Prepend our own GM-runtime / bridge specs to the user specs, when
|
|
2387
|
+
// they're wanted and not already in place.
|
|
2388
|
+
//
|
|
2389
|
+
// GM runtimes first (one per world) so each is in its world's registry
|
|
2390
|
+
// ahead of any wrapper in its world that compiles in GM_* bindings.
|
|
2391
|
+
// Bridge next so MAIN wrappers have a listener for their requests.
|
|
2392
|
+
// Then the user scripts. (Cross-registration ordering isn't guaranteed
|
|
2393
|
+
// by the userScripts API, but in practice Chrome runs registrations in
|
|
2394
|
+
// order. Both benefit, but neither relies on it: a `document-start`
|
|
2395
|
+
// script on a first load runs its body at once (`canSkipDecision` in
|
|
2396
|
+
// the prelude), so either can still be missing then.
|
|
2397
|
+
// - The bridge: a request is a synchronous DOM event, so one
|
|
2398
|
+
// dispatched before the bridge's listener is in finds no one. The
|
|
2399
|
+
// bridge cancels what it takes, and the senders (the prelude, the
|
|
2400
|
+
// MAIN-world GM runtime) hold what it didn't and send it again
|
|
2401
|
+
// when the bridge announces itself (`sourcemonkey-bridge-ready`).
|
|
2402
|
+
// - The GM runtime: the GM_* declarations gm-bindings.ts emits are
|
|
2403
|
+
// lazy, touching `__smReg.gm` only when called, but the eager
|
|
2404
|
+
// calls (`storage.init`, `urlchange.enable`) and any GM_* call in
|
|
2405
|
+
// the body's first run need it in place. A `document-start` wrapper
|
|
2406
|
+
// that needs it and finds none asks the SW rather than skip the
|
|
2407
|
+
// round trip, and by the time the answer comes every
|
|
2408
|
+
// document_start registration has executed.
|
|
2409
|
+
// The order is honoured. Chrome moves an UPDATED registration to the
|
|
2410
|
+
// end of its set (verified), so grouping the placement calls by kind
|
|
2411
|
+
// would silently invert this — `placeSpecs` therefore places our own
|
|
2412
|
+
// specs first and individually, before any user spec.
|
|
2413
|
+
//
|
|
2414
|
+
// An incremental sync that moves an own spec's pattern union UPDATES
|
|
2415
|
+
// it, which puts it after every user script already registered. That
|
|
2416
|
+
// is tolerated rather than repaired: what a wrapper ahead of the bridge
|
|
2417
|
+
// sends is held until the bridge announces itself (above), and a
|
|
2418
|
+
// wrapper only decides whether it can use a hot-swapped
|
|
2419
|
+
// body once the body is about to run, by which point every
|
|
2420
|
+
// document_start registration has executed — see `needsRuntime` in
|
|
2421
|
+
// `handleCheckPending` and `runMain` in the prelude. Re-placing the
|
|
2422
|
+
// world's user scripts behind the runtime would cost each of them an
|
|
2423
|
+
// injection gap for an ordering nothing depends on.
|
|
2424
|
+
//
|
|
2425
|
+
// `alreadyPlaced` names ids not worth re-offering — an incremental sync
|
|
2426
|
+
// passes what it believes is registered, so an ordinary script edit
|
|
2427
|
+
// doesn't churn our own registrations. A full sync passes nothing.
|
|
2428
|
+
function orderWithOwnSpecs(userSpecs, ownSpecs, alreadyPlaced) {
|
|
2429
|
+
const specs = [];
|
|
2430
|
+
for (const spec of ownSpecs) {
|
|
2431
|
+
if (!alreadyPlaced.has(spec.id))
|
|
2432
|
+
specs.push(spec);
|
|
2433
|
+
}
|
|
2434
|
+
specs.push(...userSpecs);
|
|
2435
|
+
return specs;
|
|
2436
|
+
}
|
|
2437
|
+
// The pattern-union fingerprint of each of our own specs in a list.
|
|
2438
|
+
function ownFingerprints(specs) {
|
|
2439
|
+
return new Map(specs.filter((spec) => !isUserScriptId(spec.id))
|
|
2440
|
+
.map((spec) => [spec.id, gmRuntimeFingerprint(spec)]));
|
|
2441
|
+
}
|
|
2442
|
+
// Our own specs are placed before the user scripts (for injection
|
|
2443
|
+
// order), so they are built from patterns Chrome hasn't ruled on yet.
|
|
2444
|
+
// One pattern Chrome refuses takes the whole union — and with it the
|
|
2445
|
+
// bridge or a runtime for every page in that world — while the script
|
|
2446
|
+
// that carried it is refused separately and reads REJECTED. So a
|
|
2447
|
+
// refused own spec is rebuilt from what Chrome actually accepted (the
|
|
2448
|
+
// registered placements, which exclude the refused scripts) and
|
|
2449
|
+
// offered again; every pattern in that union was just accepted, so
|
|
2450
|
+
// only a non-pattern failure can refuse it twice, and that is logged.
|
|
2451
|
+
//
|
|
2452
|
+
// The retried spec lands after the user scripts in Chrome's order.
|
|
2453
|
+
// That is survivable, as for any helper an incremental sync moves (see
|
|
2454
|
+
// `orderWithOwnSpecs`): what a wrapper sends before the bridge is in
|
|
2455
|
+
// is held until it is, and a `document-start` wrapper that needs the
|
|
2456
|
+
// runtime and finds none asks the SW first. The next full sync
|
|
2457
|
+
// restores the order (`helpersFirst`).
|
|
2458
|
+
async function retryRefusedOwnSpecs(applied, registered, fingerprints, present) {
|
|
2459
|
+
const refusedOwn = [...applied.refused].filter((id) => !isUserScriptId(id));
|
|
2460
|
+
if (refusedOwn.length === 0)
|
|
2461
|
+
return;
|
|
2462
|
+
const rebuilt = ownSpecsFor(userPlacements(registered)).filter((spec) => refusedOwn.includes(spec.id));
|
|
2463
|
+
if (rebuilt.length === 0)
|
|
2464
|
+
return;
|
|
2465
|
+
console.info(`[SourceMonkey] re-offering ${rebuilt.map((s) => s.id).join(', ')} on the patterns Chrome accepted`);
|
|
2466
|
+
// `present` minus the refused ids: `applyPlan` unregistered whatever
|
|
2467
|
+
// Chrome held under them.
|
|
2468
|
+
const believed = new Map([...present].filter(([id]) => !applied.refused.has(id)));
|
|
2469
|
+
const again = await applyPlan({ specs: rebuilt, removeIds: [], present: believed, owned: new Set() });
|
|
2470
|
+
for (const [id, placement] of placementsOf(rebuilt, again, new Map()))
|
|
2471
|
+
registered.set(id, placement);
|
|
2472
|
+
for (const spec of rebuilt) {
|
|
2473
|
+
if (!again.refused.has(spec.id))
|
|
2474
|
+
fingerprints.set(spec.id, gmRuntimeFingerprint(spec));
|
|
2475
|
+
}
|
|
2476
|
+
applied.placed.push(...again.placed);
|
|
2477
|
+
}
|
|
2478
|
+
// What Chrome now holds, for the specs that actually went in.
|
|
2479
|
+
function placementsOf(specs, applied, gmRuntime) {
|
|
2480
|
+
const out = new Map();
|
|
2481
|
+
const placements = placementsForSpecs(specs, gmRuntime);
|
|
2482
|
+
for (let i = 0; i < specs.length; i++) {
|
|
2483
|
+
if (applied.refused.has(specs[i].id))
|
|
2484
|
+
continue;
|
|
2485
|
+
out.set(specs[i].id, placements[i]);
|
|
2486
|
+
}
|
|
2487
|
+
return out;
|
|
2488
|
+
}
|
|
2489
|
+
// `held`: specs Chrome already held exactly, counted as placed but not
|
|
2490
|
+
// sent (a full sync's `inPlace`).
|
|
2491
|
+
function reportSyncResult(specs, placed, held = new Set(), kind = 'full') {
|
|
2492
|
+
// A sync whose scope turned out to hold nothing offered nothing, and
|
|
2493
|
+
// "registered 0 userscript(s)" would read as though it had emptied
|
|
2494
|
+
// the set. Say nothing instead.
|
|
2495
|
+
if (specs.length === 0)
|
|
2496
|
+
return;
|
|
2497
|
+
// Counted apart from the user's scripts: a toggle that re-registers
|
|
2498
|
+
// one script also re-registers each helper whose pattern union it
|
|
2499
|
+
// moved, and a single total read as more scripts than the page has.
|
|
2500
|
+
const offered = specs.filter((s) => isUserScriptId(s.id)).length;
|
|
2501
|
+
const registered = placed.filter(isUserScriptId).length;
|
|
2502
|
+
const helpers = placed.filter((id) => !isUserScriptId(id)).map(helperName);
|
|
2503
|
+
// `placed` includes a helper `retryRefusedOwnSpecs` got in on its
|
|
2504
|
+
// second try, so this is what Chrome is still refusing.
|
|
2505
|
+
const refusedHelpers = specs
|
|
2506
|
+
.filter((s) => !isUserScriptId(s.id) && !placed.includes(s.id))
|
|
2507
|
+
.map((s) => helperName(s.id));
|
|
2508
|
+
const failed = offered - registered;
|
|
2509
|
+
const parts = [
|
|
2510
|
+
...(offered > 0 ? [`${failed > 0 ? `${registered}/${offered}` : registered} userscript(s)`] : []),
|
|
2511
|
+
...(helpers.length > 0 ? [`helper(s): ${helpers.join(', ')}`] : []),
|
|
2512
|
+
];
|
|
2513
|
+
const what = parts.join(' and ') || 'nothing';
|
|
2514
|
+
const problems = [
|
|
2515
|
+
...(failed > 0 ? [`${failed} userscript(s) rejected by chrome.userScripts (see per-script errors)`] : []),
|
|
2516
|
+
...(refusedHelpers.length > 0 ? [`helper(s) refused by chrome.userScripts: ${refusedHelpers.join(', ')}`] : []),
|
|
2517
|
+
];
|
|
2518
|
+
// How many of those went to Chrome, when some it already held.
|
|
2519
|
+
const sent = held.size > 0
|
|
2520
|
+
? ` (${placed.filter((id) => !held.has(id)).length} registration(s) sent, ${held.size} already held)`
|
|
2521
|
+
: '';
|
|
2522
|
+
// A full sync states the whole set; an incremental one, what it
|
|
2523
|
+
// registered (its `held` are only the scripts it rebuilt).
|
|
2524
|
+
const verb = held.size > 0 && kind === 'full' ? 'in place' : 'registered';
|
|
2525
|
+
if (problems.length > 0) {
|
|
2526
|
+
console.info(`[SourceMonkey] ${verb}: ${what}${sent}; ${problems.join('; ')}`);
|
|
2527
|
+
}
|
|
2528
|
+
else {
|
|
2529
|
+
console.log(`[SourceMonkey] ${verb} with chrome.userScripts: ${what}${sent}`);
|
|
2530
|
+
}
|
|
2531
|
+
}
|
|
2532
|
+
// How the sync log names one of our own registrations.
|
|
2533
|
+
function helperName(id) {
|
|
2534
|
+
switch (id) {
|
|
2535
|
+
case BRIDGE_SCRIPT_ID: return 'bridge';
|
|
2536
|
+
case GM_RUNTIME_MAIN_SCRIPT_ID: return 'GM runtime (MAIN world)';
|
|
2537
|
+
case GM_RUNTIME_SCRIPT_ID: return 'GM runtime (USER_SCRIPT world)';
|
|
2538
|
+
default: return id;
|
|
2539
|
+
}
|
|
2540
|
+
}
|
|
2541
|
+
// ---------- Retrying a refused registration ----------
|
|
2542
|
+
// Shortest gap between two automatic retries. A navigation fans out
|
|
2543
|
+
// into several checks of the same script — the reload fast-path plus an
|
|
2544
|
+
// opportunistic check per commit event, per frame — and without a floor
|
|
2545
|
+
// each one would re-offer the spec and write its own Log row, so a
|
|
2546
|
+
// single page load produced a burst of identical entries.
|
|
2547
|
+
//
|
|
2548
|
+
// A genuine Refresh click ignores the floor on the way in (see
|
|
2549
|
+
// `userInitiated`): the user asked, so they get an answer every time.
|
|
2550
|
+
// It still resets it on the way out, so a click also postpones the next
|
|
2551
|
+
// automatic attempt by 10 s — which is right, since the click just did
|
|
2552
|
+
// that work.
|
|
2553
|
+
export const REJECTION_RETRY_MIN_INTERVAL_MS = 10_000;
|
|
2554
|
+
let lastAutoRetryAt = 0;
|
|
2555
|
+
let retryInFlight = null;
|
|
2556
|
+
// Ids waiting for an attempt. A caller that arrives while one is
|
|
2557
|
+
// running adds to this and rides the same promise; the running attempt
|
|
2558
|
+
// drains it before finishing, so nobody's ids are dropped just because
|
|
2559
|
+
// somebody else got there first.
|
|
2560
|
+
const pendingRetryIds = new Set();
|
|
2561
|
+
// Swappable clock, so the rate limit is testable without sleeping.
|
|
2562
|
+
let retryNow = () => Date.now();
|
|
2563
|
+
export function _setRetryNowForTests(fn) {
|
|
2564
|
+
retryNow = fn;
|
|
2565
|
+
resetRejectionRetryGate();
|
|
2566
|
+
}
|
|
2567
|
+
// Forget that we retried recently.
|
|
2568
|
+
//
|
|
2569
|
+
// The rate limit is module state, so it outlives a storage wipe — which
|
|
2570
|
+
// means one e2e test's retry would silently suppress the next test's,
|
|
2571
|
+
// exactly the kind of cross-test bleed `resetAllState` exists to
|
|
2572
|
+
// prevent. Same category as the parse check's circuit breaker, and
|
|
2573
|
+
// reset from the same place.
|
|
2574
|
+
//
|
|
2575
|
+
// Leaves `retryInFlight` alone: nulling it while an attempt is still
|
|
2576
|
+
// running would let a second one start concurrently, which is the
|
|
2577
|
+
// opposite of a clean slate. The running attempt clears it itself.
|
|
2578
|
+
export function resetRejectionRetryGate() {
|
|
2579
|
+
lastAutoRetryAt = 0;
|
|
2580
|
+
pendingRetryIds.clear();
|
|
2581
|
+
}
|
|
2582
|
+
// Re-attempt registration for whichever of `scriptIds` Chrome is
|
|
2583
|
+
// currently refusing. No-op when none of them is.
|
|
2584
|
+
//
|
|
2585
|
+
// Why the refresh paths need this at all: `syncRegistrations` is
|
|
2586
|
+
// normally driven by `onCacheChanged`, which rides on
|
|
2587
|
+
// `chrome.storage.onChanged` — and a Refresh that finds nothing new
|
|
2588
|
+
// raises no such event, so it never re-offers the spec. Nothing fires
|
|
2589
|
+
// by either of two routes: the refresh skips the write entirely for a
|
|
2590
|
+
// row that didn't change (see `changedScriptRows` in script-store.ts),
|
|
2591
|
+
// and Chrome raises no change event even when a `set` does write a
|
|
2592
|
+
// value identical to what's already stored. For a healthy script that
|
|
2593
|
+
// silence is right — its registration already matches. For a rejected
|
|
2594
|
+
// one it left the row reading REJECTED with nothing retrying it.
|
|
2595
|
+
//
|
|
2596
|
+
// Deliberately NOT a scopeless `syncRegistrations()`. That rebuilds
|
|
2597
|
+
// every spec and lists Chrome's whole set, which is far too heavy to
|
|
2598
|
+
// run per navigation. A rejected script is by definition not
|
|
2599
|
+
// registered, so re-offering just its own spec is both cheaper and
|
|
2600
|
+
// touches nothing else.
|
|
2601
|
+
export async function retryRejectedRegistrations(scriptIds, opts = {}) {
|
|
2602
|
+
await loadChannelKey();
|
|
2603
|
+
if (scriptIds.length === 0)
|
|
2604
|
+
return;
|
|
2605
|
+
// Nothing to register into. Without this the register call below
|
|
2606
|
+
// throws a raw TypeError, which the catch would then write into the
|
|
2607
|
+
// user's Log as Chrome's reason for refusing the script.
|
|
2608
|
+
if (!isUserScriptsAvailable())
|
|
2609
|
+
return;
|
|
2610
|
+
if (!opts.userInitiated && retryNow() - lastAutoRetryAt < REJECTION_RETRY_MIN_INTERVAL_MS) {
|
|
2611
|
+
return;
|
|
2612
|
+
}
|
|
2613
|
+
for (const id of scriptIds)
|
|
2614
|
+
pendingRetryIds.add(id);
|
|
2615
|
+
// One attempt at a time — concurrent register() calls for the same id
|
|
2616
|
+
// would race each other. Late callers ride this promise, and the
|
|
2617
|
+
// running attempt drains whatever they queued before it resolves, so
|
|
2618
|
+
// riding it is not the same as being dropped.
|
|
2619
|
+
if (retryInFlight)
|
|
2620
|
+
return retryInFlight;
|
|
2621
|
+
retryInFlight = (async () => {
|
|
2622
|
+
try {
|
|
2623
|
+
// Drain: a caller that queued ids while we were mid-attempt gets
|
|
2624
|
+
// them offered in this same run rather than waiting for whatever
|
|
2625
|
+
// triggers the next one (which, under the rate limit, could be
|
|
2626
|
+
// 10 s away — or never, for a script nothing else checks).
|
|
2627
|
+
while (pendingRetryIds.size > 0) {
|
|
2628
|
+
const ids = [...pendingRetryIds];
|
|
2629
|
+
pendingRetryIds.clear();
|
|
2630
|
+
// Under the same lock as a full sync: this re-offers ids the
|
|
2631
|
+
// sync also owns, and the two interleaving is precisely how a
|
|
2632
|
+
// registered script ends up reported as a duplicate.
|
|
2633
|
+
//
|
|
2634
|
+
// Which ids are still refused is read INSIDE the lock, because
|
|
2635
|
+
// waiting for it is exactly when that answer goes stale: a sync
|
|
2636
|
+
// that lands in the wait clears the map and places the scripts,
|
|
2637
|
+
// and a retry working from the older answer would then
|
|
2638
|
+
// unregister and re-register healthy ones — the mid-navigation
|
|
2639
|
+
// deregistration this path exists to avoid.
|
|
2640
|
+
await withRegistrationLock(async () => {
|
|
2641
|
+
const rejections = await readRejections();
|
|
2642
|
+
const refused = ids.filter((id) => rejections[id] !== undefined);
|
|
2643
|
+
if (refused.length === 0)
|
|
2644
|
+
return;
|
|
2645
|
+
// Stamped at attempt time, not queue time, for the same
|
|
2646
|
+
// reason: the rate limit counts attempts we actually made.
|
|
2647
|
+
lastAutoRetryAt = retryNow();
|
|
2648
|
+
await reregisterScripts(refused);
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
catch (err) {
|
|
2653
|
+
// Contained like the version probe beside it in script-updater:
|
|
2654
|
+
// this is incidental work hanging off a refresh, and a failure
|
|
2655
|
+
// here must not fail the Refresh the user actually asked for (the
|
|
2656
|
+
// collection paths await us) or surface as an unhandled rejection
|
|
2657
|
+
// (the per-script path does not).
|
|
2658
|
+
console.info('[SourceMonkey] registration retry failed:', describeError(err));
|
|
2659
|
+
}
|
|
2660
|
+
finally {
|
|
2661
|
+
retryInFlight = null;
|
|
2662
|
+
}
|
|
2663
|
+
})();
|
|
2664
|
+
return retryInFlight;
|
|
2665
|
+
}
|
|
2666
|
+
// Put one spec in place, whether or not Chrome already holds its id.
|
|
2667
|
+
//
|
|
2668
|
+
// `believedPresent` says which of `update` and `register` to try first;
|
|
2669
|
+
// the other one is the fallback. Both calls place the spec, so getting
|
|
2670
|
+
// the guess wrong costs a round trip and nothing else — and neither
|
|
2671
|
+
// call ever leaves the id unregistered, which is the property the whole
|
|
2672
|
+
// sync now rests on.
|
|
2673
|
+
//
|
|
2674
|
+
// That self-correction covers *presence* only. A belief carrying stale
|
|
2675
|
+
// glob VALUES that happen to equal the spec's makes `needsReplace`
|
|
2676
|
+
// false, `update` succeeds, and no second call ever disagrees — so
|
|
2677
|
+
// Chrome can hold globs neither the record nor the header describes
|
|
2678
|
+
// until the next full reconcile rebuilds `present` from `getScripts`.
|
|
2679
|
+
// Bounded by the worker's life, and it takes something other than this
|
|
2680
|
+
// worker's syncs placing a different glob set for the same id with no
|
|
2681
|
+
// storage write behind it, so we live with it rather than spending a
|
|
2682
|
+
// round trip per spec.
|
|
2683
|
+
//
|
|
2684
|
+
// Chrome answers a `register` for an id it already knows with
|
|
2685
|
+
// "Duplicate script ID '<id>'" rather than replacing it, and there are
|
|
2686
|
+
// two ordinary ways it already knows one of ours:
|
|
2687
|
+
//
|
|
2688
|
+
// - **Our record of what Chrome holds is wrong.** A reset, or a
|
|
2689
|
+
// record lost with a stopped worker. (Pages used to place
|
|
2690
|
+
// registrations too; only the worker does now, docs/worker-writes.md.)
|
|
2691
|
+
// - **A stale rejection record.** We believe the id is unplaced, so
|
|
2692
|
+
// we rebuild and re-offer it; Chrome disagrees.
|
|
2693
|
+
//
|
|
2694
|
+
// Either way the duplicate is a fact about our own ordering, not about
|
|
2695
|
+
// the script: reporting it badged a running script REJECTED and wrote a
|
|
2696
|
+
// Log row blaming it for our sequencing. Which of the two specs is
|
|
2697
|
+
// newer we can't tell from here, and don't need to — every `script:*` /
|
|
2698
|
+
// `collection:*` write schedules a sync of its own, so the set
|
|
2699
|
+
// converges on the last write either way.
|
|
2700
|
+
//
|
|
2701
|
+
// **Never unregister-then-register.** That arrangement turns any
|
|
2702
|
+
// register failure into a script that WAS running and now isn't, and
|
|
2703
|
+
// leaves a window — however short — in which a document created right
|
|
2704
|
+
// then gets nothing injected. `chrome.userScripts.update` replaces a
|
|
2705
|
+
// registration in place with no such window, so the pair here is
|
|
2706
|
+
// update/register rather than unregister/register.
|
|
2707
|
+
//
|
|
2708
|
+
// A refusal isn't necessarily a duplicate: a spec Chrome rejects
|
|
2709
|
+
// outright (a malformed `@match` port, say) lands in the same place.
|
|
2710
|
+
// Which one it is gets answered by ASKING Chrome (`getScripts` for the
|
|
2711
|
+
// one id) rather than by reading its wording for the refusal — a string
|
|
2712
|
+
// match there would break the moment Chrome rephrases, and Chrome's own
|
|
2713
|
+
// error is the one thing here we pass through to the user verbatim.
|
|
2714
|
+
//
|
|
2715
|
+
// Why ask rather than just try the other call: in the
|
|
2716
|
+
// `believedPresent` direction the other call is `register`, which is
|
|
2717
|
+
// *guaranteed* to answer `Duplicate script ID` when the id really is
|
|
2718
|
+
// there. It would carry no information, and treating its failure as
|
|
2719
|
+
// confirmation would let any transient `update` failure demote a
|
|
2720
|
+
// registration that was working.
|
|
2721
|
+
//
|
|
2722
|
+
// Shared by the retry path and the sync's per-spec fallback so the two
|
|
2723
|
+
// can't drift on this.
|
|
2724
|
+
//
|
|
2725
|
+
// `codeInPlace`: Chrome already holds this id with the same code, so the
|
|
2726
|
+
// update leaves the code out (`withoutCode`). The register and replace
|
|
2727
|
+
// fallbacks still send it, since they start from nothing.
|
|
2728
|
+
async function placeSpec(spec, believed, codeInPlace = false) {
|
|
2729
|
+
if (believed) {
|
|
2730
|
+
// A spec whose pattern fields `update` won't apply — changed
|
|
2731
|
+
// globs, or a dropped `matches` — can't go through it at all. See
|
|
2732
|
+
// `needsReplace`.
|
|
2733
|
+
if (needsReplace(believed, spec))
|
|
2734
|
+
return await replaceRegistration(spec);
|
|
2735
|
+
let refusal;
|
|
2736
|
+
try {
|
|
2737
|
+
await chrome.userScripts.update([codeInPlace ? withoutCode(spec) : spec]);
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
catch (err) {
|
|
2741
|
+
refusal = err;
|
|
2742
|
+
}
|
|
2743
|
+
const actual = await placedPatterns(spec.id);
|
|
2744
|
+
// It IS registered, so the refusal is about the spec. Report it.
|
|
2745
|
+
if (actual)
|
|
2746
|
+
throw refusal;
|
|
2747
|
+
dlog(`[SourceMonkey] script ${spec.id} was not registered after all; registering it`);
|
|
2748
|
+
try {
|
|
2749
|
+
await chrome.userScripts.register([spec]);
|
|
2750
|
+
return;
|
|
2751
|
+
}
|
|
2752
|
+
catch {
|
|
2753
|
+
// Our belief was wrong AND the register failed — or we couldn't
|
|
2754
|
+
// ask at all (`placedPatterns` answers "nothing there" when the
|
|
2755
|
+
// list call itself fails). Either way the update's reason is the
|
|
2756
|
+
// one that describes this spec.
|
|
2757
|
+
throw refusal;
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
let refusal;
|
|
2761
|
+
try {
|
|
2762
|
+
await chrome.userScripts.register([spec]);
|
|
2763
|
+
return;
|
|
2764
|
+
}
|
|
2765
|
+
catch (err) {
|
|
2766
|
+
refusal = err;
|
|
2767
|
+
}
|
|
2768
|
+
const actual = await placedPatterns(spec.id);
|
|
2769
|
+
// Nothing is holding the id, so the spec itself is the problem and
|
|
2770
|
+
// its original reason is what the caller should report.
|
|
2771
|
+
if (!actual)
|
|
2772
|
+
throw refusal;
|
|
2773
|
+
dlog(`[SourceMonkey] script ${spec.id} was already registered; replacing it`);
|
|
2774
|
+
// Now we know the registration's real shape, which is what says
|
|
2775
|
+
// whether an update would silently keep the pattern fields it holds.
|
|
2776
|
+
if (needsReplace(actual, spec))
|
|
2777
|
+
return await replaceRegistration(spec);
|
|
2778
|
+
await chrome.userScripts.update([spec]);
|
|
2779
|
+
}
|
|
2780
|
+
// The specs whose code is exactly what Chrome holds under their id.
|
|
2781
|
+
function sameCode(specs, held) {
|
|
2782
|
+
const byId = new Map(held.map((s) => [s.id, s]));
|
|
2783
|
+
return new Set(specs.filter((spec) => {
|
|
2784
|
+
const h = byId.get(spec.id);
|
|
2785
|
+
return h !== undefined && sameJs(spec.js, h.js);
|
|
2786
|
+
}).map((spec) => spec.id));
|
|
2787
|
+
}
|
|
2788
|
+
// Compared field by field, not as JSON: the code runs to megabytes, and
|
|
2789
|
+
// a string `===` compares it in place, where JSON would copy and escape
|
|
2790
|
+
// it first.
|
|
2791
|
+
function sameJs(a, b) {
|
|
2792
|
+
const x = a ?? [];
|
|
2793
|
+
const y = b ?? [];
|
|
2794
|
+
return x.length === y.length && x.every((part, i) => part.code === y[i].code && part.file === y[i].file);
|
|
2795
|
+
}
|
|
2796
|
+
// The specs to count as placed without a call, keeping our helpers ahead
|
|
2797
|
+
// of the user scripts in Chrome's order. Chrome moves an updated
|
|
2798
|
+
// registration to the end of its list, so:
|
|
2799
|
+
// - if a helper is behind a user script already, nothing is skipped:
|
|
2800
|
+
// everything is placed again, helpers first;
|
|
2801
|
+
// - if a helper is about to be sent, no user script is skipped either,
|
|
2802
|
+
// or the helper would land behind the skipped ones.
|
|
2803
|
+
function keepHelpersFirst(specs, held) {
|
|
2804
|
+
if (!helpersFirst(held))
|
|
2805
|
+
return new Set();
|
|
2806
|
+
const same = sameRegistrations(specs, held);
|
|
2807
|
+
const helperSent = specs.some((s) => !isUserScriptId(s.id) && !same.has(s.id));
|
|
2808
|
+
return helperSent ? new Set([...same].filter((id) => !isUserScriptId(id))) : same;
|
|
2809
|
+
}
|
|
2810
|
+
// Whether every one of our own registrations comes before every user
|
|
2811
|
+
// script in Chrome's list, the order the sync places them in.
|
|
2812
|
+
function helpersFirst(held) {
|
|
2813
|
+
const firstUser = held.findIndex((s) => isUserScriptId(s.id));
|
|
2814
|
+
return firstUser < 0 || held.slice(firstUser).every((s) => isUserScriptId(s.id));
|
|
2815
|
+
}
|
|
2816
|
+
// The specs Chrome holds exactly as given: the same code and every
|
|
2817
|
+
// setting. Fields left out compare as Chrome's defaults, and pattern
|
|
2818
|
+
// lists as sets. A difference this misses only costs an update, so it
|
|
2819
|
+
// errs toward "different".
|
|
2820
|
+
function sameRegistrations(specs, held) {
|
|
2821
|
+
const byId = new Map(held.map((s) => [s.id, s]));
|
|
2822
|
+
return new Set(specs.filter((spec) => {
|
|
2823
|
+
const h = byId.get(spec.id);
|
|
2824
|
+
return h !== undefined && sameSettings(spec, h) && sameJs(spec.js, h.js);
|
|
2825
|
+
}).map((spec) => spec.id));
|
|
2826
|
+
}
|
|
2827
|
+
function sameSettings(a, b) {
|
|
2828
|
+
const set = (v) => JSON.stringify([...new Set(v ?? [])].sort());
|
|
2829
|
+
const worldId = (s) => s.worldId ?? '';
|
|
2830
|
+
return set(a.matches) === set(b.matches)
|
|
2831
|
+
&& set(a.excludeMatches) === set(b.excludeMatches)
|
|
2832
|
+
&& set(a.includeGlobs) === set(b.includeGlobs)
|
|
2833
|
+
&& set(a.excludeGlobs) === set(b.excludeGlobs)
|
|
2834
|
+
&& (a.runAt ?? 'document_idle') === (b.runAt ?? 'document_idle')
|
|
2835
|
+
&& (a.allFrames ?? false) === (b.allFrames ?? false)
|
|
2836
|
+
&& (a.world ?? 'USER_SCRIPT') === (b.world ?? 'USER_SCRIPT')
|
|
2837
|
+
&& worldId(a) === worldId(b);
|
|
2838
|
+
}
|
|
2839
|
+
// An update that changes only the patterns. `update` keeps any property
|
|
2840
|
+
// it isn't given, and our helpers' code (the GM runtime's is over
|
|
2841
|
+
// 100 KB) would otherwise go to Chrome again on a toggle that moved one
|
|
2842
|
+
// pattern. Chrome then stores it and sends it on to renderers, which
|
|
2843
|
+
// was most of the sync's register time.
|
|
2844
|
+
function withoutCode(spec) {
|
|
2845
|
+
const { js: _js, ...rest } = spec;
|
|
2846
|
+
// `js` is required by the type, which describes a registration, not
|
|
2847
|
+
// an update.
|
|
2848
|
+
return rest;
|
|
2849
|
+
}
|
|
2850
|
+
// The one place a live registration is deliberately dropped and put
|
|
2851
|
+
// back, and the one place the closed window reopens — for one script,
|
|
2852
|
+
// for the length of one call pair.
|
|
2853
|
+
//
|
|
2854
|
+
// Unavoidable: Chrome's `update` ignores `includeGlobs` /
|
|
2855
|
+
// `excludeGlobs` outright and cannot clear `matches` (verified — see
|
|
2856
|
+
// `PlacedPatterns`). A script that just gained an `@exclude` would
|
|
2857
|
+
// otherwise keep running on the site its header now excludes, and one
|
|
2858
|
+
// that just lost its last `@exclude` would keep an exclusion its header
|
|
2859
|
+
// no longer declares — both with nothing in the UI to explain it.
|
|
2860
|
+
//
|
|
2861
|
+
// The trade is narrow and in the right direction: the gap costs one
|
|
2862
|
+
// script one navigation, and only a script whose match set the user is
|
|
2863
|
+
// actively rewriting; the alternative is Chrome and our own matcher
|
|
2864
|
+
// disagreeing indefinitely.
|
|
2865
|
+
async function replaceRegistration(spec) {
|
|
2866
|
+
dlog(`[SourceMonkey] script ${spec.id} changed a pattern field; re-registering it outright`);
|
|
2867
|
+
try {
|
|
2868
|
+
await chrome.userScripts.unregister({ ids: [spec.id] });
|
|
2869
|
+
}
|
|
2870
|
+
catch { /* already gone; the register below is then the whole job */ }
|
|
2871
|
+
await chrome.userScripts.register([spec]);
|
|
2872
|
+
}
|
|
2873
|
+
// Put every spec in place, batching when we can and falling back to one
|
|
2874
|
+
// at a time when Chrome refuses the batch — so one malformed pattern
|
|
2875
|
+
// can't stop every other script from being registered. (Chrome applies
|
|
2876
|
+
// a batch all-or-nothing: one bad spec and none of them land.)
|
|
2877
|
+
//
|
|
2878
|
+
// Ordering. Chrome moves an UPDATED registration to the END of its set,
|
|
2879
|
+
// in batch order (verified), so grouping the calls by kind reorders the
|
|
2880
|
+
// result. Our own registrations have to precede every user script, so
|
|
2881
|
+
// they are placed first and individually; the user specs then go out
|
|
2882
|
+
// as batches, because the relative order of two user scripts is not
|
|
2883
|
+
// something we promise anyway (see docs/injection.md#run-at-gating).
|
|
2884
|
+
async function placeSpecs(specs, present, codeInPlace = new Set()) {
|
|
2885
|
+
const placed = [];
|
|
2886
|
+
const refused = new Map();
|
|
2887
|
+
const placeOne = async (spec, beliefs = present) => {
|
|
2888
|
+
try {
|
|
2889
|
+
await placeSpec(spec, beliefs.get(spec.id), codeInPlace.has(spec.id));
|
|
2890
|
+
placed.push(spec.id);
|
|
2891
|
+
}
|
|
2892
|
+
catch (err) {
|
|
2893
|
+
refused.set(spec.id, describeError(err));
|
|
2894
|
+
}
|
|
2895
|
+
};
|
|
2896
|
+
for (const spec of specs.filter((s) => !isUserScriptId(s.id)))
|
|
2897
|
+
await placeOne(spec);
|
|
2898
|
+
const users = specs.filter((s) => isUserScriptId(s.id));
|
|
2899
|
+
// A one-spec batch IS the per-spec pass, so trying it first would
|
|
2900
|
+
// just offer the same spec to Chrome twice. Matters beyond the wasted
|
|
2901
|
+
// round trip: the targeted retry offers exactly one spec, and its
|
|
2902
|
+
// whole rate-limiting story is about how many attempts we make.
|
|
2903
|
+
// What the per-spec fallback below believes about each id. Starts as
|
|
2904
|
+
// the caller's `present` and is corrected as the batch pass lands, so
|
|
2905
|
+
// a failure part-way through doesn't make the fallback redo work that
|
|
2906
|
+
// already succeeded.
|
|
2907
|
+
const believed = new Map(present);
|
|
2908
|
+
if (users.length > 1) {
|
|
2909
|
+
// One partition pass: `needsReplaceFrom` sorts two arrays per call,
|
|
2910
|
+
// so asking it twice per spec is worth avoiding.
|
|
2911
|
+
const replace = [];
|
|
2912
|
+
const existing = [];
|
|
2913
|
+
const fresh = [];
|
|
2914
|
+
for (const spec of users) {
|
|
2915
|
+
if (!believed.has(spec.id))
|
|
2916
|
+
fresh.push(spec);
|
|
2917
|
+
else if (needsReplaceFrom(believed, spec))
|
|
2918
|
+
replace.push(spec);
|
|
2919
|
+
else
|
|
2920
|
+
existing.push(spec);
|
|
2921
|
+
}
|
|
2922
|
+
try {
|
|
2923
|
+
if (existing.length > 0) {
|
|
2924
|
+
await chrome.userScripts.update(existing.map((spec) => (codeInPlace.has(spec.id) ? withoutCode(spec) : spec)));
|
|
2925
|
+
}
|
|
2926
|
+
if (fresh.length > 0)
|
|
2927
|
+
await chrome.userScripts.register(fresh);
|
|
2928
|
+
for (const spec of replace) {
|
|
2929
|
+
await replaceRegistration(spec);
|
|
2930
|
+
// Chrome now holds this spec outright. Recording that matters
|
|
2931
|
+
// for the catch below: without it, a later `replaceRegistration`
|
|
2932
|
+
// throwing would send the fallback back over this script with a
|
|
2933
|
+
// stale `present`, and it would be unregistered and registered a
|
|
2934
|
+
// second time — a second injection gap for a script that is
|
|
2935
|
+
// already correct.
|
|
2936
|
+
believed.set(spec.id, patternsOf(spec));
|
|
2937
|
+
}
|
|
2938
|
+
return { placed: [...placed, ...users.map((s) => s.id)], refused };
|
|
2939
|
+
}
|
|
2940
|
+
catch {
|
|
2941
|
+
// Either a spec is bad or our belief about who holds an id is
|
|
2942
|
+
// stale. Which, and whose, is what the per-spec pass answers.
|
|
2943
|
+
// Re-placing the ones that already went in is harmless.
|
|
2944
|
+
}
|
|
2945
|
+
}
|
|
2946
|
+
for (const spec of users)
|
|
2947
|
+
await placeOne(spec, believed);
|
|
2948
|
+
return { placed, refused };
|
|
2949
|
+
}
|
|
2950
|
+
// `items` split by `test`: those it holds for, then the rest.
|
|
2951
|
+
function partition(items, test) {
|
|
2952
|
+
const yes = [];
|
|
2953
|
+
const no = [];
|
|
2954
|
+
for (const item of items)
|
|
2955
|
+
(test(item) ? yes : no).push(item);
|
|
2956
|
+
return [yes, no];
|
|
2957
|
+
}
|
|
2958
|
+
// `timer`, when given, gets one mark per step: each is its own round
|
|
2959
|
+
// trip to Chrome, and a sync's register time is usually one of them.
|
|
2960
|
+
async function applyPlan(plan, timer) {
|
|
2961
|
+
// Read alongside the Chrome calls below, which don't depend on it.
|
|
2962
|
+
// Never rejects (`readRejections` answers {} on failure), so nothing
|
|
2963
|
+
// is left unhandled if a call below throws first.
|
|
2964
|
+
const priorRead = readRejections();
|
|
2965
|
+
// Removals before registering. When a change swaps one copy of a
|
|
2966
|
+
// script for another, registering first left a moment in which a page
|
|
2967
|
+
// that loaded got both copies and ran the script twice. This order
|
|
2968
|
+
// leaves a moment in which it gets neither, which a reload fixes. The
|
|
2969
|
+
// two lists never share an id, so no registration is replaced this way
|
|
2970
|
+
// (see `placeSpec` on why that would be wrong).
|
|
2971
|
+
//
|
|
2972
|
+
// Only the ids we believe Chrome holds, which covers the old copy in a
|
|
2973
|
+
// swap. The rest are dropped in case our record is wrong about them, and
|
|
2974
|
+
// usually Chrome holds none of them: that fails the batch call, and
|
|
2975
|
+
// the one-at-a-time fallback in `unregisterIds` would hold up the
|
|
2976
|
+
// registering.
|
|
2977
|
+
const [held, unsure] = partition(plan.removeIds, (id) => plan.present.has(id));
|
|
2978
|
+
// Before the first change to Chrome's set, and only when there is one:
|
|
2979
|
+
// a sync with nothing to do leaves the saved record as it is.
|
|
2980
|
+
const inPlaceIds = plan.inPlace ?? new Set();
|
|
2981
|
+
if (plan.removeIds.length > 0 || plan.specs.some((s) => !inPlaceIds.has(s.id)))
|
|
2982
|
+
await markRecordStale();
|
|
2983
|
+
await unregisterIds(held);
|
|
2984
|
+
timer?.mark('unregister');
|
|
2985
|
+
const inPlace = plan.inPlace ?? new Set();
|
|
2986
|
+
const sent = await placeSpecs(plan.specs.filter((s) => !inPlace.has(s.id)), plan.present, plan.codeInPlace);
|
|
2987
|
+
const refused = sent.refused;
|
|
2988
|
+
const placedNow = new Set(sent.placed);
|
|
2989
|
+
const placed = plan.specs.map((s) => s.id).filter((id) => inPlace.has(id) || placedNow.has(id));
|
|
2990
|
+
timer?.mark('register');
|
|
2991
|
+
// A spec Chrome refuses for an id it already holds leaves the
|
|
2992
|
+
// PREVIOUS registration in place, so the script would keep injecting
|
|
2993
|
+
// stale code while its row read REJECTED — and REJECTED says the
|
|
2994
|
+
// script will not run. Drop it, so the badge stays true. Only known
|
|
2995
|
+
// once the registering is done, so it comes after.
|
|
2996
|
+
const after = [...unsure, ...[...refused.keys()].filter((id) => plan.present.has(id))];
|
|
2997
|
+
if (after.length > 0) {
|
|
2998
|
+
await unregisterIds(after);
|
|
2999
|
+
timer?.mark('unregister after');
|
|
3000
|
+
}
|
|
3001
|
+
const prior = await priorRead;
|
|
3002
|
+
timer?.mark('read rejections');
|
|
3003
|
+
const next = plan.owned === 'all' ? {} : { ...prior };
|
|
3004
|
+
if (plan.owned !== 'all')
|
|
3005
|
+
for (const id of plan.owned)
|
|
3006
|
+
delete next[id];
|
|
3007
|
+
for (const [id, msg] of refused) {
|
|
3008
|
+
console.info(`[SourceMonkey] script ${id} rejected: ${msg}`);
|
|
3009
|
+
// Our own bridge / GM-runtime specs get the console line above and
|
|
3010
|
+
// nothing else. They are not scripts: no row can render REJECTED
|
|
3011
|
+
// for them, no Log tab exists to read the entry, and
|
|
3012
|
+
// `retryRejectedRegistrations` could never clear the map entry
|
|
3013
|
+
// because `collectActiveSpecs` never produces their ids. A failure
|
|
3014
|
+
// here is ours to fix, not something to file against a script.
|
|
3015
|
+
if (!isUserScriptId(id))
|
|
3016
|
+
continue;
|
|
3017
|
+
next[id] = msg;
|
|
3018
|
+
// A rejected registration means the script silently never runs.
|
|
3019
|
+
// Its status cell says so for as long as the rejection stands
|
|
3020
|
+
// (REJECTED, from the map we're building here); the Log is where
|
|
3021
|
+
// the reason gets a timestamp and a permanent home. See
|
|
3022
|
+
// `logRejection` for why every attempt gets a row.
|
|
3023
|
+
logRejection(id, msg);
|
|
3024
|
+
}
|
|
3025
|
+
await writeRejections(prior, next);
|
|
3026
|
+
timer?.mark('write rejections');
|
|
3027
|
+
logRecoveries(placed.filter((id) => prior[id] !== undefined));
|
|
3028
|
+
return { placed, refused: new Set(refused.keys()) };
|
|
3029
|
+
}
|
|
3030
|
+
// Rebuild and re-offer the specs for `ids`, updating the rejection map
|
|
3031
|
+
// and the Log with whatever Chrome says this time. Caller must hold the
|
|
3032
|
+
// registration lock.
|
|
3033
|
+
async function reregisterScripts(ids) {
|
|
3034
|
+
await loadChannelKey();
|
|
3035
|
+
// Same precondition as the sync: `configureWorld` must precede
|
|
3036
|
+
// `register`. Guarded and idempotent: a retry can run before this
|
|
3037
|
+
// worker's first sync has configured it.
|
|
3038
|
+
await ensureWorldConfigured();
|
|
3039
|
+
const { scripts, collections, numbers } = await readAll();
|
|
3040
|
+
const handlerVersion = chrome.runtime.getManifest().version;
|
|
3041
|
+
const wanted = new Set(ids);
|
|
3042
|
+
// Bodies for the scripts being retried only.
|
|
3043
|
+
const candidates = await withBodies(scripts.filter((s) => wanted.has(s.id)), collections);
|
|
3044
|
+
// Built through collectActiveSpecs rather than buildRegistrationSpec
|
|
3045
|
+
// directly so the retry can't drift from the sync on eligibility or
|
|
3046
|
+
// on log labels.
|
|
3047
|
+
// Only the user specs: the shared GM runtime and bridge registrations
|
|
3048
|
+
// belong to the last full sync, which built them for this same script
|
|
3049
|
+
// set — a targeted retry re-offers the refused script, not the
|
|
3050
|
+
// scaffolding already installed around it.
|
|
3051
|
+
const logIds = computeLogIds(scripts.map((s) => ({ id: s.id, relativePath: s.relativePath })), numbers);
|
|
3052
|
+
const active = await collectActiveSpecs(candidates, collections, handlerVersion, logIds);
|
|
3053
|
+
const specs = active.specs;
|
|
3054
|
+
// `owned: wanted` is what makes this targeted: the rejection map is
|
|
3055
|
+
// re-read inside `applyPlan` and only these ids are touched, so a full
|
|
3056
|
+
// sync that landed while we were registering keeps every other entry.
|
|
3057
|
+
// It also covers a script that is rejected but no longer eligible
|
|
3058
|
+
// (disabled or gone since the map was written) — it produces no spec,
|
|
3059
|
+
// and being owned is what drops it so its row stops reading REJECTED.
|
|
3060
|
+
//
|
|
3061
|
+
// `present` is empty because a rejected script is by definition not
|
|
3062
|
+
// registered. When that's wrong, `placeSpec`'s fallback covers it.
|
|
3063
|
+
const applied = await applyPlan({
|
|
3064
|
+
specs, removeIds: [], present: new Map(), owned: wanted,
|
|
3065
|
+
});
|
|
3066
|
+
if (recordUnsaved)
|
|
3067
|
+
scheduleRecordSave();
|
|
3068
|
+
for (const id of applied.placed) {
|
|
3069
|
+
dlog(`[SourceMonkey] script ${id} registered on retry`);
|
|
3070
|
+
// Keep this context's record of the registration set honest, so a
|
|
3071
|
+
// later incremental sync knows to update rather than register.
|
|
3072
|
+
const spec = specs.find((s) => s.id === id);
|
|
3073
|
+
if (syncState && spec) {
|
|
3074
|
+
syncState.registered.set(id, placementsForSpecs([spec], active.gmRuntime)[0]);
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
// The one place the rejection row is worded. Shared by the sync and the
|
|
3079
|
+
// targeted retry so the two can't drift.
|
|
3080
|
+
//
|
|
3081
|
+
// Written on EVERY refused attempt, not just when the state changes. A
|
|
3082
|
+
// suppressed retry is indistinguishable from no retry: the Log would go
|
|
3083
|
+
// quiet while we were still trying, and "Clear log" followed by a
|
|
3084
|
+
// Refresh would leave the row explaining nothing. Each entry is a real
|
|
3085
|
+
// event — we offered the spec, Chrome said no. The retry rate limit
|
|
3086
|
+
// above is what keeps that from becoming a burst; the answer is to make
|
|
3087
|
+
// fewer attempts, not to hide the ones we make.
|
|
3088
|
+
function logRejection(scriptId, reason) {
|
|
3089
|
+
logScriptEvent(scriptId, {
|
|
3090
|
+
level: 'error',
|
|
3091
|
+
state: 'rejected',
|
|
3092
|
+
source: 'chrome-injector',
|
|
3093
|
+
message: describeRejection(reason),
|
|
3094
|
+
});
|
|
3095
|
+
}
|
|
3096
|
+
// The other half: a script Chrome had been refusing that just went in.
|
|
3097
|
+
//
|
|
3098
|
+
// Required by the rule that every surfaced failure has a matching
|
|
3099
|
+
// recovery line — see
|
|
3100
|
+
// [docs/script-log.md](../docs/script-log.md#recovery-is-logged-too).
|
|
3101
|
+
// Without it the Log's last word on a fixed script is the refusal, and
|
|
3102
|
+
// the user has no confirmation their fix took.
|
|
3103
|
+
//
|
|
3104
|
+
// Info level with `state: 'ok'`, like every other recovery row. The
|
|
3105
|
+
// badge itself comes from the rejection map, which the caller has
|
|
3106
|
+
// already rewritten; neither the refusal nor this row touches the
|
|
3107
|
+
// `loglevel:` sentinel, so nothing residual is left once the map clears.
|
|
3108
|
+
function logRecoveries(scriptIds) {
|
|
3109
|
+
for (const id of scriptIds) {
|
|
3110
|
+
logScriptEvent(id, {
|
|
3111
|
+
level: 'info',
|
|
3112
|
+
state: 'ok',
|
|
3113
|
+
source: 'chrome-injector',
|
|
3114
|
+
message: 'Chrome accepted this script\'s registration, so it runs again',
|
|
3115
|
+
});
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
// Place a complete registration set, none of whose ids Chrome is
|
|
3119
|
+
// believed to hold, and rewrite the rejection map wholesale from the
|
|
3120
|
+
// result. Returns the count of specs that actually went in.
|
|
3121
|
+
//
|
|
3122
|
+
// Thin wrapper over `applyPlan` — kept because it is the shape the unit
|
|
3123
|
+
// tests exercise, and because "these specs are the whole set" is worth
|
|
3124
|
+
// naming. A sync reaches `applyPlan` directly; it also has removals and
|
|
3125
|
+
// a `present` set to pass, neither of which this shape can express.
|
|
3126
|
+
//
|
|
3127
|
+
// Caller must hold the registration lock.
|
|
3128
|
+
export async function registerWithFallback(specs) {
|
|
3129
|
+
await loadChannelKey();
|
|
3130
|
+
const applied = await applyPlan({
|
|
3131
|
+
specs, removeIds: [], present: new Map(), owned: 'all',
|
|
3132
|
+
});
|
|
3133
|
+
if (recordUnsaved)
|
|
3134
|
+
scheduleRecordSave();
|
|
3135
|
+
return applied.placed.length;
|
|
3136
|
+
}
|
|
3137
|
+
// True for a stored script's registration id, false for the reserved
|
|
3138
|
+
// ids we register for our own plumbing: the bridge and one runtime per
|
|
3139
|
+
// world. Derived from the world list so a new world can't be missed —
|
|
3140
|
+
// every piece of own-spec bookkeeping (placement order, fingerprints,
|
|
3141
|
+
// the rejection map, the refusal retry, the bridge scope) turns on this.
|
|
3142
|
+
const OWN_IDS = new Set([BRIDGE_SCRIPT_ID, ...GM_RUNTIME_WORLDS.map(gmRuntimeIdFor)]);
|
|
3143
|
+
function isUserScriptId(id) {
|
|
3144
|
+
return !OWN_IDS.has(id);
|
|
3145
|
+
}
|