sourcemonkey 0.0.0-stage → 0.5.0

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