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,3507 @@
|
|
|
1
|
+
// Persistent userscript store backed by chrome.storage.local.
|
|
2
|
+
//
|
|
3
|
+
// Layout (one row per record, no monolithic blob):
|
|
4
|
+
//
|
|
5
|
+
// "collections" → string[] index of collection IDs
|
|
6
|
+
// "collection:<cid>" → StoredCollection per-collection metadata
|
|
7
|
+
// "script:<scid>" → StoredScript per-script content+header
|
|
8
|
+
//
|
|
9
|
+
// Collections own their scripts via `scriptIds`. Scripts back-reference
|
|
10
|
+
// their collection via `collectionId` so the viewer page can refresh a
|
|
11
|
+
// single script without first looking up its parent.
|
|
12
|
+
//
|
|
13
|
+
// All add/refresh flows go through one pipeline:
|
|
14
|
+
// listing → fetch each file → splitHeaderBody → parseHeader →
|
|
15
|
+
// validateHeader → distillMatching → buildScriptFromText.
|
|
16
|
+
// `validateHeader` onward is in script-build.ts, which touches no
|
|
17
|
+
// storage (the header parser is userscript-header.ts).
|
|
18
|
+
//
|
|
19
|
+
// A script is `ok` iff fetch succeeded AND splitHeaderBody returned no
|
|
20
|
+
// error AND validateHeader returned null. Otherwise `status: 'invalid'`
|
|
21
|
+
// with an `errorMessage`. On refresh, files that were known before but
|
|
22
|
+
// no longer appear in the scan listing are dropped — row and storage key
|
|
23
|
+
// both — whatever kind of collection they sat in, and however many
|
|
24
|
+
// went: a successful scan is the collection saying what it holds now.
|
|
25
|
+
// (A listing that isn't one — an error page, a moved directory — fails
|
|
26
|
+
// the scan instead, and a failed scan touches nothing; see
|
|
27
|
+
// `isDirectoryListing`.) In every error case we preserve the
|
|
28
|
+
// previously loaded content so the viewer (and the injector) keep
|
|
29
|
+
// working.
|
|
30
|
+
//
|
|
31
|
+
// Refresh failures get special, gentler handling so a transient
|
|
32
|
+
// outage never disables a working script. When a prior `ok` row's
|
|
33
|
+
// refresh fails — a hard fetch failure (network / 404 / unreadable
|
|
34
|
+
// file / disconnected mount), an http(s) 200 that parses `invalid` (a
|
|
35
|
+
// login / error page in place of the script), or a failed
|
|
36
|
+
// `@require`/`@resource` fetch — we keep the last-known-good row
|
|
37
|
+
// running (`status` stays `ok`, still injectable) and record the
|
|
38
|
+
// reason in `refreshError`. The next clean refresh clears it. A
|
|
39
|
+
// file:// body that parses `invalid` is the one exception: a local
|
|
40
|
+
// read returns the file's real bytes, so that's a genuine edit and we
|
|
41
|
+
// flag it `invalid` immediately. See [docs/storage.md].
|
|
42
|
+
//
|
|
43
|
+
// If the scan step itself fails (unreadable listing, unreadable
|
|
44
|
+
// manifest, …), refreshCollection records the error in `scanError` on
|
|
45
|
+
// the collection row and leaves every existing script untouched — see
|
|
46
|
+
// that function's docstring for the rationale.
|
|
47
|
+
import { canonicalCollectionInput, containerKindForSingleFileInput, containerPath, fetchScriptFileUrlText, fetchScriptFileWithStat, fetchHttpText, isContainerCollection, isHttpUrl, isLocalDirectoryCollection, isSingleFileInput, isScratchUrl, scratchFilesKey, scratchSourceKey, scratchTextKey, scratchUrl, isSmDevCollection, smDevContainerPath, smDevRunOfContainer, baseName, isFileUrl, isManifestCollection, parentDir, newContainerPath, normalizeCollectionInput, SINGLE_SCRIPT_USE_ADD_FIELD, scanForUserScripts, scriptFileUrl, statFileByListing, collectionCanonicalUrl, locationFsPath, statFromListing, hasCachedListing, } from './directories.js';
|
|
48
|
+
import { getValue, parseHeader, } from '../userscript-header.js';
|
|
49
|
+
import { bindListingCache, prefetchKnownStandIns, } from '../external-files.js';
|
|
50
|
+
import { scanAllStorage, scanStorageRows } from './storage-scan.js';
|
|
51
|
+
import { cachedRows, cachedScriptRows, allCachedRows } from './row-cache.js';
|
|
52
|
+
import { callWorker } from './worker-client.js';
|
|
53
|
+
import { assertWorkerContext, isPageContext } from '../run-context.js';
|
|
54
|
+
import { blobKeysOf, clearMigrationFailureForTests, fromStorage, hydrateScriptRows, carryBodyRef, MISSING_BLOB_MESSAGE, markMissingBlobs, newBlobItems, storedForm, migrateToBlobs, STORE_FORMAT, STORE_FORMAT_KEY, sweepUnusedBlobs, withBlobs, } from './script-blobs.js';
|
|
55
|
+
import { sameScript } from './script-keys.js';
|
|
56
|
+
import { compareInCollection, preferredCopy } from './copy-preference.js';
|
|
57
|
+
import { wipeValues } from './value-store.js';
|
|
58
|
+
import { connectGrantsKey } from './connect-grants.js';
|
|
59
|
+
import { sourceUsesUnsafeWindow } from '../unsafe-window-use.js';
|
|
60
|
+
import { describeDiagnostic, } from '../diagnostic-location.js';
|
|
61
|
+
import { collectionNameInUse, inferDisplayName, mayUseReservedName, RESERVED_COLLECTION_NAME, } from './collection-display.js';
|
|
62
|
+
import { appendScriptLog, forgetScriptLogs } from './script-log.js';
|
|
63
|
+
import { displayLocation, formatBytes, stripSriFragment } from '../script-display.js';
|
|
64
|
+
import { setRefreshError, pruneRefreshErrors } from './refresh-error.js';
|
|
65
|
+
import { mapWithConcurrency, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, FETCH_COLLECTIONS_CONCURRENCY, PARSE_RECHECK_CONCURRENCY, } from './concurrency.js';
|
|
66
|
+
import { dlog } from '../debug-log.js';
|
|
67
|
+
import { COLLECTIONS_INDEX_KEY, COLLECTION_PREFIX, ENABLEMENT_TIMES_KEY, SCRIPT_PREFIX, collectionKey, scriptKey, } from './structural-keys.js';
|
|
68
|
+
import { startPhaseTimer, storageSize, timePhase, formatMs, timingNow } from '../phase-timing.js';
|
|
69
|
+
import { applyFetchError, applyFiles, applyParseCheck, applyVerdictFields, buildInitialInvalidEntry, buildScriptFromText, describeError, locationIn, parseChecker, parseCheckKey, rederivedStartupLogs, RETIRED_CONTEXT_MENU_UNSUPPORTED, setScriptError, validateHeader, } from '../script-build.js';
|
|
70
|
+
// The storage-free half of the pipeline, re-exported for the callers
|
|
71
|
+
// that reach it through here.
|
|
72
|
+
export { applyFetchError, applyFiles, buildInitialInvalidEntry, buildScriptFromText, RETIRED_CONTEXT_MENU_UNSUPPORTED, describeError, distillMatching, extractStartupLogs, hashSource, isolatedUnsafeWindowWarning, NO_MATCH_OR_INCLUDE, NO_NAME, noConnectInfo, setParseChecker, setScriptError, SYNTAX_ERROR_PREFIX, unsupportedRunAtWarning, validateHeader, } from '../script-build.js';
|
|
73
|
+
// Two states that used to share the message `not loaded`, which read as
|
|
74
|
+
// the same fact but isn't: one is a row waiting for its first fetch, the
|
|
75
|
+
// other is a row whose fetch never happened and won't be retried until
|
|
76
|
+
// the next refresh.
|
|
77
|
+
const NOT_LOADED_YET = 'Not loaded yet';
|
|
78
|
+
const NOT_READ = 'Could not be read';
|
|
79
|
+
// The per-script Log entry a row's *persisted load state* warrants, or
|
|
80
|
+
// null when the row is healthy (`ok` with no `refreshError`).
|
|
81
|
+
//
|
|
82
|
+
// Single source of truth for "how do we phrase this failure in the
|
|
83
|
+
// Log tab", shared by every path that can surface one: the add path
|
|
84
|
+
// (script-store), the refresh outcome path and the startup replay
|
|
85
|
+
// (script-updater), and the deferred parse check. Sharing it means the
|
|
86
|
+
// same failure reads the same way whichever path reported it, so a
|
|
87
|
+
// user comparing two rows can tell "the same problem again" from "a
|
|
88
|
+
// different problem".
|
|
89
|
+
//
|
|
90
|
+
// Each entry carries the `state` it moved the row into — the same word
|
|
91
|
+
// the Status badge shows — so the Log tab's status column and the badge
|
|
92
|
+
// agree (see `LogEntry.state`). Because it carries a state, the entry
|
|
93
|
+
// never bumps the `loglevel:` sentinel: the badge comes from the row
|
|
94
|
+
// and clears when the row recovers.
|
|
95
|
+
//
|
|
96
|
+
// Levels (still recorded on the entry, though the Log tab's Status
|
|
97
|
+
// cell shows the `state` word instead):
|
|
98
|
+
// - `invalid` → **error**. The script cannot run: the injector skips
|
|
99
|
+
// `invalid` rows outright.
|
|
100
|
+
// - `refreshError` → **warning**. The script IS still running, from
|
|
101
|
+
// its last-known-good copy; only the *update* failed.
|
|
102
|
+
export function loadStateLogEntry(script) {
|
|
103
|
+
if (script.status === 'invalid') {
|
|
104
|
+
// The location travels as its own field rather than being baked
|
|
105
|
+
// into the text, so the Log tab can link it — and so this entry
|
|
106
|
+
// says exactly what the status badge's hover text says.
|
|
107
|
+
return {
|
|
108
|
+
level: 'error',
|
|
109
|
+
state: 'invalid',
|
|
110
|
+
message: script.errorMessage ?? 'Unknown error',
|
|
111
|
+
...(script.errorLocation ? { location: script.errorLocation } : {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
if (script.refreshError) {
|
|
115
|
+
return { level: 'warning', state: 'refresh-failed', message: `Refresh failed: ${script.refreshError}` };
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
// Map a StartupLog level (page-console-flavored: log/info/warn/error)
|
|
120
|
+
// to the per-script LogEntry level (info/warning/error). 'log' and
|
|
121
|
+
// 'info' both land as 'info' because the Log tab doesn't render a
|
|
122
|
+
// separate 'log' level.
|
|
123
|
+
function startupLogLevelToLogLevel(level) {
|
|
124
|
+
if (level === 'warn')
|
|
125
|
+
return 'warning';
|
|
126
|
+
if (level === 'error')
|
|
127
|
+
return 'error';
|
|
128
|
+
return 'info';
|
|
129
|
+
}
|
|
130
|
+
// A `StartupLog` as the Log tab's own entry shape. Every replay path —
|
|
131
|
+
// the new-content one in `emitScriptOutcomeLog`, the once-per-session
|
|
132
|
+
// one in `ensureStartupLogsInLogTab` and the add path's
|
|
133
|
+
// `emitInitialLoadLog` — goes through here so a field added
|
|
134
|
+
// to StartupLog can't reach one of them and not the others. `location`
|
|
135
|
+
// is exactly such a field.
|
|
136
|
+
export function startupLogEntry(sl) {
|
|
137
|
+
return {
|
|
138
|
+
level: startupLogLevelToLogLevel(sl.level),
|
|
139
|
+
source: 'parser',
|
|
140
|
+
message: sl.message,
|
|
141
|
+
...(sl.location ? { location: sl.location } : {}),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
// Log lines for local stand-ins that started, stopped, or changed on
|
|
145
|
+
// disk between two versions of a row.
|
|
146
|
+
//
|
|
147
|
+
// Why log this at all: running something other than what the `@require`
|
|
148
|
+
// line names is exactly the kind of substitution that must not be
|
|
149
|
+
// silent. The viewer marks it too (a `local` chip on the tab and in the
|
|
150
|
+
// details panel), but the Log is what says *when* it happened, and a
|
|
151
|
+
// re-read after an edit is the signal a developer is actually watching
|
|
152
|
+
// for.
|
|
153
|
+
//
|
|
154
|
+
// Why it isn't noisy: `contentHash` gates the "still using it" case, so
|
|
155
|
+
// a stand-in that hasn't changed says nothing on the dozens of
|
|
156
|
+
// opportunistic refreshes between edits.
|
|
157
|
+
//
|
|
158
|
+
// All `info`, losing a stand-in included: the script is back on the
|
|
159
|
+
// code its `@require` line names, which is not a fault. The line says
|
|
160
|
+
// what is loaded now, so the switch is still on record.
|
|
161
|
+
// How a declared reference reads in a Log line: the SRI hash tail
|
|
162
|
+
// dropped (it's most of the line's width and nothing the reader can act
|
|
163
|
+
// on) and percent-escapes decoded, so a URL carrying a script's name
|
|
164
|
+
// reads as `my lib/util.js` rather than `my%20lib/util.js`. Same
|
|
165
|
+
// treatment every other URL a human sees already gets.
|
|
166
|
+
const refText = (ref) => displayLocation(stripSriFragment(ref));
|
|
167
|
+
export function localFileLogMessages(prior, next) {
|
|
168
|
+
const out = [];
|
|
169
|
+
// Keyed by KIND + declared URL. The URL alone would collide: a
|
|
170
|
+
// `@require` and a `@resource` pointing at the same URL is an
|
|
171
|
+
// explicitly supported case (they share one fetch — see
|
|
172
|
+
// external-files.ts), and a single map would hand the require the
|
|
173
|
+
// resource's record on a last-write-wins basis.
|
|
174
|
+
//
|
|
175
|
+
// The URL rather than the ref is the right half of the key: it's the
|
|
176
|
+
// field that stays put while the stand-in appears and disappears
|
|
177
|
+
// underneath it.
|
|
178
|
+
const priorFiles = new Map();
|
|
179
|
+
const priorKey = (kind, url) => `${kind}\n${url}`;
|
|
180
|
+
for (const f of prior?.requires ?? []) {
|
|
181
|
+
priorFiles.set(priorKey('require', f.url), { localUrl: f.localUrl, contentHash: f.contentHash });
|
|
182
|
+
}
|
|
183
|
+
for (const f of prior?.resources ?? []) {
|
|
184
|
+
priorFiles.set(priorKey('resource', f.url), { localUrl: f.localUrl, contentHash: f.contentHash });
|
|
185
|
+
}
|
|
186
|
+
for (const kind of ['require', 'resource']) {
|
|
187
|
+
const list = kind === 'require' ? (next.requires ?? []) : (next.resources ?? []);
|
|
188
|
+
for (const f of list) {
|
|
189
|
+
const was = priorFiles.get(priorKey(kind, f.url));
|
|
190
|
+
if (f.localUrl) {
|
|
191
|
+
// Same file, same bytes as last pass → nothing happened.
|
|
192
|
+
if (was?.localUrl === f.localUrl && was.contentHash === f.contentHash)
|
|
193
|
+
continue;
|
|
194
|
+
out.push({
|
|
195
|
+
level: 'info',
|
|
196
|
+
message: `@${kind} ${refText(f.ref)} is loading from ${displayLocation(f.localUrl)}`,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (was?.localUrl) {
|
|
200
|
+
out.push({
|
|
201
|
+
level: 'info',
|
|
202
|
+
message: `@${kind} ${refText(f.ref)} is no longer loading from a local file; `
|
|
203
|
+
+ 'the remote version is loaded instead',
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// A declaration the script dropped isn't reported — the `@require`
|
|
209
|
+
// line going away is the event, and it isn't this function's to tell.
|
|
210
|
+
//
|
|
211
|
+
// Deduped because two `@resource` lines may name the same URL under
|
|
212
|
+
// different names (deliberately — see `distillResources`), and both
|
|
213
|
+
// entries would otherwise produce the identical line.
|
|
214
|
+
const seen = new Set();
|
|
215
|
+
return out.filter((e) => !seen.has(e.message) && seen.add(e.message));
|
|
216
|
+
}
|
|
217
|
+
// Treat any pre-`localFiles`-era stored entry as enabled, the same way
|
|
218
|
+
// `isCollectionEnabled` treats `enabled`.
|
|
219
|
+
export function collectionUsesLocalFiles(collection) {
|
|
220
|
+
return collection.localFiles !== false;
|
|
221
|
+
}
|
|
222
|
+
// Everything the local-stand-in resolver needs from a collection row,
|
|
223
|
+
// in one value — so no caller can pass the toggle and forget the root.
|
|
224
|
+
export function localFileScope(collection) {
|
|
225
|
+
return { enabled: collectionUsesLocalFiles(collection), collectionPath: collection.path };
|
|
226
|
+
}
|
|
227
|
+
// Treat any pre-`enabled`-era stored entry as enabled. The schema now
|
|
228
|
+
// requires `enabled: boolean`, but rows persisted before this field was
|
|
229
|
+
// introduced will read back as undefined; only an explicit `false`
|
|
230
|
+
// should count as disabled.
|
|
231
|
+
export function isCollectionEnabled(collection) {
|
|
232
|
+
return collection.enabled !== false;
|
|
233
|
+
}
|
|
234
|
+
export function isScriptEnabled(script) {
|
|
235
|
+
return script.enabled !== false;
|
|
236
|
+
}
|
|
237
|
+
// Re-derive the parse verdict for rows that carry none, and persist
|
|
238
|
+
// what comes back.
|
|
239
|
+
//
|
|
240
|
+
// ---------- Why a second entry point ----------
|
|
241
|
+
//
|
|
242
|
+
// `applyParseCheck` runs inside the load pipeline, so it only ever
|
|
243
|
+
// sees rows that are being *built* — added, refreshed, edited. A row
|
|
244
|
+
// that was stored before the check existed, or while the check was
|
|
245
|
+
// failing open (no offscreen document, sandbox unavailable), keeps
|
|
246
|
+
// `status: 'ok'` with no `parseCheckedHash` and is never revisited
|
|
247
|
+
// until something rebuilds it. Registering one of those is how
|
|
248
|
+
// unparsable source reaches Chrome.
|
|
249
|
+
//
|
|
250
|
+
// That matters beyond the script not running. A registered entry that
|
|
251
|
+
// V8 refuses to compile raises an uncaught `SyntaxError` attributed to
|
|
252
|
+
// the extension, which Chrome files under
|
|
253
|
+
// `chrome://extensions/?errors` — nothing can catch it in-page, and it
|
|
254
|
+
// reads as SourceMonkey being broken rather than the script being
|
|
255
|
+
// broken. Not registering it is the only way to keep it out of there;
|
|
256
|
+
// the seal entry (the registry's `sealed`) stays as the backstop for whatever
|
|
257
|
+
// still gets through.
|
|
258
|
+
//
|
|
259
|
+
// Caller passes the rows it is about to register — checking a
|
|
260
|
+
// disabled script would spend a round trip on code nothing will run.
|
|
261
|
+
//
|
|
262
|
+
// Returns, keyed by id, the rows whose STORED state differs from the
|
|
263
|
+
// copy the caller passed in — the ones this pass wrote, and the ones it
|
|
264
|
+
// refused to write because someone else had already moved the row. The
|
|
265
|
+
// caller's job is the same either way: swap ours in. Returning only the
|
|
266
|
+
// rows we WROTE is what made the refusal branches below a bug —
|
|
267
|
+
// `revalidateCandidates` falls back to the snapshot it read before the
|
|
268
|
+
// check, which is the row that still says `ok`, and registers the very
|
|
269
|
+
// body we just declined to write a verdict over.
|
|
270
|
+
//
|
|
271
|
+
// One case is deliberately not covered: a row DELETED while the check
|
|
272
|
+
// was out is absent from the result, because there is no stored row to
|
|
273
|
+
// hand back. The caller keeps its snapshot and registers a script that
|
|
274
|
+
// no longer exists for one cycle, until the write that deleted it
|
|
275
|
+
// schedules the sync that drops it.
|
|
276
|
+
//
|
|
277
|
+
// Fails open exactly like the pipeline check: no checker, an
|
|
278
|
+
// inconclusive verdict, or a throw all leave the row alone.
|
|
279
|
+
export async function verifyParseChecks(scripts) {
|
|
280
|
+
const changed = new Map();
|
|
281
|
+
if (!parseChecker)
|
|
282
|
+
return changed;
|
|
283
|
+
// A row the check couldn't decide on records nothing, so it stays
|
|
284
|
+
// stale and is asked about again on the next sync. That is
|
|
285
|
+
// deliberate: the two ways to be undecided are "the machinery isn't
|
|
286
|
+
// there" (answered without compiling anything, and it can come back
|
|
287
|
+
// — the off-switch the tests use lives here) and "V8 ran out of
|
|
288
|
+
// parser budget", which the sandbox's own circuit breaker already
|
|
289
|
+
// rations. Remembering the verdict-free attempt instead would mean a
|
|
290
|
+
// row checked while the machinery was down never gets checked again.
|
|
291
|
+
const stale = scripts.filter((s) => s.status === 'ok' && s.parseCheckedHash !== parseCheckKey(s));
|
|
292
|
+
if (stale.length === 0)
|
|
293
|
+
return changed;
|
|
294
|
+
// Rows another pass is already compiling are joined rather than
|
|
295
|
+
// compiled again. Both entry points reach the same bytes on the
|
|
296
|
+
// reload path — the deferred check the refresh started, and the sync
|
|
297
|
+
// that same refresh's write scheduled — and a compile is the
|
|
298
|
+
// expensive part of this whole feature. See `inFlightChecks`.
|
|
299
|
+
const own = [];
|
|
300
|
+
const joined = [];
|
|
301
|
+
for (const s of stale) {
|
|
302
|
+
const running = inFlightChecks.get(inFlightCheckKey(s));
|
|
303
|
+
if (running)
|
|
304
|
+
joined.push(running.then((row) => { if (row)
|
|
305
|
+
changed.set(s.id, row); }));
|
|
306
|
+
else
|
|
307
|
+
own.push(s);
|
|
308
|
+
}
|
|
309
|
+
// Declared out here and filled inside the `try` so a throw partway
|
|
310
|
+
// through the claim loop still reaches the `finally` that settles it.
|
|
311
|
+
// A claimed key with no resolver would wedge every later pass over
|
|
312
|
+
// that row on a promise that never settles. (`const settle =` inside
|
|
313
|
+
// the `try` cannot work — the `finally` would read it in its TDZ.)
|
|
314
|
+
const settle = new Map();
|
|
315
|
+
try {
|
|
316
|
+
claimInFlightChecks(own, settle);
|
|
317
|
+
await Promise.all([runOwnParseChecks(own, changed), ...joined]);
|
|
318
|
+
}
|
|
319
|
+
finally {
|
|
320
|
+
// Resolve with the row as it now stands so a joiner gets what a
|
|
321
|
+
// caller of its own would have got — including the refusal cases,
|
|
322
|
+
// where the row we hand back is someone else's write rather than
|
|
323
|
+
// ours. A throw resolves them with the input row, which is what
|
|
324
|
+
// "nothing was recorded" looks like to every reader.
|
|
325
|
+
for (const [key, entry] of settle) {
|
|
326
|
+
entry.resolve(changed.get(entry.id) ?? null);
|
|
327
|
+
inFlightChecks.delete(key);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return changed;
|
|
331
|
+
}
|
|
332
|
+
// The compile-and-store half of `verifyParseChecks`, for the rows this
|
|
333
|
+
// call owns. Fills `changed` in place.
|
|
334
|
+
async function runOwnParseChecks(own, changed) {
|
|
335
|
+
if (own.length === 0)
|
|
336
|
+
return;
|
|
337
|
+
// `prior` is deliberately undefined: the short-circuit it exists for
|
|
338
|
+
// is the one the staleness filter already applied.
|
|
339
|
+
//
|
|
340
|
+
// Capped rather than `Promise.all`: `own` is normally empty or tiny,
|
|
341
|
+
// but the first sync after an extension update finds the whole
|
|
342
|
+
// library stale at once (the version is part of the key), and each
|
|
343
|
+
// compile composes a whole program in memory.
|
|
344
|
+
const checked = await mapWithConcurrency(own, PARSE_RECHECK_CONCURRENCY, (s) => applyParseCheck({ ...s }, undefined));
|
|
345
|
+
const verdicts = new Map();
|
|
346
|
+
for (const next of checked) {
|
|
347
|
+
// Nothing to record when the check couldn't decide — no verdict,
|
|
348
|
+
// no fingerprint, and the row is untouched.
|
|
349
|
+
if (next.status === 'ok' && !next.parseCheckedHash)
|
|
350
|
+
continue;
|
|
351
|
+
verdicts.set(next.id, next);
|
|
352
|
+
}
|
|
353
|
+
if (verdicts.size === 0)
|
|
354
|
+
return;
|
|
355
|
+
// Rows this pass wrote and that can no longer run. Collected inside
|
|
356
|
+
// the lock, reported outside it — a Log append is not storage-row
|
|
357
|
+
// work and has its own queue.
|
|
358
|
+
const flipped = [];
|
|
359
|
+
await mutate(async () => {
|
|
360
|
+
const keys = [...verdicts.keys()].map(scriptKey);
|
|
361
|
+
const got = await getMany(keys);
|
|
362
|
+
const toSet = {};
|
|
363
|
+
for (const [id, next] of verdicts) {
|
|
364
|
+
// Re-read rather than trusting the copy we checked: the check is
|
|
365
|
+
// a round trip to the offscreen document, and a refresh could have
|
|
366
|
+
// rewritten the row while it was out. A row whose fingerprint
|
|
367
|
+
// inputs moved is one our verdict is no longer about.
|
|
368
|
+
const current = got[scriptKey(id)];
|
|
369
|
+
// The row was deleted while the check was out. Nothing to write
|
|
370
|
+
// and nothing to hand back — a row that isn't stored is not a row
|
|
371
|
+
// the caller can be given a fresher copy of.
|
|
372
|
+
if (!current)
|
|
373
|
+
continue;
|
|
374
|
+
if (parseCheckKey(current) !== parseCheckKey(next)) {
|
|
375
|
+
// Refused, not dropped: the caller is holding a copy from
|
|
376
|
+
// before this row moved, and letting it fall back to that is
|
|
377
|
+
// how an unchecked body gets registered.
|
|
378
|
+
changed.set(id, current);
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
// The verdict was formed against a row that was `ok`. A row that
|
|
382
|
+
// has since left that state left it for a reason this check knows
|
|
383
|
+
// nothing about — a read failed, a `@require` stopped loading —
|
|
384
|
+
// and writing our `status` over it would resurrect a state that
|
|
385
|
+
// is no longer true. The fingerprint alone doesn't catch this:
|
|
386
|
+
// `applyFetchError` preserves the content the fingerprint is made
|
|
387
|
+
// of.
|
|
388
|
+
if (current.status !== 'ok') {
|
|
389
|
+
changed.set(id, current);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
// Merge the verdict ONTO the row we just read, rather than
|
|
393
|
+
// writing the copy we checked. Same fingerprint doesn't mean same
|
|
394
|
+
// row: `enabled`, `versionProbes` and the rest can have moved
|
|
395
|
+
// while the check was out, and writing our copy would roll them
|
|
396
|
+
// back.
|
|
397
|
+
const merged = applyVerdictFields(current, next);
|
|
398
|
+
changed.set(id, merged);
|
|
399
|
+
toSet[scriptKey(id)] = merged;
|
|
400
|
+
if (merged.status !== 'ok')
|
|
401
|
+
flipped.push(merged);
|
|
402
|
+
}
|
|
403
|
+
if (Object.keys(toSet).length > 0)
|
|
404
|
+
await writeLocal(toSet);
|
|
405
|
+
});
|
|
406
|
+
// Say so in the Log. This lives here rather than at a caller because
|
|
407
|
+
// whichever pass wins the write is the one that knows — the deferred
|
|
408
|
+
// check, the sync's gate before registration, or a later sync after
|
|
409
|
+
// the worker was evicted mid-check. Keyed off what was WRITTEN, so a
|
|
410
|
+
// refused verdict stays quiet rather than announcing a state it
|
|
411
|
+
// didn't put there.
|
|
412
|
+
for (const row of flipped) {
|
|
413
|
+
const entry = loadStateLogEntry(row);
|
|
414
|
+
if (entry)
|
|
415
|
+
void appendScriptLog(row.id, { ...entry, source: 'parser' });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Save as `invalid` the rows among `ids` that point at a blob that isn't
|
|
419
|
+
// there, and say so in each one's Log. The registration sync calls this
|
|
420
|
+
// for the candidates it found that way: it's the one body reader that
|
|
421
|
+
// runs on its own, and without the write the pages, which read only the
|
|
422
|
+
// rows, would show the script as fine while it no longer runs.
|
|
423
|
+
//
|
|
424
|
+
// Decided again inside the lock from the rows and blobs stored then, so
|
|
425
|
+
// a refresh that repaired a row since isn't undone. A row already
|
|
426
|
+
// `invalid` is left alone.
|
|
427
|
+
export async function recordMissingBlobs(ids) {
|
|
428
|
+
if (ids.length === 0)
|
|
429
|
+
return;
|
|
430
|
+
const flipped = [];
|
|
431
|
+
await mutate(async () => {
|
|
432
|
+
const rows = await getRows(ids.map(scriptKey));
|
|
433
|
+
const keys = [...new Set(Object.values(rows).flatMap(blobKeysOf))];
|
|
434
|
+
const blobs = keys.length > 0 ? await chrome.storage.local.get(keys) : {};
|
|
435
|
+
const toSet = {};
|
|
436
|
+
for (const [key, value] of Object.entries(rows)) {
|
|
437
|
+
const row = value;
|
|
438
|
+
if (row.status !== 'ok')
|
|
439
|
+
continue;
|
|
440
|
+
const marked = markMissingBlobs(row, blobs);
|
|
441
|
+
if (!marked)
|
|
442
|
+
continue;
|
|
443
|
+
toSet[key] = marked;
|
|
444
|
+
flipped.push(marked);
|
|
445
|
+
}
|
|
446
|
+
if (flipped.length > 0)
|
|
447
|
+
await writeLocal(toSet);
|
|
448
|
+
});
|
|
449
|
+
for (const row of flipped) {
|
|
450
|
+
const entry = loadStateLogEntry(row);
|
|
451
|
+
if (entry)
|
|
452
|
+
void appendScriptLog(row.id, { ...entry, source: 'storage' });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
const inFlightChecks = new Map();
|
|
456
|
+
const inFlightCheckKey = (s) => `${s.id}|${parseCheckKey(s)}`;
|
|
457
|
+
// Register `own` as in flight, recording into `settle` the resolvers the
|
|
458
|
+
// caller must settle when it is done. Claimed synchronously, before the
|
|
459
|
+
// first await, so a pass starting later in the same turn joins rather
|
|
460
|
+
// than duplicating.
|
|
461
|
+
//
|
|
462
|
+
// Fills a caller-owned map rather than returning one so a throw
|
|
463
|
+
// mid-loop still leaves the caller holding what was claimed so far —
|
|
464
|
+
// see the `finally` in `verifyParseChecks`.
|
|
465
|
+
function claimInFlightChecks(own, settle) {
|
|
466
|
+
for (const s of own) {
|
|
467
|
+
const key = inFlightCheckKey(s);
|
|
468
|
+
if (inFlightChecks.has(key))
|
|
469
|
+
continue; // same row twice in one call
|
|
470
|
+
let resolve;
|
|
471
|
+
inFlightChecks.set(key, new Promise((r) => { resolve = r; }));
|
|
472
|
+
settle.set(key, { id: s.id, resolve });
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
// No reset hook for `inFlightChecks`, unlike the other worker module
|
|
476
|
+
// state `resetAllState` clears by name: an entry only exists while a
|
|
477
|
+
// check is running and its `finally` removes it, so there is nothing
|
|
478
|
+
// for a storage wipe to strand. A wipe landing mid-check leaves at most
|
|
479
|
+
// one already-started compile whose write is refused when the row it
|
|
480
|
+
// judged is gone.
|
|
481
|
+
// Run the check a `ParseCheckMode: 'defer'` refresh skipped, after that
|
|
482
|
+
// refresh has already stored its row. Not awaited by its caller — the
|
|
483
|
+
// point of deferring is that nothing waits for the verdict.
|
|
484
|
+
//
|
|
485
|
+
// `verifyParseChecks` is the whole implementation: it re-reads the row
|
|
486
|
+
// inside the write lock, refuses to apply a verdict the row has moved
|
|
487
|
+
// out from under, and merges only the verdict's own fields. That is
|
|
488
|
+
// exactly the race a late verdict has to survive, so this deliberately
|
|
489
|
+
// does not open a second path to the same write.
|
|
490
|
+
//
|
|
491
|
+
// If the worker dies before the verdict lands, the row simply stays
|
|
492
|
+
// unchecked: it carries no `parseCheckedHash`, so the gate every
|
|
493
|
+
// registration sync runs (`revalidateCandidates` in injector.ts) finds
|
|
494
|
+
// it stale and derives the verdict before Chrome is offered the body.
|
|
495
|
+
// What is lost is timeliness, not the check.
|
|
496
|
+
export function checkParsesInBackground(script) {
|
|
497
|
+
if (script.status !== 'ok')
|
|
498
|
+
return;
|
|
499
|
+
if (script.parseCheckedHash === parseCheckKey(script))
|
|
500
|
+
return;
|
|
501
|
+
void verifyParseChecks([script]).catch((err) => {
|
|
502
|
+
// Same failing-open contract as the pipeline check: the row stays
|
|
503
|
+
// unchecked, and the gate before registration asks again.
|
|
504
|
+
dlog(`[SourceMonkey] deferred parse check failed for ${script.relativePath}: ${describeError(err)}`);
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
// Does an `invalid` parse of this entry keep the PRIOR copy running,
|
|
508
|
+
// rather than becoming the row's new state?
|
|
509
|
+
//
|
|
510
|
+
// True for an http(s) entry with a prior `ok` copy: a 200 that doesn't
|
|
511
|
+
// parse is usually a login or error page served in place of the script,
|
|
512
|
+
// so it is a *refresh* failure (`refreshError`, still injecting the
|
|
513
|
+
// cached body) and not a content replacement. A `file://` read returns
|
|
514
|
+
// the file's real bytes, so an `invalid` parse there is a genuine local
|
|
515
|
+
// edit the user wants flagged immediately.
|
|
516
|
+
//
|
|
517
|
+
// One predicate, two readers: the rule itself in `rebuildCollection`,
|
|
518
|
+
// and the decision about whether that entry's parse check may be
|
|
519
|
+
// deferred. Deferring is only safe where the verdict lands on the row
|
|
520
|
+
// directly, so the two have to answer the same question — hence one
|
|
521
|
+
// function rather than two spellings of it 60 lines apart.
|
|
522
|
+
function keepsPriorOnInvalid(fileUrl, prior) {
|
|
523
|
+
return isHttpUrl(fileUrl) && prior?.status === 'ok';
|
|
524
|
+
}
|
|
525
|
+
// Pure orchestrator: given the prior state for a collection plus a
|
|
526
|
+
// fresh scan and the per-file fetch results, return the new list of
|
|
527
|
+
// `StoredScript`s in canonical order (scan order). Stable IDs come
|
|
528
|
+
// from looking up by relativePath in `existing`. No I/O — easy to test.
|
|
529
|
+
//
|
|
530
|
+
// Vanished-entry handling — an entry the scan no longer reports is
|
|
531
|
+
// dropped entirely (the `refreshCollection` caller then deletes the
|
|
532
|
+
// storage row), because a successful scan is the collection telling us
|
|
533
|
+
// what it holds now: a renamed or deleted file would otherwise linger
|
|
534
|
+
// as a row the user has to clear by hand. That holds for a scan that
|
|
535
|
+
// found nothing at all, too — an empty directory, or one with no
|
|
536
|
+
// `.user.js` files left in it, is as definite as an empty manifest.
|
|
537
|
+
// What it does NOT hold for is a scan that failed, and that never
|
|
538
|
+
// reaches here: `refreshCollection` records the `scanError` and leaves
|
|
539
|
+
// every row alone. The listing reader is what keeps the two apart
|
|
540
|
+
// (`isDirectoryListing` in directories.ts).
|
|
541
|
+
export async function rebuildCollection(args) {
|
|
542
|
+
const newId = args.newId ?? (() => crypto.randomUUID());
|
|
543
|
+
const recheckFiles = args.recheckFiles ?? 'all';
|
|
544
|
+
const fileFetchCache = args.fileFetchCache ?? new Map();
|
|
545
|
+
const localFiles = args.localFiles ?? { enabled: false };
|
|
546
|
+
const parseCheck = args.parseCheck ?? 'await';
|
|
547
|
+
// Deferring is only safe where the deferred verdict does exactly what
|
|
548
|
+
// an awaited one would have done — and where `keepsPriorOnInvalid`
|
|
549
|
+
// holds it would not, because that rule reads the status the pipeline
|
|
550
|
+
// hands back and a verdict arriving after it has run would replace a
|
|
551
|
+
// working script with the garbage instead of preserving it. Same
|
|
552
|
+
// predicate as the rule itself, so the two cannot drift apart.
|
|
553
|
+
const parseCheckFor = (fileUrl, prior) => parseCheck === 'defer' && !keepsPriorOnInvalid(fileUrl, prior) ? 'defer' : 'await';
|
|
554
|
+
const existingByPath = new Map();
|
|
555
|
+
for (const e of args.existing)
|
|
556
|
+
existingByPath.set(e.relativePath, e);
|
|
557
|
+
// Each scanned entry resolves to one StoredScript. Most paths are
|
|
558
|
+
// synchronous (unchanged, error, defensive fallbacks); the `ok`
|
|
559
|
+
// path goes through buildScriptFromText which is async because the
|
|
560
|
+
// content-hash uses SubtleCrypto, and applyFiles which fetches this
|
|
561
|
+
// entry's `@require` / `@resource` bodies. We collect every branch as
|
|
562
|
+
// a thunk and run them with bounded concurrency at the end, so the
|
|
563
|
+
// async work overlaps without putting every entry's dependency
|
|
564
|
+
// payloads in flight at once, and the output order matches scan order.
|
|
565
|
+
const entryTasks = [];
|
|
566
|
+
for (const found of args.scan) {
|
|
567
|
+
const prior = existingByPath.get(found.relativePath);
|
|
568
|
+
const id = prior?.id ?? newId();
|
|
569
|
+
const fileUrl = scriptFileUrl(args.collectionPath, found.relativePath);
|
|
570
|
+
const fr = args.fetchResults.get(found.relativePath);
|
|
571
|
+
if (fr && 'ok' in fr && fr.ok) {
|
|
572
|
+
entryTasks.push(async () => {
|
|
573
|
+
const built = await timePhase('build', () => buildScriptFromText({
|
|
574
|
+
id,
|
|
575
|
+
collectionId: args.collectionId,
|
|
576
|
+
relativePath: found.relativePath,
|
|
577
|
+
fileUrl,
|
|
578
|
+
text: fr.text,
|
|
579
|
+
now: args.now,
|
|
580
|
+
// Pass prior so buildScriptFromText can preserve loadedAt when
|
|
581
|
+
// the freshly fetched bytes hash to the same value — the
|
|
582
|
+
// post-fetch leg of the unified change-detection path.
|
|
583
|
+
prior,
|
|
584
|
+
httpEtag: fr.httpEtag,
|
|
585
|
+
httpLastModified: fr.httpLastModified,
|
|
586
|
+
}));
|
|
587
|
+
// Preserve the user's per-script enabled choice across refreshes.
|
|
588
|
+
// `versionProbes` is deliberately NOT carried here — it's
|
|
589
|
+
// re-applied from the persisted row by preserveConcurrentEdits
|
|
590
|
+
// inside the write lock, which is strictly more current than
|
|
591
|
+
// this pre-fetch snapshot.
|
|
592
|
+
if (prior)
|
|
593
|
+
built.enabled = prior.enabled;
|
|
594
|
+
// Record the listing's mtime so the next refresh can decide to
|
|
595
|
+
// skip a refetch.
|
|
596
|
+
if (found.mtime !== undefined)
|
|
597
|
+
built.fsMtime = found.mtime;
|
|
598
|
+
const withFiles = await applyFiles(built, prior, recheckFiles, fileFetchCache, args.now, localFiles, parseCheckFor(fileUrl, prior), await pushedFilesOf(fileUrl));
|
|
599
|
+
// Soft-failure preservation — see `keepsPriorOnInvalid`. Keep
|
|
600
|
+
// the prior good body running and annotate `refreshError`
|
|
601
|
+
// instead of clobbering it with what came back.
|
|
602
|
+
if (withFiles.status === 'invalid' && keepsPriorOnInvalid(fileUrl, prior)) {
|
|
603
|
+
return {
|
|
604
|
+
...prior,
|
|
605
|
+
// Described, not bare: `refreshError` is a plain string
|
|
606
|
+
// with no location field of its own, so flattening the
|
|
607
|
+
// location into the text is the only way "where" survives
|
|
608
|
+
// — and where in the fetched copy it went wrong is the
|
|
609
|
+
// useful half of this message.
|
|
610
|
+
refreshError: `Fetched copy is not a valid userscript (${withFiles.errorMessage
|
|
611
|
+
? describeDiagnostic(withFiles.errorMessage, withFiles.errorLocation)
|
|
612
|
+
// Capitalized to match the `describeDiagnostic` string
|
|
613
|
+
// it stands in for — the two share this slot, so a
|
|
614
|
+
// lowercase fallback would read as a different kind of
|
|
615
|
+
// thing depending on which branch ran.
|
|
616
|
+
: 'Parse failed'})`,
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
return withFiles;
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
else if (fr && 'unchanged' in fr) {
|
|
623
|
+
// Optimization path: the listing's size+mtime matched the prior
|
|
624
|
+
// entry, so we never fetched. Reuse the prior entry verbatim.
|
|
625
|
+
// (Defensive fallback if there's somehow no prior: treat as a
|
|
626
|
+
// fresh-fetch failure — shouldn't happen because the orchestrator
|
|
627
|
+
// only emits `unchanged` when prior exists.)
|
|
628
|
+
if (prior) {
|
|
629
|
+
// Recompute fileUrl unconditionally — if the collection's
|
|
630
|
+
// path/URL ever changes (e.g. a future edit-collection UI), the
|
|
631
|
+
// prior's fileUrl would silently lie. Cheap to recompute here.
|
|
632
|
+
const next = { ...prior, fileUrl };
|
|
633
|
+
if (found.mtime !== undefined)
|
|
634
|
+
next.fsMtime = found.mtime;
|
|
635
|
+
if (found.size !== undefined)
|
|
636
|
+
next.size = found.size;
|
|
637
|
+
// A proven-unchanged refetch (HTTP 304 or matching size+mtime)
|
|
638
|
+
// means the collection is reachable and serving the same good
|
|
639
|
+
// bytes again — clear any stale "refresh failed" annotation. If
|
|
640
|
+
// a dep recheck below fails again, applyFiles re-sets
|
|
641
|
+
// refreshError.
|
|
642
|
+
delete next.refreshError;
|
|
643
|
+
// Even when the body is unchanged, explicit Refresh still
|
|
644
|
+
// rechecks @require + @resource bodies (the user may want to
|
|
645
|
+
// pick up remote dep updates without touching the main
|
|
646
|
+
// script). The opportunistic path passes 'preserve' which
|
|
647
|
+
// short-circuits.
|
|
648
|
+
entryTasks.push(async () => applyFiles(next, prior, recheckFiles, fileFetchCache, args.now, localFiles, parseCheckFor(fileUrl, prior), await pushedFilesOf(fileUrl)));
|
|
649
|
+
}
|
|
650
|
+
else {
|
|
651
|
+
entryTasks.push(() => Promise.resolve(buildInitialInvalidEntry({
|
|
652
|
+
id,
|
|
653
|
+
collectionId: args.collectionId,
|
|
654
|
+
relativePath: found.relativePath,
|
|
655
|
+
fileUrl,
|
|
656
|
+
// Prefixed as internal because it is: the orchestrator told us
|
|
657
|
+
// "unchanged" for a file we have never held. Nothing the user
|
|
658
|
+
// can do about it, and saying so beats an accusation.
|
|
659
|
+
errorMessage: 'Internal error: unchanged marker with no prior entry',
|
|
660
|
+
now: args.now,
|
|
661
|
+
})));
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
else if (fr) {
|
|
665
|
+
// Failed fetch. If we had prior content, keep it.
|
|
666
|
+
const errorMessage = 'errorMessage' in fr ? fr.errorMessage : 'Could not be read (no reason reported)';
|
|
667
|
+
if (prior) {
|
|
668
|
+
entryTasks.push(() => Promise.resolve(applyFetchError(prior, errorMessage)));
|
|
669
|
+
}
|
|
670
|
+
else {
|
|
671
|
+
entryTasks.push(() => Promise.resolve(buildInitialInvalidEntry({
|
|
672
|
+
id,
|
|
673
|
+
collectionId: args.collectionId,
|
|
674
|
+
relativePath: found.relativePath,
|
|
675
|
+
fileUrl,
|
|
676
|
+
errorMessage,
|
|
677
|
+
now: args.now,
|
|
678
|
+
})));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
else {
|
|
682
|
+
// No fetch attempted at all. Treat like a fetch error with a
|
|
683
|
+
// generic message; this branch is mostly defensive — the
|
|
684
|
+
// orchestrator should always supply a result.
|
|
685
|
+
entryTasks.push(() => Promise.resolve(prior
|
|
686
|
+
? applyFetchError(prior, NOT_READ)
|
|
687
|
+
: buildInitialInvalidEntry({
|
|
688
|
+
id, collectionId: args.collectionId, relativePath: found.relativePath,
|
|
689
|
+
fileUrl, errorMessage: NOT_READ, now: args.now,
|
|
690
|
+
})));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const out = await mapWithConcurrency(entryTasks, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, (task) => task());
|
|
694
|
+
// Anything in `existing` that the scan didn't see is dropped: it is
|
|
695
|
+
// simply not in `out`. See the header comment.
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
// ---------- chrome.storage.local I/O ----------
|
|
699
|
+
// Serialize every read-modify-write of the structural rows (the
|
|
700
|
+
// collection index, collection rows, script rows). chrome.storage.local
|
|
701
|
+
// has no compare-and-swap, so two operations that each read a row,
|
|
702
|
+
// derive a new value, and write it back would otherwise drop one
|
|
703
|
+
// another's change — e.g. a user's enable toggle reverted by a refresh
|
|
704
|
+
// that captured the old flag before the fetch, or an addCollection
|
|
705
|
+
// racing a removeCollection on the shared index. Callers run their read
|
|
706
|
+
// AND write inside one `mutate()` so the pair is atomic with respect to
|
|
707
|
+
// other mutators.
|
|
708
|
+
//
|
|
709
|
+
// The critical sections here are short (no network) — slow fetches stay
|
|
710
|
+
// OUTSIDE the lock (refresh fetches, then re-reads + writes inside it),
|
|
711
|
+
// so this doesn't serialize I/O. IMPORTANT: a `mutate()` block must
|
|
712
|
+
// never call another mutator (refreshCollection / addCollection / a set*),
|
|
713
|
+
// since this mutex is non-reentrant — keep orchestrator calls outside.
|
|
714
|
+
//
|
|
715
|
+
// SCOPE: `mutateChain` is module state, so the lock covers one JS
|
|
716
|
+
// context. Only the service worker writes (docs/worker-writes.md): pages
|
|
717
|
+
// ask it, and `writeLocal` throws in a page. So every mutator is under
|
|
718
|
+
// this one lock. Two rules still hold:
|
|
719
|
+
//
|
|
720
|
+
// - Re-read inside the lock and merge, rather than writing values
|
|
721
|
+
// captured before it: a refresh fetches outside the lock, and an
|
|
722
|
+
// edit made meanwhile has to survive its write. See
|
|
723
|
+
// `preserveConcurrentEdits` and the joined/departed reconcile in
|
|
724
|
+
// `writeRefreshedCollection`.
|
|
725
|
+
// - Keep sections short: every write in the worker waits on them.
|
|
726
|
+
//
|
|
727
|
+
// See docs/storage.md#one-lock-in-the-worker.
|
|
728
|
+
// A section's writes must be awaited before it returns: the blob cleanup
|
|
729
|
+
// relies on no script row landing outside a section
|
|
730
|
+
// (`checkScriptRowWrite`), and a write left running would land after.
|
|
731
|
+
let mutateChain = Promise.resolve();
|
|
732
|
+
// Whether a section is running: what `checkScriptRowWrite` asks.
|
|
733
|
+
let sectionsRunning = 0;
|
|
734
|
+
function mutate(fn) {
|
|
735
|
+
const section = async () => {
|
|
736
|
+
sectionsRunning++;
|
|
737
|
+
try {
|
|
738
|
+
return await fn();
|
|
739
|
+
}
|
|
740
|
+
finally {
|
|
741
|
+
sectionsRunning--;
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
// Run after the prior section settles, regardless of its outcome.
|
|
745
|
+
const run = mutateChain.then(section, section);
|
|
746
|
+
// Advance the chain, swallowing the result so one section's rejection
|
|
747
|
+
// doesn't reject the next waiter's gate (the rejection still
|
|
748
|
+
// propagates to THIS caller via `run`).
|
|
749
|
+
mutateChain = run.then(() => undefined, () => undefined);
|
|
750
|
+
return run;
|
|
751
|
+
}
|
|
752
|
+
// Set to `true` the first time the SW seeds the default collections, so
|
|
753
|
+
// the seeding never runs again even after the user removes those
|
|
754
|
+
// collections.
|
|
755
|
+
const FIRST_RUN_DONE_KEY = 'firstRunComplete';
|
|
756
|
+
// Collections seeded on the very first run (by the collections page —
|
|
757
|
+
// see `seedDefaultCollectionsViaUi` in collections.ts). Each is fed
|
|
758
|
+
// through the normal add → Refresh flow, so any input shape
|
|
759
|
+
// `addCollection` accepts (github-dir / http(s) `.user.js` / manifest
|
|
760
|
+
// URL, or an absolute directory path) works here. `addCollection`
|
|
761
|
+
// dedupes by canonical collection path, so any accidental repeat
|
|
762
|
+
// collapses to one collection.
|
|
763
|
+
export const DEFAULT_COLLECTIONS = [
|
|
764
|
+
'https://github.com/jshute96/SourceMonkey/tree/main/tests/examples',
|
|
765
|
+
];
|
|
766
|
+
// ---------- Legacy key migration (temporary — see TODO.md) ----------
|
|
767
|
+
//
|
|
768
|
+
// Collections were called "sources" before the August 2026 rename, and
|
|
769
|
+
// a profile written then holds a `"sources"` index, `source:<id>` rows,
|
|
770
|
+
// and a `sourceId` back-reference on every script. Without this, an
|
|
771
|
+
// upgrade would read as a wiped profile: every collection gone, every
|
|
772
|
+
// script unregistered. Rewrite the rows once, on the first storage read
|
|
773
|
+
// in each JS context, and drop the old keys.
|
|
774
|
+
const LEGACY_INDEX_KEY = 'sources';
|
|
775
|
+
const LEGACY_COLLECTION_PREFIX = 'source:';
|
|
776
|
+
let migrationPromise = null;
|
|
777
|
+
// Idempotent, memoized per context. Awaited by every read path in this
|
|
778
|
+
// module, and exported for the two SW modules that read the structural
|
|
779
|
+
// rows straight out of `chrome.storage.local` (`match-index.ts` and
|
|
780
|
+
// `injector.ts`) rather than through here. `script-log-id.ts` reads
|
|
781
|
+
// `script:*` rows directly too and deliberately does NOT await: script
|
|
782
|
+
// keys don't move, and it reads only `relativePath`.
|
|
783
|
+
//
|
|
784
|
+
// Only the worker writes (docs/worker-writes.md): a page that finds the
|
|
785
|
+
// old rows asks it to move them, and waits.
|
|
786
|
+
export function ensureCollectionKeysMigrated() {
|
|
787
|
+
const migrate = isPageContext() ? migrateViaWorker : () => asMigration(migrateLegacyCollectionKeys);
|
|
788
|
+
migrationPromise ??= migrate().catch((err) => {
|
|
789
|
+
// Never let a failed migration wedge every read behind it: clear the
|
|
790
|
+
// memo so the next reader retries, and leave the old rows in place.
|
|
791
|
+
console.info('[SourceMonkey] collection-key migration failed', err);
|
|
792
|
+
migrationPromise = null;
|
|
793
|
+
});
|
|
794
|
+
return migrationPromise;
|
|
795
|
+
}
|
|
796
|
+
// Forget that this context has already checked. Only for the test
|
|
797
|
+
// surface's `resetAllState`, which wipes storage — the memo describes a
|
|
798
|
+
// profile that no longer exists, and a test seeding pre-rename rows
|
|
799
|
+
// afterwards would otherwise never see them migrated.
|
|
800
|
+
//
|
|
801
|
+
// Scope is the calling context only: `resetAllState` runs in the service
|
|
802
|
+
// worker, so an extension page already open keeps its own resolved memo.
|
|
803
|
+
// A test that seeds pre-rename rows has to open the page afterwards.
|
|
804
|
+
export function resetCollectionKeyMigrationForTests() {
|
|
805
|
+
migrationPromise = null;
|
|
806
|
+
}
|
|
807
|
+
async function migrateViaWorker() {
|
|
808
|
+
if (await hasLegacyRows())
|
|
809
|
+
await callWorker('migrateCollectionKeys');
|
|
810
|
+
}
|
|
811
|
+
async function hasLegacyRows() {
|
|
812
|
+
return LEGACY_INDEX_KEY in await chrome.storage.local.get(LEGACY_INDEX_KEY);
|
|
813
|
+
}
|
|
814
|
+
// The worker's answer to a page that found the old rows: the
|
|
815
|
+
// migration, then a failure if the rows are still there, so the page
|
|
816
|
+
// forgets its own memo and asks again on its next read.
|
|
817
|
+
export async function migrateCollectionKeysForPage() {
|
|
818
|
+
const first = ensureCollectionKeysMigrated();
|
|
819
|
+
await first;
|
|
820
|
+
// This worker's memo may predate the rows (old data restored since it
|
|
821
|
+
// checked): look again, once. Only the first page to ask resets it;
|
|
822
|
+
// another one asking meanwhile shares the new run.
|
|
823
|
+
if (await hasLegacyRows()) {
|
|
824
|
+
if (migrationPromise === first)
|
|
825
|
+
migrationPromise = null;
|
|
826
|
+
await ensureCollectionKeysMigrated();
|
|
827
|
+
}
|
|
828
|
+
if (await hasLegacyRows())
|
|
829
|
+
throw new Error('Internal error: Could not move the collections to their current storage keys');
|
|
830
|
+
}
|
|
831
|
+
async function migrateLegacyCollectionKeys() {
|
|
832
|
+
// Cheap gate first. A profile with any collection at all has the old
|
|
833
|
+
// index row, so its absence means there is nothing to move. The memo
|
|
834
|
+
// means this runs at most once per worker wake (a page's request can
|
|
835
|
+
// re-run it), and the full scan below reads every row.
|
|
836
|
+
const probe = await chrome.storage.local.get(LEGACY_INDEX_KEY);
|
|
837
|
+
if (!(LEGACY_INDEX_KEY in probe))
|
|
838
|
+
return;
|
|
839
|
+
const all = await scanAllStorage('key migration');
|
|
840
|
+
const toSet = {};
|
|
841
|
+
const toRemove = [LEGACY_INDEX_KEY];
|
|
842
|
+
// Seed the index ONLY when the new spelling isn't there yet. This runs
|
|
843
|
+
// outside `mutate()`, so a run that read `all` before an earlier run
|
|
844
|
+
// finished migrating would otherwise write the stale legacy id list
|
|
845
|
+
// over an index a later add had already extended — orphaning that
|
|
846
|
+
// collection's row from the index.
|
|
847
|
+
if (!(COLLECTIONS_INDEX_KEY in all)) {
|
|
848
|
+
const ids = all[LEGACY_INDEX_KEY];
|
|
849
|
+
toSet[COLLECTIONS_INDEX_KEY] = Array.isArray(ids) ? ids : [];
|
|
850
|
+
}
|
|
851
|
+
let scriptsMoved = 0;
|
|
852
|
+
for (const [key, value] of Object.entries(all)) {
|
|
853
|
+
if (key.startsWith(LEGACY_COLLECTION_PREFIX)) {
|
|
854
|
+
toSet[collectionKey(key.slice(LEGACY_COLLECTION_PREFIX.length))] = value;
|
|
855
|
+
toRemove.push(key);
|
|
856
|
+
continue;
|
|
857
|
+
}
|
|
858
|
+
if (!key.startsWith('script:'))
|
|
859
|
+
continue;
|
|
860
|
+
const row = value;
|
|
861
|
+
if (row?.sourceId === undefined)
|
|
862
|
+
continue;
|
|
863
|
+
const { sourceId, ...rest } = row;
|
|
864
|
+
toSet[key] = { ...rest, collectionId: rest.collectionId ?? sourceId };
|
|
865
|
+
scriptsMoved++;
|
|
866
|
+
}
|
|
867
|
+
// Write the new rows before dropping the old ones: an interrupted
|
|
868
|
+
// migration then leaves both spellings present, and the re-run is a
|
|
869
|
+
// no-op over the rows it already moved.
|
|
870
|
+
await setLocal(toSet);
|
|
871
|
+
await chrome.storage.local.remove(toRemove);
|
|
872
|
+
console.log(`[SourceMonkey] migrated ${toRemove.length - 1} collections and `
|
|
873
|
+
+ `${scriptsMoved} scripts to the collection:* key spelling`);
|
|
874
|
+
}
|
|
875
|
+
// Script rows come back as full scripts, their bodies read from their
|
|
876
|
+
// blobs (script-blobs.ts). `getRows` reads them as stored, without the
|
|
877
|
+
// bodies (`ScriptMeta`), for code that doesn't need them.
|
|
878
|
+
async function getRaw(key) {
|
|
879
|
+
return (await getMany([key]))[key];
|
|
880
|
+
}
|
|
881
|
+
async function getMany(keys) {
|
|
882
|
+
return await hydrateScriptRows(await getRows(keys));
|
|
883
|
+
}
|
|
884
|
+
// Rows as stored. A script row written back from here with a field
|
|
885
|
+
// changed keeps its bodies (its blob refs) as they are, and the write
|
|
886
|
+
// hashes nothing.
|
|
887
|
+
//
|
|
888
|
+
// In the worker, from its row cache when that is loaded (row-cache.ts).
|
|
889
|
+
async function getRows(keys) {
|
|
890
|
+
if (keys.length === 0)
|
|
891
|
+
return {};
|
|
892
|
+
await ensureCollectionKeysMigrated();
|
|
893
|
+
const cached = cachedRows(keys);
|
|
894
|
+
if (!cached)
|
|
895
|
+
return await chrome.storage.local.get(keys);
|
|
896
|
+
if (cached.rest.length === 0)
|
|
897
|
+
return cached.found;
|
|
898
|
+
return { ...await chrome.storage.local.get(cached.rest), ...cached.found };
|
|
899
|
+
}
|
|
900
|
+
async function getRow(key) {
|
|
901
|
+
return (await getRows([key]))[key];
|
|
902
|
+
}
|
|
903
|
+
// ---------- Storage format ----------
|
|
904
|
+
//
|
|
905
|
+
// Format 2 keeps bodies out of the script rows (docs/storage.md#content-blobs).
|
|
906
|
+
// The service worker moves format 1 rows over once, at startup
|
|
907
|
+
// (`migrateToBlobs`). Reads take either format, so only writes wait for
|
|
908
|
+
// it; only the worker writes.
|
|
909
|
+
let storeFormatPromise = null;
|
|
910
|
+
// Resolves once script rows may be written. Never rejects, so a failure
|
|
911
|
+
// can't wedge every write behind it: a failed migration is logged and
|
|
912
|
+
// tried again in the next browser session (`migrateToBlobs`). Writes
|
|
913
|
+
// carry on meanwhile, in format 2, as they always do.
|
|
914
|
+
export function storeFormatReady() {
|
|
915
|
+
storeFormatPromise ??= migrateStore().catch((err) => {
|
|
916
|
+
console.info('[SourceMonkey] storage format migration failed:', describeError(err));
|
|
917
|
+
});
|
|
918
|
+
return storeFormatPromise;
|
|
919
|
+
}
|
|
920
|
+
async function migrateStore() {
|
|
921
|
+
// The old key spelling first: it moves rows the blob migration reads.
|
|
922
|
+
await ensureCollectionKeysMigrated();
|
|
923
|
+
await asMigration(migrateToBlobs);
|
|
924
|
+
}
|
|
925
|
+
// One unused-blob check (`sweepUnusedBlobs`), inside the write lock. A
|
|
926
|
+
// write of a script row can't land between the check reading the rows
|
|
927
|
+
// and deleting what they don't use: every one takes the lock too
|
|
928
|
+
// (`checkScriptRowWrite`). One that found a blob still stored, and so
|
|
929
|
+
// didn't write it again, would otherwise be left pointing at nothing.
|
|
930
|
+
//
|
|
931
|
+
// `started`: called once the check holds the lock; false skips the check
|
|
932
|
+
// (null result).
|
|
933
|
+
export function sweepUnusedBlobsLocked(started) {
|
|
934
|
+
return mutate(async () => {
|
|
935
|
+
if (started && !started())
|
|
936
|
+
return null;
|
|
937
|
+
await storeFormatReady();
|
|
938
|
+
return await sweepUnusedBlobs((await cachedScriptRows()) ?? undefined);
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
// For the test surface: rows a test writes straight into storage, under
|
|
942
|
+
// the lock like every other write of a script row
|
|
943
|
+
// (`checkScriptRowWrite`).
|
|
944
|
+
export function seedStorageForTests(items) {
|
|
945
|
+
return mutate(() => chrome.storage.local.set(items));
|
|
946
|
+
}
|
|
947
|
+
// The migrations write script rows outside the lock: they run before
|
|
948
|
+
// any write that takes it (`storeFormatReady`), and the cleanup waits
|
|
949
|
+
// for them the same way. While one runs, every script-row write is let
|
|
950
|
+
// through, so a legacy-key migration started mid-session (a page found
|
|
951
|
+
// old rows) widens the check for its length. Rare: old rows restored
|
|
952
|
+
// into a running profile.
|
|
953
|
+
//
|
|
954
|
+
// That mid-session run is also unguarded: it writes the script rows as
|
|
955
|
+
// its scan read them, so a `removeScript` landing between the scan and
|
|
956
|
+
// the write gets its row written back (its blobs possibly already
|
|
957
|
+
// swept). It isn't moved under the lock because reads inside the lock
|
|
958
|
+
// await this migration, so a section waiting on it would deadlock.
|
|
959
|
+
let migrationsRunning = 0;
|
|
960
|
+
async function asMigration(run) {
|
|
961
|
+
migrationsRunning++;
|
|
962
|
+
try {
|
|
963
|
+
return await run();
|
|
964
|
+
}
|
|
965
|
+
finally {
|
|
966
|
+
migrationsRunning--;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// The worker's wrapped write calls this before every write (row-cache.ts):
|
|
970
|
+
// a script row written outside the lock could race the blob cleanup.
|
|
971
|
+
// A bug, so it throws. It sees whether SOME section is running, not
|
|
972
|
+
// whether this write is in it: a write outside the lock made while a
|
|
973
|
+
// section runs passes, so this catches most mistakes, not all.
|
|
974
|
+
export function checkScriptRowWrite(keys) {
|
|
975
|
+
if (sectionsRunning > 0 || migrationsRunning > 0)
|
|
976
|
+
return;
|
|
977
|
+
const row = keys.find((k) => k.startsWith(SCRIPT_PREFIX));
|
|
978
|
+
if (row)
|
|
979
|
+
throw new Error(`Internal error: ${row} was written outside the store's write lock`);
|
|
980
|
+
}
|
|
981
|
+
// Test-only: run the migration again, as a worker start would. For
|
|
982
|
+
// tests that seed format 1 rows.
|
|
983
|
+
export async function rerunStoreMigrationForTests() {
|
|
984
|
+
await forgetStoreMigrationForTests();
|
|
985
|
+
await storeFormatReady();
|
|
986
|
+
}
|
|
987
|
+
// Test-only: as a worker just started, before anything asked for the
|
|
988
|
+
// format. The next write here runs the migration.
|
|
989
|
+
export async function forgetStoreMigrationForTests() {
|
|
990
|
+
storeFormatPromise = null;
|
|
991
|
+
await clearMigrationFailureForTests();
|
|
992
|
+
}
|
|
993
|
+
// Only for the test surface's `resetAllState`, which wipes storage: the
|
|
994
|
+
// worker records the empty profile as format 2 again, as a fresh
|
|
995
|
+
// profile's migration would, so a worker started later in the test
|
|
996
|
+
// doesn't scan for format 1 rows.
|
|
997
|
+
export async function markStoreFormatForTests() {
|
|
998
|
+
await chrome.storage.local.set({ [STORE_FORMAT_KEY]: STORE_FORMAT });
|
|
999
|
+
}
|
|
1000
|
+
async function readCollectionIndex() {
|
|
1001
|
+
return (await getRaw(COLLECTIONS_INDEX_KEY)) ?? [];
|
|
1002
|
+
}
|
|
1003
|
+
// Test-only: while set, EVERY collection-refresh write throws this
|
|
1004
|
+
// message instead of touching storage.
|
|
1005
|
+
//
|
|
1006
|
+
// Module state, so it only arms the context that set it. The service
|
|
1007
|
+
// worker and each extension page run their own copy of this module, and
|
|
1008
|
+
// the collections page does its own storage work — so a test arming
|
|
1009
|
+
// this over the SW test surface does NOT affect an add or refresh the
|
|
1010
|
+
// page performs itself. Sticky until cleared with null —
|
|
1011
|
+
// the out-of-quota case it stands in for fails every collection, not
|
|
1012
|
+
// just the next one, and a test covering that needs one arming call.
|
|
1013
|
+
//
|
|
1014
|
+
// Exists because the failure it stands in for — an exhausted
|
|
1015
|
+
// `chrome.storage.local` quota — is impractical to produce honestly in a
|
|
1016
|
+
// test. Filling 10 MB means writing ~10 MB of userscripts through the
|
|
1017
|
+
// real pipeline on every run, which is slow, and it leaves the profile
|
|
1018
|
+
// wedged for whatever runs next. The reporting path is the same either
|
|
1019
|
+
// way: the write throws, and everything downstream reacts to that.
|
|
1020
|
+
let collectionWriteFailure = null;
|
|
1021
|
+
export function _setCollectionWriteFailureForTests(message) {
|
|
1022
|
+
collectionWriteFailure = message;
|
|
1023
|
+
}
|
|
1024
|
+
// How full `chrome.storage.local` is, or null when the browser won't
|
|
1025
|
+
// say. `quota` is Chrome's own `QUOTA_BYTES` — 10 MB shared by the whole
|
|
1026
|
+
// extension, because the manifest does not take `unlimitedStorage`.
|
|
1027
|
+
//
|
|
1028
|
+
// That coupling is load-bearing and unenforced: `QUOTA_BYTES` reads the
|
|
1029
|
+
// same 10485760 whether or not the permission is held, so taking
|
|
1030
|
+
// `unlimitedStorage` (still an open option in TODO.md) would leave this
|
|
1031
|
+
// reporting a ceiling that no longer applies, and the collections page
|
|
1032
|
+
// calling a healthy profile full. Revisit here if that permission lands.
|
|
1033
|
+
//
|
|
1034
|
+
// Read live rather than tracked, so it needs nothing persisted and is
|
|
1035
|
+
// correct after a write from any context. The collections page uses it
|
|
1036
|
+
// to explain a refresh that couldn't be saved: "the write failed" is a
|
|
1037
|
+
// symptom, "storage is full" is the cause and the thing the user can
|
|
1038
|
+
// act on.
|
|
1039
|
+
export async function readLocalStorageUsage() {
|
|
1040
|
+
try {
|
|
1041
|
+
const quota = chrome.storage.local.QUOTA_BYTES;
|
|
1042
|
+
if (typeof quota !== 'number' || quota <= 0)
|
|
1043
|
+
return null;
|
|
1044
|
+
return { used: await chrome.storage.local.getBytesInUse(null), quota };
|
|
1045
|
+
}
|
|
1046
|
+
catch {
|
|
1047
|
+
// Purely advisory — a page that can't measure simply doesn't show
|
|
1048
|
+
// the banner.
|
|
1049
|
+
return null;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
// Fraction of the quota at which the page starts warning. Below this a
|
|
1053
|
+
// refresh that failed to save had some other cause, and pointing at
|
|
1054
|
+
// storage would send the reader somewhere useless.
|
|
1055
|
+
export const STORAGE_NEARLY_FULL = 0.9;
|
|
1056
|
+
// Read every registered collection's metadata (without scripts). Order
|
|
1057
|
+
// preserved from the index.
|
|
1058
|
+
export async function getCollections() {
|
|
1059
|
+
const ids = await readCollectionIndex();
|
|
1060
|
+
const got = await getMany(ids.map(collectionKey));
|
|
1061
|
+
return ids
|
|
1062
|
+
.map((id) => got[collectionKey(id)])
|
|
1063
|
+
.filter((s) => s != null);
|
|
1064
|
+
}
|
|
1065
|
+
// Read a collection plus all its scripts in declared order.
|
|
1066
|
+
export async function getCollectionWithScripts(id) {
|
|
1067
|
+
const collection = await getRaw(collectionKey(id));
|
|
1068
|
+
if (!collection)
|
|
1069
|
+
return null;
|
|
1070
|
+
const got = await getMany(collection.scriptIds.map(scriptKey));
|
|
1071
|
+
const scripts = collection.scriptIds
|
|
1072
|
+
.map((sid) => got[scriptKey(sid)])
|
|
1073
|
+
.filter((s) => s != null);
|
|
1074
|
+
return { collection, scripts };
|
|
1075
|
+
}
|
|
1076
|
+
// Same as `getCollectionWithScripts`, without the scripts' bodies.
|
|
1077
|
+
export async function getCollectionWithScriptMeta(id) {
|
|
1078
|
+
const collection = await getRow(collectionKey(id));
|
|
1079
|
+
if (!collection)
|
|
1080
|
+
return null;
|
|
1081
|
+
return { collection, scripts: await getScriptMetas(collection.scriptIds) };
|
|
1082
|
+
}
|
|
1083
|
+
export async function getScript(scriptId) {
|
|
1084
|
+
return await getRaw(scriptKey(scriptId));
|
|
1085
|
+
}
|
|
1086
|
+
// A full script as its row would read without the bodies, for a caller
|
|
1087
|
+
// that holds one and needs only the rest (an answer sent to a page).
|
|
1088
|
+
export function withoutBodies(script) {
|
|
1089
|
+
const { bodyText, requires, resources, ...rest } = script;
|
|
1090
|
+
return {
|
|
1091
|
+
...rest,
|
|
1092
|
+
...(requires ? { requires: requires.map(({ source, ...r }) => r) } : {}),
|
|
1093
|
+
...(resources ? { resources: resources.map(({ bytesBase64, ...r }) => r) } : {}),
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
export async function getScriptMeta(scriptId) {
|
|
1097
|
+
return await getRow(scriptKey(scriptId));
|
|
1098
|
+
}
|
|
1099
|
+
// Read several scripts' rows, without their bodies, in one storage
|
|
1100
|
+
// call. Missing ids are left out.
|
|
1101
|
+
export async function getScriptMetas(scriptIds) {
|
|
1102
|
+
const got = await getRows(scriptIds.map(scriptKey));
|
|
1103
|
+
return scriptIds
|
|
1104
|
+
.map((id) => got[scriptKey(id)])
|
|
1105
|
+
.filter((s) => s != null);
|
|
1106
|
+
}
|
|
1107
|
+
// Every stored script without its bodies (across all collections,
|
|
1108
|
+
// regardless of status): only the script rows are read.
|
|
1109
|
+
//
|
|
1110
|
+
// In the worker, from its row cache, which this loads (row-cache.ts).
|
|
1111
|
+
export async function getAllScriptMetas(reason = 'all script rows') {
|
|
1112
|
+
await ensureCollectionKeysMigrated();
|
|
1113
|
+
const rows = await cachedScriptRows()
|
|
1114
|
+
?? await scanStorageRows(reason, (k) => k.startsWith(SCRIPT_PREFIX));
|
|
1115
|
+
return Object.values(rows);
|
|
1116
|
+
}
|
|
1117
|
+
// Read every stored script (across all collections, regardless of
|
|
1118
|
+
// status), bodies included. One `get(null)` scan rather than N
|
|
1119
|
+
// per-collection reads. Prefer `getAllScriptMetas` unless the bodies
|
|
1120
|
+
// are needed.
|
|
1121
|
+
export async function getAllScripts() {
|
|
1122
|
+
await ensureCollectionKeysMigrated();
|
|
1123
|
+
const all = await scanAllStorage('all scripts');
|
|
1124
|
+
const rows = [];
|
|
1125
|
+
for (const [key, value] of Object.entries(all)) {
|
|
1126
|
+
if (key.startsWith('script:'))
|
|
1127
|
+
rows.push(value);
|
|
1128
|
+
}
|
|
1129
|
+
return await fromStorage(rows, all);
|
|
1130
|
+
}
|
|
1131
|
+
// Several collection rows in one storage call. Missing ids are left out.
|
|
1132
|
+
export async function getCollectionsById(ids) {
|
|
1133
|
+
const got = await getRows(ids.map(collectionKey));
|
|
1134
|
+
return ids
|
|
1135
|
+
.map((id) => got[collectionKey(id)])
|
|
1136
|
+
.filter((c) => c != null);
|
|
1137
|
+
}
|
|
1138
|
+
export async function getCollection(id) {
|
|
1139
|
+
return await getRaw(collectionKey(id));
|
|
1140
|
+
}
|
|
1141
|
+
// Messages shared by the store's collection-shape checks and the
|
|
1142
|
+
// collection dialog's live validation, so the dialog can't say one
|
|
1143
|
+
// thing and the store another.
|
|
1144
|
+
export const COLLECTION_ALREADY_REGISTERED = 'This collection is already registered';
|
|
1145
|
+
export const COLLECTION_NAME_REQUIRED = 'Name is required for scripts collections';
|
|
1146
|
+
export const COLLECTION_GONE = 'This collection no longer exists';
|
|
1147
|
+
export function collectionNameInUseMessage(name) {
|
|
1148
|
+
return `Collection named "${name.trim()}" already exists`;
|
|
1149
|
+
}
|
|
1150
|
+
// Register a new collection from a raw user input — the Add field's
|
|
1151
|
+
// one-string entry point. Dispatches by input shape:
|
|
1152
|
+
// - single-file `.user.js` URL/path → installed into the built-in
|
|
1153
|
+
// default container for its scheme (created on demand). The script
|
|
1154
|
+
// is added to the container's `scriptIds` and its initial fetch
|
|
1155
|
+
// runs immediately so the table populates without a manual
|
|
1156
|
+
// Refresh.
|
|
1157
|
+
// - everything else (directory, manifest, github-dir URL) → a new
|
|
1158
|
+
// location-backed collection, as `createCollection` would make.
|
|
1159
|
+
//
|
|
1160
|
+
// `normalizeCollectionInput` runs first so a github.com
|
|
1161
|
+
// `/blob/.../foo.user.js` (or `/blob/.../script_manifest.json`) URL
|
|
1162
|
+
// gets rewritten to its raw counterpart before we hit the duplicate
|
|
1163
|
+
// check. The github blob URL serves HTML, not raw bytes, so storing it
|
|
1164
|
+
// verbatim would silently load the page chrome instead of the script —
|
|
1165
|
+
// see [docs/http-collections.md].
|
|
1166
|
+
export async function addCollection(rawPath) {
|
|
1167
|
+
const path = await canonicalLocation(rawPath);
|
|
1168
|
+
if (isSingleFileInput(path)) {
|
|
1169
|
+
return await addScriptToContainer(path, { schemeDefault: true });
|
|
1170
|
+
}
|
|
1171
|
+
return await createLocationCollection(path, {});
|
|
1172
|
+
}
|
|
1173
|
+
// Two canonicalizations, in order: provider rewriting (github blob →
|
|
1174
|
+
// raw), then the one-spelling-per-location rule. The second is what
|
|
1175
|
+
// keeps `/x/a.user.js` typed into the collections page and
|
|
1176
|
+
// `file:///x/a.user.js` arriving from a captured navigation from
|
|
1177
|
+
// becoming two rows for one file — see `canonicalCollectionInput`.
|
|
1178
|
+
export async function canonicalLocation(rawPath) {
|
|
1179
|
+
return canonicalCollectionInput(await normalizeCollectionInput(rawPath));
|
|
1180
|
+
}
|
|
1181
|
+
// Create a collection the collection dialog described. A scripts
|
|
1182
|
+
// collection is an empty container row with the user's name; a
|
|
1183
|
+
// location collection is what `addCollection` makes, plus the name and
|
|
1184
|
+
// local-files choice from the dialog.
|
|
1185
|
+
export async function createCollection(spec) {
|
|
1186
|
+
if (spec.kind === 'scripts') {
|
|
1187
|
+
const name = spec.name.trim();
|
|
1188
|
+
if (!name)
|
|
1189
|
+
throw new Error(COLLECTION_NAME_REQUIRED);
|
|
1190
|
+
const created = await mutate(async () => {
|
|
1191
|
+
const collections = await getCollections();
|
|
1192
|
+
if (!mayUseReservedName(name, null))
|
|
1193
|
+
throw new Error(RESERVED_COLLECTION_NAME);
|
|
1194
|
+
if (collectionNameInUse(name, collections)) {
|
|
1195
|
+
throw new Error(collectionNameInUseMessage(name));
|
|
1196
|
+
}
|
|
1197
|
+
const id = crypto.randomUUID();
|
|
1198
|
+
const seed = {
|
|
1199
|
+
id,
|
|
1200
|
+
path: newContainerPath(),
|
|
1201
|
+
lastRefreshedAt: 0,
|
|
1202
|
+
scriptIds: [],
|
|
1203
|
+
enabled: true,
|
|
1204
|
+
displayName: name,
|
|
1205
|
+
};
|
|
1206
|
+
if (spec.localFiles === false)
|
|
1207
|
+
seed.localFiles = false;
|
|
1208
|
+
await writeLocal({
|
|
1209
|
+
[COLLECTIONS_INDEX_KEY]: [...collections.map((s) => s.id), id],
|
|
1210
|
+
[collectionKey(id)]: seed,
|
|
1211
|
+
});
|
|
1212
|
+
return seed;
|
|
1213
|
+
});
|
|
1214
|
+
return { kind: 'newCollection', collection: created };
|
|
1215
|
+
}
|
|
1216
|
+
const path = await canonicalLocation(spec.path);
|
|
1217
|
+
if (isSingleFileInput(path))
|
|
1218
|
+
throw new Error(SINGLE_SCRIPT_USE_ADD_FIELD);
|
|
1219
|
+
return await createLocationCollection(path, spec);
|
|
1220
|
+
}
|
|
1221
|
+
// What the collection dialog runs before creating a location-backed
|
|
1222
|
+
// collection: the same listing / manifest fetch / GitHub lookup a
|
|
1223
|
+
// refresh does, so a location that can't be read is refused in the
|
|
1224
|
+
// dialog instead of landing as a collection stuck in a scan error.
|
|
1225
|
+
// Nothing is written. The count lets the dialog warn about an empty
|
|
1226
|
+
// directory without refusing it — a directory can fill up later.
|
|
1227
|
+
export async function probeCollectionLocation(rawPath) {
|
|
1228
|
+
const path = await canonicalLocation(rawPath);
|
|
1229
|
+
if (isSingleFileInput(path))
|
|
1230
|
+
throw new Error(SINGLE_SCRIPT_USE_ADD_FIELD);
|
|
1231
|
+
const found = await scanForUserScripts(path);
|
|
1232
|
+
return { path, scriptCount: found.length };
|
|
1233
|
+
}
|
|
1234
|
+
// Mint a UUID, persist an empty collection row for an already-canonical
|
|
1235
|
+
// location, then refresh to populate the scripts.
|
|
1236
|
+
async function createLocationCollection(path, opts) {
|
|
1237
|
+
// Only a name the user actually chose is held to the uniqueness rule.
|
|
1238
|
+
// Inferred names can legitimately collide — two refs of one GitHub
|
|
1239
|
+
// tree both read as `<owner>/<repo>/<path>` — and refusing those
|
|
1240
|
+
// would block adding a real, distinct collection.
|
|
1241
|
+
const inferred = inferDisplayName(path);
|
|
1242
|
+
const chosen = opts.name?.trim() || '';
|
|
1243
|
+
const explicitName = chosen && chosen !== inferred ? chosen : '';
|
|
1244
|
+
// Dedup-and-register atomically so two concurrent adds of the same
|
|
1245
|
+
// path can't both create a collection (and so this index write can't
|
|
1246
|
+
// lose a race with another index mutator). The refresh runs OUTSIDE
|
|
1247
|
+
// the lock (it acquires the lock itself for its own write).
|
|
1248
|
+
const created = await mutate(async () => {
|
|
1249
|
+
const collections = await getCollections();
|
|
1250
|
+
// Canonicalize the stored side too, for the same reason the
|
|
1251
|
+
// container check does: a collection row written before
|
|
1252
|
+
// `addCollection` collapsed `file://` inputs still carries the URL
|
|
1253
|
+
// spelling.
|
|
1254
|
+
if (collections.some((s) => canonicalCollectionInput(s.path) === path))
|
|
1255
|
+
return null;
|
|
1256
|
+
if (explicitName) {
|
|
1257
|
+
if (!mayUseReservedName(explicitName, null))
|
|
1258
|
+
throw new Error(RESERVED_COLLECTION_NAME);
|
|
1259
|
+
if (collectionNameInUse(explicitName, collections)) {
|
|
1260
|
+
throw new Error(collectionNameInUseMessage(explicitName));
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
const id = crypto.randomUUID();
|
|
1264
|
+
const seed = {
|
|
1265
|
+
id,
|
|
1266
|
+
path,
|
|
1267
|
+
lastRefreshedAt: 0,
|
|
1268
|
+
scriptIds: [],
|
|
1269
|
+
enabled: true,
|
|
1270
|
+
// Seed a displayName at add-time so a viewer opened mid-add (or
|
|
1271
|
+
// any other reader) doesn't briefly see a row whose name only
|
|
1272
|
+
// materialises on the next backfill pass.
|
|
1273
|
+
displayName: explicitName || inferred,
|
|
1274
|
+
};
|
|
1275
|
+
if (opts.localFiles === false)
|
|
1276
|
+
seed.localFiles = false;
|
|
1277
|
+
const nextIndex = [...collections.map((s) => s.id), id];
|
|
1278
|
+
await writeLocal({
|
|
1279
|
+
[COLLECTIONS_INDEX_KEY]: nextIndex,
|
|
1280
|
+
[collectionKey(id)]: seed,
|
|
1281
|
+
});
|
|
1282
|
+
return { id, seed };
|
|
1283
|
+
});
|
|
1284
|
+
if (!created)
|
|
1285
|
+
return null;
|
|
1286
|
+
const { id, seed } = created;
|
|
1287
|
+
const refreshed = await refreshCollection(id, { adding: true });
|
|
1288
|
+
// The updater-driven refresh paths write their own per-script Log
|
|
1289
|
+
// entries. This initial-add path bypasses the updater, so each new
|
|
1290
|
+
// script's install line, failure and warnings are written here (see
|
|
1291
|
+
// `emitInitialLoadLog`). Awaited so the loglevel sentinel write
|
|
1292
|
+
// lands before the caller's `getAllMaxLogLevels` read — otherwise
|
|
1293
|
+
// the first paint of the row would briefly show OK and only flip on
|
|
1294
|
+
// the storage-change listener.
|
|
1295
|
+
if (refreshed) {
|
|
1296
|
+
await Promise.all(refreshed.scripts.map((s) => emitInitialLoadLog(s, true)));
|
|
1297
|
+
}
|
|
1298
|
+
return { kind: 'newCollection', collection: refreshed?.collection ?? seed };
|
|
1299
|
+
}
|
|
1300
|
+
// First-run gate. Seeding the default collections is driven from the
|
|
1301
|
+
// collections page (so the user watches the normal add → Refresh flow,
|
|
1302
|
+
// rather than scripts appearing silently from the SW); the SW only
|
|
1303
|
+
// opens that page on install. These two helpers are the persisted flag
|
|
1304
|
+
// the page and SW share.
|
|
1305
|
+
export async function isFirstRunComplete() {
|
|
1306
|
+
return (await getRaw(FIRST_RUN_DONE_KEY)) === true;
|
|
1307
|
+
}
|
|
1308
|
+
// Record that first-run handling is done so the SW stops opening the
|
|
1309
|
+
// seeding page and the collections page stops re-seeding — including
|
|
1310
|
+
// after the user deletes the seeded collections. Also called by the
|
|
1311
|
+
// test-surface reset so a cleared store isn't re-seeded.
|
|
1312
|
+
export async function markFirstRunComplete() {
|
|
1313
|
+
await writeLocal({ [FIRST_RUN_DONE_KEY]: true });
|
|
1314
|
+
}
|
|
1315
|
+
// Install one `.user.js` location into a container collection. Returns
|
|
1316
|
+
// null if the location is already a script in THAT container — the
|
|
1317
|
+
// same location may be installed into several containers, which is
|
|
1318
|
+
// what the "Other versions" panel and its exclusive-enable rule exist
|
|
1319
|
+
// for. The fetch runs as part of `refreshCollection` so the new row
|
|
1320
|
+
// lands populated.
|
|
1321
|
+
export async function installScript(rawLocation, target) {
|
|
1322
|
+
const path = await canonicalLocation(rawLocation);
|
|
1323
|
+
if (!isSingleFileInput(path)) {
|
|
1324
|
+
throw new Error('Internal error: only a .user.js location can be installed into a collection');
|
|
1325
|
+
}
|
|
1326
|
+
return await addScriptToContainer(path, target);
|
|
1327
|
+
}
|
|
1328
|
+
// ---------- Scratch scripts ----------
|
|
1329
|
+
//
|
|
1330
|
+
// Scripts whose only copy is their text in storage (see directories.ts).
|
|
1331
|
+
// Only sm-dev writes them (docs/sm-dev-design.md). Each sm-dev run
|
|
1332
|
+
// gets its own collection of them, its test collection, shown on the
|
|
1333
|
+
// collections page like any other; their text is a storage row the
|
|
1334
|
+
// fetcher reads back, so every refresh path treats them as ordinary
|
|
1335
|
+
// scripts.
|
|
1336
|
+
// The bodies pushed with a scratch script, or undefined when none were
|
|
1337
|
+
// (or the script isn't one).
|
|
1338
|
+
async function pushedFilesOf(url) {
|
|
1339
|
+
if (!isScratchUrl(url))
|
|
1340
|
+
return undefined;
|
|
1341
|
+
const files = await getRaw(scratchFilesKey(url));
|
|
1342
|
+
return files && Object.keys(files).length > 0 ? files : undefined;
|
|
1343
|
+
}
|
|
1344
|
+
// The name a run's test collection is shown under.
|
|
1345
|
+
export function smDevCollectionName(run) {
|
|
1346
|
+
return `sm-dev test collection (run ${run})`;
|
|
1347
|
+
}
|
|
1348
|
+
// Install or replace one of run `run`'s scripts, in its test collection.
|
|
1349
|
+
// The name (the `@name`, or one the caller gives) is the identity:
|
|
1350
|
+
// writing it again replaces the earlier text in place, keeping the row's
|
|
1351
|
+
// id and its GM values, which is what lets a page reload hot-swap the
|
|
1352
|
+
// new version. A new script that doesn't parse is reported and not
|
|
1353
|
+
// kept, so the collection holds only scripts the run has.
|
|
1354
|
+
export async function writeRunScript(args) {
|
|
1355
|
+
// Its scripts' URLs carry it as is, and '' would be no run's.
|
|
1356
|
+
if (!/^[A-Za-z0-9._-]+$/.test(args.run))
|
|
1357
|
+
throw new Error(`Internal error: ${JSON.stringify(args.run)} is not an sm-dev run id`);
|
|
1358
|
+
const header = parseHeader(args.text);
|
|
1359
|
+
const name = args.name?.trim() || (header ? getValue(header, 'name')?.trim() : '') || 'script';
|
|
1360
|
+
const url = scratchUrl(args.run, name);
|
|
1361
|
+
// The text and its files in one write, so no refresh reads one
|
|
1362
|
+
// without the other. Replaced whole, so a body the script no longer
|
|
1363
|
+
// reads from disk goes.
|
|
1364
|
+
const hasFiles = args.files !== undefined && Object.keys(args.files).length > 0;
|
|
1365
|
+
const source = args.source ? { fileUrl: args.source, given: name } : undefined;
|
|
1366
|
+
await writeLocal({
|
|
1367
|
+
[scratchTextKey(url)]: args.text,
|
|
1368
|
+
...(hasFiles ? { [scratchFilesKey(url)]: args.files } : {}),
|
|
1369
|
+
...(source ? { [scratchSourceKey(url)]: source } : {}),
|
|
1370
|
+
});
|
|
1371
|
+
const stale = [...(hasFiles ? [] : [scratchFilesKey(url)]), ...(source ? [] : [scratchSourceKey(url)])];
|
|
1372
|
+
if (stale.length > 0)
|
|
1373
|
+
await removeLocal(stale);
|
|
1374
|
+
const container = (await getCollections()).find((c) => c.path === smDevContainerPath(args.run));
|
|
1375
|
+
let scriptId;
|
|
1376
|
+
let collectionId;
|
|
1377
|
+
let replaced = false;
|
|
1378
|
+
if (container) {
|
|
1379
|
+
const rows = await getRows(container.scriptIds.map(scriptKey));
|
|
1380
|
+
for (const sid of container.scriptIds) {
|
|
1381
|
+
const row = rows[scriptKey(sid)];
|
|
1382
|
+
if (row?.relativePath === url) {
|
|
1383
|
+
scriptId = sid;
|
|
1384
|
+
collectionId = container.id;
|
|
1385
|
+
replaced = true;
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (scriptId === undefined) {
|
|
1390
|
+
let result;
|
|
1391
|
+
try {
|
|
1392
|
+
result = await installScript(url, { smDevRun: args.run });
|
|
1393
|
+
}
|
|
1394
|
+
catch (err) {
|
|
1395
|
+
// The install took its seed back out; the text rows go with it.
|
|
1396
|
+
await removeLocal([scratchTextKey(url), scratchFilesKey(url), scratchSourceKey(url)]);
|
|
1397
|
+
throw err;
|
|
1398
|
+
}
|
|
1399
|
+
if (!result)
|
|
1400
|
+
throw new Error('Internal error: the scratch script was neither installed nor found');
|
|
1401
|
+
collectionId = result.collection.id;
|
|
1402
|
+
scriptId = result.kind === 'existingContainer'
|
|
1403
|
+
? result.scriptId
|
|
1404
|
+
: result.collection.scriptIds[result.collection.scriptIds.length - 1];
|
|
1405
|
+
}
|
|
1406
|
+
else {
|
|
1407
|
+
// Re-read the text; the scratch fetcher has no size or mtime to skip
|
|
1408
|
+
// on, so the new content lands and the hash says whether it changed.
|
|
1409
|
+
await refreshScript(scriptId);
|
|
1410
|
+
}
|
|
1411
|
+
const row = await getScript(scriptId);
|
|
1412
|
+
if (!row)
|
|
1413
|
+
throw new Error('Internal error: the scratch script vanished during install');
|
|
1414
|
+
const out = { scriptId, collectionId: collectionId, status: row.status, replaced };
|
|
1415
|
+
if (row.errorMessage)
|
|
1416
|
+
out.errorMessage = row.errorMessage;
|
|
1417
|
+
if (row.errorLocation)
|
|
1418
|
+
out.errorLocation = row.errorLocation;
|
|
1419
|
+
if (!replaced && row.status !== 'ok')
|
|
1420
|
+
await removeScript(scriptId);
|
|
1421
|
+
return out;
|
|
1422
|
+
}
|
|
1423
|
+
// Remove run `run`'s test collection, its rows, their GM values, and the
|
|
1424
|
+
// text rows behind them. Returns what went, for the caller's
|
|
1425
|
+
// registration sync. `removeCollection` deletes the text rows, keeping
|
|
1426
|
+
// any that a copy moved into another collection still reads.
|
|
1427
|
+
export async function removeSmDevCollection(run) {
|
|
1428
|
+
const container = (await getCollections()).find((c) => smDevRunOfContainer(c.path) === run);
|
|
1429
|
+
if (!container)
|
|
1430
|
+
return { scriptIds: [], collectionId: null };
|
|
1431
|
+
for (const sid of container.scriptIds)
|
|
1432
|
+
await wipeValues(sid);
|
|
1433
|
+
await removeCollection(container.id);
|
|
1434
|
+
return { scriptIds: container.scriptIds, collectionId: container.id };
|
|
1435
|
+
}
|
|
1436
|
+
// The runs that have a test collection.
|
|
1437
|
+
export async function smDevCollectionRuns() {
|
|
1438
|
+
return (await getCollections()).map((c) => smDevRunOfContainer(c.path)).filter((run) => run !== null);
|
|
1439
|
+
}
|
|
1440
|
+
// Does this container already hold `canonicalPath`?
|
|
1441
|
+
//
|
|
1442
|
+
// A container holds one row per location, and both the install and the
|
|
1443
|
+
// move path have to agree on what "the same location" means — so the
|
|
1444
|
+
// rule lives here rather than being spelled twice. The STORED value is
|
|
1445
|
+
// canonicalized too: rows written before `addCollection` started
|
|
1446
|
+
// collapsing `file://` inputs still carry the URL spelling, and
|
|
1447
|
+
// comparing raw would offer a second copy of a file we already have.
|
|
1448
|
+
//
|
|
1449
|
+
// `canonicalPath` must already be through `canonicalCollectionInput`.
|
|
1450
|
+
// Caller-side, because both callers have it in hand.
|
|
1451
|
+
async function containerHoldsLocation(container, canonicalPath) {
|
|
1452
|
+
const rows = await getRows(container.scriptIds.map(scriptKey));
|
|
1453
|
+
for (const sid of container.scriptIds) {
|
|
1454
|
+
const s = rows[scriptKey(sid)];
|
|
1455
|
+
if (s && canonicalCollectionInput(s.relativePath) === canonicalPath)
|
|
1456
|
+
return true;
|
|
1457
|
+
}
|
|
1458
|
+
return false;
|
|
1459
|
+
}
|
|
1460
|
+
// A brand-new container row. One spelling of the default field values,
|
|
1461
|
+
// so an install-created container and a move-created one can't drift
|
|
1462
|
+
// apart on `enabled`, the inferred name, or the never-refreshed marker.
|
|
1463
|
+
function newContainerRow(id, path, scriptIds) {
|
|
1464
|
+
return {
|
|
1465
|
+
id,
|
|
1466
|
+
path,
|
|
1467
|
+
lastRefreshedAt: 0,
|
|
1468
|
+
scriptIds,
|
|
1469
|
+
enabled: true,
|
|
1470
|
+
displayName: inferDisplayName(path),
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
async function addScriptToContainer(path, target) {
|
|
1474
|
+
// Dedup-check and seed the container + script atomically so a
|
|
1475
|
+
// concurrent install (or index mutator) can't lose this scriptIds/index
|
|
1476
|
+
// write. The refresh runs OUTSIDE the lock. Returns null on duplicate.
|
|
1477
|
+
const seeded = await mutate(async () => {
|
|
1478
|
+
const collections = await getCollections();
|
|
1479
|
+
let existingContainer;
|
|
1480
|
+
let cPath;
|
|
1481
|
+
if ('collectionId' in target) {
|
|
1482
|
+
existingContainer = collections.find((s) => s.id === target.collectionId);
|
|
1483
|
+
// The dropdown was built from a snapshot; the row can have been
|
|
1484
|
+
// removed from another tab since.
|
|
1485
|
+
if (!existingContainer)
|
|
1486
|
+
throw new Error(COLLECTION_GONE);
|
|
1487
|
+
if (!isContainerCollection(existingContainer.path)) {
|
|
1488
|
+
throw new Error('Internal error: scripts can only be installed into a scripts collection');
|
|
1489
|
+
}
|
|
1490
|
+
if (isSmDevCollection(existingContainer.path))
|
|
1491
|
+
throw new Error(SM_DEV_COLLECTION_LOCKED);
|
|
1492
|
+
cPath = existingContainer.path;
|
|
1493
|
+
}
|
|
1494
|
+
else if ('smDevRun' in target) {
|
|
1495
|
+
cPath = smDevContainerPath(target.smDevRun);
|
|
1496
|
+
existingContainer = collections.find((s) => s.path === cPath);
|
|
1497
|
+
}
|
|
1498
|
+
else {
|
|
1499
|
+
cPath = containerPath(containerKindForSingleFileInput(path));
|
|
1500
|
+
existingContainer = collections.find((s) => s.path === cPath);
|
|
1501
|
+
}
|
|
1502
|
+
const isNew = !existingContainer;
|
|
1503
|
+
// Each container script's `relativePath` is the full URL/path the
|
|
1504
|
+
// user added; any prior row with the same value is a duplicate.
|
|
1505
|
+
// `path` arrived canonical (see `installScript`).
|
|
1506
|
+
if (existingContainer && await containerHoldsLocation(existingContainer, path))
|
|
1507
|
+
return null;
|
|
1508
|
+
const now = Date.now();
|
|
1509
|
+
const containerId = existingContainer?.id ?? crypto.randomUUID();
|
|
1510
|
+
const scriptId = crypto.randomUUID();
|
|
1511
|
+
const priorScriptIds = existingContainer?.scriptIds ?? [];
|
|
1512
|
+
const nextContainer = existingContainer
|
|
1513
|
+
? { ...existingContainer, scriptIds: [...priorScriptIds, scriptId] }
|
|
1514
|
+
: newContainerRow(containerId, cPath, [scriptId]);
|
|
1515
|
+
if (isNew && 'smDevRun' in target)
|
|
1516
|
+
nextContainer.displayName = smDevCollectionName(target.smDevRun);
|
|
1517
|
+
const seedScript = buildInitialInvalidEntry({
|
|
1518
|
+
id: scriptId,
|
|
1519
|
+
collectionId: containerId,
|
|
1520
|
+
relativePath: path,
|
|
1521
|
+
fileUrl: scriptFileUrl(cPath, path),
|
|
1522
|
+
errorMessage: NOT_LOADED_YET,
|
|
1523
|
+
now,
|
|
1524
|
+
});
|
|
1525
|
+
// Single set call so a SW restart between writes can never leave a
|
|
1526
|
+
// half-populated container — either the new script lands with its
|
|
1527
|
+
// container's scriptIds pointing at it (and, for a new container,
|
|
1528
|
+
// the collections index pointing at the container), or nothing does.
|
|
1529
|
+
const toSet = {
|
|
1530
|
+
[collectionKey(containerId)]: nextContainer,
|
|
1531
|
+
[scriptKey(scriptId)]: seedScript,
|
|
1532
|
+
};
|
|
1533
|
+
if (isNew) {
|
|
1534
|
+
toSet[COLLECTIONS_INDEX_KEY] = [...collections.map((s) => s.id), containerId];
|
|
1535
|
+
}
|
|
1536
|
+
await writeLocal(toSet);
|
|
1537
|
+
return { isNew, containerId, scriptId, nextContainer };
|
|
1538
|
+
});
|
|
1539
|
+
if (!seeded)
|
|
1540
|
+
return null;
|
|
1541
|
+
const { isNew, containerId, scriptId, nextContainer } = seeded;
|
|
1542
|
+
let refreshed;
|
|
1543
|
+
try {
|
|
1544
|
+
refreshed = await refreshCollection(containerId);
|
|
1545
|
+
}
|
|
1546
|
+
catch (err) {
|
|
1547
|
+
// The seed above is a placeholder with no content and no header —
|
|
1548
|
+
// it only becomes a script once this refresh writes the fetched
|
|
1549
|
+
// body. When that write is refused (an exhausted storage quota is
|
|
1550
|
+
// the realistic case — though anything the refresh throws lands
|
|
1551
|
+
// here), leaving the seed behind installs a row that
|
|
1552
|
+
// matches nothing, runs nothing, and shows "Not loaded yet" until
|
|
1553
|
+
// the user notices and refreshes it by hand. Nothing else about the
|
|
1554
|
+
// install has happened yet, so take the seed back out and let the
|
|
1555
|
+
// install fail as the failure it is. A fetch that fails is
|
|
1556
|
+
// different: the refresh then WRITES the row with its error, and
|
|
1557
|
+
// that row is the honest record of an install that landed.
|
|
1558
|
+
await unseedScript(scriptId, containerId, isNew);
|
|
1559
|
+
throw err;
|
|
1560
|
+
}
|
|
1561
|
+
if (refreshed) {
|
|
1562
|
+
await Promise.all(refreshed.scripts.map((s) => emitInitialLoadLog(s, s.id === scriptId)));
|
|
1563
|
+
}
|
|
1564
|
+
const finalCollection = refreshed?.collection ?? nextContainer;
|
|
1565
|
+
if (isNew)
|
|
1566
|
+
return { kind: 'newCollection', collection: finalCollection };
|
|
1567
|
+
return { kind: 'existingContainer', collection: finalCollection, scriptId };
|
|
1568
|
+
}
|
|
1569
|
+
// Undo `addScriptToContainer`'s seed after its refresh could not be
|
|
1570
|
+
// saved: drop the placeholder row and its slot in the container, and
|
|
1571
|
+
// the container itself (plus its index entry) when this install created
|
|
1572
|
+
// it. Re-read under the lock rather than replayed from the seed: another
|
|
1573
|
+
// tab can have installed into, or removed, the same container since.
|
|
1574
|
+
//
|
|
1575
|
+
// Every write here makes storage smaller — a `remove`, or a `set` of a
|
|
1576
|
+
// row with one id fewer — which Chrome accepts on a full quota, so this
|
|
1577
|
+
// works in exactly the state that made it necessary. Best effort all the
|
|
1578
|
+
// same: the caller is already reporting a failure, and a rollback that
|
|
1579
|
+
// can't land must not replace that report with its own.
|
|
1580
|
+
async function unseedScript(scriptId, containerId, createdContainer) {
|
|
1581
|
+
try {
|
|
1582
|
+
await mutate(async () => {
|
|
1583
|
+
const container = await getRaw(collectionKey(containerId));
|
|
1584
|
+
const remaining = container?.scriptIds.filter((id) => id !== scriptId) ?? [];
|
|
1585
|
+
const dropContainer = createdContainer && remaining.length === 0;
|
|
1586
|
+
const toRemove = [scriptKey(scriptId)];
|
|
1587
|
+
const toSet = {};
|
|
1588
|
+
if (container) {
|
|
1589
|
+
if (dropContainer) {
|
|
1590
|
+
toRemove.push(collectionKey(containerId));
|
|
1591
|
+
const index = await readCollectionIndex();
|
|
1592
|
+
toSet[COLLECTIONS_INDEX_KEY] = index.filter((id) => id !== containerId);
|
|
1593
|
+
}
|
|
1594
|
+
else if (remaining.length !== container.scriptIds.length) {
|
|
1595
|
+
toSet[collectionKey(containerId)] = { ...container, scriptIds: remaining };
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
// The `set` first: it is the only step Chrome can refuse, so if
|
|
1599
|
+
// it does, nothing has changed. A `remove` can't be refused, so
|
|
1600
|
+
// the pair only splits if the worker dies between them — leaving
|
|
1601
|
+
// an unlisted row, which `reconcileScriptMembership` re-lists.
|
|
1602
|
+
if (Object.keys(toSet).length > 0)
|
|
1603
|
+
await writeLocal(toSet);
|
|
1604
|
+
await removeLocal(toRemove);
|
|
1605
|
+
if (dropContainer)
|
|
1606
|
+
await setRefreshError(containerId, null);
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
catch (err) {
|
|
1610
|
+
// Not debug-gated: the state this leaves (a "Not loaded yet" row
|
|
1611
|
+
// nothing will explain) is otherwise invisible.
|
|
1612
|
+
console.info(`[SourceMonkey] could not undo a failed install: ${describeError(err)}`);
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
export const SCRIPT_GONE = 'This script no longer exists';
|
|
1616
|
+
// The one thing both `installScript` and `moveScript` decline: the
|
|
1617
|
+
// target container already holds this location. Shared, so the install
|
|
1618
|
+
// callout and the viewer's Move picker can't word the same refusal two
|
|
1619
|
+
// ways.
|
|
1620
|
+
export const ALREADY_INSTALLED_IN_TARGET = 'This script is already installed there';
|
|
1621
|
+
// Both ends of a move have to be container collections. A directory or
|
|
1622
|
+
// manifest script's `relativePath` is relative to its collection root,
|
|
1623
|
+
// so moving it would mean rewriting it to an absolute location — a
|
|
1624
|
+
// different feature. The UI never offers the button in that case, so
|
|
1625
|
+
// this is a wiring error, not something the user can act on.
|
|
1626
|
+
export const NOT_A_MOVABLE_SCRIPT = 'Internal error: only a script in an individual scripts collection can be moved';
|
|
1627
|
+
// The pages never offer it (see `installOptionsFor`); said if another
|
|
1628
|
+
// path tries.
|
|
1629
|
+
export const SM_DEV_COLLECTION_LOCKED = 'Scripts can\'t be moved into or out of an sm-dev test collection, or installed into one, since only its sm-dev run changes it';
|
|
1630
|
+
// Move one already-installed script from its container collection into
|
|
1631
|
+
// another. The stored row keeps its `id`, so its Chrome registration,
|
|
1632
|
+
// its `gmval:` rows, and its Log all follow it untouched — only the
|
|
1633
|
+
// `collectionId` and the two `scriptIds` lists change.
|
|
1634
|
+
//
|
|
1635
|
+
// Returns null when the destination already holds this location (the
|
|
1636
|
+
// same case `installScript` returns null for) or is the collection the
|
|
1637
|
+
// script is already in.
|
|
1638
|
+
//
|
|
1639
|
+
// What DOES change with the parent: whether the script runs at all
|
|
1640
|
+
// (`isRegistrationCandidate` requires the collection to be enabled
|
|
1641
|
+
// too), and the local-stand-in policy — so a move between collections
|
|
1642
|
+
// that disagree about `localFiles` ends with a refresh, because the
|
|
1643
|
+
// `@require` / `@resource` bodies were resolved under the old rule.
|
|
1644
|
+
export async function moveScript(scriptId, target) {
|
|
1645
|
+
const moved = await mutate(async () => {
|
|
1646
|
+
const script = await getRow(scriptKey(scriptId));
|
|
1647
|
+
if (!script)
|
|
1648
|
+
throw new Error(SCRIPT_GONE);
|
|
1649
|
+
const source = await getRaw(collectionKey(script.collectionId));
|
|
1650
|
+
if (!source || !isContainerCollection(source.path))
|
|
1651
|
+
throw new Error(NOT_A_MOVABLE_SCRIPT);
|
|
1652
|
+
if (isSmDevCollection(source.path))
|
|
1653
|
+
throw new Error(SM_DEV_COLLECTION_LOCKED);
|
|
1654
|
+
const collections = await getCollections();
|
|
1655
|
+
let dest;
|
|
1656
|
+
let destPath;
|
|
1657
|
+
if ('collectionId' in target) {
|
|
1658
|
+
dest = collections.find((c) => c.id === target.collectionId);
|
|
1659
|
+
// The dropdown was built from a snapshot; the row can have been
|
|
1660
|
+
// removed from another tab since.
|
|
1661
|
+
if (!dest)
|
|
1662
|
+
throw new Error(COLLECTION_GONE);
|
|
1663
|
+
if (!isContainerCollection(dest.path))
|
|
1664
|
+
throw new Error(NOT_A_MOVABLE_SCRIPT);
|
|
1665
|
+
if (isSmDevCollection(dest.path))
|
|
1666
|
+
throw new Error(SM_DEV_COLLECTION_LOCKED);
|
|
1667
|
+
destPath = dest.path;
|
|
1668
|
+
}
|
|
1669
|
+
else {
|
|
1670
|
+
destPath = containerPath(containerKindForSingleFileInput(script.relativePath));
|
|
1671
|
+
dest = collections.find((c) => c.path === destPath);
|
|
1672
|
+
}
|
|
1673
|
+
if (dest?.id === source.id)
|
|
1674
|
+
return null;
|
|
1675
|
+
// Same dedup rule as an install, via the same helper — the stored
|
|
1676
|
+
// `relativePath` needs canonicalizing here because it may predate
|
|
1677
|
+
// the store doing so on the way in.
|
|
1678
|
+
const here = canonicalCollectionInput(script.relativePath);
|
|
1679
|
+
if (dest && await containerHoldsLocation(dest, here))
|
|
1680
|
+
return null;
|
|
1681
|
+
const destId = dest?.id ?? crypto.randomUUID();
|
|
1682
|
+
const nextDest = dest
|
|
1683
|
+
? { ...dest, scriptIds: [...dest.scriptIds, scriptId] }
|
|
1684
|
+
: newContainerRow(destId, destPath, [scriptId]);
|
|
1685
|
+
const nextScript = { ...script, collectionId: destId };
|
|
1686
|
+
// One set call: a SW restart between writes must never leave the
|
|
1687
|
+
// row listed by both collections, or by neither.
|
|
1688
|
+
const toSet = {
|
|
1689
|
+
[collectionKey(source.id)]: {
|
|
1690
|
+
...source,
|
|
1691
|
+
scriptIds: source.scriptIds.filter((id) => id !== scriptId),
|
|
1692
|
+
},
|
|
1693
|
+
[collectionKey(destId)]: nextDest,
|
|
1694
|
+
[scriptKey(scriptId)]: nextScript,
|
|
1695
|
+
};
|
|
1696
|
+
if (!dest)
|
|
1697
|
+
toSet[COLLECTIONS_INDEX_KEY] = [...collections.map((c) => c.id), destId];
|
|
1698
|
+
await writeLocal(toSet);
|
|
1699
|
+
return {
|
|
1700
|
+
script: nextScript,
|
|
1701
|
+
localFilesChanged: collectionUsesLocalFiles(source) !== collectionUsesLocalFiles(nextDest),
|
|
1702
|
+
};
|
|
1703
|
+
});
|
|
1704
|
+
if (!moved)
|
|
1705
|
+
return null;
|
|
1706
|
+
if (!moved.localFilesChanged)
|
|
1707
|
+
return moved.script;
|
|
1708
|
+
try {
|
|
1709
|
+
// Outside the lock: `refreshScript` takes it itself.
|
|
1710
|
+
return (await refreshScript(scriptId)) ?? moved.script;
|
|
1711
|
+
}
|
|
1712
|
+
catch (err) {
|
|
1713
|
+
// The move itself landed and is what the caller asked for; only the
|
|
1714
|
+
// re-resolve of the declared files failed. Reporting it as a failed
|
|
1715
|
+
// move would be wrong, and the user can click Refresh.
|
|
1716
|
+
dlog(`[SourceMonkey] refresh after move failed: ${describeError(err)}`);
|
|
1717
|
+
return moved.script;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
// Write a freshly-added script's first Log lines: the install itself,
|
|
1721
|
+
// any local stand-in it picked up, its load failure, and its parse-time
|
|
1722
|
+
// warnings.
|
|
1723
|
+
//
|
|
1724
|
+
// The updater-driven refresh paths do this themselves; the add path
|
|
1725
|
+
// bypasses the updater, so without this a newly installed script would
|
|
1726
|
+
// start with an empty Log: no record of the install, a red badge with
|
|
1727
|
+
// nothing to click through to, and header warnings that only the page
|
|
1728
|
+
// console ever showed.
|
|
1729
|
+
//
|
|
1730
|
+
// Adding a single file to an *existing* container refreshes every
|
|
1731
|
+
// sibling, and we run over all of them with `installed` false for the
|
|
1732
|
+
// siblings. Those get only the stand-in and failure lines: a sibling
|
|
1733
|
+
// that is still broken logs that again. It was genuinely re-read, so
|
|
1734
|
+
// saying so is honest, and the add path is a deliberate user action
|
|
1735
|
+
// rather than something that fires on its own. They weren't installed
|
|
1736
|
+
// now, and their content didn't change, so they get no install line or
|
|
1737
|
+
// parser restatement.
|
|
1738
|
+
export async function emitInitialLoadLog(script, installed) {
|
|
1739
|
+
const failure = loadStateLogEntry(script);
|
|
1740
|
+
if (installed) {
|
|
1741
|
+
// No size when nothing was fetched: such a row's `size` is null.
|
|
1742
|
+
const size = script.size != null ? ` (${formatBytes(script.size)})` : '';
|
|
1743
|
+
// `state: 'ok'` only when it loaded: an invalid row's failure line
|
|
1744
|
+
// below carries its state, and an OK beside it would contradict it.
|
|
1745
|
+
await appendScriptLog(script.id, {
|
|
1746
|
+
level: 'info',
|
|
1747
|
+
...(failure ? {} : { state: 'ok' }),
|
|
1748
|
+
source: 'add',
|
|
1749
|
+
message: `Installed${size}`,
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1752
|
+
// `undefined` prior: everything this row loaded is news at add time.
|
|
1753
|
+
for (const entry of localFileLogMessages(undefined, script)) {
|
|
1754
|
+
await appendScriptLog(script.id, { ...entry, source: 'add' });
|
|
1755
|
+
}
|
|
1756
|
+
if (failure)
|
|
1757
|
+
await appendScriptLog(script.id, { ...failure, source: 'add' });
|
|
1758
|
+
// After the failure, unlike a refresh (which stops there): a row that
|
|
1759
|
+
// is invalid on a failed `@require` still has a header whose warnings
|
|
1760
|
+
// hold, and at install time they are part of what the user is asking
|
|
1761
|
+
// about.
|
|
1762
|
+
if (installed) {
|
|
1763
|
+
for (const entry of script.startupLogs ?? []) {
|
|
1764
|
+
await appendScriptLog(script.id, startupLogEntry(entry));
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
// A row with no name that one can be inferred for: a user-created
|
|
1769
|
+
// container infers none, so is left as it is.
|
|
1770
|
+
export function needsDisplayName(collection) {
|
|
1771
|
+
return !collection.displayName?.trim() && inferDisplayName(collection.path) !== '';
|
|
1772
|
+
}
|
|
1773
|
+
// Fill in `displayName` on any collection row that doesn't have one
|
|
1774
|
+
// yet. The collections page asks for it at boot when a row needs one.
|
|
1775
|
+
// Cheap: one read of the collection index plus one bulk write iff
|
|
1776
|
+
// anything was missing. The pure-inference rules live in
|
|
1777
|
+
// `collection-display.ts`; that module only imports a TYPE from this
|
|
1778
|
+
// file so the cycle is compile-time only.
|
|
1779
|
+
export async function backfillCollectionDisplayNames() {
|
|
1780
|
+
// Read and written under the lock: a removal or refresh landing in
|
|
1781
|
+
// between would otherwise be written over with the rows read before.
|
|
1782
|
+
await mutate(async () => {
|
|
1783
|
+
const collections = await getCollections();
|
|
1784
|
+
const toSet = {};
|
|
1785
|
+
for (const s of collections) {
|
|
1786
|
+
if (!needsDisplayName(s))
|
|
1787
|
+
continue;
|
|
1788
|
+
const next = { ...s, displayName: inferDisplayName(s.path) };
|
|
1789
|
+
toSet[collectionKey(s.id)] = next;
|
|
1790
|
+
}
|
|
1791
|
+
if (Object.keys(toSet).length > 0) {
|
|
1792
|
+
await writeLocal(toSet);
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
// One-time pass over stored script rows to fill `usesUnsafeWindow` for
|
|
1797
|
+
// rows written before the field existed. Returns how many rows changed.
|
|
1798
|
+
//
|
|
1799
|
+
// Why it can't wait for a refresh: `buildScriptFromText` derives the flag
|
|
1800
|
+
// on every load, but a row is only rebuilt when a fetch actually happens.
|
|
1801
|
+
// The cheap-skip paths (unchanged size+mtime on disk, a conditional GET
|
|
1802
|
+
// answered 304) keep an untouched script's row exactly as it was, so a
|
|
1803
|
+
// script that uses `unsafeWindow` ungranted could sit broken
|
|
1804
|
+
// indefinitely after the upgrade that would have fixed it.
|
|
1805
|
+
//
|
|
1806
|
+
// Idempotent and cheap to repeat: every row whose body doesn't contain
|
|
1807
|
+
// the name at all costs one `indexOf`. Rows that already carry the flag,
|
|
1808
|
+
// and error rows (which never run), are skipped. `startupLogs` is
|
|
1809
|
+
// re-derived for the rows that change so the row stays self-consistent
|
|
1810
|
+
// with what the injector will do with it.
|
|
1811
|
+
//
|
|
1812
|
+
// The backfills read and write inside the write lock, as every write of
|
|
1813
|
+
// a script row must (`checkScriptRowWrite`): a row removed between the
|
|
1814
|
+
// read and the write would otherwise come back, pointing at blobs the
|
|
1815
|
+
// cleanup has deleted. They run once per extension update, so holding
|
|
1816
|
+
// the lock for a full read costs nothing that matters.
|
|
1817
|
+
export async function backfillUsesUnsafeWindow() {
|
|
1818
|
+
return mutate(async () => {
|
|
1819
|
+
const scripts = await getAllScripts();
|
|
1820
|
+
const toSet = {};
|
|
1821
|
+
for (const script of scripts) {
|
|
1822
|
+
if (script.status !== 'ok' || script.usesUnsafeWindow === true)
|
|
1823
|
+
continue;
|
|
1824
|
+
if (!sourceUsesUnsafeWindow(script.bodyText))
|
|
1825
|
+
continue;
|
|
1826
|
+
const next = { ...script, usesUnsafeWindow: true };
|
|
1827
|
+
const logs = rederivedStartupLogs(script, true);
|
|
1828
|
+
if (logs.length > 0)
|
|
1829
|
+
next.startupLogs = logs;
|
|
1830
|
+
else
|
|
1831
|
+
delete next.startupLogs;
|
|
1832
|
+
toSet[scriptKey(script.id)] = next;
|
|
1833
|
+
}
|
|
1834
|
+
const changed = Object.keys(toSet).length;
|
|
1835
|
+
if (changed > 0)
|
|
1836
|
+
await writeLocal(toSet);
|
|
1837
|
+
return changed;
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
// Re-derive `startupLogs` on stored rows, so a new parse-time check
|
|
1841
|
+
// (e.g. the no-`@connect` warning) reaches scripts already installed.
|
|
1842
|
+
// Same reason as `backfillUsesUnsafeWindow`: an unchanged script's row
|
|
1843
|
+
// is never rebuilt. Runs after that backfill, so `usesUnsafeWindow` is
|
|
1844
|
+
// current. Writes only rows whose logs changed; returns how many.
|
|
1845
|
+
export async function backfillStartupLogs() {
|
|
1846
|
+
return mutate(async () => {
|
|
1847
|
+
const scripts = await getAllScriptMetas('startup log backfill');
|
|
1848
|
+
const toSet = {};
|
|
1849
|
+
for (const script of scripts) {
|
|
1850
|
+
if (script.status !== 'ok')
|
|
1851
|
+
continue;
|
|
1852
|
+
const logs = rederivedStartupLogs(script, script.usesUnsafeWindow === true);
|
|
1853
|
+
if (JSON.stringify(logs) === JSON.stringify(script.startupLogs ?? []))
|
|
1854
|
+
continue;
|
|
1855
|
+
const next = { ...script };
|
|
1856
|
+
if (logs.length > 0)
|
|
1857
|
+
next.startupLogs = logs;
|
|
1858
|
+
else
|
|
1859
|
+
delete next.startupLogs;
|
|
1860
|
+
toSet[scriptKey(script.id)] = next;
|
|
1861
|
+
}
|
|
1862
|
+
const changed = Object.keys(toSet).length;
|
|
1863
|
+
if (changed > 0)
|
|
1864
|
+
await writeLocal(toSet);
|
|
1865
|
+
return changed;
|
|
1866
|
+
});
|
|
1867
|
+
}
|
|
1868
|
+
// Re-run all of `validateHeader` on stored `ok` rows, so a header
|
|
1869
|
+
// check added in an update stops scripts already installed rather than
|
|
1870
|
+
// only new or changed ones. Same reason as the backfills above: an
|
|
1871
|
+
// unchanged script's row is never rebuilt. Writes only rows that now
|
|
1872
|
+
// fail; returns how many.
|
|
1873
|
+
//
|
|
1874
|
+
// Each flipped row gets its error in the Log here: the boot replay
|
|
1875
|
+
// runs on the same wake and may have read the row while it was still
|
|
1876
|
+
// `ok`, which would leave an INVALID badge over a Log that says nothing.
|
|
1877
|
+
export async function backfillHeaderProblems() {
|
|
1878
|
+
const flipped = await mutate(async () => {
|
|
1879
|
+
const scripts = await getAllScriptMetas('header check backfill');
|
|
1880
|
+
const toSet = {};
|
|
1881
|
+
for (const script of scripts) {
|
|
1882
|
+
if (script.status !== 'ok')
|
|
1883
|
+
continue;
|
|
1884
|
+
const problem = validateHeader(script.header);
|
|
1885
|
+
if (!problem)
|
|
1886
|
+
continue;
|
|
1887
|
+
const next = { ...script, status: 'invalid' };
|
|
1888
|
+
setScriptError(next, problem.message, locationIn(script.relativePath, problem.line));
|
|
1889
|
+
// Same shape as a freshly built invalid row (see
|
|
1890
|
+
// `buildScriptFromText`): no startup logs, parse verdict or world
|
|
1891
|
+
// flag, and no stale refresh failure competing with the error.
|
|
1892
|
+
delete next.startupLogs;
|
|
1893
|
+
delete next.parseCheckedHash;
|
|
1894
|
+
delete next.usesUnsafeWindow;
|
|
1895
|
+
delete next.refreshError;
|
|
1896
|
+
toSet[scriptKey(script.id)] = next;
|
|
1897
|
+
}
|
|
1898
|
+
const rows = Object.values(toSet);
|
|
1899
|
+
if (rows.length > 0)
|
|
1900
|
+
await writeLocal(toSet);
|
|
1901
|
+
return rows;
|
|
1902
|
+
});
|
|
1903
|
+
// Outside the lock: the Log has its own queue.
|
|
1904
|
+
for (const row of flipped) {
|
|
1905
|
+
const entry = loadStateLogEntry(row);
|
|
1906
|
+
if (entry)
|
|
1907
|
+
await appendScriptLog(row.id, { ...entry, source: 'extension-update' });
|
|
1908
|
+
}
|
|
1909
|
+
return flipped.length;
|
|
1910
|
+
}
|
|
1911
|
+
// The opposite of `backfillHeaderProblems`: ids of rows an older build
|
|
1912
|
+
// refused with a check that no longer exists (`@run-at context-menu`,
|
|
1913
|
+
// before manual scripts). The caller refreshes them; a refresh always
|
|
1914
|
+
// refetches an invalid row (see `fetchOrSkipInParallel`), and the
|
|
1915
|
+
// rebuild then also loads the `@require` / `@resource` files an invalid
|
|
1916
|
+
// row never got. Flipping the row to `ok` in place would skip those.
|
|
1917
|
+
export async function retiredHeaderProblemIds() {
|
|
1918
|
+
const scripts = await getAllScriptMetas('retired header check');
|
|
1919
|
+
return scripts
|
|
1920
|
+
.filter((s) => s.status === 'invalid' && s.errorMessage === RETIRED_CONTEXT_MENU_UNSUPPORTED)
|
|
1921
|
+
.map((s) => s.id);
|
|
1922
|
+
}
|
|
1923
|
+
// Reorder the collections index by moving the given collection one
|
|
1924
|
+
// position up or down. No-op if the collection is already at the
|
|
1925
|
+
// boundary in the requested direction (caller-side Up/Down buttons
|
|
1926
|
+
// disable themselves at the ends, but the function tolerates a
|
|
1927
|
+
// redundant call). Only the index row is rewritten; per-collection rows
|
|
1928
|
+
// are untouched.
|
|
1929
|
+
export async function moveCollection(id, direction) {
|
|
1930
|
+
await mutate(async () => {
|
|
1931
|
+
const ids = await readCollectionIndex();
|
|
1932
|
+
const idx = ids.indexOf(id);
|
|
1933
|
+
if (idx < 0)
|
|
1934
|
+
return;
|
|
1935
|
+
const swapWith = direction === 'up' ? idx - 1 : idx + 1;
|
|
1936
|
+
if (swapWith < 0 || swapWith >= ids.length)
|
|
1937
|
+
return;
|
|
1938
|
+
const next = ids.slice();
|
|
1939
|
+
[next[idx], next[swapWith]] = [next[swapWith], next[idx]];
|
|
1940
|
+
await writeLocal({ [COLLECTIONS_INDEX_KEY]: next });
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
export async function removeCollection(id) {
|
|
1944
|
+
const nextIndex = await mutate(async () => {
|
|
1945
|
+
const collection = await getRaw(collectionKey(id));
|
|
1946
|
+
const ids = await readCollectionIndex();
|
|
1947
|
+
const next = ids.filter((sid) => sid !== id);
|
|
1948
|
+
const scriptIds = collection?.scriptIds ?? [];
|
|
1949
|
+
const rows = await getRows(scriptIds.map(scriptKey));
|
|
1950
|
+
const removed = scriptIds.map((sid) => rows[scriptKey(sid)])
|
|
1951
|
+
.filter((r) => r !== undefined);
|
|
1952
|
+
const removeKeys = [
|
|
1953
|
+
collectionKey(id),
|
|
1954
|
+
...scriptIds.map(scriptKey),
|
|
1955
|
+
...scriptIds.map(connectGrantsKey),
|
|
1956
|
+
...await unreferencedScratchKeys(removed),
|
|
1957
|
+
];
|
|
1958
|
+
await writeLocal({ [COLLECTIONS_INDEX_KEY]: next, ...await withoutTimes(scriptIds) });
|
|
1959
|
+
await removeLocal(removeKeys);
|
|
1960
|
+
return next;
|
|
1961
|
+
});
|
|
1962
|
+
// Session-storage bookkeeping, so a removed collection can't leave a
|
|
1963
|
+
// banner counting a section that is no longer on the page. After the
|
|
1964
|
+
// write, not inside it: the local write is the one that has to be
|
|
1965
|
+
// atomic, and a failure here must not undo a removal that succeeded.
|
|
1966
|
+
await pruneRefreshErrors(nextIndex);
|
|
1967
|
+
}
|
|
1968
|
+
// Drop a single script row from storage and remove its ID from the
|
|
1969
|
+
// parent collection's scriptIds list. Used by the collections-page
|
|
1970
|
+
// trash-cans (container rows, `missing` rows from older versions), the
|
|
1971
|
+
// delete-local-file dialog, and `refreshScript` when a directory no
|
|
1972
|
+
// longer lists the file — refreshCollection won't recreate the row
|
|
1973
|
+
// because it only adds entries the scan currently reports.
|
|
1974
|
+
export async function removeScript(id) {
|
|
1975
|
+
await mutate(async () => {
|
|
1976
|
+
const script = await getRow(scriptKey(id));
|
|
1977
|
+
if (!script)
|
|
1978
|
+
return;
|
|
1979
|
+
const collection = await getRaw(collectionKey(script.collectionId));
|
|
1980
|
+
const toSet = await withoutTimes([id]);
|
|
1981
|
+
if (collection) {
|
|
1982
|
+
const nextIds = collection.scriptIds.filter((sid) => sid !== id);
|
|
1983
|
+
if (nextIds.length !== collection.scriptIds.length) {
|
|
1984
|
+
toSet[collectionKey(collection.id)] = { ...collection, scriptIds: nextIds };
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (Object.keys(toSet).length > 0)
|
|
1988
|
+
await writeLocal(toSet);
|
|
1989
|
+
await removeLocal([
|
|
1990
|
+
scriptKey(id), connectGrantsKey(id), ...await unreferencedScratchKeys([script]),
|
|
1991
|
+
]);
|
|
1992
|
+
});
|
|
1993
|
+
// After the write, not inside it: the local write is the one that has
|
|
1994
|
+
// to be atomic, and a failure here must not undo a removal that
|
|
1995
|
+
// succeeded. The refresh path drops rows the same way and clears their
|
|
1996
|
+
// logs the same way.
|
|
1997
|
+
await forgetScriptLogs([id]);
|
|
1998
|
+
}
|
|
1999
|
+
// The text rows (and pushed-file rows) of the scratch scripts among
|
|
2000
|
+
// `removed` that no other script row still reads. A scratch script moved into another collection
|
|
2001
|
+
// keeps reading its text row, so a URL some remaining row names keeps
|
|
2002
|
+
// its text. Reads nothing more when no scratch script is among those
|
|
2003
|
+
// removed, and otherwise only the container collections' rows: a
|
|
2004
|
+
// scratch script can only be moved into another container. Call
|
|
2005
|
+
// inside `mutate`, before the rows are deleted.
|
|
2006
|
+
async function unreferencedScratchKeys(removed) {
|
|
2007
|
+
const urls = new Set(removed.map((r) => r.relativePath).filter(isScratchUrl));
|
|
2008
|
+
if (urls.size === 0)
|
|
2009
|
+
return [];
|
|
2010
|
+
const removedIds = new Set(removed.map((r) => r.id));
|
|
2011
|
+
const collectionRows = await getMany((await readCollectionIndex()).map(collectionKey));
|
|
2012
|
+
const others = [];
|
|
2013
|
+
for (const value of Object.values(collectionRows)) {
|
|
2014
|
+
const c = value;
|
|
2015
|
+
if (!c || !isContainerCollection(c.path))
|
|
2016
|
+
continue;
|
|
2017
|
+
for (const sid of c.scriptIds)
|
|
2018
|
+
if (!removedIds.has(sid))
|
|
2019
|
+
others.push(sid);
|
|
2020
|
+
}
|
|
2021
|
+
const rows = await getRows(others.map(scriptKey));
|
|
2022
|
+
for (const value of Object.values(rows)) {
|
|
2023
|
+
urls.delete(value.relativePath);
|
|
2024
|
+
}
|
|
2025
|
+
return [...urls].flatMap((url) => [scratchTextKey(url), scratchFilesKey(url), scratchSourceKey(url)]);
|
|
2026
|
+
}
|
|
2027
|
+
// ---------- Membership reconciliation ----------
|
|
2028
|
+
// The rows `reconcileScriptMembership` needs when the caller hasn't
|
|
2029
|
+
// already read them: every script row, and every collection row the
|
|
2030
|
+
// INDEX still lists.
|
|
2031
|
+
//
|
|
2032
|
+
// Index-filtered so this agrees with the collections page, which passes
|
|
2033
|
+
// what `getCollections` gave it. A `collection:` row the index has lost
|
|
2034
|
+
// is not somewhere a script can be put back — nothing renders or
|
|
2035
|
+
// refreshes it — so both callers have to treat scripts naming one as
|
|
2036
|
+
// collectionless rather than re-link them into a collection no surface
|
|
2037
|
+
// shows.
|
|
2038
|
+
//
|
|
2039
|
+
// The filter also means a de-indexed row's stale cross-listing is left
|
|
2040
|
+
// alone rather than dropped. That listing is invisible either way, and
|
|
2041
|
+
// leaving one row of garbage beats writing to a collection we've just
|
|
2042
|
+
// decided isn't real.
|
|
2043
|
+
async function scanMembershipRows() {
|
|
2044
|
+
const all = await allCachedRows() ?? await scanStorageRows('membership reconcile', (k) => k === COLLECTIONS_INDEX_KEY || k.startsWith(COLLECTION_PREFIX) || k.startsWith(SCRIPT_PREFIX));
|
|
2045
|
+
// Out of the scan rather than a second read, so the index and the rows
|
|
2046
|
+
// it filters are one snapshot. `ensureCollectionKeysMigrated` already
|
|
2047
|
+
// ran, so this is the post-rename spelling.
|
|
2048
|
+
const index = all[COLLECTIONS_INDEX_KEY];
|
|
2049
|
+
const indexed = new Set(Array.isArray(index) ? index : []);
|
|
2050
|
+
const collections = [];
|
|
2051
|
+
const scripts = [];
|
|
2052
|
+
for (const [key, value] of Object.entries(all)) {
|
|
2053
|
+
if (key.startsWith(COLLECTION_PREFIX)) {
|
|
2054
|
+
if (indexed.has(key.slice(COLLECTION_PREFIX.length))) {
|
|
2055
|
+
collections.push(value);
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
else if (key.startsWith(SCRIPT_PREFIX)) {
|
|
2059
|
+
scripts.push(value);
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
return { collections, scripts };
|
|
2063
|
+
}
|
|
2064
|
+
//
|
|
2065
|
+
// Make `collection.scriptIds` agree with the `collectionId` on each
|
|
2066
|
+
// script row. Every mutator writes both together, so a disagreement is
|
|
2067
|
+
// a bug state rather than a shape the store means to represent — but a
|
|
2068
|
+
// refresh that raced an install or a move used to overwrite the list
|
|
2069
|
+
// from its pre-fetch snapshot and leave one behind (both directions
|
|
2070
|
+
// now handled in `writeRefreshedCollection`).
|
|
2071
|
+
//
|
|
2072
|
+
// This heals profiles that already hit it. The row's own `collectionId`
|
|
2073
|
+
// is the authority, because that is what the injector and the viewer
|
|
2074
|
+
// read — a row unlisted by it is invisible on the collections page and
|
|
2075
|
+
// never refreshed again, and one listed by a collection it doesn't name
|
|
2076
|
+
// shows up under a collection it doesn't belong to.
|
|
2077
|
+
//
|
|
2078
|
+
// Finding a row nothing points at needs every row, so this does not run
|
|
2079
|
+
// on a schedule of its own. The collections page (the surface where an
|
|
2080
|
+
// unlisted row is missing, and the one the user is looking at when they
|
|
2081
|
+
// notice) asks the worker for it on every full render, which reads the
|
|
2082
|
+
// rows from its row cache.
|
|
2083
|
+
//
|
|
2084
|
+
// A row whose collection is GONE is left alone: `removeCollection`
|
|
2085
|
+
// drops both together so it shouldn't arise, there is nowhere to put
|
|
2086
|
+
// such a row back, and quietly deleting a user's script on a hunch is
|
|
2087
|
+
// worse than leaving an inert row.
|
|
2088
|
+
export async function reconcileScriptMembership() {
|
|
2089
|
+
await ensureCollectionKeysMigrated();
|
|
2090
|
+
const rows = await scanMembershipRows();
|
|
2091
|
+
// {script id → the collections currently listing it}. Built over
|
|
2092
|
+
// every collection row rather than a flat "is listed" set, so a row
|
|
2093
|
+
// listed by the WRONG collection is caught too.
|
|
2094
|
+
const listedBy = new Map();
|
|
2095
|
+
const known = new Set();
|
|
2096
|
+
for (const collection of rows.collections) {
|
|
2097
|
+
known.add(collection.id);
|
|
2098
|
+
for (const sid of collection.scriptIds ?? []) {
|
|
2099
|
+
const owners = listedBy.get(sid);
|
|
2100
|
+
if (owners)
|
|
2101
|
+
owners.push(collection.id);
|
|
2102
|
+
else
|
|
2103
|
+
listedBy.set(sid, [collection.id]);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
// Per collection: ids it should list but doesn't, and ids it lists
|
|
2107
|
+
// that name somewhere else.
|
|
2108
|
+
const toAdd = new Map();
|
|
2109
|
+
const toDrop = new Map();
|
|
2110
|
+
const push = (map, cid, sid) => {
|
|
2111
|
+
const ids = map.get(cid);
|
|
2112
|
+
if (ids)
|
|
2113
|
+
ids.push(sid);
|
|
2114
|
+
else
|
|
2115
|
+
map.set(cid, [sid]);
|
|
2116
|
+
};
|
|
2117
|
+
let collectionless = 0;
|
|
2118
|
+
for (const script of rows.scripts) {
|
|
2119
|
+
const owners = listedBy.get(script.id) ?? [];
|
|
2120
|
+
const cid = script.collectionId;
|
|
2121
|
+
if (cid === undefined || !known.has(cid)) {
|
|
2122
|
+
collectionless++;
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
if (!owners.includes(cid))
|
|
2126
|
+
push(toAdd, cid, script.id);
|
|
2127
|
+
for (const other of owners)
|
|
2128
|
+
if (other !== cid)
|
|
2129
|
+
push(toDrop, other, script.id);
|
|
2130
|
+
}
|
|
2131
|
+
if (collectionless > 0) {
|
|
2132
|
+
console.log(`[SourceMonkey] membership: ${collectionless} script row(s) name no `
|
|
2133
|
+
+ 'existing collection; left in place');
|
|
2134
|
+
}
|
|
2135
|
+
await pruneEnablementTimes(new Set(rows.scripts.map((sc) => sc.id)));
|
|
2136
|
+
if (toAdd.size === 0 && toDrop.size === 0)
|
|
2137
|
+
return 0;
|
|
2138
|
+
// Re-read inside the lock: the scan is stale by now, and a concurrent
|
|
2139
|
+
// refresh or move may already have adopted (or legitimately removed)
|
|
2140
|
+
// one of these rows. Read every row this pass touches in two batched
|
|
2141
|
+
// gets rather than one per id — the mutex is held throughout, and the
|
|
2142
|
+
// rest of the module keeps its critical sections short.
|
|
2143
|
+
return await mutate(async () => {
|
|
2144
|
+
const cids = new Set([...toAdd.keys(), ...toDrop.keys()]);
|
|
2145
|
+
const sids = new Set([...toAdd.values(), ...toDrop.values()].flat());
|
|
2146
|
+
const collections = await getMany([...cids].map(collectionKey));
|
|
2147
|
+
const scripts = await getRows([...sids].map(scriptKey));
|
|
2148
|
+
const ownerOf = (sid) => scripts[scriptKey(sid)]?.collectionId;
|
|
2149
|
+
const toSet = {};
|
|
2150
|
+
let moved = 0;
|
|
2151
|
+
for (const cid of cids) {
|
|
2152
|
+
const collection = collections[collectionKey(cid)];
|
|
2153
|
+
if (!collection)
|
|
2154
|
+
continue;
|
|
2155
|
+
const before = collection.scriptIds ?? [];
|
|
2156
|
+
const has = new Set(before);
|
|
2157
|
+
// Re-checked against the row as it stands now, so a move that
|
|
2158
|
+
// landed since the scan isn't undone by acting on stale reads.
|
|
2159
|
+
const add = (toAdd.get(cid) ?? []).filter((sid) => !has.has(sid) && ownerOf(sid) === cid);
|
|
2160
|
+
const drop = new Set((toDrop.get(cid) ?? []).filter((sid) => ownerOf(sid) !== cid));
|
|
2161
|
+
const after = [...before.filter((sid) => !drop.has(sid)), ...add];
|
|
2162
|
+
if (after.length === before.length && add.length === 0)
|
|
2163
|
+
continue;
|
|
2164
|
+
toSet[collectionKey(cid)] = { ...collection, scriptIds: after };
|
|
2165
|
+
moved += add.length + drop.size;
|
|
2166
|
+
}
|
|
2167
|
+
if (moved === 0)
|
|
2168
|
+
return 0;
|
|
2169
|
+
await writeLocal(toSet);
|
|
2170
|
+
console.log(`[SourceMonkey] membership: reconciled ${moved} script listing(s) across `
|
|
2171
|
+
+ `${Object.keys(toSet).length} collection(s)`);
|
|
2172
|
+
return moved;
|
|
2173
|
+
});
|
|
2174
|
+
}
|
|
2175
|
+
export async function refreshCollection(id, opts = {}) {
|
|
2176
|
+
const timer = startPhaseTimer('collection refresh');
|
|
2177
|
+
const prior = await getCollectionWithScripts(id);
|
|
2178
|
+
if (!prior)
|
|
2179
|
+
return null;
|
|
2180
|
+
const { collection, scripts: existingScripts } = prior;
|
|
2181
|
+
timer.mark('read');
|
|
2182
|
+
timer.note(collection.path);
|
|
2183
|
+
let scan = [];
|
|
2184
|
+
let scanError;
|
|
2185
|
+
let manifestStat = null;
|
|
2186
|
+
if (isContainerCollection(collection.path)) {
|
|
2187
|
+
// Container collections have no scan step — their child scripts are
|
|
2188
|
+
// explicit. Build a synthetic scan from the existing rows so the
|
|
2189
|
+
// rest of the pipeline can refetch each URL/path. A `file://` child
|
|
2190
|
+
// gets the same listing stat a directory scan would give it (one
|
|
2191
|
+
// listing per folder), so an unchanged one is skipped and a page
|
|
2192
|
+
// can tell an edited one; HTTP children skip via conditional GET.
|
|
2193
|
+
const listings = new Map();
|
|
2194
|
+
scan = await Promise.all(existingScripts.map(async (s) => {
|
|
2195
|
+
const entry = { relativePath: s.relativePath };
|
|
2196
|
+
if (isFileUrl(s.fileUrl)) {
|
|
2197
|
+
const stat = await statFileByListing(locationFsPath(s.fileUrl), listings);
|
|
2198
|
+
if (stat?.size !== undefined)
|
|
2199
|
+
entry.size = stat.size;
|
|
2200
|
+
if (stat?.mtime !== undefined)
|
|
2201
|
+
entry.mtime = stat.mtime;
|
|
2202
|
+
}
|
|
2203
|
+
return entry;
|
|
2204
|
+
}));
|
|
2205
|
+
}
|
|
2206
|
+
else {
|
|
2207
|
+
// A `file://` manifest's own stat, recorded beside the scan it
|
|
2208
|
+
// produced so the collections page can tell the manifest was
|
|
2209
|
+
// edited since. Taken BEFORE the read: an edit landing between the
|
|
2210
|
+
// two then shows as a change rather than being silently folded
|
|
2211
|
+
// into this refresh. The listing is shared with the scan, whose
|
|
2212
|
+
// entries usually sit in the same folder.
|
|
2213
|
+
const listings = new Map();
|
|
2214
|
+
if (isManifestCollection(collection.path) && isFileUrl(collectionCanonicalUrl(collection.path))) {
|
|
2215
|
+
manifestStat = await statFileByListing(locationFsPath(collection.path), listings);
|
|
2216
|
+
}
|
|
2217
|
+
try {
|
|
2218
|
+
scan = await scanForUserScripts(collection.path, 0, '', listings);
|
|
2219
|
+
}
|
|
2220
|
+
catch (err) {
|
|
2221
|
+
scanError = describeError(err);
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
timer.mark('scan');
|
|
2225
|
+
const now = Date.now();
|
|
2226
|
+
// Scan failure (unreadable manifest, unreadable top-level directory,
|
|
2227
|
+
// bad URL shape) is treated as "we don't actually know what's there
|
|
2228
|
+
// right now" — keep every existing script row, its content, and its
|
|
2229
|
+
// enabled state untouched. The collection row records the scanError so
|
|
2230
|
+
// the collections page can surface it. Without this guard, the
|
|
2231
|
+
// rebuildCollection path below would drop every script — silently
|
|
2232
|
+
// taking the scripts away. That's the wrong call when the collection might
|
|
2233
|
+
// just be a remote mount with timed-out credentials, a flaky network,
|
|
2234
|
+
// etc.; the user wants the scripts to stay enabled until the collection
|
|
2235
|
+
// is actually proven gone.
|
|
2236
|
+
if (scanError) {
|
|
2237
|
+
return await mutate(async () => {
|
|
2238
|
+
// Re-read inside the lock so a concurrent setCollectionEnabled
|
|
2239
|
+
// (or displayName backfill) that landed during the scan isn't
|
|
2240
|
+
// reverted — we only own lastRefreshedAt + scanError here.
|
|
2241
|
+
const curCollection = await getRaw(collectionKey(id)) ?? collection;
|
|
2242
|
+
const nextCollection = {
|
|
2243
|
+
...curCollection,
|
|
2244
|
+
lastRefreshedAt: now,
|
|
2245
|
+
scanError,
|
|
2246
|
+
};
|
|
2247
|
+
await writeLocal({ [collectionKey(id)]: nextCollection });
|
|
2248
|
+
return { collection: nextCollection, scripts: existingScripts };
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
// Fetch every scanned file — with bounded concurrency — except those
|
|
2252
|
+
// whose size+mtime are already current.
|
|
2253
|
+
const fetchResults = await fetchOrSkipInParallel(collection.path, scan, existingScripts);
|
|
2254
|
+
// Marked here so the breakdown says whether the time went to reading
|
|
2255
|
+
// files or to everything downstream of having read them (dependency
|
|
2256
|
+
// fetches, hashing, the parse check).
|
|
2257
|
+
timer.mark('fetch');
|
|
2258
|
+
const scripts = await rebuildCollection({
|
|
2259
|
+
collectionId: collection.id,
|
|
2260
|
+
collectionPath: collection.path,
|
|
2261
|
+
existing: existingScripts,
|
|
2262
|
+
scan,
|
|
2263
|
+
fetchResults,
|
|
2264
|
+
now,
|
|
2265
|
+
recheckFiles: opts.recheckFiles,
|
|
2266
|
+
fileFetchCache: opts.fileFetchCache,
|
|
2267
|
+
localFiles: localFileScope(collection),
|
|
2268
|
+
});
|
|
2269
|
+
timer.mark('rebuild');
|
|
2270
|
+
// Thunked: counting the skips walks every fetch result, which a
|
|
2271
|
+
// disabled timer must not pay for.
|
|
2272
|
+
timer.note(() => {
|
|
2273
|
+
const skipped = [...fetchResults.values()].filter((r) => 'unchanged' in r).length;
|
|
2274
|
+
return `${scripts.length} scripts, ${skipped} unchanged`;
|
|
2275
|
+
});
|
|
2276
|
+
// Re-read the current rows INSIDE the lock and write atomically, so a
|
|
2277
|
+
// concurrent enable toggle (setCollectionEnabled / setScriptEnabled)
|
|
2278
|
+
// that landed during the fetch isn't reverted by stale values
|
|
2279
|
+
// captured before it. Content (and any deliberate refresh-owned
|
|
2280
|
+
// `enabled` change) is kept; we only re-apply the user's toggle when the
|
|
2281
|
+
// persisted `enabled` actually changed vs the snapshot this refresh
|
|
2282
|
+
// started from — that's the signal a setScriptEnabled landed
|
|
2283
|
+
// mid-fetch.
|
|
2284
|
+
const priorById = new Map(existingScripts.map((s) => [s.id, s]));
|
|
2285
|
+
// The write is the last thing that can fail, and it is the one failure
|
|
2286
|
+
// `chrome.storage.local` can't record — an exhausted quota rejects the
|
|
2287
|
+
// error row for the same reason it rejected the refresh. The reason
|
|
2288
|
+
// goes to `chrome.storage.session` instead, where the collections page
|
|
2289
|
+
// reads it. Every refresh path funnels through here, including the one
|
|
2290
|
+
// `addCollection` runs directly, so this is the only place that needs
|
|
2291
|
+
// to know. See src/extension/refresh-error.ts.
|
|
2292
|
+
// Copies elsewhere that a new copy might meet (`settleNewCopies`):
|
|
2293
|
+
// found with a full scan here, before the lock, so the write's window
|
|
2294
|
+
// stays one round-trip. Inside the lock only these rows are re-read.
|
|
2295
|
+
const scannedNew = scripts.filter((s) => !priorById.has(s.id));
|
|
2296
|
+
const rivalIds = scannedNew.length > 0 ? await possibleRivals(id, scannedNew) : [];
|
|
2297
|
+
try {
|
|
2298
|
+
const written = await writeRefreshedCollection();
|
|
2299
|
+
timer.mark('write');
|
|
2300
|
+
timer.done();
|
|
2301
|
+
// Clears on the way out, so a collection that recovers stops
|
|
2302
|
+
// reporting a failure the next refresh already fixed.
|
|
2303
|
+
await setRefreshError(id, null);
|
|
2304
|
+
if (!written)
|
|
2305
|
+
return null;
|
|
2306
|
+
// After the write, not inside it — same rule `removeScript` follows:
|
|
2307
|
+
// the storage.local write is the one that has to be atomic, and a
|
|
2308
|
+
// log we failed to clear must not undo a refresh that landed.
|
|
2309
|
+
await forgetScriptLogs(written.droppedIds);
|
|
2310
|
+
return { collection: written.collection, scripts: written.scripts };
|
|
2311
|
+
}
|
|
2312
|
+
catch (err) {
|
|
2313
|
+
await setRefreshError(id, describeError(err));
|
|
2314
|
+
throw err;
|
|
2315
|
+
}
|
|
2316
|
+
// Reports `droppedIds` alongside the written state so the caller can
|
|
2317
|
+
// clear those scripts' logs outside the lock.
|
|
2318
|
+
async function writeRefreshedCollection() {
|
|
2319
|
+
return await mutate(async () => {
|
|
2320
|
+
const curCollection = await getRaw(collectionKey(id));
|
|
2321
|
+
if (!curCollection) {
|
|
2322
|
+
// Collection was removed during the refresh — abandon the write
|
|
2323
|
+
// rather than resurrect a deleted collection row.
|
|
2324
|
+
return null;
|
|
2325
|
+
}
|
|
2326
|
+
// Without their bodies: see `changedScriptRows`.
|
|
2327
|
+
const curScripts = await getRows(scripts.map((s) => scriptKey(s.id)));
|
|
2328
|
+
const priorIds = new Set(existingScripts.map((s) => s.id));
|
|
2329
|
+
// Membership moves under us in both directions, and the freshly
|
|
2330
|
+
// re-read row is the authority on both. `scripts` was rebuilt
|
|
2331
|
+
// from a snapshot taken before the fetch (for a container, that
|
|
2332
|
+
// snapshot IS the synthetic scan), so it describes a membership
|
|
2333
|
+
// that may be minutes stale.
|
|
2334
|
+
//
|
|
2335
|
+
// LEFT during the fetch — the row is gone (`removeScript`) or now
|
|
2336
|
+
// names another collection (`moveScript`). Writing the rebuilt
|
|
2337
|
+
// row back would resurrect a deleted script, or revert the move
|
|
2338
|
+
// AND leave the destination still listing it, so the same script
|
|
2339
|
+
// shows under two collections. `preserveConcurrentEdits` can't
|
|
2340
|
+
// rescue this: it re-applies fields onto the rebuilt row, and
|
|
2341
|
+
// what's wrong here is that the row belongs to someone else now.
|
|
2342
|
+
const departed = new Set();
|
|
2343
|
+
for (const s of scripts) {
|
|
2344
|
+
const cur = curScripts[scriptKey(s.id)];
|
|
2345
|
+
// A row the scan found for the first time has no stored
|
|
2346
|
+
// counterpart yet — that's a new script, not a departure.
|
|
2347
|
+
if (!cur) {
|
|
2348
|
+
if (priorIds.has(s.id))
|
|
2349
|
+
departed.add(s.id);
|
|
2350
|
+
}
|
|
2351
|
+
else if (cur.collectionId !== id) {
|
|
2352
|
+
departed.add(s.id);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
const kept = scripts.filter((s) => !departed.has(s.id));
|
|
2356
|
+
const finalScripts = kept.map((s) => preserveConcurrentEdits(s, priorById.get(s.id), curScripts[scriptKey(s.id)]));
|
|
2357
|
+
// JOINED during the fetch — an `installScript` / `moveScript`
|
|
2358
|
+
// into this collection that the snapshot never saw. Writing the
|
|
2359
|
+
// rebuilt list verbatim would erase its id while leaving the
|
|
2360
|
+
// `script:` row in storage — unlisted: still pointing here via
|
|
2361
|
+
// `collectionId`, still injected, but shown by no collection and
|
|
2362
|
+
// never refreshed again.
|
|
2363
|
+
const rebuiltIds = finalScripts.map((s) => s.id);
|
|
2364
|
+
const rebuilt = new Set(rebuiltIds);
|
|
2365
|
+
const joinedDuringRefresh = curCollection.scriptIds.filter((sid) => !rebuilt.has(sid) && !priorIds.has(sid) && !departed.has(sid));
|
|
2366
|
+
const nextCollection = {
|
|
2367
|
+
...curCollection,
|
|
2368
|
+
lastRefreshedAt: now,
|
|
2369
|
+
scriptIds: [...rebuiltIds, ...joinedDuringRefresh],
|
|
2370
|
+
};
|
|
2371
|
+
// Clear any scanError left by a previous failed refresh — control
|
|
2372
|
+
// only reaches here when the scan succeeded (the early-return above
|
|
2373
|
+
// handles the failure case).
|
|
2374
|
+
delete nextCollection.scanError;
|
|
2375
|
+
// Both or neither, like a script row's size + fsMtime: a lone
|
|
2376
|
+
// value can't be compared against a listing.
|
|
2377
|
+
if (manifestStat?.size !== undefined && manifestStat.mtime !== undefined) {
|
|
2378
|
+
nextCollection.manifestSize = manifestStat.size;
|
|
2379
|
+
nextCollection.manifestMtime = manifestStat.mtime;
|
|
2380
|
+
}
|
|
2381
|
+
else {
|
|
2382
|
+
delete nextCollection.manifestSize;
|
|
2383
|
+
delete nextCollection.manifestMtime;
|
|
2384
|
+
}
|
|
2385
|
+
// Drop rows whose IDs are no longer referenced — an entry that
|
|
2386
|
+
// fell off the listing, or a manifest entry the user removed. Of
|
|
2387
|
+
// the ids the snapshot had, since a row that joined mid-refresh
|
|
2388
|
+
// was never reconciled against the scan; and never a departed
|
|
2389
|
+
// one, whose row is either already deleted or now owned by the
|
|
2390
|
+
// collection it moved to.
|
|
2391
|
+
const droppedIds = [...priorIds]
|
|
2392
|
+
.filter((sid) => !rebuilt.has(sid) && !departed.has(sid));
|
|
2393
|
+
// The collection row always goes: `lastRefreshedAt` moved, and
|
|
2394
|
+
// that's the record of the check having happened at all. The
|
|
2395
|
+
// script rows only go if they actually changed — see
|
|
2396
|
+
// `changedScriptRows`.
|
|
2397
|
+
const toSet = { [collectionKey(id)]: nextCollection };
|
|
2398
|
+
// Scripts the scan found for the first time: one active copy per
|
|
2399
|
+
// script, and their active times. Also drops the times of rows
|
|
2400
|
+
// dropped below.
|
|
2401
|
+
const newIds = new Set(finalScripts
|
|
2402
|
+
.filter((s) => !priorIds.has(s.id) && !curScripts[scriptKey(s.id)])
|
|
2403
|
+
.map((s) => s.id));
|
|
2404
|
+
if (newIds.size > 0 || droppedIds.length > 0) {
|
|
2405
|
+
Object.assign(toSet, await settleNewCopies(nextCollection, finalScripts, newIds, droppedIds, rivalIds, opts.adding === true));
|
|
2406
|
+
}
|
|
2407
|
+
const changed = await changedScriptRows(finalScripts, curScripts, (sid) => priorById.get(sid));
|
|
2408
|
+
Object.assign(toSet, changed.items);
|
|
2409
|
+
timer.note(`rows written: ${changed.count}`);
|
|
2410
|
+
// Placed where a real quota rejection lands — after everything was
|
|
2411
|
+
// fetched and reconciled, with nothing written — so the test
|
|
2412
|
+
// exercises the same state the production failure produces.
|
|
2413
|
+
if (collectionWriteFailure !== null) {
|
|
2414
|
+
throw translateStorageError(new Error(collectionWriteFailure));
|
|
2415
|
+
}
|
|
2416
|
+
await writeLocal(toSet);
|
|
2417
|
+
if (droppedIds.length > 0) {
|
|
2418
|
+
await removeLocal([...droppedIds.map(scriptKey), ...droppedIds.map(connectGrantsKey)]);
|
|
2419
|
+
}
|
|
2420
|
+
return { collection: nextCollection, scripts: finalScripts, droppedIds };
|
|
2421
|
+
});
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
// The ids of stored rows in other collections that are copies of one of
|
|
2425
|
+
// `newRows` (`sameScript`). One full scan, run before the refresh's lock.
|
|
2426
|
+
async function possibleRivals(collectionId, newRows) {
|
|
2427
|
+
const all = await scanStorageRows('new copies', (k) => k.startsWith(SCRIPT_PREFIX));
|
|
2428
|
+
const out = [];
|
|
2429
|
+
for (const value of Object.values(all)) {
|
|
2430
|
+
const row = value;
|
|
2431
|
+
if (row.collectionId !== collectionId && newRows.some((n) => sameScript(n, row)))
|
|
2432
|
+
out.push(row.id);
|
|
2433
|
+
}
|
|
2434
|
+
return out;
|
|
2435
|
+
}
|
|
2436
|
+
// What a refresh does about copies it finds for the first time, inside
|
|
2437
|
+
// its write lock (docs/script-enablement.md#new-copies). Edits the new
|
|
2438
|
+
// rows in `finalScripts` in place, and returns the other writes: rows
|
|
2439
|
+
// it turned off elsewhere, and the active-times map.
|
|
2440
|
+
//
|
|
2441
|
+
// - Two new copies of one script: only the first by
|
|
2442
|
+
// `compareInCollection` (highest `@version`, then location) stays
|
|
2443
|
+
// ticked.
|
|
2444
|
+
// - `adding` (the collection was just added): it counts as turning
|
|
2445
|
+
// the collection on, so each new active copy turns off the other
|
|
2446
|
+
// active copies of its script elsewhere.
|
|
2447
|
+
// - Otherwise (a later refresh found a new file): a new copy whose
|
|
2448
|
+
// script already has an active copy lands unticked, and the active
|
|
2449
|
+
// one stays active.
|
|
2450
|
+
//
|
|
2451
|
+
// `rivalIds` are the rows elsewhere that might be copies
|
|
2452
|
+
// (`possibleRivals`); only they are re-read here. An sm-dev run's
|
|
2453
|
+
// collection is left out both ways, as everywhere.
|
|
2454
|
+
async function settleNewCopies(collection, finalScripts, newIds, droppedIds, rivalIds, adding) {
|
|
2455
|
+
const out = {};
|
|
2456
|
+
const got = await getRows([...rivalIds.map(scriptKey), ENABLEMENT_TIMES_KEY]);
|
|
2457
|
+
const times = { ...got[ENABLEMENT_TIMES_KEY] };
|
|
2458
|
+
let timesChanged = false;
|
|
2459
|
+
for (const sid of droppedIds) {
|
|
2460
|
+
if (times[sid]) {
|
|
2461
|
+
delete times[sid];
|
|
2462
|
+
timesChanged = true;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
// Re-read inside the lock: a rival moved into this collection since
|
|
2466
|
+
// the scan is one of `finalScripts` now, or not listed at all.
|
|
2467
|
+
const others = rivalIds
|
|
2468
|
+
.map((sid) => got[scriptKey(sid)])
|
|
2469
|
+
.filter((r) => r !== undefined && r.collectionId !== collection.id);
|
|
2470
|
+
const collIds = [...new Set(others.map((r) => r.collectionId))];
|
|
2471
|
+
const gotColls = await getMany(collIds.map(collectionKey));
|
|
2472
|
+
const collections = new Map([[collection.id, collection]]);
|
|
2473
|
+
for (const cid of collIds) {
|
|
2474
|
+
const c = gotColls[collectionKey(cid)];
|
|
2475
|
+
if (c)
|
|
2476
|
+
collections.set(cid, c);
|
|
2477
|
+
}
|
|
2478
|
+
const counts = (c) => !c || !isSmDevCollection(c.path);
|
|
2479
|
+
const isActive = (row) => {
|
|
2480
|
+
const c = collections.get(row.collectionId);
|
|
2481
|
+
return isScriptEnabled(row) && (c ? isCollectionEnabled(c) : true);
|
|
2482
|
+
};
|
|
2483
|
+
const newRows = finalScripts.filter((s) => newIds.has(s.id));
|
|
2484
|
+
// Rows of this collection that were already there count as "already
|
|
2485
|
+
// active" copies too, as their refreshed rows say.
|
|
2486
|
+
const existing = finalScripts.filter((s) => !newIds.has(s.id));
|
|
2487
|
+
const at = nextEnablementStamp();
|
|
2488
|
+
if (counts(collection)) {
|
|
2489
|
+
// One ticked copy among the new ones of each script.
|
|
2490
|
+
const facts = (row) => ({
|
|
2491
|
+
enabled: true, collectionOn: true, collectionName: '',
|
|
2492
|
+
version: (row.header ? getValue(row.header, 'version')?.trim() : '') || null,
|
|
2493
|
+
location: row.fileUrl,
|
|
2494
|
+
});
|
|
2495
|
+
for (const row of newRows) {
|
|
2496
|
+
if (!isScriptEnabled(row))
|
|
2497
|
+
continue;
|
|
2498
|
+
const group = newRows.filter((o) => isScriptEnabled(o) && sameScript(o, row));
|
|
2499
|
+
if (preferredCopy(group, facts, compareInCollection) !== row)
|
|
2500
|
+
row.enabled = false;
|
|
2501
|
+
}
|
|
2502
|
+
for (const row of newRows) {
|
|
2503
|
+
if (!isScriptEnabled(row))
|
|
2504
|
+
continue;
|
|
2505
|
+
const rivals = [...others, ...existing].filter((o) => isActive(o) && counts(collections.get(o.collectionId)) && sameScript(o, row));
|
|
2506
|
+
if (rivals.length === 0)
|
|
2507
|
+
continue;
|
|
2508
|
+
if (!adding) {
|
|
2509
|
+
row.enabled = false;
|
|
2510
|
+
continue;
|
|
2511
|
+
}
|
|
2512
|
+
// Only a copy that is itself active displaces: one under a
|
|
2513
|
+
// collection turned off mid-add doesn't.
|
|
2514
|
+
if (!isActive(row))
|
|
2515
|
+
continue;
|
|
2516
|
+
for (const o of rivals) {
|
|
2517
|
+
if (o.collectionId === collection.id)
|
|
2518
|
+
continue;
|
|
2519
|
+
out[scriptKey(o.id)] = { ...o, enabled: false };
|
|
2520
|
+
if (stampTimes(times, o.id, true, false, at))
|
|
2521
|
+
timesChanged = true;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
for (const row of newRows) {
|
|
2526
|
+
if (isActive(row) && stampTimes(times, row.id, false, true, at))
|
|
2527
|
+
timesChanged = true;
|
|
2528
|
+
}
|
|
2529
|
+
if (timesChanged)
|
|
2530
|
+
out[ENABLEMENT_TIMES_KEY] = times;
|
|
2531
|
+
return out;
|
|
2532
|
+
}
|
|
2533
|
+
export function createRefreshBatch(localLookup = 'search') {
|
|
2534
|
+
const listings = new Map();
|
|
2535
|
+
const fileFetchCache = new Map();
|
|
2536
|
+
bindListingCache(fileFetchCache, listings);
|
|
2537
|
+
return { collectionRows: new Map(), listings, fileFetchCache, localLookup };
|
|
2538
|
+
}
|
|
2539
|
+
// How many collection rows `refreshScript` has actually read from
|
|
2540
|
+
// storage since the last reset — batch hits excluded. The other half of
|
|
2541
|
+
// what a `RefreshBatch` shares, and the phase (`read-collection`) that
|
|
2542
|
+
// motivated it, so it gets the same kind of counter the listings do:
|
|
2543
|
+
// without one, a change that stopped threading the batch into the row
|
|
2544
|
+
// read would leave every test green. Same scoping caveats as
|
|
2545
|
+
// `getDirectoryListingFetches` — per JS context, reset around the window
|
|
2546
|
+
// under test.
|
|
2547
|
+
let collectionRowReads = 0;
|
|
2548
|
+
export function getCollectionRowReads() {
|
|
2549
|
+
return collectionRowReads;
|
|
2550
|
+
}
|
|
2551
|
+
export function resetCollectionRowReads() {
|
|
2552
|
+
collectionRowReads = 0;
|
|
2553
|
+
}
|
|
2554
|
+
// The batch's copy of a collection row, or a fresh read when there is no
|
|
2555
|
+
// batch. Returns whether it was shared so the caller's phase timer can
|
|
2556
|
+
// say so rather than just reporting a suspiciously fast phase.
|
|
2557
|
+
async function batchCollectionRow(batch, collectionId) {
|
|
2558
|
+
const hit = batch?.collectionRows.get(collectionId);
|
|
2559
|
+
if (hit)
|
|
2560
|
+
return { row: await hit, shared: true, ownReadMs: 0 };
|
|
2561
|
+
collectionRowReads += 1;
|
|
2562
|
+
const started = timingNow();
|
|
2563
|
+
const finish = async (p) => ({ row: await p, shared: false, ownReadMs: timingNow() - started });
|
|
2564
|
+
if (!batch)
|
|
2565
|
+
return finish(getRaw(collectionKey(collectionId)));
|
|
2566
|
+
const pending = getRaw(collectionKey(collectionId));
|
|
2567
|
+
batch.collectionRows.set(collectionId, pending);
|
|
2568
|
+
// A failed read must not be remembered. A cached rejection is rethrown
|
|
2569
|
+
// into every later sibling, so each one throws out of `refreshScript`
|
|
2570
|
+
// and `runScriptCheck` writes it a `Could not check for updates` Log
|
|
2571
|
+
// row — one transient storage failure reported once per script on the
|
|
2572
|
+
// page, with nothing ever retrying it. Dropping the entry restores the
|
|
2573
|
+
// pre-batch blast radius: the read that failed fails, its siblings try
|
|
2574
|
+
// again. The identity check keeps a late rejection from evicting a
|
|
2575
|
+
// newer entry that has already replaced it.
|
|
2576
|
+
void pending.catch(() => {
|
|
2577
|
+
if (batch.collectionRows.get(collectionId) === pending) {
|
|
2578
|
+
batch.collectionRows.delete(collectionId);
|
|
2579
|
+
}
|
|
2580
|
+
});
|
|
2581
|
+
return finish(pending);
|
|
2582
|
+
}
|
|
2583
|
+
// Refetch a single script. Goes through the same per-file pipeline as
|
|
2584
|
+
// the directory-level Refresh (fetchOrSkipInParallel →
|
|
2585
|
+
// rebuildCollection) so the two paths can't drift — same skip rules,
|
|
2586
|
+
// same fsMtime handling, same enabled-preservation, same error shape.
|
|
2587
|
+
// The single-file path additionally gets the size+mtime the skip check
|
|
2588
|
+
// needs: by listing the file's parent dir, or, on a page-load check
|
|
2589
|
+
// (`localLookup: 'known'`), from a direct read of the file, listing only
|
|
2590
|
+
// if that read fails.
|
|
2591
|
+
//
|
|
2592
|
+
// A local-directory script whose file the parent listing no longer
|
|
2593
|
+
// shows, and whose read then fails, is dropped here, the same as the
|
|
2594
|
+
// directory-level Refresh would drop it: the listing is the directory
|
|
2595
|
+
// saying what it holds now, and this path lists it for the size+mtime
|
|
2596
|
+
// skip (a page-load check only once its direct read has failed). Only a
|
|
2597
|
+
// listing that was read — a failed listing leaves the scan bare and the
|
|
2598
|
+
// row alone — and only in a directory
|
|
2599
|
+
// collection: a manifest entry or a single file is listed for the
|
|
2600
|
+
// skip too, but its row is the manifest's or the user's to drop, so a
|
|
2601
|
+
// vanished one fails its fetch and keeps running from the cached body
|
|
2602
|
+
// with a `refreshError`. The read is required too because this path
|
|
2603
|
+
// runs on every navigation and tab focus, so it can catch a file in
|
|
2604
|
+
// the gap of an unlink-and-rename save; one that reads fine is not
|
|
2605
|
+
// gone.
|
|
2606
|
+
//
|
|
2607
|
+
// Timed, because this is what the reload fast-path races against a
|
|
2608
|
+
// 500 ms window (docs/injection.md#reload-fast-path) and "it didn't
|
|
2609
|
+
// make it" was previously unattributable. The phases mirror the
|
|
2610
|
+
// collection-refresh timer's so the two lines read the same way.
|
|
2611
|
+
export async function refreshScript(scriptId, opts = {}) {
|
|
2612
|
+
const timer = startPhaseTimer('script check');
|
|
2613
|
+
// The script's row (from the worker's row cache, so usually no
|
|
2614
|
+
// storage call) and its collection's row, necessarily in series: the
|
|
2615
|
+
// collection key comes out of the script row. The script's stored
|
|
2616
|
+
// code is read separately, alongside the file reads below
|
|
2617
|
+
// (`bodies` in the `gets:` note).
|
|
2618
|
+
//
|
|
2619
|
+
// History: these were two storage reads, measured at ~150-190ms
|
|
2620
|
+
// combined on the reload fast path — by far its largest fixed cost,
|
|
2621
|
+
// and paid by unchanged scripts too — so they are marked separately.
|
|
2622
|
+
// The script row then carried the whole source; the collection row is
|
|
2623
|
+
// a few fields, and it used to read SLOWER — which was never about
|
|
2624
|
+
// either row. A
|
|
2625
|
+
// full-namespace storage scan elsewhere in the worker was holding
|
|
2626
|
+
// the only thread, so the read's callback couldn't run. Neither
|
|
2627
|
+
// payload nor the batch's duplicate reads explained it; both were
|
|
2628
|
+
// guessed first and both were wrong.
|
|
2629
|
+
//
|
|
2630
|
+
// The `gets:` note is what settled it, and is why it stays: it
|
|
2631
|
+
// measures the read itself, next to a phase that measures the read
|
|
2632
|
+
// PLUS whoever else owns the thread. When those two disagree, the
|
|
2633
|
+
// phase is not describing this code. See
|
|
2634
|
+
// docs/diagnostics.md#read-collection-is-still-slow.
|
|
2635
|
+
//
|
|
2636
|
+
// "The read itself" is really `getRaw`, which gates on
|
|
2637
|
+
// `ensureCollectionKeysMigrated`. Memoised, so free after the first
|
|
2638
|
+
// call in a worker generation — but the first check after a wake
|
|
2639
|
+
// attributes the migration probe to `gets: script`, and on an
|
|
2640
|
+
// unmigrated profile the whole migration scan with it.
|
|
2641
|
+
const scriptReadStarted = timingNow();
|
|
2642
|
+
const meta = await getScriptMeta(scriptId);
|
|
2643
|
+
const scriptReadMs = timingNow() - scriptReadStarted;
|
|
2644
|
+
if (!meta)
|
|
2645
|
+
return null;
|
|
2646
|
+
timer.mark('read-script');
|
|
2647
|
+
// The bodies (one storage call for the script's blobs), in flight
|
|
2648
|
+
// while the collection row and the files are read. Nothing before
|
|
2649
|
+
// `read-bodies` below needs them.
|
|
2650
|
+
let bodiesReadMs = 0;
|
|
2651
|
+
const priorRead = (async () => {
|
|
2652
|
+
const started = timingNow();
|
|
2653
|
+
try {
|
|
2654
|
+
return await getScript(scriptId);
|
|
2655
|
+
}
|
|
2656
|
+
finally {
|
|
2657
|
+
bodiesReadMs = timingNow() - started;
|
|
2658
|
+
}
|
|
2659
|
+
})();
|
|
2660
|
+
// Settled below in every path; this keeps an early return from
|
|
2661
|
+
// leaving a rejection unhandled.
|
|
2662
|
+
priorRead.catch(() => { });
|
|
2663
|
+
// Shared across the batch when there is one: the scripts a page
|
|
2664
|
+
// matches are usually all in the same collection, so this row is read
|
|
2665
|
+
// once for the group instead of once per script.
|
|
2666
|
+
const collection = await batchCollectionRow(opts.batch, meta.collectionId);
|
|
2667
|
+
const collectionRow = collection.row;
|
|
2668
|
+
if (!collectionRow)
|
|
2669
|
+
return null;
|
|
2670
|
+
timer.mark('read-collection');
|
|
2671
|
+
if (collection.shared)
|
|
2672
|
+
timer.note('collection row shared with batch');
|
|
2673
|
+
else
|
|
2674
|
+
timer.note(() => `gets: script ${formatMs(scriptReadMs)}, collection ${formatMs(collection.ownReadMs)}`);
|
|
2675
|
+
timer.note(meta.relativePath);
|
|
2676
|
+
timer.note(() => `${Math.round((meta.size ?? 0) / 1024)} KB row`);
|
|
2677
|
+
// Build a single-entry scan. Any `file://` script gets the size+mtime
|
|
2678
|
+
// its collection's refresh would give it, from a listing of its
|
|
2679
|
+
// folder or a direct read; HTTP entries rely on the conditional GET
|
|
2680
|
+
// inside fetchOrSkipInParallel.
|
|
2681
|
+
const scan = [{ relativePath: meta.relativePath }];
|
|
2682
|
+
const fileUrl = scriptFileUrl(collectionRow.path, meta.relativePath);
|
|
2683
|
+
const usesListingStat = isFileUrl(fileUrl);
|
|
2684
|
+
const knownPaths = opts.batch?.localLookup === 'known';
|
|
2685
|
+
// The parent listing was read and does not show the file.
|
|
2686
|
+
let unlisted = false;
|
|
2687
|
+
const localFiles = {
|
|
2688
|
+
...localFileScope(collectionRow),
|
|
2689
|
+
...(knownPaths ? { lookup: 'known' } : {}),
|
|
2690
|
+
};
|
|
2691
|
+
if (knownPaths && opts.batch && usesListingStat && localFiles.enabled) {
|
|
2692
|
+
// Read in parallel with the script file below, rather than after
|
|
2693
|
+
// it: which files to load comes from the script's header, but the
|
|
2694
|
+
// stand-ins the last check found are the likely answer. Only a
|
|
2695
|
+
// `file://` script with local files on can have stand-ins; a
|
|
2696
|
+
// scratch script's `localUrl`s name files sm-dev pushed, which are
|
|
2697
|
+
// read from storage instead.
|
|
2698
|
+
prefetchKnownStandIns(opts.fileFetchCache ?? opts.batch.fileFetchCache, meta);
|
|
2699
|
+
}
|
|
2700
|
+
// A page-load check reads the file itself rather than listing its
|
|
2701
|
+
// folder: the read reports the same size and modified time, and the
|
|
2702
|
+
// body comes with it. Only a read that fails lists the folder, below,
|
|
2703
|
+
// to tell a deleted file from one that wouldn't read.
|
|
2704
|
+
if (usesListingStat && knownPaths) {
|
|
2705
|
+
try {
|
|
2706
|
+
const read = await fetchScriptFileWithStat(fileUrl);
|
|
2707
|
+
scan[0].size = read.size;
|
|
2708
|
+
if (read.fsMtime !== undefined)
|
|
2709
|
+
scan[0].mtime = read.fsMtime;
|
|
2710
|
+
scan[0].text = read.text;
|
|
2711
|
+
timer.note('read directly');
|
|
2712
|
+
}
|
|
2713
|
+
catch {
|
|
2714
|
+
timer.note('direct read failed; listed the folder');
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
if (usesListingStat && scan[0].text === undefined) {
|
|
2718
|
+
const fsPath = locationFsPath(fileUrl);
|
|
2719
|
+
const dirAbs = parentDir(fsPath);
|
|
2720
|
+
// Read BEFORE the call, which is what populates the cache.
|
|
2721
|
+
const sharedListing = hasCachedListing(opts.batch?.listings, dirAbs);
|
|
2722
|
+
let stat;
|
|
2723
|
+
try {
|
|
2724
|
+
stat = await statFromListing(dirAbs, baseName(fsPath), opts.batch?.listings);
|
|
2725
|
+
}
|
|
2726
|
+
catch {
|
|
2727
|
+
/* listing failed; scan stays bare → always-fetch */
|
|
2728
|
+
}
|
|
2729
|
+
if (sharedListing)
|
|
2730
|
+
timer.note('listing shared with batch');
|
|
2731
|
+
// Listed, and not there. Not dropped yet: the fetch below runs
|
|
2732
|
+
// anyway (a bare scan means always-fetch), and a file that is back
|
|
2733
|
+
// by the time its body is read is a file mid-save — an editor that
|
|
2734
|
+
// writes by unlink + rename — not a deleted one. Only a listing
|
|
2735
|
+
// that lacked it AND a read that failed drops the row.
|
|
2736
|
+
unlisted = stat === null;
|
|
2737
|
+
if (stat) {
|
|
2738
|
+
if (stat.size !== undefined)
|
|
2739
|
+
scan[0].size = stat.size;
|
|
2740
|
+
if (stat.mtime !== undefined)
|
|
2741
|
+
scan[0].mtime = stat.mtime;
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
// A direct read on a page-load check, else one `file://` directory
|
|
2745
|
+
// listing, shared across the batch when there is one — so N scripts
|
|
2746
|
+
// in the same folder list it once between them rather than N times.
|
|
2747
|
+
// Unbatched callers still list per script.
|
|
2748
|
+
timer.mark('stat');
|
|
2749
|
+
const prior = await priorRead;
|
|
2750
|
+
if (!prior)
|
|
2751
|
+
return null;
|
|
2752
|
+
// Moved to another file or collection between the two reads: the
|
|
2753
|
+
// file read above was of where it used to be. Rare (a move takes the
|
|
2754
|
+
// store's lock); start again from where it is now.
|
|
2755
|
+
if (prior.relativePath !== meta.relativePath || prior.collectionId !== meta.collectionId) {
|
|
2756
|
+
timer.note('moved during the check; checking again');
|
|
2757
|
+
timer.done();
|
|
2758
|
+
return await refreshScript(scriptId, opts);
|
|
2759
|
+
}
|
|
2760
|
+
// Usually 0: the bodies arrived while the file was read.
|
|
2761
|
+
timer.mark('read-bodies');
|
|
2762
|
+
timer.note(() => `bodies ${formatMs(bodiesReadMs)}`);
|
|
2763
|
+
const fetchResults = await fetchOrSkipInParallel(collectionRow.path, scan, [prior]);
|
|
2764
|
+
// Cheap when size+mtime match the stored row (no body read at all),
|
|
2765
|
+
// expensive when they don't. The `skipped` note is what separates
|
|
2766
|
+
// "the file really changed" from "we re-read it for no reason".
|
|
2767
|
+
timer.mark('fetch');
|
|
2768
|
+
timer.note(() => {
|
|
2769
|
+
const one = [...fetchResults.values()][0];
|
|
2770
|
+
return one && 'unchanged' in one ? 'unchanged' : 'refetched';
|
|
2771
|
+
});
|
|
2772
|
+
const fetched = fetchResults.get(prior.relativePath);
|
|
2773
|
+
// Only a directory collection mirrors the disk. A manifest entry
|
|
2774
|
+
// whose file is gone is still the manifest's to keep or drop, so it
|
|
2775
|
+
// stays — running from its cached body with a `refreshError`.
|
|
2776
|
+
if (unlisted && isLocalDirectoryCollection(collectionRow.path)
|
|
2777
|
+
&& fetched && 'ok' in fetched && !fetched.ok) {
|
|
2778
|
+
// The directory doesn't list it and it can't be read: the file is
|
|
2779
|
+
// gone. Dropped the way the collection-level refresh drops it
|
|
2780
|
+
// (row, storage key, logs) — see the header comment. Callers
|
|
2781
|
+
// already handle a null result as "the row went away during the
|
|
2782
|
+
// check".
|
|
2783
|
+
console.info(`[SourceMonkey] ${prior.relativePath}: file no longer in its directory; dropping the script`);
|
|
2784
|
+
timer.note('file not in listing and unreadable; dropping');
|
|
2785
|
+
await removeScript(scriptId);
|
|
2786
|
+
timer.mark('drop');
|
|
2787
|
+
timer.done();
|
|
2788
|
+
return null;
|
|
2789
|
+
}
|
|
2790
|
+
const result = await rebuildCollection({
|
|
2791
|
+
collectionId: prior.collectionId,
|
|
2792
|
+
collectionPath: collectionRow.path,
|
|
2793
|
+
existing: [prior],
|
|
2794
|
+
scan,
|
|
2795
|
+
fetchResults,
|
|
2796
|
+
now: Date.now(),
|
|
2797
|
+
recheckFiles: opts.recheckFiles,
|
|
2798
|
+
// The batch's, when there is one — so two scripts on the same page
|
|
2799
|
+
// `@require`ing one library fetch it once between them. The `??`
|
|
2800
|
+
// only reflects that `fileFetchCache` is part of the inherited
|
|
2801
|
+
// `RefreshOptions`; no caller of `refreshScript` sets it today.
|
|
2802
|
+
fileFetchCache: opts.fileFetchCache ?? opts.batch?.fileFetchCache,
|
|
2803
|
+
localFiles,
|
|
2804
|
+
parseCheck: opts.parseCheck,
|
|
2805
|
+
});
|
|
2806
|
+
const next = result[0];
|
|
2807
|
+
if (!next)
|
|
2808
|
+
return null;
|
|
2809
|
+
// Covers the header parse, the `@require`/`@resource` pass (which
|
|
2810
|
+
// reaches the network in `recheckFiles: 'all'` mode), the source
|
|
2811
|
+
// hash, and the parse check.
|
|
2812
|
+
timer.mark('rebuild');
|
|
2813
|
+
// Nothing to write, the common case: answered from the worker's copy
|
|
2814
|
+
// of the row, without the store's lock or a storage call. Only when
|
|
2815
|
+
// nothing would be written, so it's no write outside the lock.
|
|
2816
|
+
const unchanged = await unchangedAsStored(scriptId, next, prior);
|
|
2817
|
+
if (unchanged) {
|
|
2818
|
+
timer.note('nothing to write');
|
|
2819
|
+
timer.mark('write');
|
|
2820
|
+
timer.done();
|
|
2821
|
+
if (opts.parseCheck === 'defer')
|
|
2822
|
+
checkParsesInBackground(unchanged);
|
|
2823
|
+
return unchanged;
|
|
2824
|
+
}
|
|
2825
|
+
// Re-read inside the lock and write atomically so edits that landed
|
|
2826
|
+
// during the fetch (an enable toggle, an update probe) aren't
|
|
2827
|
+
// reverted by values captured before them.
|
|
2828
|
+
const written = await mutate(async () => {
|
|
2829
|
+
// Without its bodies: see `changedScriptRows`.
|
|
2830
|
+
const cur = await getRow(scriptKey(scriptId));
|
|
2831
|
+
// The row is gone. We read it at the top of this function, so its
|
|
2832
|
+
// absence now means something deleted it while we were fetching —
|
|
2833
|
+
// `removeCollection` (which drops the collection row and every
|
|
2834
|
+
// script under it) or a per-script removal. Writing would resurrect
|
|
2835
|
+
// an orphan: a script row no collection's `scriptIds` references,
|
|
2836
|
+
// invisible to the UI and never refreshed again.
|
|
2837
|
+
//
|
|
2838
|
+
// The window pre-dates the batch — the collection row was always
|
|
2839
|
+
// read long before this write, with the fetch and rebuild in
|
|
2840
|
+
// between — but a shared row widens it, because a sibling that
|
|
2841
|
+
// starts after the removal now takes the cached collection row
|
|
2842
|
+
// instead of reading `undefined` and bailing early. Checked here
|
|
2843
|
+
// rather than by re-reading the collection row: this covers both
|
|
2844
|
+
// removal paths, and costs nothing since we needed `cur` anyway.
|
|
2845
|
+
if (!cur)
|
|
2846
|
+
return null;
|
|
2847
|
+
const merged = preserveConcurrentEdits(next, prior, cur);
|
|
2848
|
+
// Same rule as the collection path: a check that found nothing new
|
|
2849
|
+
// doesn't rewrite the row. This one matters most — it's the
|
|
2850
|
+
// opportunistic path, so it runs on navigations rather than clicks.
|
|
2851
|
+
// There's no collection row here to carry a "we checked" timestamp,
|
|
2852
|
+
// and that's fine: nothing reads a per-script one.
|
|
2853
|
+
const changed = await changedScriptRows([merged], { [scriptKey(scriptId)]: cur }, () => prior);
|
|
2854
|
+
if (changed.count > 0)
|
|
2855
|
+
await writeLocal(changed.items);
|
|
2856
|
+
return merged;
|
|
2857
|
+
});
|
|
2858
|
+
timer.mark('write');
|
|
2859
|
+
timer.done();
|
|
2860
|
+
// Deferred check, started AFTER the write and deliberately not
|
|
2861
|
+
// awaited: the caller that asked to defer is one a page is waiting
|
|
2862
|
+
// on. Started from the row as written, so the verdict is judged
|
|
2863
|
+
// against what is actually stored.
|
|
2864
|
+
//
|
|
2865
|
+
// Asks about the row rather than about the mode the ENTRY ran in, so
|
|
2866
|
+
// an http(s) entry that `rebuildCollection` downgraded to `'await'`
|
|
2867
|
+
// reaches here too. It is a no-op unless that awaited check failed
|
|
2868
|
+
// open, in which case this retries it — one extra compile the pure-
|
|
2869
|
+
// await path wouldn't have made, on a row that genuinely has no
|
|
2870
|
+
// verdict. Not worth plumbing the per-entry decision back out for.
|
|
2871
|
+
// `null` when the row was deleted mid-refresh (see the write above).
|
|
2872
|
+
// Callers already have a story for it: `runScriptCheck` logs "row
|
|
2873
|
+
// vanished" to the SW console, and nothing to the script's Log, which
|
|
2874
|
+
// went with the row.
|
|
2875
|
+
if (!written)
|
|
2876
|
+
return null;
|
|
2877
|
+
if (opts.parseCheck === 'defer')
|
|
2878
|
+
checkParsesInBackground(written);
|
|
2879
|
+
return written;
|
|
2880
|
+
}
|
|
2881
|
+
// A refresh's result merged onto the stored row, when writing it would
|
|
2882
|
+
// change nothing; else null, and the caller takes the lock and writes.
|
|
2883
|
+
//
|
|
2884
|
+
// Checked outside the lock against the row as stored now (the row
|
|
2885
|
+
// cache, so no storage call in the worker), as `changedScriptRows`
|
|
2886
|
+
// would inside it. A write by someone else after this doesn't make it
|
|
2887
|
+
// wrong: not writing leaves their row, as a later write of ours would
|
|
2888
|
+
// have been compared against it anyway.
|
|
2889
|
+
//
|
|
2890
|
+
// Only when the bodies the row points at are known to be there: the
|
|
2891
|
+
// read of `prior` found them all (a missing one makes it a
|
|
2892
|
+
// `MISSING_BLOB_MESSAGE` row). The locked path's key listing is what
|
|
2893
|
+
// restores a missing one, so that case goes there.
|
|
2894
|
+
async function unchangedAsStored(scriptId, next, prior) {
|
|
2895
|
+
if (prior.errorMessage === MISSING_BLOB_MESSAGE)
|
|
2896
|
+
return null;
|
|
2897
|
+
const cur = await getRow(scriptKey(scriptId));
|
|
2898
|
+
if (!cur)
|
|
2899
|
+
return null;
|
|
2900
|
+
// New content always moves one of these, so a write is known without
|
|
2901
|
+
// hashing the bodies here, which the locked path would do again.
|
|
2902
|
+
if (next.loadedAt !== cur.loadedAt || next.contentHash !== cur.contentHash)
|
|
2903
|
+
return null;
|
|
2904
|
+
const merged = preserveConcurrentEdits(next, prior, cur);
|
|
2905
|
+
carryBodyRef(prior, merged);
|
|
2906
|
+
const form = await storedForm(merged);
|
|
2907
|
+
return sameStoredValue(form.row, cur) ? merged : null;
|
|
2908
|
+
}
|
|
2909
|
+
// Re-apply the parts of a script row that a refresh doesn't own, onto
|
|
2910
|
+
// the row it just built. Called inside the write lock by both refresh
|
|
2911
|
+
// entry points, against the row as it stands right now (`cur`) and the
|
|
2912
|
+
// snapshot the refresh started from (`prior`).
|
|
2913
|
+
//
|
|
2914
|
+
// - `enabled` — only when it actually changed under us, which is the
|
|
2915
|
+
// signal that a setScriptEnabled landed mid-fetch. Otherwise a
|
|
2916
|
+
// deliberate refresh-owned change has to win.
|
|
2917
|
+
// - `versionProbes` — always, because no refresh path ever produces
|
|
2918
|
+
// them. They arrive only from setVersionProbes, so whatever is
|
|
2919
|
+
// persisted now is by definition newer than what the refresh
|
|
2920
|
+
// started with.
|
|
2921
|
+
// - `collectionId` — always, for the same reason: only `moveScript`
|
|
2922
|
+
// writes it, so the stored value is a move that landed during the
|
|
2923
|
+
// fetch. A refresh rebuilds the row under the collection it was
|
|
2924
|
+
// asked about, and writing that back would silently revert the
|
|
2925
|
+
// move and leave the two collections disagreeing about who owns
|
|
2926
|
+
// the row. (The collection path drops such a row before it gets
|
|
2927
|
+
// here — see `departed` in `writeRefreshedCollection`; this is
|
|
2928
|
+
// what covers the single-script path.)
|
|
2929
|
+
function preserveConcurrentEdits(next, prior, cur) {
|
|
2930
|
+
if (!cur)
|
|
2931
|
+
return next;
|
|
2932
|
+
const out = { ...next };
|
|
2933
|
+
if (prior && cur.enabled !== prior.enabled)
|
|
2934
|
+
out.enabled = cur.enabled;
|
|
2935
|
+
out.collectionId = cur.collectionId;
|
|
2936
|
+
if (cur.versionProbes)
|
|
2937
|
+
out.versionProbes = cur.versionProbes;
|
|
2938
|
+
else
|
|
2939
|
+
delete out.versionProbes;
|
|
2940
|
+
return out;
|
|
2941
|
+
}
|
|
2942
|
+
// The writes for those of `finalScripts` that actually differ from
|
|
2943
|
+
// what's already stored, or point at a blob that isn't stored: each
|
|
2944
|
+
// one's small row, plus the blobs not yet stored.
|
|
2945
|
+
//
|
|
2946
|
+
// Every refresh path rebuilds a full row per script — including the ones
|
|
2947
|
+
// that took the size+mtime / 304 skip and came back byte-identical — so
|
|
2948
|
+
// without this a refresh of N scripts writes N rows to answer a question
|
|
2949
|
+
// about one.
|
|
2950
|
+
//
|
|
2951
|
+
// Compared in stored form (`storedForm`): the rebuilt script's small row
|
|
2952
|
+
// against the stored row, read without its bodies. A body that didn't
|
|
2953
|
+
// change has the same hash, so the comparison needs no blob read, and
|
|
2954
|
+
// the write that follows doesn't hash the body again. A format 1 row
|
|
2955
|
+
// (bodies inline) never compares equal, so it's rewritten in format 2.
|
|
2956
|
+
//
|
|
2957
|
+
// Chrome already declines to fire `storage.onChanged` for a `set` whose
|
|
2958
|
+
// value matches what's stored (the behavior `retryRejectedRegistrations`
|
|
2959
|
+
// in injector.ts is built around), so this changes no downstream
|
|
2960
|
+
// listener — it drops the serialize-and-store cost that happens before
|
|
2961
|
+
// Chrome gets far enough to compare.
|
|
2962
|
+
//
|
|
2963
|
+
// Callers must still return the FULL list to their own callers: what
|
|
2964
|
+
// gets written narrows, what a refresh reports does not.
|
|
2965
|
+
//
|
|
2966
|
+
// Takes the rows as read inside the write lock, never the pre-fetch
|
|
2967
|
+
// snapshot — comparing against a stale read would skip a write that a
|
|
2968
|
+
// concurrent edit had made necessary. (`refreshScript` asks first,
|
|
2969
|
+
// outside the lock, against the row as stored now: `unchangedAsStored`.)
|
|
2970
|
+
//
|
|
2971
|
+
// `priorOf` gives the copy each refresh started from, read with its
|
|
2972
|
+
// bodies: a body unchanged from it reuses its hash (`carryBodyRef`).
|
|
2973
|
+
async function changedScriptRows(finalScripts, curByKey, priorOf) {
|
|
2974
|
+
if (finalScripts.length === 0)
|
|
2975
|
+
return { count: 0, items: {} };
|
|
2976
|
+
const stored = new Set(await chrome.storage.local.getKeys());
|
|
2977
|
+
const items = {};
|
|
2978
|
+
let count = 0;
|
|
2979
|
+
for (const s of finalScripts) {
|
|
2980
|
+
carryBodyRef(priorOf(s.id), s);
|
|
2981
|
+
const form = await storedForm(s);
|
|
2982
|
+
const absent = newBlobItems(form.blobs, stored);
|
|
2983
|
+
// An equal row whose blob is gone still gets written: the refresh
|
|
2984
|
+
// that just fetched the body again is what puts the blob back.
|
|
2985
|
+
if (Object.keys(absent).length === 0 && sameStoredValue(form.row, curByKey[scriptKey(s.id)]))
|
|
2986
|
+
continue;
|
|
2987
|
+
Object.assign(items, { [scriptKey(s.id)]: form.row }, absent);
|
|
2988
|
+
count++;
|
|
2989
|
+
}
|
|
2990
|
+
return { count, items };
|
|
2991
|
+
}
|
|
2992
|
+
// The fields a `script:*` write can move without touching anything
|
|
2993
|
+
// downstream of the match index's change listener: the registration,
|
|
2994
|
+
// the match index, the tab badges. See `onlyAnnotationsChanged`.
|
|
2995
|
+
// - `versionProbes`: an update check's result (`setVersionProbes`).
|
|
2996
|
+
// - `parseCheckedHash`: the parse check's verdict, when it passed. It
|
|
2997
|
+
// lands just after the change it checked, whose own write started
|
|
2998
|
+
// a sync then; the registration built from the row is the same
|
|
2999
|
+
// either way. A failed one changes `status` too, which does count.
|
|
3000
|
+
const ANNOTATION_FIELDS = ['versionProbes', 'parseCheckedHash'];
|
|
3001
|
+
// A row without its annotations, for comparing two rows apart from them.
|
|
3002
|
+
// `sameStoredValue` treats an undefined-valued key as absent, so that's
|
|
3003
|
+
// how a field is left out.
|
|
3004
|
+
export function withoutAnnotations(row) {
|
|
3005
|
+
const out = { ...row };
|
|
3006
|
+
for (const field of ANNOTATION_FIELDS)
|
|
3007
|
+
out[field] = undefined;
|
|
3008
|
+
return out;
|
|
3009
|
+
}
|
|
3010
|
+
// Structural equality over the JSON shapes `chrome.storage.local` round-
|
|
3011
|
+
// trips: objects, arrays, and primitives.
|
|
3012
|
+
//
|
|
3013
|
+
// Key ORDER is deliberately not significant. A stored row's order comes
|
|
3014
|
+
// from however it was last serialized, while a rebuilt one's comes from
|
|
3015
|
+
// the spread-and-delete sequence that built it (`{...prior}` then
|
|
3016
|
+
// `delete next.refreshError`, say) — so two rows that mean the same
|
|
3017
|
+
// thing routinely disagree on order, and a `JSON.stringify` comparison
|
|
3018
|
+
// would call every one of them changed and quietly do nothing.
|
|
3019
|
+
//
|
|
3020
|
+
// `undefined`-valued keys are treated as absent, matching what a storage
|
|
3021
|
+
// round-trip does to them: a freshly built row carrying `foo: undefined`
|
|
3022
|
+
// and a stored row with no `foo` at all are the same row.
|
|
3023
|
+
//
|
|
3024
|
+
// Exported for unit tests.
|
|
3025
|
+
export function sameStoredValue(a, b) {
|
|
3026
|
+
if (a === b)
|
|
3027
|
+
return true;
|
|
3028
|
+
if (a === null || b === null)
|
|
3029
|
+
return false;
|
|
3030
|
+
if (typeof a !== 'object' || typeof b !== 'object')
|
|
3031
|
+
return false;
|
|
3032
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
3033
|
+
if (!Array.isArray(a) || !Array.isArray(b))
|
|
3034
|
+
return false;
|
|
3035
|
+
if (a.length !== b.length)
|
|
3036
|
+
return false;
|
|
3037
|
+
return a.every((v, i) => sameStoredValue(v, b[i]));
|
|
3038
|
+
}
|
|
3039
|
+
const ao = a;
|
|
3040
|
+
const bo = b;
|
|
3041
|
+
const aKeys = Object.keys(ao).filter((k) => ao[k] !== undefined);
|
|
3042
|
+
const bKeys = Object.keys(bo).filter((k) => bo[k] !== undefined);
|
|
3043
|
+
if (aKeys.length !== bKeys.length)
|
|
3044
|
+
return false;
|
|
3045
|
+
// `hasOwnProperty`, not `k in bo`: a row that came back through
|
|
3046
|
+
// `JSON.parse` can carry an own `__proto__`, and `in` would answer for
|
|
3047
|
+
// the prototype chain — comparing a stored value against an inherited
|
|
3048
|
+
// function. That direction only ever yields "changed" (a redundant
|
|
3049
|
+
// write), but own-keys-only is what this means, so it says so.
|
|
3050
|
+
return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && sameStoredValue(ao[k], bo[k]));
|
|
3051
|
+
}
|
|
3052
|
+
// Fetch each scanned file, at most
|
|
3053
|
+
// FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY at a time.
|
|
3054
|
+
//
|
|
3055
|
+
// Dispatch is per *entry URL*, not per collection: a manifest
|
|
3056
|
+
// collection may list http(s):// entries, file:// entries, or a mix, so
|
|
3057
|
+
// each entry picks its fetcher and skip strategy based on the resolved
|
|
3058
|
+
// URL scheme.
|
|
3059
|
+
//
|
|
3060
|
+
// Scheme behaviors:
|
|
3061
|
+
// - http(s)://: conditional GET. We send the prior row's ETag /
|
|
3062
|
+
// Last-Modified as If-None-Match / If-Modified-Since; a server 304
|
|
3063
|
+
// becomes `unchanged`, a 200 carries new validators back through
|
|
3064
|
+
// FetchResult.ok for rebuildCollection to persist.
|
|
3065
|
+
// - file://: direct fetch. If the scan supplied size+mtime (every
|
|
3066
|
+
// scan does for a local file whose folder listed), we apply the
|
|
3067
|
+
// pre-fetch listing skip when those match the prior row.
|
|
3068
|
+
//
|
|
3069
|
+
// In both modes a prior error/missing entry always refetches so it has
|
|
3070
|
+
// a chance to recover.
|
|
3071
|
+
export async function fetchOrSkipInParallel(collectionPath, scan, existing) {
|
|
3072
|
+
const byPath = new Map(existing.map((s) => [s.relativePath, s]));
|
|
3073
|
+
// Bounded, not `Promise.all`: an unbounded fan-out over a large
|
|
3074
|
+
// collection holds every fetched body live at once. See
|
|
3075
|
+
// src/extension/concurrency.ts.
|
|
3076
|
+
const results = await mapWithConcurrency(scan, FETCH_SCRIPTS_PER_COLLECTION_CONCURRENCY, async (found) => {
|
|
3077
|
+
const prior = byPath.get(found.relativePath);
|
|
3078
|
+
const url = scriptFileUrl(collectionPath, found.relativePath);
|
|
3079
|
+
if (isHttpUrl(url)) {
|
|
3080
|
+
try {
|
|
3081
|
+
const fetched = await fetchHttpText(url, prior?.status === 'ok' ? {
|
|
3082
|
+
etag: prior.httpEtag,
|
|
3083
|
+
lastModified: prior.httpLastModified,
|
|
3084
|
+
} : {});
|
|
3085
|
+
if (fetched.kind === 'unchanged') {
|
|
3086
|
+
return [found.relativePath, { unchanged: true }];
|
|
3087
|
+
}
|
|
3088
|
+
const ok = { ok: true, text: fetched.text };
|
|
3089
|
+
if (fetched.etag)
|
|
3090
|
+
ok.httpEtag = fetched.etag;
|
|
3091
|
+
if (fetched.lastModified)
|
|
3092
|
+
ok.httpLastModified = fetched.lastModified;
|
|
3093
|
+
return [found.relativePath, ok];
|
|
3094
|
+
}
|
|
3095
|
+
catch (err) {
|
|
3096
|
+
return [found.relativePath, { ok: false, errorMessage: describeError(err) }];
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
// file:// (or any other non-HTTP URL the file fetcher can handle).
|
|
3100
|
+
const canSkip = prior != null &&
|
|
3101
|
+
prior.status === 'ok' &&
|
|
3102
|
+
found.size !== undefined && found.mtime !== undefined &&
|
|
3103
|
+
prior.size === found.size &&
|
|
3104
|
+
prior.fsMtime === found.mtime;
|
|
3105
|
+
if (canSkip) {
|
|
3106
|
+
return [found.relativePath, { unchanged: true }];
|
|
3107
|
+
}
|
|
3108
|
+
if (found.text !== undefined)
|
|
3109
|
+
return [found.relativePath, { ok: true, text: found.text }];
|
|
3110
|
+
try {
|
|
3111
|
+
const text = await fetchScriptFileUrlText(url);
|
|
3112
|
+
return [found.relativePath, { ok: true, text }];
|
|
3113
|
+
}
|
|
3114
|
+
catch (err) {
|
|
3115
|
+
return [found.relativePath, { ok: false, errorMessage: describeError(err) }];
|
|
3116
|
+
}
|
|
3117
|
+
});
|
|
3118
|
+
return new Map(results);
|
|
3119
|
+
}
|
|
3120
|
+
// Refresh every registered collection, a bounded few at a time. Returns
|
|
3121
|
+
// the new state per collection so callers can compute "what changed"
|
|
3122
|
+
// without a second read.
|
|
3123
|
+
//
|
|
3124
|
+
// One collection failing never takes the pass down, and never costs the
|
|
3125
|
+
// others their results — its slot in the result map is simply absent.
|
|
3126
|
+
// Both failure kinds have a durable home to be read from afterwards, so
|
|
3127
|
+
// dropping the throw loses nothing:
|
|
3128
|
+
// - a scan failure → the collection row's `scanError`
|
|
3129
|
+
// - a write failure → the session-storage map in src/extension/refresh-error.ts
|
|
3130
|
+
//
|
|
3131
|
+
// Rethrowing instead would reject the caller at the first failure while
|
|
3132
|
+
// the remaining collections were still being written, so their outcomes
|
|
3133
|
+
// could never be reported — and on an exhausted quota EVERY collection
|
|
3134
|
+
// fails, which made that the normal case rather than the rare one.
|
|
3135
|
+
export async function refreshAllCollections(opts = {}) {
|
|
3136
|
+
const collections = await getCollections();
|
|
3137
|
+
// One shared file-fetch cache across every collection in this pass so
|
|
3138
|
+
// two scripts (across two collections) referencing the same canonical
|
|
3139
|
+
// URL share one fetch. Caller can override by passing their own.
|
|
3140
|
+
const fileFetchCache = opts.fileFetchCache ?? new Map();
|
|
3141
|
+
// Bounded like the per-file fan-out inside each collection: without a
|
|
3142
|
+
// cap here, N collections would each run their own in-flight set at
|
|
3143
|
+
// the same time and the per-collection bound wouldn't cap anything.
|
|
3144
|
+
// Deliberately the smaller limit of the pair, because each slot here
|
|
3145
|
+
// expands into a whole collection's worth of fetches — and because a
|
|
3146
|
+
// profile with many collections is the rarer case. See
|
|
3147
|
+
// src/extension/concurrency.ts.
|
|
3148
|
+
const entries = await mapWithConcurrency(collections, FETCH_COLLECTIONS_CONCURRENCY, async (s) => {
|
|
3149
|
+
try {
|
|
3150
|
+
const r = await refreshCollection(s.id, { ...opts, fileFetchCache });
|
|
3151
|
+
return [s.id, r];
|
|
3152
|
+
}
|
|
3153
|
+
catch (err) {
|
|
3154
|
+
// `refreshCollection` already recorded the reason where the UI
|
|
3155
|
+
// reads it; all this has to do is keep the sibling collections
|
|
3156
|
+
// running. The console line is for the SW log, not the user —
|
|
3157
|
+
// named, because the motivating case fails every collection at
|
|
3158
|
+
// once and an unattributed line would be noise. Says "failed"
|
|
3159
|
+
// rather than naming a cause: most throws here are the write,
|
|
3160
|
+
// but this catch doesn't verify that.
|
|
3161
|
+
console.info(`[SourceMonkey] collection refresh failed: ${s.path}: ${describeError(err)}`);
|
|
3162
|
+
return [s.id, null];
|
|
3163
|
+
}
|
|
3164
|
+
});
|
|
3165
|
+
const out = new Map();
|
|
3166
|
+
for (const [id, r] of entries) {
|
|
3167
|
+
if (r)
|
|
3168
|
+
out.set(id, r);
|
|
3169
|
+
}
|
|
3170
|
+
return out;
|
|
3171
|
+
}
|
|
3172
|
+
// Chrome's own wording for a `chrome.storage.local` write that would
|
|
3173
|
+
// exceed the extension's quota. The text has changed across versions
|
|
3174
|
+
// (`QUOTA_BYTES quota exceeded`, `Resource::kQuotaBytes quota
|
|
3175
|
+
// exceeded`), so match the constant part.
|
|
3176
|
+
const CHROME_QUOTA_REJECTION = /quota exceeded/i;
|
|
3177
|
+
// The one message for a write that didn't fit. Names the cause and the
|
|
3178
|
+
// fix, and takes the place of the internal-vocabulary wording Chrome
|
|
3179
|
+
// gives — a user reading `Resource::kQuotaBytes quota exceeded` in a
|
|
3180
|
+
// Log entry has nothing to act on. Two sentences, so both are
|
|
3181
|
+
// punctuated (docs/diagnostics.md#wording).
|
|
3182
|
+
export function storageFullMessage() {
|
|
3183
|
+
const quota = chrome.storage.local.QUOTA_BYTES;
|
|
3184
|
+
const limit = typeof quota === 'number' && quota > 0
|
|
3185
|
+
? ` (Chrome allows ${Math.round(quota / (1024 * 1024))} MB)`
|
|
3186
|
+
: '';
|
|
3187
|
+
// "Out of storage space" is also the collections banner's heading
|
|
3188
|
+
// (`renderStorageBanner`), so the two read as one condition.
|
|
3189
|
+
return `SourceMonkey is out of storage space${limit}. `
|
|
3190
|
+
+ 'Remove a script or collection you no longer need to free space.';
|
|
3191
|
+
}
|
|
3192
|
+
// Every `chrome.storage.local.set` in this module goes through here, so
|
|
3193
|
+
// an out-of-quota rejection reads the same wherever it surfaces — the
|
|
3194
|
+
// install callout, a Log entry, the collections banner. Any other
|
|
3195
|
+
// rejection is passed through as Chrome worded it.
|
|
3196
|
+
//
|
|
3197
|
+
// Script rows are written in format 2: a full script splits into its
|
|
3198
|
+
// small row plus the blobs it points at, in this same `set`.
|
|
3199
|
+
async function writeLocal(items) {
|
|
3200
|
+
assertWorkerContext('write storage');
|
|
3201
|
+
await storeFormatReady();
|
|
3202
|
+
await setLocal(await withBlobs(items));
|
|
3203
|
+
}
|
|
3204
|
+
// `writeLocal` without the format handling, for the key-rename
|
|
3205
|
+
// migration, which runs before the format migration and writes rows as
|
|
3206
|
+
// it read them.
|
|
3207
|
+
async function setLocal(items) {
|
|
3208
|
+
assertWorkerContext('write storage');
|
|
3209
|
+
try {
|
|
3210
|
+
await chrome.storage.local.set(items);
|
|
3211
|
+
}
|
|
3212
|
+
catch (err) {
|
|
3213
|
+
throw translateStorageError(err);
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
// Every script-row removal in this module, so none lands while the
|
|
3217
|
+
// worker is still converting rows (a converted copy would bring the row
|
|
3218
|
+
// back). The blobs stay; see docs/storage.md#writing-and-cleanup.
|
|
3219
|
+
async function removeLocal(keys) {
|
|
3220
|
+
assertWorkerContext('write storage');
|
|
3221
|
+
await storeFormatReady();
|
|
3222
|
+
await chrome.storage.local.remove(keys);
|
|
3223
|
+
}
|
|
3224
|
+
// Chrome's error rides along as `cause` for DevTools; `describeError`
|
|
3225
|
+
// reads only `.message`, so it never reaches a user-facing string.
|
|
3226
|
+
function translateStorageError(err) {
|
|
3227
|
+
return CHROME_QUOTA_REJECTION.test(describeError(err))
|
|
3228
|
+
? new Error(storageFullMessage(), { cause: err })
|
|
3229
|
+
: err;
|
|
3230
|
+
}
|
|
3231
|
+
// Flip a collection's enabled flag. The match-index listens for
|
|
3232
|
+
// `collection:<id>` changes and re-evaluates the effective enabled state
|
|
3233
|
+
// of every script under this collection. Turning it on or off doesn't
|
|
3234
|
+
// touch other collections; `setCollectionEnabledExclusive`
|
|
3235
|
+
// (script-enablement.ts) is the toggle that keeps one copy active.
|
|
3236
|
+
export async function setCollectionEnabled(id, enabled) {
|
|
3237
|
+
return (await writeEnablement({ collection: { id, enabled } })).collection;
|
|
3238
|
+
}
|
|
3239
|
+
// When each copy last became active and last went inactive, in epoch
|
|
3240
|
+
// ms. Active means its own flag and its collection's are both on, so it
|
|
3241
|
+
// fires on the pages it matches. One storage key holding every script's
|
|
3242
|
+
// times, rather than fields on the script rows: a collection toggle
|
|
3243
|
+
// changes the times of every copy under it, and stamping them on the
|
|
3244
|
+
// rows would rewrite each full row (bodies and all). See
|
|
3245
|
+
// [docs/script-enablement.md](../../docs/script-enablement.md#active-times).
|
|
3246
|
+
export { ENABLEMENT_TIMES_KEY };
|
|
3247
|
+
export async function getEnablementTimes() {
|
|
3248
|
+
return (await getRaw(ENABLEMENT_TIMES_KEY)) ?? {};
|
|
3249
|
+
}
|
|
3250
|
+
// The time for one enable change. Strictly later than this context's
|
|
3251
|
+
// previous one, so two writes in one millisecond can't look like one
|
|
3252
|
+
// change: the restore on collection-off (script-enablement.ts) matches
|
|
3253
|
+
// times exactly.
|
|
3254
|
+
let lastEnablementStamp = 0;
|
|
3255
|
+
function nextEnablementStamp() {
|
|
3256
|
+
lastEnablementStamp = Math.max(Date.now(), lastEnablementStamp + 1);
|
|
3257
|
+
return lastEnablementStamp;
|
|
3258
|
+
}
|
|
3259
|
+
// Record one copy's change in `times` (a copy the caller owns): became
|
|
3260
|
+
// active, went inactive, or (`restarted`) stays active but is stamped as
|
|
3261
|
+
// becoming active now. Returns whether anything changed.
|
|
3262
|
+
function stampTimes(times, id, wasActive, nowActive, at, restarted = false) {
|
|
3263
|
+
if (nowActive && (!wasActive || restarted)) {
|
|
3264
|
+
times[id] = { ...times[id], activeSince: at };
|
|
3265
|
+
return true;
|
|
3266
|
+
}
|
|
3267
|
+
if (wasActive && !nowActive) {
|
|
3268
|
+
times[id] = { ...times[id], inactiveSince: at };
|
|
3269
|
+
return true;
|
|
3270
|
+
}
|
|
3271
|
+
return false;
|
|
3272
|
+
}
|
|
3273
|
+
// Drop active times kept for scripts that no longer exist. A removal
|
|
3274
|
+
// drops its rows' times in the same write, so these are left from
|
|
3275
|
+
// before only the worker wrote, when a removal in a page could race
|
|
3276
|
+
// another context's write of the times back in. `live` is a scan of
|
|
3277
|
+
// the script rows; an id missing from it is re-checked inside the lock,
|
|
3278
|
+
// so a row added since the scan keeps its times.
|
|
3279
|
+
async function pruneEnablementTimes(live) {
|
|
3280
|
+
const stale = Object.keys(await getEnablementTimes()).filter((id) => !live.has(id));
|
|
3281
|
+
if (stale.length === 0)
|
|
3282
|
+
return;
|
|
3283
|
+
await mutate(async () => {
|
|
3284
|
+
const got = await getRows([...stale.map(scriptKey), ENABLEMENT_TIMES_KEY]);
|
|
3285
|
+
const times = { ...got[ENABLEMENT_TIMES_KEY] };
|
|
3286
|
+
const gone = stale.filter((id) => !got[scriptKey(id)] && times[id]);
|
|
3287
|
+
if (gone.length === 0)
|
|
3288
|
+
return;
|
|
3289
|
+
for (const id of gone)
|
|
3290
|
+
delete times[id];
|
|
3291
|
+
await writeLocal({ [ENABLEMENT_TIMES_KEY]: times });
|
|
3292
|
+
});
|
|
3293
|
+
}
|
|
3294
|
+
// The active-times write that forgets `ids` (rows being removed), or
|
|
3295
|
+
// nothing when none of them has times. Called inside the removal's lock.
|
|
3296
|
+
async function withoutTimes(ids) {
|
|
3297
|
+
const times = await getEnablementTimes();
|
|
3298
|
+
if (!ids.some((id) => times[id]))
|
|
3299
|
+
return {};
|
|
3300
|
+
const next = { ...times };
|
|
3301
|
+
for (const id of ids)
|
|
3302
|
+
delete next[id];
|
|
3303
|
+
return { [ENABLEMENT_TIMES_KEY]: next };
|
|
3304
|
+
}
|
|
3305
|
+
// Every enable write goes through here: the flags and the active times
|
|
3306
|
+
// land together in ONE storage write, so one change fires one
|
|
3307
|
+
// `storage.onChanged` and one registration sync. Each copy whose active
|
|
3308
|
+
// state the change flips is stamped with the same time
|
|
3309
|
+
// (`nextEnablementStamp`); a copy whose state stays put is left alone.
|
|
3310
|
+
// Only script rows whose own flag changes are rewritten.
|
|
3311
|
+
//
|
|
3312
|
+
// Returns the collection as written (null when it's gone, or none was
|
|
3313
|
+
// asked for) and every script row whose flag it changed.
|
|
3314
|
+
//
|
|
3315
|
+
// `timer` is the caller's, when it timed its own steps first; the
|
|
3316
|
+
// write's steps are added to it and the caller prints the line.
|
|
3317
|
+
// Otherwise the write prints its own `enablement write` line.
|
|
3318
|
+
export async function writeEnablement(w, timer) {
|
|
3319
|
+
const t = timer ?? startPhaseTimer('enablement write');
|
|
3320
|
+
const result = await mutate(async () => {
|
|
3321
|
+
t.mark('lock wait');
|
|
3322
|
+
return applyEnablement(w, t);
|
|
3323
|
+
});
|
|
3324
|
+
if (!timer)
|
|
3325
|
+
t.done();
|
|
3326
|
+
return result;
|
|
3327
|
+
}
|
|
3328
|
+
// An enable write whose changes depend on the store: which other copies
|
|
3329
|
+
// are active, which ones a collection displaced. `plan` decides them
|
|
3330
|
+
// inside the lock, from `scriptIds` and `collectionIds` (plus each of
|
|
3331
|
+
// those scripts' collections) as they are at that moment, so a write
|
|
3332
|
+
// that landed while this one waited is part of what it sees. Planned
|
|
3333
|
+
// outside the lock, two quick clicks on two copies each saw the other
|
|
3334
|
+
// as off, and both stayed on.
|
|
3335
|
+
//
|
|
3336
|
+
// The caller picks the ids from a read made before the lock, and only
|
|
3337
|
+
// those are re-read: a full read here would hold the lock (and every
|
|
3338
|
+
// other write in this context) for as long as it takes. A copy that
|
|
3339
|
+
// appears between the two reads is missed, which needs an install to
|
|
3340
|
+
// land at that moment.
|
|
3341
|
+
//
|
|
3342
|
+
// `plan` returning null writes nothing.
|
|
3343
|
+
export async function writeEnablementFrom(scope, plan, timer) {
|
|
3344
|
+
const t = timer ?? startPhaseTimer('enablement write');
|
|
3345
|
+
const result = await mutate(async () => {
|
|
3346
|
+
t.mark('lock wait');
|
|
3347
|
+
const ids = [...new Set(scope.scriptIds)];
|
|
3348
|
+
const got = await getRows([...ids.map(scriptKey), ENABLEMENT_TIMES_KEY]);
|
|
3349
|
+
const scripts = ids
|
|
3350
|
+
.map((id) => got[scriptKey(id)])
|
|
3351
|
+
.filter((s) => s != null);
|
|
3352
|
+
const collIds = [...new Set([...(scope.collectionIds ?? []), ...scripts.map((s) => s.collectionId)])];
|
|
3353
|
+
const colls = await getRows(collIds.map(collectionKey));
|
|
3354
|
+
t.mark('re-read', () => storageSize({ ...got, ...colls }));
|
|
3355
|
+
const w = plan({
|
|
3356
|
+
scripts,
|
|
3357
|
+
collections: collIds
|
|
3358
|
+
.map((id) => colls[collectionKey(id)])
|
|
3359
|
+
.filter((c) => c != null),
|
|
3360
|
+
times: got[ENABLEMENT_TIMES_KEY] ?? {},
|
|
3361
|
+
});
|
|
3362
|
+
t.mark('plan');
|
|
3363
|
+
return w ? applyEnablement(w, t) : { collection: null, scripts: [] };
|
|
3364
|
+
});
|
|
3365
|
+
if (!timer)
|
|
3366
|
+
t.done();
|
|
3367
|
+
return result;
|
|
3368
|
+
}
|
|
3369
|
+
// The body of an enable write. Caller holds the lock.
|
|
3370
|
+
async function applyEnablement(w, t) {
|
|
3371
|
+
const at = nextEnablementStamp();
|
|
3372
|
+
const toSet = {};
|
|
3373
|
+
let collection = null;
|
|
3374
|
+
const scriptIds = new Set([...(w.scripts ?? []).map((u) => u.id), ...(w.started ?? [])]);
|
|
3375
|
+
if (w.collection) {
|
|
3376
|
+
collection = (await getRow(collectionKey(w.collection.id))) ?? null;
|
|
3377
|
+
t.mark('read collection');
|
|
3378
|
+
if (collection) {
|
|
3379
|
+
// Its scripts' active state follows its flag, so they may need
|
|
3380
|
+
// stamping even though none of their own flags change.
|
|
3381
|
+
for (const sid of collection.scriptIds)
|
|
3382
|
+
scriptIds.add(sid);
|
|
3383
|
+
collection = { ...collection, enabled: w.collection.enabled };
|
|
3384
|
+
toSet[collectionKey(collection.id)] = collection;
|
|
3385
|
+
}
|
|
3386
|
+
}
|
|
3387
|
+
// The rows as stored: an enable change rewrites a script's small row
|
|
3388
|
+
// and leaves its bodies alone.
|
|
3389
|
+
const got = await getRows([...[...scriptIds].map(scriptKey), ENABLEMENT_TIMES_KEY]);
|
|
3390
|
+
t.mark('read rows', () => storageSize(got));
|
|
3391
|
+
const scripts = [...scriptIds]
|
|
3392
|
+
.map((sid) => got[scriptKey(sid)])
|
|
3393
|
+
.filter((s) => s != null);
|
|
3394
|
+
const times = { ...got[ENABLEMENT_TIMES_KEY] };
|
|
3395
|
+
// Each script's collection, before and after this write.
|
|
3396
|
+
const collIds = [...new Set(scripts.map((s) => s.collectionId))];
|
|
3397
|
+
const colls = await getRows(collIds.map(collectionKey));
|
|
3398
|
+
t.mark('read collections', () => storageSize(colls));
|
|
3399
|
+
const collOn = (cid, after) => {
|
|
3400
|
+
if (after && collection && cid === collection.id)
|
|
3401
|
+
return isCollectionEnabled(collection);
|
|
3402
|
+
const c = colls[collectionKey(cid)];
|
|
3403
|
+
return c ? isCollectionEnabled(c) : true;
|
|
3404
|
+
};
|
|
3405
|
+
const wanted = new Map((w.scripts ?? []).map((u) => [u.id, u.enabled]));
|
|
3406
|
+
const started = new Set(w.started ?? []);
|
|
3407
|
+
const written = [];
|
|
3408
|
+
let timesChanged = false;
|
|
3409
|
+
for (const script of scripts) {
|
|
3410
|
+
const flag = wanted.get(script.id) ?? isScriptEnabled(script);
|
|
3411
|
+
const wasActive = isScriptEnabled(script) && collOn(script.collectionId, false);
|
|
3412
|
+
const nowActive = flag && collOn(script.collectionId, true);
|
|
3413
|
+
if (stampTimes(times, script.id, wasActive, nowActive, at, started.has(script.id)))
|
|
3414
|
+
timesChanged = true;
|
|
3415
|
+
if (script.enabled === flag)
|
|
3416
|
+
continue;
|
|
3417
|
+
const next = { ...script, enabled: flag };
|
|
3418
|
+
toSet[scriptKey(script.id)] = next;
|
|
3419
|
+
written.push(next);
|
|
3420
|
+
}
|
|
3421
|
+
if (timesChanged)
|
|
3422
|
+
toSet[ENABLEMENT_TIMES_KEY] = times;
|
|
3423
|
+
if (Object.keys(toSet).length > 0) {
|
|
3424
|
+
await writeLocal(toSet);
|
|
3425
|
+
t.mark('write', () => storageSize(toSet));
|
|
3426
|
+
}
|
|
3427
|
+
return { collection, scripts: written };
|
|
3428
|
+
}
|
|
3429
|
+
export async function setCollectionOptions(id, options) {
|
|
3430
|
+
return mutate(async () => {
|
|
3431
|
+
const collection = await getRaw(collectionKey(id));
|
|
3432
|
+
if (!collection)
|
|
3433
|
+
return null;
|
|
3434
|
+
const next = { ...collection };
|
|
3435
|
+
if (options.displayName !== undefined) {
|
|
3436
|
+
const name = options.displayName.trim();
|
|
3437
|
+
const inferred = inferDisplayName(collection.path);
|
|
3438
|
+
if (!name && !inferred)
|
|
3439
|
+
throw new Error(COLLECTION_NAME_REQUIRED);
|
|
3440
|
+
// Same rule as `createLocationCollection`: only a name the user
|
|
3441
|
+
// chose is held unique; the inferred one may collide.
|
|
3442
|
+
if (name && name !== inferred) {
|
|
3443
|
+
// The default that already owns a reserved name is exempt — it
|
|
3444
|
+
// keeps it — but nothing else may take it.
|
|
3445
|
+
if (!mayUseReservedName(name, collection.path))
|
|
3446
|
+
throw new Error(RESERVED_COLLECTION_NAME);
|
|
3447
|
+
const all = await getCollections();
|
|
3448
|
+
if (collectionNameInUse(name, all, id))
|
|
3449
|
+
throw new Error(collectionNameInUseMessage(name));
|
|
3450
|
+
}
|
|
3451
|
+
next.displayName = name || inferred;
|
|
3452
|
+
}
|
|
3453
|
+
if (options.localFiles !== undefined)
|
|
3454
|
+
next.localFiles = options.localFiles;
|
|
3455
|
+
await writeLocal({ [collectionKey(id)]: next });
|
|
3456
|
+
return next;
|
|
3457
|
+
});
|
|
3458
|
+
}
|
|
3459
|
+
// Test-surface convenience; the dialog goes through `setCollectionOptions`.
|
|
3460
|
+
export async function setCollectionLocalFiles(id, localFiles) {
|
|
3461
|
+
return setCollectionOptions(id, { localFiles });
|
|
3462
|
+
}
|
|
3463
|
+
// Record what a script's upstream `@updateURL` / `@downloadURL` last
|
|
3464
|
+
// claimed. Separate from every refresh entry point on purpose: a probe
|
|
3465
|
+
// annotates, it never changes what runs, so this touches exactly one
|
|
3466
|
+
// field and leaves `status`, `refreshError`, and the body alone.
|
|
3467
|
+
//
|
|
3468
|
+
// The caller is expected to have already decided a probe was due (see
|
|
3469
|
+
// `probesAreFresh` and `ProbePolicy`), so this always writes: the
|
|
3470
|
+
// refreshed `checkedAt` is what stops the next check from probing
|
|
3471
|
+
// again. Every write costs a storage.onChanged, and thus a userscript
|
|
3472
|
+
// re-registration pass in the background SW — so the only thing
|
|
3473
|
+
// keeping that off the per-navigation and per-reload paths is the
|
|
3474
|
+
// caller's staleness gate. Don't hand this a probe you didn't pace.
|
|
3475
|
+
//
|
|
3476
|
+
// Passing an empty list clears the field, which is how a script that
|
|
3477
|
+
// dropped its `@updateURL` sheds a stale annotation.
|
|
3478
|
+
export async function setVersionProbes(id, probes) {
|
|
3479
|
+
return mutate(async () => {
|
|
3480
|
+
const script = await getRow(scriptKey(id));
|
|
3481
|
+
if (!script)
|
|
3482
|
+
return null;
|
|
3483
|
+
if (probes.length === 0 && !script.versionProbes)
|
|
3484
|
+
return script;
|
|
3485
|
+
const next = { ...script };
|
|
3486
|
+
if (probes.length > 0)
|
|
3487
|
+
next.versionProbes = probes;
|
|
3488
|
+
else
|
|
3489
|
+
delete next.versionProbes;
|
|
3490
|
+
await writeLocal({ [scriptKey(id)]: next });
|
|
3491
|
+
return next;
|
|
3492
|
+
});
|
|
3493
|
+
}
|
|
3494
|
+
export async function setScriptEnabled(id, enabled) {
|
|
3495
|
+
const { scripts } = await writeEnablement({ scripts: [{ id, enabled }] });
|
|
3496
|
+
return scripts[0] ?? (await getScriptMeta(id)) ?? null;
|
|
3497
|
+
}
|
|
3498
|
+
// Flip many scripts' enabled flags in a single storage write. This is
|
|
3499
|
+
// the bulk-toggle path: it fires exactly one `storage.onChanged` event
|
|
3500
|
+
// (instead of N), so the background SW's syncRegistrations runs once
|
|
3501
|
+
// instead of once per script — turning a 15-script toggle from
|
|
3502
|
+
// quadratic-feeling into a single round-trip.
|
|
3503
|
+
export async function setScriptsEnabled(updates, timer) {
|
|
3504
|
+
if (updates.length === 0)
|
|
3505
|
+
return;
|
|
3506
|
+
await writeEnablement({ scripts: updates }, timer);
|
|
3507
|
+
}
|