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,199 @@
|
|
|
1
|
+
// How one extension page opens another.
|
|
2
|
+
//
|
|
3
|
+
// Two of our pages are *named tabs*: Collections and Options each have
|
|
4
|
+
// at most one tab by default, and every link to them reuses it rather
|
|
5
|
+
// than stacking up copies. A viewer tab is not named — it is per-script,
|
|
6
|
+
// and having several open at once is the point.
|
|
7
|
+
//
|
|
8
|
+
// The rules this module exists to keep in one place:
|
|
9
|
+
// - A link to a named page opens in a NEW tab, never in the tab the
|
|
10
|
+
// user is reading. Leaving the viewer to look at a collection and
|
|
11
|
+
// losing the viewer is the behaviour this replaces.
|
|
12
|
+
// - "New tab" means the named one where there already is one: focus
|
|
13
|
+
// it and tell it where to scroll, instead of opening a second.
|
|
14
|
+
// - A modified click (Ctrl/Cmd/Shift/middle) is the user overriding
|
|
15
|
+
// us, and is left to the browser — which is why every one of these
|
|
16
|
+
// links is a real anchor with a real `href`, not a button.
|
|
17
|
+
//
|
|
18
|
+
// Usable from an extension page and from the service worker: both can
|
|
19
|
+
// call `chrome.tabs` and `chrome.runtime.getContexts`, and the worker
|
|
20
|
+
// passes the window it wants explicitly (see `openCollectionsPage` in
|
|
21
|
+
// src/extension/background.ts).
|
|
22
|
+
const PAGE_FILES = {
|
|
23
|
+
collections: 'collections.html',
|
|
24
|
+
options: 'options.html',
|
|
25
|
+
};
|
|
26
|
+
// Sent to a named tab that was reused rather than opened. A tab that is
|
|
27
|
+
// already loaded does not act on a fragment we write into its URL, so
|
|
28
|
+
// the scroll has to be asked for directly.
|
|
29
|
+
export const NAMED_TAB_FOCUS_MESSAGE = 'sm:named-tab-focus';
|
|
30
|
+
// The page-relative URL of a named page, for an anchor's `href`. Every
|
|
31
|
+
// extension page lives at the extension root, so the browser resolves
|
|
32
|
+
// it against the current page and the `chrome-extension://<id>/` prefix
|
|
33
|
+
// stays out of the markup.
|
|
34
|
+
export function namedPageUrl(page, hash) {
|
|
35
|
+
return hash ? `${PAGE_FILES[page]}#${hash}` : PAGE_FILES[page];
|
|
36
|
+
}
|
|
37
|
+
// Focus the named tab for `page` — reusing the open one where there is
|
|
38
|
+
// one — and land it on `hash`.
|
|
39
|
+
export async function openNamedTab(page, opts = {}) {
|
|
40
|
+
const hash = opts.hash ?? '';
|
|
41
|
+
const url = chrome.runtime.getURL(namedPageUrl(page, hash));
|
|
42
|
+
const windowId = opts.windowId !== undefined ? opts.windowId : await currentWindowId();
|
|
43
|
+
const existing = await findNamedTab(page, windowId, opts.sameWindowOnly === true);
|
|
44
|
+
if (!existing) {
|
|
45
|
+
await chrome.tabs.create(windowId == null ? { url } : { url, windowId });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await chrome.tabs.update(existing.tabId, { active: true });
|
|
49
|
+
// Only when it is somewhere else: the current window is already
|
|
50
|
+
// focused, and asking again is a no-op at best.
|
|
51
|
+
if (existing.windowId != null && existing.windowId !== windowId) {
|
|
52
|
+
try {
|
|
53
|
+
await chrome.windows.update(existing.windowId, { focused: true });
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// A window that closed between the lookup and here. The tab
|
|
57
|
+
// activation above is the part that mattered.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (hash || !opts.preserveScroll)
|
|
61
|
+
await notifyNamedTabFocus(page, hash);
|
|
62
|
+
}
|
|
63
|
+
// The open tab showing `page`, preferring one in `windowId`.
|
|
64
|
+
//
|
|
65
|
+
// getContexts, not tabs.query({url}): filtering a query by URL needs
|
|
66
|
+
// permission for that URL, and `<all_urls>` does not cover the
|
|
67
|
+
// `chrome-extension:` scheme — the query comes back empty for our own
|
|
68
|
+
// pages and we'd open a duplicate tab every time. getContexts needs no
|
|
69
|
+
// permission and reports tabId/windowId directly. It only sees loaded
|
|
70
|
+
// contexts, so a named tab still navigating is missed; the cost of that
|
|
71
|
+
// is one extra tab.
|
|
72
|
+
async function findNamedTab(page, windowId, sameWindowOnly) {
|
|
73
|
+
const base = chrome.runtime.getURL(PAGE_FILES[page]);
|
|
74
|
+
let contexts;
|
|
75
|
+
try {
|
|
76
|
+
contexts = await chrome.runtime.getContexts({ contextTypes: [chrome.runtime.ContextType.TAB] });
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Nothing to reuse that we can prove; opening a tab is the safe
|
|
80
|
+
// failure.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const matches = contexts.filter((ctx) => (ctx.tabId != null && ctx.tabId >= 0
|
|
84
|
+
&& typeof ctx.documentUrl === 'string' && isPageUrl(ctx.documentUrl, base)));
|
|
85
|
+
const here = windowId == null ? undefined : matches.find((ctx) => ctx.windowId === windowId);
|
|
86
|
+
const pick = here ?? (sameWindowOnly ? undefined : matches[0]);
|
|
87
|
+
return pick ? { tabId: pick.tabId, windowId: pick.windowId } : null;
|
|
88
|
+
}
|
|
89
|
+
// Whether a document URL is this page, with or without a query string
|
|
90
|
+
// and a fragment — `?first_run=1` and `#collection-…` variants are the
|
|
91
|
+
// same tab. The delimiter check is what keeps a prefix match from
|
|
92
|
+
// claiming a future `collections-archive.html`.
|
|
93
|
+
function isPageUrl(documentUrl, base) {
|
|
94
|
+
if (!documentUrl.startsWith(base))
|
|
95
|
+
return false;
|
|
96
|
+
const rest = documentUrl.slice(base.length);
|
|
97
|
+
return rest === '' || rest.startsWith('?') || rest.startsWith('#');
|
|
98
|
+
}
|
|
99
|
+
// This page's window, so a link prefers the named tab the user can
|
|
100
|
+
// already see. Null in the worker, and on any page Chrome won't name a
|
|
101
|
+
// tab for.
|
|
102
|
+
async function currentWindowId() {
|
|
103
|
+
try {
|
|
104
|
+
const tab = await chrome.tabs.getCurrent();
|
|
105
|
+
return tab?.windowId ?? null;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// Tell the reused tab where to go. Broadcast rather than addressed at
|
|
112
|
+
// the tab: a page cannot name itself as a message target, and the only
|
|
113
|
+
// listeners are the named pages themselves — where a second Collections
|
|
114
|
+
// tab exists, both scrolling to the same collection is the harmless
|
|
115
|
+
// answer.
|
|
116
|
+
async function notifyNamedTabFocus(page, hash) {
|
|
117
|
+
const message = { type: NAMED_TAB_FOCUS_MESSAGE, page, hash };
|
|
118
|
+
try {
|
|
119
|
+
await chrome.runtime.sendMessage(message);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// No listener answered — the tab is mid-load and will read the
|
|
123
|
+
// fragment out of its own URL instead. Not worth reporting.
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Register a named page's handler for "you were reused; go here". The
|
|
127
|
+
// argument is the fragment without its `#`, empty for the top.
|
|
128
|
+
export function onNamedTabFocus(page, handler) {
|
|
129
|
+
chrome.runtime.onMessage.addListener((message) => {
|
|
130
|
+
const msg = message;
|
|
131
|
+
if (!msg || msg.type !== NAMED_TAB_FOCUS_MESSAGE || msg.page !== page)
|
|
132
|
+
return undefined;
|
|
133
|
+
handler(typeof msg.hash === 'string' ? msg.hash : '');
|
|
134
|
+
// No reply, and no `true`: holding the channel open for an answer
|
|
135
|
+
// we never send makes the sender wait for the timeout.
|
|
136
|
+
return undefined;
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
// Turn an anchor into a link to a named page: a real href (so the
|
|
140
|
+
// browser's own open-in-new-tab gestures work and the URL is
|
|
141
|
+
// copyable), and a plain left click routed through `openNamedTab`.
|
|
142
|
+
export function wireNamedTabLink(a, page, hash) {
|
|
143
|
+
a.href = namedPageUrl(page, hash);
|
|
144
|
+
// The href alone would open a new tab; the handler below is what
|
|
145
|
+
// makes it the NAMED one. `target` is what a modified click and a
|
|
146
|
+
// pre-script click both fall back to.
|
|
147
|
+
a.target = '_blank';
|
|
148
|
+
a.rel = 'noopener';
|
|
149
|
+
a.addEventListener('click', (ev) => {
|
|
150
|
+
if (!isPlainClick(ev))
|
|
151
|
+
return;
|
|
152
|
+
ev.preventDefault();
|
|
153
|
+
void openNamedTab(page, { hash });
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// The header bar's product name and icon, on every page that is not the
|
|
157
|
+
// collections page, are a link home. One delegated listener rather than
|
|
158
|
+
// per-element wiring: the viewer repaints its heading (and the link
|
|
159
|
+
// inside it) on every in-place navigation.
|
|
160
|
+
export function installHomeLinkHandler() {
|
|
161
|
+
document.addEventListener('click', (ev) => {
|
|
162
|
+
if (!isPlainClick(ev))
|
|
163
|
+
return;
|
|
164
|
+
const target = ev.target;
|
|
165
|
+
if (!(target instanceof Element))
|
|
166
|
+
return;
|
|
167
|
+
if (!target.closest('a.page-home'))
|
|
168
|
+
return;
|
|
169
|
+
ev.preventDefault();
|
|
170
|
+
void openNamedTab('collections');
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
// A click we are allowed to reinterpret: primary button, no modifier.
|
|
174
|
+
// Anything else is the user asking the browser for a new tab, a new
|
|
175
|
+
// window, or a download, and the anchor's own href serves that.
|
|
176
|
+
function isPlainClick(ev) {
|
|
177
|
+
return !ev.defaultPrevented && ev.button === 0
|
|
178
|
+
&& !ev.ctrlKey && !ev.metaKey && !ev.shiftKey && !ev.altKey;
|
|
179
|
+
}
|
|
180
|
+
// Open an extension page in a plain new tab — no reuse, because the
|
|
181
|
+
// destination is per-script (a viewer or an install preview) and two of
|
|
182
|
+
// them is a normal thing to want.
|
|
183
|
+
//
|
|
184
|
+
// Returns the promise rather than dropping it: an unhandled rejection
|
|
185
|
+
// in an extension page is reported into chrome://extensions/?errors,
|
|
186
|
+
// where it reads as SourceMonkey being broken.
|
|
187
|
+
export function openExtensionTab(url) {
|
|
188
|
+
return chrome.tabs.create({ url }).then(() => undefined);
|
|
189
|
+
}
|
|
190
|
+
// Open an extension page in a new background tab, as a middle click or
|
|
191
|
+
// Ctrl/Cmd-click on a link does.
|
|
192
|
+
export function openBackgroundTab(url) {
|
|
193
|
+
return chrome.tabs.create({ url, active: false }).then(() => undefined);
|
|
194
|
+
}
|
|
195
|
+
// Open an extension page in a new window, as a Shift-click on a link
|
|
196
|
+
// does.
|
|
197
|
+
export function openExtensionWindow(url) {
|
|
198
|
+
return chrome.windows.create({ url }).then(() => undefined);
|
|
199
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Page-side compile-only parse check, for the install preview: it
|
|
2
|
+
// compiles a script it hasn't added yet, before the user decides, so
|
|
3
|
+
// there's no stored row for the store's checker to run on.
|
|
4
|
+
//
|
|
5
|
+
// Every stored row is checked in the service worker, which runs every
|
|
6
|
+
// refresh, add and install (docs/worker-writes.md), through the
|
|
7
|
+
// offscreen document.
|
|
8
|
+
//
|
|
9
|
+
// A page has a DOM, so it embeds its own `parse-sandbox.html` and
|
|
10
|
+
// compiles locally — `parse-sandbox-host.ts` does the driving, exactly
|
|
11
|
+
// as it does for the offscreen document on the service worker's
|
|
12
|
+
// behalf. The service worker is not involved: no wake, and no copy of
|
|
13
|
+
// the script across contexts, before the user has even chosen to
|
|
14
|
+
// install.
|
|
15
|
+
//
|
|
16
|
+
// Only the transport differs from the service worker's checker. Both
|
|
17
|
+
// compose the checked text with `buildParseCheckSource`, so both judge
|
|
18
|
+
// the same program.
|
|
19
|
+
//
|
|
20
|
+
// Fails open like every other leg of this feature (see
|
|
21
|
+
// [docs/parse-check.md]): a sandbox that won't load, a timeout, a
|
|
22
|
+
// verdict in an unrecognised shape, or a throw anywhere in between all
|
|
23
|
+
// resolve to `null` = "couldn't check", never to "didn't parse".
|
|
24
|
+
import { buildParseCheckSource } from '../composed-source.js';
|
|
25
|
+
import { dlog } from '../debug-log.js';
|
|
26
|
+
import { attachParseLocation } from '../parse-location.js';
|
|
27
|
+
import { isParseCheckDisabled } from '../parse-check-protocol.js';
|
|
28
|
+
import { createParseSandboxHost } from './parse-sandbox-host.js';
|
|
29
|
+
export const checkScriptParses = async (script) => {
|
|
30
|
+
if (await isParseCheckDisabled())
|
|
31
|
+
return null;
|
|
32
|
+
try {
|
|
33
|
+
const source = buildParseCheckSource(script);
|
|
34
|
+
const verdicts = await ensureHost().compile([source.text]);
|
|
35
|
+
// Resolved here rather than by the caller: `source` is what maps a
|
|
36
|
+
// reported line back to a file, and this is the last point where
|
|
37
|
+
// we still hold it.
|
|
38
|
+
return verdicts ? attachParseLocation(verdicts[0], source) : null;
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
dlog(`[SourceMonkey] parse check unavailable in this page: ${String(err)}`);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// One frame per page, created on the first check rather than at
|
|
46
|
+
// startup: a page that never adds or refreshes anything (the common
|
|
47
|
+
// case for the viewer) never pays for it. Once created it stays for
|
|
48
|
+
// the life of the page — checks come in bursts (a Refresh All is one
|
|
49
|
+
// per script), and re-creating the frame per check would pay the load
|
|
50
|
+
// and readiness handshake every time.
|
|
51
|
+
let host = null;
|
|
52
|
+
function ensureHost() {
|
|
53
|
+
if (host)
|
|
54
|
+
return host;
|
|
55
|
+
const frame = document.createElement('iframe');
|
|
56
|
+
// `src`, not `srcdoc`: the URL has to resolve to the page listed in
|
|
57
|
+
// the manifest's `sandbox.pages`, or the frame inherits this page's
|
|
58
|
+
// CSP and loses the `'unsafe-eval'` that is the entire point.
|
|
59
|
+
frame.src = 'parse-sandbox.html';
|
|
60
|
+
// Belt-and-braces, mirroring offscreen.html: the page is already
|
|
61
|
+
// sandboxed by the manifest key, and this keeps the frame from
|
|
62
|
+
// silently becoming a privileged extension page — with `chrome.*`
|
|
63
|
+
// reachable from whatever it compiles — if that key is ever dropped.
|
|
64
|
+
frame.setAttribute('sandbox', 'allow-scripts');
|
|
65
|
+
frame.hidden = true;
|
|
66
|
+
frame.style.display = 'none';
|
|
67
|
+
document.body.append(frame);
|
|
68
|
+
host = createParseSandboxHost(frame);
|
|
69
|
+
return host;
|
|
70
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Service-worker side of the compile-only parse check.
|
|
2
|
+
//
|
|
3
|
+
// ---------- What this answers ----------
|
|
4
|
+
//
|
|
5
|
+
// "Would V8 accept the code we are about to register?" — asked at
|
|
6
|
+
// install / refresh time rather than discovered at injection time.
|
|
7
|
+
//
|
|
8
|
+
// Before this existed, a `@require` with a syntax error (a truncated
|
|
9
|
+
// CDN response, an HTML error page that squeaked past the content-type
|
|
10
|
+
// gate) made the whole concatenated entry fail to parse. The seal
|
|
11
|
+
// entry caught it and logged "Script source failed to parse (a
|
|
12
|
+
// @require or the script body has a syntax error); nothing was run" —
|
|
13
|
+
// but only on a page the script matched. Checking up front turns that
|
|
14
|
+
// into an `invalid` status on the collections page, next to the script,
|
|
15
|
+
// the moment it is added or refreshed.
|
|
16
|
+
//
|
|
17
|
+
// The verdict is about the whole composed program, so on its own it
|
|
18
|
+
// says nothing about WHERE. The sandbox answers that separately, by
|
|
19
|
+
// compiling the same text a second way to get a line and column out of
|
|
20
|
+
// V8, and `parse-location.ts` maps that line back to the `@require`
|
|
21
|
+
// (or the body) it was written in.
|
|
22
|
+
//
|
|
23
|
+
// This is the SERVICE WORKER's route to the sandbox. Extension pages
|
|
24
|
+
// have a DOM and embed their own sandbox frame instead — see
|
|
25
|
+
// `parse-check-client.ts`. Both share `parse-sandbox-host.ts`.
|
|
26
|
+
//
|
|
27
|
+
// ---------- Why the SW route takes three contexts ----------
|
|
28
|
+
//
|
|
29
|
+
// `new Function(src)` compiles without executing, which is exactly the
|
|
30
|
+
// check — and it is V8's own verdict on the same text we inject, not a
|
|
31
|
+
// bundled parser's opinion about a similar text. But it is `eval` as
|
|
32
|
+
// far as CSP is concerned, and MV3's extension CSP forbids that in the
|
|
33
|
+
// service worker, in extension pages, and in offscreen documents.
|
|
34
|
+
//
|
|
35
|
+
// A **sandboxed page** may declare `'unsafe-eval'`, and is the only
|
|
36
|
+
// context that may. It also has an opaque origin and no `chrome.*`, so
|
|
37
|
+
// it cannot be messaged by the service worker directly. Hence:
|
|
38
|
+
//
|
|
39
|
+
// service worker → offscreen document → sandboxed iframe
|
|
40
|
+
// (chrome.*, (chrome.* + DOM, ('unsafe-eval',
|
|
41
|
+
// no DOM, no eval) no chrome.*)
|
|
42
|
+
// no eval)
|
|
43
|
+
//
|
|
44
|
+
// Considered and rejected: bundling a real parser (acorn, vendored the
|
|
45
|
+
// way Prism is). It needs no offscreen machinery and runs synchronously
|
|
46
|
+
// in the worker — but it parses a *Program* where our injected code is
|
|
47
|
+
// a *function body*, so it disagrees with V8 on top-level `return`,
|
|
48
|
+
// `await` and `new.target`. Those appear in real userscripts, and a
|
|
49
|
+
// parser that rejects code V8 accepts would flip working scripts to
|
|
50
|
+
// invalid. Being wrong in that direction is much worse than the setup
|
|
51
|
+
// cost here.
|
|
52
|
+
//
|
|
53
|
+
// ---------- Failing open ----------
|
|
54
|
+
//
|
|
55
|
+
// Every failure of this machinery itself — no `chrome.offscreen`, the
|
|
56
|
+
// document won't open, the sandbox never answers — resolves to "I
|
|
57
|
+
// don't know", never to "didn't parse". A script that works must not
|
|
58
|
+
// be marked invalid because our plumbing broke. The seal entry is
|
|
59
|
+
// still in place as the backstop, so a parse failure we miss here is
|
|
60
|
+
// reported at run time exactly as it was before.
|
|
61
|
+
import { dlog } from '../debug-log.js';
|
|
62
|
+
import { PARSE_CHECK_MSG } from '../parse-check-protocol.js';
|
|
63
|
+
const OFFSCREEN_URL = 'offscreen.html';
|
|
64
|
+
// How long to wait for the sandbox before giving up on a batch. The
|
|
65
|
+
// compile itself is fast (V8 parsing a few hundred KB), so this is a
|
|
66
|
+
// guard against the offscreen document being torn down mid-flight
|
|
67
|
+
// rather than a realistic budget. Kept short because opportunistic
|
|
68
|
+
// refresh runs on the navigation path: a wedged check should cost a
|
|
69
|
+
// moment, not ten seconds per pass.
|
|
70
|
+
const PARSE_TIMEOUT_MS = 3_000;
|
|
71
|
+
// After this many consecutive failures, stop trying for the rest of
|
|
72
|
+
// the service-worker's life. Without it, a permanently broken sandbox
|
|
73
|
+
// costs every refresh a full timeout per script forever — a
|
|
74
|
+
// degraded-capability path that gets slower the more scripts you have
|
|
75
|
+
// is worse than one that turns itself off.
|
|
76
|
+
const FAILURE_LIMIT = 3;
|
|
77
|
+
let consecutiveFailures = 0;
|
|
78
|
+
// `chrome.offscreen.createDocument` rejects if one already exists, and
|
|
79
|
+
// there is no "create if absent" call. Serializing through one promise
|
|
80
|
+
// keeps two concurrent refreshes from racing into that rejection.
|
|
81
|
+
let creating = null;
|
|
82
|
+
// True when this browser can run the check at all. Chrome gained
|
|
83
|
+
// `chrome.offscreen` in 109; we don't support anything near that old,
|
|
84
|
+
// but the guard also covers test environments and any future context
|
|
85
|
+
// where the API is absent.
|
|
86
|
+
export function parseCheckAvailable() {
|
|
87
|
+
return typeof chrome !== 'undefined' && !!chrome.offscreen;
|
|
88
|
+
}
|
|
89
|
+
// Reset the circuit breaker. Exposed for tests, which need a clean
|
|
90
|
+
// slate between cases that deliberately break the machinery.
|
|
91
|
+
export function resetParseCheckFailures() {
|
|
92
|
+
consecutiveFailures = 0;
|
|
93
|
+
}
|
|
94
|
+
async function ensureOffscreenDocument() {
|
|
95
|
+
if (creating)
|
|
96
|
+
return creating;
|
|
97
|
+
creating = (async () => {
|
|
98
|
+
// hasDocument() landed alongside the API; getContexts() is the
|
|
99
|
+
// newer spelling. Prefer whichever exists rather than assuming.
|
|
100
|
+
const existing = await hasOffscreenDocument();
|
|
101
|
+
if (existing)
|
|
102
|
+
return;
|
|
103
|
+
await chrome.offscreen.createDocument({
|
|
104
|
+
url: OFFSCREEN_URL,
|
|
105
|
+
reasons: [chrome.offscreen.Reason.IFRAME_SCRIPTING],
|
|
106
|
+
justification: 'Compile userscript source in a sandboxed iframe to detect syntax errors without running it.',
|
|
107
|
+
});
|
|
108
|
+
})().finally(() => {
|
|
109
|
+
creating = null;
|
|
110
|
+
});
|
|
111
|
+
return creating;
|
|
112
|
+
}
|
|
113
|
+
async function hasOffscreenDocument() {
|
|
114
|
+
const runtime = chrome.runtime;
|
|
115
|
+
if (runtime.getContexts) {
|
|
116
|
+
const contexts = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
|
|
117
|
+
return contexts.length > 0;
|
|
118
|
+
}
|
|
119
|
+
const offscreen = chrome.offscreen;
|
|
120
|
+
return offscreen.hasDocument ? offscreen.hasDocument() : false;
|
|
121
|
+
}
|
|
122
|
+
// Compile each source independently. Returns one verdict per input, or
|
|
123
|
+
// null when the check could not be performed at all — callers must
|
|
124
|
+
// treat null as "unknown", never as failure. See "Failing open" above.
|
|
125
|
+
export async function checkParses(sources) {
|
|
126
|
+
if (sources.length === 0)
|
|
127
|
+
return [];
|
|
128
|
+
if (!parseCheckAvailable())
|
|
129
|
+
return null;
|
|
130
|
+
if (consecutiveFailures >= FAILURE_LIMIT)
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
await ensureOffscreenDocument();
|
|
134
|
+
// Correlating a reply with its request is the sandbox host's job
|
|
135
|
+
// (`parse-sandbox-host.ts`) — this leg is a single awaited
|
|
136
|
+
// sendMessage, so there is nothing here to mix up.
|
|
137
|
+
const reply = await withTimeout(chrome.runtime.sendMessage({ type: PARSE_CHECK_MSG, sources }), PARSE_TIMEOUT_MS);
|
|
138
|
+
const verdicts = reply?.verdicts;
|
|
139
|
+
if (!Array.isArray(verdicts) || verdicts.length !== sources.length) {
|
|
140
|
+
dlog('[SourceMonkey] parse check returned no usable verdicts');
|
|
141
|
+
consecutiveFailures++;
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
consecutiveFailures = 0;
|
|
145
|
+
return verdicts;
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
// console.info, not .warn/.error: this is a degraded-capability
|
|
149
|
+
// path, not an extension bug, and Chrome forwards warn/error into
|
|
150
|
+
// chrome://extensions/?errors.
|
|
151
|
+
console.info('[SourceMonkey] parse check unavailable', err);
|
|
152
|
+
consecutiveFailures++;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
function withTimeout(promise, ms) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const timer = setTimeout(() => reject(new Error('parse check timed out')), ms);
|
|
159
|
+
promise.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
|
|
160
|
+
});
|
|
161
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Drives a sandboxed parse page over `postMessage`, for any context
|
|
2
|
+
// that has a DOM.
|
|
3
|
+
//
|
|
4
|
+
// The compile itself must happen in a sandboxed page — `new Function`
|
|
5
|
+
// is `eval` as far as CSP is concerned, and a sandboxed page is the
|
|
6
|
+
// only place allowed to declare `'unsafe-eval'` (see
|
|
7
|
+
// [docs/parse-check.md]). A sandboxed page has an opaque origin and no
|
|
8
|
+
// `chrome.*`, so it can only be reached by whoever embeds it.
|
|
9
|
+
//
|
|
10
|
+
// Two contexts embed one:
|
|
11
|
+
//
|
|
12
|
+
// - `offscreen.ts`, on behalf of the service worker. The SW has no
|
|
13
|
+
// DOM, so it cannot host an iframe at all; the offscreen document
|
|
14
|
+
// exists to be the DOM it lacks.
|
|
15
|
+
// - extension pages (`parse-check-client.ts`). A page already has a
|
|
16
|
+
// DOM, so it embeds its own frame and skips the service worker
|
|
17
|
+
// entirely — no wake, no cross-context clone of the script, and no
|
|
18
|
+
// dependency on the SW being alive mid-request.
|
|
19
|
+
//
|
|
20
|
+
// Both get the same behaviour from here: readiness, correlation,
|
|
21
|
+
// timeouts, verdict validation, and the circuit breaker.
|
|
22
|
+
import { PARSE_CHECK_MSG, PARSE_CHECK_RESULT_MSG, PARSE_SANDBOX_READY_MSG, } from '../parse-check-protocol.js';
|
|
23
|
+
// Per-request ceiling. The compile is fast (V8 parsing a few hundred
|
|
24
|
+
// KB), so this guards against the frame being torn down mid-flight
|
|
25
|
+
// rather than budgeting real work. It also stops `pending` from
|
|
26
|
+
// growing a dead resolver per abandoned request — an offscreen
|
|
27
|
+
// document outlives service-worker restarts, so "leaks slowly,
|
|
28
|
+
// forever" is a real shape there.
|
|
29
|
+
//
|
|
30
|
+
// Longer than the service worker's own `PARSE_TIMEOUT_MS`
|
|
31
|
+
// (`parse-check.ts`), deliberately: the SW is on the navigation path
|
|
32
|
+
// and gives up early, this is the last resort that keeps `pending`
|
|
33
|
+
// bounded. On the SW route that means the SW usually abandons a stuck
|
|
34
|
+
// request before this fires, so the breaker below mostly counts
|
|
35
|
+
// failures on the page route.
|
|
36
|
+
const REQUEST_TIMEOUT_MS = 8_000;
|
|
37
|
+
// Readiness cannot be observed from outside. A sandboxed frame is
|
|
38
|
+
// opaque-origin, so `frame.contentDocument` is ALWAYS null and its
|
|
39
|
+
// `readyState` is unreadable — an "is it loaded?" fast path looks
|
|
40
|
+
// reasonable and can never fire. That leaves two signals, and we take
|
|
41
|
+
// whichever arrives first:
|
|
42
|
+
//
|
|
43
|
+
// - the sandbox's own ready ping, sent as the last thing it does;
|
|
44
|
+
// - the iframe's `load` event, in case the ping was posted before
|
|
45
|
+
// the listener existed.
|
|
46
|
+
//
|
|
47
|
+
// The timeout is the third leg, and the important one: without it a
|
|
48
|
+
// missed signal wedges readiness forever and every check silently
|
|
49
|
+
// times out for the rest of the session. Resolving anyway lets the
|
|
50
|
+
// request through — the sandbox is almost certainly up, and if it
|
|
51
|
+
// isn't, the per-request timeout reports "couldn't check" rather than
|
|
52
|
+
// lying.
|
|
53
|
+
const FRAME_READY_TIMEOUT_MS = 2_000;
|
|
54
|
+
// After this many consecutive failures, stop trying. Without it a
|
|
55
|
+
// permanently broken sandbox costs every refresh a full timeout per
|
|
56
|
+
// script forever — a degraded-capability path that gets slower the
|
|
57
|
+
// more scripts you have is worse than one that turns itself off.
|
|
58
|
+
//
|
|
59
|
+
// Per host, not global: one wedged page no longer speaks for the whole
|
|
60
|
+
// extension, which is part of what embedding per-context buys.
|
|
61
|
+
const FAILURE_LIMIT = 3;
|
|
62
|
+
// ...but the trip is not permanent. The offscreen document is never
|
|
63
|
+
// torn down and is reused for the life of the browser session, so a
|
|
64
|
+
// sticky breaker there would mean three unlucky timeouts disable the
|
|
65
|
+
// check until the browser restarts — including after the sandbox
|
|
66
|
+
// recovers. Instead a tripped host lets one probe request through per
|
|
67
|
+
// cooldown: if it succeeds the breaker resets, if it fails the host
|
|
68
|
+
// goes quiet for another cooldown. The cost of staying wedged stays
|
|
69
|
+
// bounded, and recovery costs one timeout every 30s rather than never
|
|
70
|
+
// happening.
|
|
71
|
+
const BREAKER_COOLDOWN_MS = 30_000;
|
|
72
|
+
export function createParseSandboxHost(frame) {
|
|
73
|
+
// Requests waiting on the sandbox, by correlation id.
|
|
74
|
+
const pending = new Map();
|
|
75
|
+
let consecutiveFailures = 0;
|
|
76
|
+
let breakerTrippedAt = 0;
|
|
77
|
+
const frameReady = new Promise((resolve) => {
|
|
78
|
+
// Named and removed rather than inline: readiness is answered once,
|
|
79
|
+
// but this listener would otherwise keep type-checking every
|
|
80
|
+
// message reaching the context for as long as it lives.
|
|
81
|
+
const onReadyMessage = (event) => {
|
|
82
|
+
if (event.source !== frame.contentWindow)
|
|
83
|
+
return;
|
|
84
|
+
if (event.data?.type === PARSE_SANDBOX_READY_MSG)
|
|
85
|
+
done();
|
|
86
|
+
};
|
|
87
|
+
const done = () => {
|
|
88
|
+
window.removeEventListener('message', onReadyMessage);
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
window.addEventListener('message', onReadyMessage);
|
|
92
|
+
frame.addEventListener('load', done, { once: true });
|
|
93
|
+
setTimeout(done, FRAME_READY_TIMEOUT_MS);
|
|
94
|
+
});
|
|
95
|
+
window.addEventListener('message', (event) => {
|
|
96
|
+
// Only the sandboxed frame can reach an offscreen document in
|
|
97
|
+
// practice, but a normal extension page is a richer target — check
|
|
98
|
+
// the source so nothing else can resolve a request.
|
|
99
|
+
if (event.source !== frame.contentWindow)
|
|
100
|
+
return;
|
|
101
|
+
const data = event.data;
|
|
102
|
+
if (!data || data.type !== PARSE_CHECK_RESULT_MSG)
|
|
103
|
+
return;
|
|
104
|
+
if (typeof data.id !== 'string' || !Array.isArray(data.verdicts))
|
|
105
|
+
return;
|
|
106
|
+
const resolve = pending.get(data.id);
|
|
107
|
+
if (!resolve)
|
|
108
|
+
return;
|
|
109
|
+
pending.delete(data.id);
|
|
110
|
+
// A verdict we don't recognise is not a verdict. `applyParseCheck`
|
|
111
|
+
// treats anything that isn't `ok`/`unknown` as `invalid`, so an
|
|
112
|
+
// unexpected status string would DISABLE the script — exactly what
|
|
113
|
+
// the three-state design exists to prevent.
|
|
114
|
+
resolve(data.verdicts.every(isParseVerdict) ? data.verdicts : null);
|
|
115
|
+
});
|
|
116
|
+
// Stamp the trip on the failure that reaches the limit, so the
|
|
117
|
+
// cooldown runs from when the host went quiet.
|
|
118
|
+
const noteFailure = () => {
|
|
119
|
+
consecutiveFailures++;
|
|
120
|
+
if (consecutiveFailures === FAILURE_LIMIT)
|
|
121
|
+
breakerTrippedAt = Date.now();
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
async compile(sources) {
|
|
125
|
+
if (sources.length === 0)
|
|
126
|
+
return [];
|
|
127
|
+
if (consecutiveFailures >= FAILURE_LIMIT) {
|
|
128
|
+
if (Date.now() - breakerTrippedAt < BREAKER_COOLDOWN_MS)
|
|
129
|
+
return null;
|
|
130
|
+
// Probe: let this one through, and re-arm the cooldown now so a
|
|
131
|
+
// second caller during the probe doesn't get a second probe.
|
|
132
|
+
breakerTrippedAt = Date.now();
|
|
133
|
+
}
|
|
134
|
+
await frameReady;
|
|
135
|
+
const win = frame.contentWindow;
|
|
136
|
+
if (!win) {
|
|
137
|
+
// The frame went away (document teardown racing a request).
|
|
138
|
+
noteFailure();
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
// Random rather than a counter: an offscreen document outlives
|
|
142
|
+
// service-worker restarts, and a counter restarts at zero with
|
|
143
|
+
// it. A reused id can resolve a NEW request with an OLD
|
|
144
|
+
// request's verdicts, which is the one way this machinery could
|
|
145
|
+
// mark a good script invalid.
|
|
146
|
+
const id = crypto.randomUUID();
|
|
147
|
+
const verdicts = await new Promise((resolve) => {
|
|
148
|
+
const timer = setTimeout(() => {
|
|
149
|
+
pending.delete(id);
|
|
150
|
+
resolve(null);
|
|
151
|
+
}, REQUEST_TIMEOUT_MS);
|
|
152
|
+
pending.set(id, (v) => { clearTimeout(timer); resolve(v); });
|
|
153
|
+
win.postMessage({ type: PARSE_CHECK_MSG, id, sources }, '*');
|
|
154
|
+
});
|
|
155
|
+
if (!verdicts || verdicts.length !== sources.length) {
|
|
156
|
+
noteFailure();
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
consecutiveFailures = 0;
|
|
160
|
+
return verdicts;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const PARSE_STATUSES = ['ok', 'syntax-error', 'unknown'];
|
|
165
|
+
// Exported for the unit test: this predicate is the last thing between
|
|
166
|
+
// an unrecognised status string and `applyParseCheck` disabling the
|
|
167
|
+
// script it describes, so it is worth testing directly rather than
|
|
168
|
+
// only through a sandbox that would have to be made to misbehave.
|
|
169
|
+
export function isParseVerdict(value) {
|
|
170
|
+
const status = value?.status;
|
|
171
|
+
return PARSE_STATUSES.includes(status);
|
|
172
|
+
}
|