@arraypress/extension-kit 1.9.0 → 1.11.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 (88) hide show
  1. package/README.md +145 -18
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. package/src/ui/toast.js +2 -0
@@ -6,6 +6,13 @@
6
6
  * The module, and anything it keeps, stays loaded in that world between calls,
7
7
  * so a content script can hold a session (a picker, an inspector) across them.
8
8
  *
9
+ * `"use_dynamic_url": true` on those resources would keep a page from loading
10
+ * them to tell the extension is installed, and this import still works with
11
+ * it, but only for a module with no static imports: Chrome refuses a
12
+ * dynamic-URL module's own static import of a sibling module (checked in Chromium 156,
13
+ * tests/chrome-chromium.test.js). So it suits a single-file (bundled) page
14
+ * module, not one that imports its parts.
15
+ *
9
16
  * @module scripting/call
10
17
  */
11
18
  export type CallScripting = Pick<typeof chrome.scripting, 'executeScript'>;
@@ -15,9 +22,13 @@ export type FrameResult<T> = {
15
22
  */
16
23
  frameId: number;
17
24
  /**
18
- * The function's result, once its promise settled.
25
+ * The function's result, once its promise settled (null when it failed).
19
26
  */
20
27
  result: T;
28
+ /**
29
+ * Why it failed there: the module didn't load, or the function threw.
30
+ */
31
+ error?: string;
21
32
  };
22
33
  /**
23
34
  * The parts of `chrome.scripting` a call uses.
@@ -30,15 +41,19 @@ export type FrameResult<T> = {
30
41
  * @template T
31
42
  * @typedef {object} FrameResult
32
43
  * @property {number} frameId The frame (0 is the top one).
33
- * @property {T} result The function's result, once its promise settled.
44
+ * @property {T} result The function's result, once its promise settled (null when it failed).
45
+ * @property {string} [error] Why it failed there: the module didn't load, or the function threw.
34
46
  */
47
+ /** The key of what {@link importAndCall} answers with when the call failed: Chrome itself gives a bare null for a function that throws. */
48
+ export declare const CALL_FAILED = "extension-kit:call-failed";
35
49
  /**
36
50
  * The function `executeScript` runs in the page: alone, without closures.
37
51
  *
38
52
  * @param {string} module - The module's path in the extension, `content/main.js`.
39
53
  * @param {string} name - The export to call.
40
54
  * @param {unknown[]} args - Its arguments.
41
- * @returns {Promise<unknown>} What it returns.
55
+ * @returns {Promise<unknown>} What it returns, or `{'extension-kit:call-failed': message}` when the
56
+ * module didn't load or the function threw (`executeScript` would give null, and no reason).
42
57
  */
43
58
  export declare const importAndCall: (module: string, name: string, args: unknown[]) => Promise<unknown>;
44
59
  /**
@@ -49,15 +64,20 @@ export declare const importAndCall: (module: string, name: string, args: unknown
49
64
  * @param {chrome.scripting.InjectionTarget} target - The tab, and which frames (`allFrames`, `frameIds`).
50
65
  * @param {string} module - The module's path in the extension (listed in `web_accessible_resources`).
51
66
  * @param {string} name - The exported function.
52
- * @param {unknown[]} [args] - Its arguments (structured-cloneable).
53
- * @param {{immediately?: boolean}} [options] - `immediately` (default true): don't wait for the
54
- * page to be idle, which a page still loading ads or a player may not be for a long time.
55
- * @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out;
56
- * rejects when it can run in none (a page Chrome keeps to itself, no access).
67
+ * @param {unknown[]} [args] - Its arguments, and its result: JSON-serialisable (Chrome carries them
68
+ * as JSON, so a Map, a Set or a Date doesn't survive).
69
+ * @param {{immediately?: boolean, timeoutMs?: number}} [options] - `immediately` (default true): don't
70
+ * wait for the page to be idle, which a page still loading ads or a player may not be for a long
71
+ * time. `timeoutMs`: the longest to wait for every frame (Chrome answers only once all have, so one
72
+ * frame that never does keeps the rest waiting); default none.
73
+ * @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out; a
74
+ * frame where the call failed has `result: null` and its `error`. Rejects when it can run in none
75
+ * (a page Chrome keeps to itself, no access), and with a `TimeoutError` when `timeoutMs` passes.
57
76
  * @example
58
77
  * const [{ result }] = await callInPage(chrome.scripting, { tabId }, 'content/main.js', 'scan', [options]);
59
78
  * await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'highlight', [key]);
60
79
  */
61
- export declare function callInPage<T = unknown>(scripting: CallScripting, target: chrome.scripting.InjectionTarget, module: string, name: string, args?: unknown[], { immediately }?: {
80
+ export declare function callInPage<T = unknown>(scripting: CallScripting, target: chrome.scripting.InjectionTarget, module: string, name: string, args?: unknown[], { immediately, timeoutMs }?: {
62
81
  immediately?: boolean;
82
+ timeoutMs?: number;
63
83
  }): Promise<FrameResult<T>[]>;
@@ -6,9 +6,18 @@
6
6
  * The module, and anything it keeps, stays loaded in that world between calls,
7
7
  * so a content script can hold a session (a picker, an inspector) across them.
8
8
  *
9
+ * `"use_dynamic_url": true` on those resources would keep a page from loading
10
+ * them to tell the extension is installed, and this import still works with
11
+ * it, but only for a module with no static imports: Chrome refuses a
12
+ * dynamic-URL module's own static import of a sibling module (checked in Chromium 156,
13
+ * tests/chrome-chromium.test.js). So it suits a single-file (bundled) page
14
+ * module, not one that imports its parts.
15
+ *
9
16
  * @module scripting/call
10
17
  */
11
18
 
19
+ import { deadline } from '../time/time.js';
20
+
12
21
  /**
13
22
  * The parts of `chrome.scripting` a call uses.
14
23
  *
@@ -21,18 +30,24 @@
21
30
  * @template T
22
31
  * @typedef {object} FrameResult
23
32
  * @property {number} frameId The frame (0 is the top one).
24
- * @property {T} result The function's result, once its promise settled.
33
+ * @property {T} result The function's result, once its promise settled (null when it failed).
34
+ * @property {string} [error] Why it failed there: the module didn't load, or the function threw.
25
35
  */
26
36
 
37
+ /** The key of what {@link importAndCall} answers with when the call failed: Chrome itself gives a bare null for a function that throws. */
38
+ export const CALL_FAILED = 'extension-kit:call-failed';
39
+
27
40
  /**
28
41
  * The function `executeScript` runs in the page: alone, without closures.
29
42
  *
30
43
  * @param {string} module - The module's path in the extension, `content/main.js`.
31
44
  * @param {string} name - The export to call.
32
45
  * @param {unknown[]} args - Its arguments.
33
- * @returns {Promise<unknown>} What it returns.
46
+ * @returns {Promise<unknown>} What it returns, or `{'extension-kit:call-failed': message}` when the
47
+ * module didn't load or the function threw (`executeScript` would give null, and no reason).
34
48
  */
35
- export const importAndCall = (module, name, args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args));
49
+ export const importAndCall = (module, name, args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args))
50
+ .catch((error) => ({ 'extension-kit:call-failed': String(error?.message ?? error) }));
36
51
 
37
52
  /**
38
53
  * Call `name` from the extension module `module` in the target's frames.
@@ -42,16 +57,25 @@ export const importAndCall = (module, name, args) => import(chrome.runtime.getUR
42
57
  * @param {chrome.scripting.InjectionTarget} target - The tab, and which frames (`allFrames`, `frameIds`).
43
58
  * @param {string} module - The module's path in the extension (listed in `web_accessible_resources`).
44
59
  * @param {string} name - The exported function.
45
- * @param {unknown[]} [args] - Its arguments (structured-cloneable).
46
- * @param {{immediately?: boolean}} [options] - `immediately` (default true): don't wait for the
47
- * page to be idle, which a page still loading ads or a player may not be for a long time.
48
- * @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out;
49
- * rejects when it can run in none (a page Chrome keeps to itself, no access).
60
+ * @param {unknown[]} [args] - Its arguments, and its result: JSON-serialisable (Chrome carries them
61
+ * as JSON, so a Map, a Set or a Date doesn't survive).
62
+ * @param {{immediately?: boolean, timeoutMs?: number}} [options] - `immediately` (default true): don't
63
+ * wait for the page to be idle, which a page still loading ads or a player may not be for a long
64
+ * time. `timeoutMs`: the longest to wait for every frame (Chrome answers only once all have, so one
65
+ * frame that never does keeps the rest waiting); default none.
66
+ * @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out; a
67
+ * frame where the call failed has `result: null` and its `error`. Rejects when it can run in none
68
+ * (a page Chrome keeps to itself, no access), and with a `TimeoutError` when `timeoutMs` passes.
50
69
  * @example
51
70
  * const [{ result }] = await callInPage(chrome.scripting, { tabId }, 'content/main.js', 'scan', [options]);
52
71
  * await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'highlight', [key]);
53
72
  */
54
- export async function callInPage(scripting, target, module, name, args = [], { immediately = true } = {}) {
55
- const frames = await scripting.executeScript({ target, injectImmediately: immediately, func: importAndCall, args: [module, name, args] });
56
- return frames.map((frame) => ({ frameId: frame.frameId, result: /** @type {T} */ (frame.result) }));
73
+ export async function callInPage(scripting, target, module, name, args = [], { immediately = true, timeoutMs } = {}) {
74
+ const running = scripting.executeScript({ target, injectImmediately: immediately, func: importAndCall, args: [module, name, args] });
75
+ const frames = await (timeoutMs === undefined ? running
76
+ : deadline(running, timeoutMs, (_, reject) => reject(new DOMException(`${module} ${name}: no answer in ${timeoutMs} ms`, 'TimeoutError'))));
77
+ return frames.map(({ frameId, result }) => {
78
+ const failed = result && typeof result === 'object' ? /** @type {Record<string, unknown>} */ (result)[CALL_FAILED] : undefined;
79
+ return typeof failed === 'string' ? { frameId, result: /** @type {T} */ (null), error: failed } : { frameId, result: /** @type {T} */ (result) };
80
+ });
57
81
  }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The right-click menu built whole, one build after another. A worker builds its menu on install
3
+ * and on browser start, and after a Chrome update both events come in the same run: two builds
4
+ * side by side clear the menu twice and then each creates every item, so Chrome reports
5
+ * "Cannot create item with duplicate id" for each on the extension's Errors page. Built through
6
+ * this, the second build starts once the first is done.
7
+ *
8
+ * @module scripting/context-menus
9
+ */
10
+ export type MenuEdges = {
11
+ /**
12
+ * The menu.
13
+ */
14
+ contextMenus: Pick<typeof chrome.contextMenus, 'removeAll' | 'create'>;
15
+ /**
16
+ * Where Chrome leaves `create`'s error.
17
+ */
18
+ runtime: {
19
+ lastError?: {
20
+ message?: string;
21
+ };
22
+ };
23
+ };
24
+ /**
25
+ * The parts of `chrome` a menu build uses.
26
+ *
27
+ * @typedef {object} MenuEdges
28
+ * @property {Pick<typeof chrome.contextMenus, 'removeAll' | 'create'>} contextMenus The menu.
29
+ * @property {{lastError?: {message?: string}}} runtime Where Chrome leaves `create`'s error.
30
+ */
31
+ /**
32
+ * A builder for an extension's right-click menu: each call takes every item away and creates the
33
+ * ones given, in order (a parent before its children), after any build still under way.
34
+ * `contextMenus.create` has no promise form; an item Chrome refuses is logged, not thrown, so one
35
+ * bad item can't stop the rest.
36
+ *
37
+ * @param {MenuEdges} edges - `chrome` (or fakes in tests).
38
+ * @returns {(items: readonly chrome.contextMenus.CreateProperties[]) => Promise<void>} Builds the
39
+ * menu; settles once Chrome has made (or refused) the last item.
40
+ * @example
41
+ * const buildMenu = contextMenuBuilder(chrome);
42
+ * chrome.runtime.onInstalled.addListener(() => buildMenu(menuItems(t)));
43
+ * chrome.runtime.onStartup.addListener(() => buildMenu(menuItems(t)));
44
+ */
45
+ export declare function contextMenuBuilder({ contextMenus, runtime }: MenuEdges): (items: readonly chrome.contextMenus.CreateProperties[]) => Promise<void>;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The right-click menu built whole, one build after another. A worker builds its menu on install
3
+ * and on browser start, and after a Chrome update both events come in the same run: two builds
4
+ * side by side clear the menu twice and then each creates every item, so Chrome reports
5
+ * "Cannot create item with duplicate id" for each on the extension's Errors page. Built through
6
+ * this, the second build starts once the first is done.
7
+ *
8
+ * @module scripting/context-menus
9
+ */
10
+
11
+ /**
12
+ * The parts of `chrome` a menu build uses.
13
+ *
14
+ * @typedef {object} MenuEdges
15
+ * @property {Pick<typeof chrome.contextMenus, 'removeAll' | 'create'>} contextMenus The menu.
16
+ * @property {{lastError?: {message?: string}}} runtime Where Chrome leaves `create`'s error.
17
+ */
18
+
19
+ /**
20
+ * A builder for an extension's right-click menu: each call takes every item away and creates the
21
+ * ones given, in order (a parent before its children), after any build still under way.
22
+ * `contextMenus.create` has no promise form; an item Chrome refuses is logged, not thrown, so one
23
+ * bad item can't stop the rest.
24
+ *
25
+ * @param {MenuEdges} edges - `chrome` (or fakes in tests).
26
+ * @returns {(items: readonly chrome.contextMenus.CreateProperties[]) => Promise<void>} Builds the
27
+ * menu; settles once Chrome has made (or refused) the last item.
28
+ * @example
29
+ * const buildMenu = contextMenuBuilder(chrome);
30
+ * chrome.runtime.onInstalled.addListener(() => buildMenu(menuItems(t)));
31
+ * chrome.runtime.onStartup.addListener(() => buildMenu(menuItems(t)));
32
+ */
33
+ export function contextMenuBuilder({ contextMenus, runtime }) {
34
+ /** The last build, so the next waits for it. */
35
+ let queue = Promise.resolve();
36
+ /** @param {readonly chrome.contextMenus.CreateProperties[]} items */
37
+ const build = async (items) => {
38
+ await contextMenus.removeAll();
39
+ for (const item of items) {
40
+ await new Promise((resolve) => {
41
+ contextMenus.create(item, () => {
42
+ if (runtime.lastError) console.error(`Creating the menu item “${item.id}”:`, runtime.lastError.message);
43
+ resolve(undefined);
44
+ });
45
+ });
46
+ }
47
+ };
48
+ return (items) => {
49
+ const run = queue.then(() => build(items), () => build(items));
50
+ queue = run.catch(() => {});
51
+ return run;
52
+ };
53
+ }
@@ -25,7 +25,8 @@ export type Scripting = Pick<typeof chrome.scripting, 'getRegisteredContentScrip
25
25
  * @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
26
26
  * @param {string} id - The registered script's id.
27
27
  * @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
28
- * grants read at the time).
28
+ * grants read at the time). Give every property each time (`excludeMatches: []` for none): Chrome's
29
+ * update keeps what a script leaves out, so a list that became empty and was dropped would stay.
29
30
  * @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
30
31
  * @example
31
32
  * const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
@@ -24,7 +24,8 @@
24
24
  * @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
25
25
  * @param {string} id - The registered script's id.
26
26
  * @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
27
- * grants read at the time).
27
+ * grants read at the time). Give every property each time (`excludeMatches: []` for none): Chrome's
28
+ * update keeps what a script leaves out, so a list that became empty and was dropped would stay.
28
29
  * @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
29
30
  * @example
30
31
  * const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Reading a tab: one of the extension's page modules called in every frame
3
+ * Chrome lets it run in, the frames' answers top first; or why it can't be
4
+ * read: a page Chrome keeps to itself (its own pages, the Web Store, other
5
+ * extensions'), no access (the tab went to another site since the click
6
+ * that gave activeTab), the module failed in every frame, or no answer in
7
+ * time.
8
+ *
9
+ * @module scripting/tab
10
+ */
11
+ import type { FrameResult } from './call.js';
12
+ /**
13
+ * Whether Chrome keeps a page from extensions.
14
+ *
15
+ * @param {string} url - The page's address.
16
+ * @returns {boolean} It does.
17
+ * @example
18
+ * isBrowserPage('chrome://settings'); // true
19
+ * isBrowserPage('https://chromewebstore.google.com/detail/x'); // true
20
+ */
21
+ export declare const isBrowserPage: (url: string) => boolean;
22
+ export type TabReading<T> = {
23
+ state: 'read';
24
+ frames: FrameResult<T>[];
25
+ } | {
26
+ state: 'browser';
27
+ } | {
28
+ state: 'no-access';
29
+ } | {
30
+ state: 'failed';
31
+ error: string;
32
+ } | {
33
+ state: 'timeout';
34
+ };
35
+ export type TabEdges = {
36
+ /**
37
+ * `chrome.tabs`.
38
+ */
39
+ tabs: Pick<typeof chrome.tabs, 'get'>;
40
+ /**
41
+ * `chrome.scripting`.
42
+ */
43
+ scripting: Pick<typeof chrome.scripting, 'executeScript'>;
44
+ };
45
+ /**
46
+ * What reading a tab gave.
47
+ *
48
+ * @template T
49
+ * @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'} | {state: 'failed', error: string} | {state: 'timeout'}} TabReading
50
+ * `frames`: each frame that answered (not null), the top one first, then in Chrome's frame order.
51
+ * `failed`: no frame answered and the call threw in at least one (`error`: the first one's reason),
52
+ * which Chrome itself reports as a bare null; `timeout`: `timeoutMs` passed first.
53
+ */
54
+ /**
55
+ * The Chrome APIs a reading uses (fakes in unit tests).
56
+ *
57
+ * @typedef {object} TabEdges
58
+ * @property {Pick<typeof chrome.tabs, 'get'>} tabs `chrome.tabs`.
59
+ * @property {Pick<typeof chrome.scripting, 'executeScript'>} scripting `chrome.scripting`.
60
+ */
61
+ /**
62
+ * Call a page module's export in every frame of a tab, or say why it can't be.
63
+ *
64
+ * @template [T=unknown]
65
+ * @param {TabEdges} edges - Chrome's tabs and scripting.
66
+ * @param {number} tabId - The tab.
67
+ * @param {string} module - The module (in `web_accessible_resources`).
68
+ * @param {string} name - The export.
69
+ * @param {unknown[]} [args] - Its arguments.
70
+ * @param {{timeoutMs?: number}} [options] - `timeoutMs`: the longest to wait (Chrome answers once every
71
+ * frame has, so one that never does would keep a panel waiting for ever); default none.
72
+ * @returns {Promise<TabReading<T>>} The frames' answers, or why there are none.
73
+ * @example
74
+ * const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
75
+ * if (reading.state === 'read') show(reading.frames);
76
+ * else showMessage(reading.state); // 'browser', 'no-access', 'failed' or 'timeout'
77
+ */
78
+ export declare function callInTab<T = unknown>(edges: TabEdges, tabId: number, module: string, name: string, args?: unknown[], { timeoutMs }?: {
79
+ timeoutMs?: number;
80
+ }): Promise<TabReading<T>>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Reading a tab: one of the extension's page modules called in every frame
3
+ * Chrome lets it run in, the frames' answers top first; or why it can't be
4
+ * read: a page Chrome keeps to itself (its own pages, the Web Store, other
5
+ * extensions'), no access (the tab went to another site since the click
6
+ * that gave activeTab), the module failed in every frame, or no answer in
7
+ * time.
8
+ *
9
+ * @module scripting/tab
10
+ */
11
+
12
+ import { callInPage } from './call.js';
13
+
14
+ /** @import { FrameResult } from './call.js' */
15
+
16
+ /** Addresses Chrome keeps from every extension. */
17
+ const BROWSER_PAGE = /^(?:chrome|chrome-extension|chrome-untrusted|devtools|edge|about|view-source):|^https:\/\/(?:chromewebstore\.google\.com|chrome\.google\.com\/webstore)\//;
18
+
19
+ /** Chrome's refusals for its own pages, when the page's address isn't known (no access to the tab). */
20
+ const BROWSER_REFUSAL = /Cannot access a (?:chrome|chrome-extension|chrome-untrusted|devtools|edge):\/\/ URL|extensions gallery cannot be scripted/i;
21
+
22
+ /**
23
+ * Whether Chrome keeps a page from extensions.
24
+ *
25
+ * @param {string} url - The page's address.
26
+ * @returns {boolean} It does.
27
+ * @example
28
+ * isBrowserPage('chrome://settings'); // true
29
+ * isBrowserPage('https://chromewebstore.google.com/detail/x'); // true
30
+ */
31
+ export const isBrowserPage = (url) => BROWSER_PAGE.test(url);
32
+
33
+ /**
34
+ * What reading a tab gave.
35
+ *
36
+ * @template T
37
+ * @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'} | {state: 'failed', error: string} | {state: 'timeout'}} TabReading
38
+ * `frames`: each frame that answered (not null), the top one first, then in Chrome's frame order.
39
+ * `failed`: no frame answered and the call threw in at least one (`error`: the first one's reason),
40
+ * which Chrome itself reports as a bare null; `timeout`: `timeoutMs` passed first.
41
+ */
42
+
43
+ /**
44
+ * The Chrome APIs a reading uses (fakes in unit tests).
45
+ *
46
+ * @typedef {object} TabEdges
47
+ * @property {Pick<typeof chrome.tabs, 'get'>} tabs `chrome.tabs`.
48
+ * @property {Pick<typeof chrome.scripting, 'executeScript'>} scripting `chrome.scripting`.
49
+ */
50
+
51
+ /**
52
+ * Call a page module's export in every frame of a tab, or say why it can't be.
53
+ *
54
+ * @template [T=unknown]
55
+ * @param {TabEdges} edges - Chrome's tabs and scripting.
56
+ * @param {number} tabId - The tab.
57
+ * @param {string} module - The module (in `web_accessible_resources`).
58
+ * @param {string} name - The export.
59
+ * @param {unknown[]} [args] - Its arguments.
60
+ * @param {{timeoutMs?: number}} [options] - `timeoutMs`: the longest to wait (Chrome answers once every
61
+ * frame has, so one that never does would keep a panel waiting for ever); default none.
62
+ * @returns {Promise<TabReading<T>>} The frames' answers, or why there are none.
63
+ * @example
64
+ * const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
65
+ * if (reading.state === 'read') show(reading.frames);
66
+ * else showMessage(reading.state); // 'browser', 'no-access', 'failed' or 'timeout'
67
+ */
68
+ export async function callInTab(edges, tabId, module, name, args = [], { timeoutMs } = {}) {
69
+ let url;
70
+ try {
71
+ url = (await edges.tabs.get(tabId)).url;
72
+ } catch {
73
+ return { state: 'no-access' };
74
+ }
75
+ if (url && isBrowserPage(url)) return { state: 'browser' };
76
+ /** @type {FrameResult<T>[]} */
77
+ let frames;
78
+ try {
79
+ frames = await callInPage(edges.scripting, { tabId, allFrames: true }, module, name, args, { timeoutMs });
80
+ } catch (error) {
81
+ if (/** @type {Error} */ (error)?.name === 'TimeoutError') return { state: 'timeout' };
82
+ // Chrome's own page (said in its refusal), or no access: the tab went to another site since the click.
83
+ return { state: BROWSER_REFUSAL.test(String(/** @type {Error} */ (error)?.message)) ? 'browser' : 'no-access' };
84
+ }
85
+ const read = frames.filter((frame) => frame.result !== null && frame.result !== undefined).sort((a, b) => a.frameId - b.frameId);
86
+ if (read.length) return { state: 'read', frames: read };
87
+ const failed = frames.toSorted((a, b) => a.frameId - b.frameId).find((frame) => frame.error !== undefined);
88
+ return failed ? { state: 'failed', error: /** @type {string} */ (failed.error) } : { state: 'no-access' };
89
+ }
@@ -56,6 +56,7 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
56
56
  let retryTimer;
57
57
  /** @type {Promise<unknown>} */
58
58
  let writing = Promise.resolve();
59
+ let stopped = false;
59
60
 
60
61
  /**
61
62
  * Track a write so `flush()` can be awaited; a failure is logged, never thrown.
@@ -68,10 +69,18 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
68
69
  return writing;
69
70
  };
70
71
 
72
+ /** Try again later, if anything held could reach sync then. */
73
+ async function retryLater() {
74
+ clearTimeout(retryTimer);
75
+ retryTimer = undefined;
76
+ const worthIt = await store.retryable(storage);
77
+ if (stopped || !worthIt) return;
78
+ clearTimeout(retryTimer);
79
+ retryTimer = setTimeout(retry, retryAfter);
80
+ }
81
+
71
82
  function retry() {
72
- track(store.retryUnsynced(storage).then((synced) => {
73
- if (!synced) retryTimer = setTimeout(retry, retryAfter);
74
- }));
83
+ track(store.retryUnsynced(storage).then((synced) => (synced ? undefined : retryLater())));
75
84
  }
76
85
 
77
86
  /** Writes start at once, not after earlier ones finish: Chrome applies them in order. */
@@ -80,10 +89,10 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
80
89
  const changes = pending;
81
90
  pending = {};
82
91
  if (!Object.keys(changes).length) return writing;
83
- return track(store.save(storage, changes).then(({ settings, synced }) => {
84
- clearTimeout(retryTimer);
85
- if (!synced) retryTimer = setTimeout(retry, retryAfter);
92
+ return track(store.save(storage, changes).then(async ({ settings, synced }) => {
86
93
  onSaved(settings);
94
+ if (synced) clearTimeout(retryTimer);
95
+ else await retryLater();
87
96
  }));
88
97
  }
89
98
 
@@ -102,6 +111,7 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
102
111
  });
103
112
  },
104
113
  stop() {
114
+ stopped = true;
105
115
  clearTimeout(timer);
106
116
  clearTimeout(retryTimer);
107
117
  },
@@ -74,14 +74,45 @@ export declare const MAX_SYNC_ITEM_BYTES = 8192;
74
74
  */
75
75
  export declare function pick<T extends Record<string, unknown>>(object: T, keys: readonly string[]): Partial<T>;
76
76
  /**
77
- * How many bytes sync counts for one item: the key plus the value's JSON,
78
- * UTF-8 encoded, as Chrome measures `QUOTA_BYTES_PER_ITEM`.
77
+ * How many bytes sync counts for one item, as Chrome measures
78
+ * `QUOTA_BYTES_PER_ITEM`: the key plus the value's JSON as Chrome's own
79
+ * writer spells it, UTF-8 encoded. That writer (`base::WriteJson`) escapes
80
+ * `<` as `\u003C` and U+2028 / U+2029 as `\u2028` / `\u2029`, six bytes each,
81
+ * so text full of `<` (an HTML template) weighs far more in sync than its
82
+ * `JSON.stringify` length says. Checked against Chromium's
83
+ * `settings_storage_quota_enforcer.cc` and `base/json/string_escape.cc`.
79
84
  *
80
85
  * @param {string} key - The item's key.
81
86
  * @param {unknown} value - The item's value.
82
87
  * @returns {number} Bytes.
88
+ * @example
89
+ * syncItemBytes('k', 'a<b'); // 11: k + "a\u003Cb"
83
90
  */
84
91
  export declare function syncItemBytes(key: string, value: unknown): number;
92
+ /**
93
+ * Whether two stored values are the same, whatever order their objects' keys come in.
94
+ *
95
+ * @param {unknown} a - One value.
96
+ * @param {unknown} b - The other.
97
+ * @returns {boolean} True when they'd be stored alike.
98
+ * @example
99
+ * sameValue({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
100
+ */
101
+ export declare function sameValue(a: unknown, b: unknown): boolean;
102
+ /**
103
+ * Run `work` while nothing else runs under the same name: in this page, the
104
+ * extension's other pages and its service worker alike (Web Locks, which an
105
+ * extension's contexts share), so a read-modify-write of one storage item
106
+ * can't interleave with another. `work` is told whether it had to wait.
107
+ *
108
+ * @template T
109
+ * @param {string} name - The lock's name.
110
+ * @param {(waited: boolean) => Promise<T>} work - What to do; `waited` is true when other work held the name first.
111
+ * @returns {Promise<T>} What `work` gives.
112
+ * @example
113
+ * await exclusive('unsyncedSettings', async () => storage.local.set({ held: { ...(await storage.local.get('held')).held, days } }));
114
+ */
115
+ export declare function exclusive<T>(name: string, work: (waited: boolean) => Promise<T>): Promise<T>;
85
116
  /**
86
117
  * Whether an object has no own keys.
87
118
  *
@@ -53,16 +53,86 @@ export function pick(object, keys) {
53
53
  return /** @type {Partial<T>} */ (Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]])));
54
54
  }
55
55
 
56
+ /** What Chrome's JSON writer spells as six bytes where `JSON.stringify` writes fewer: `<` (one byte), U+2028 and U+2029 (three each). */
57
+ const WIDER_IN_CHROME = /[<\u2028\u2029]/g;
58
+
56
59
  /**
57
- * How many bytes sync counts for one item: the key plus the value's JSON,
58
- * UTF-8 encoded, as Chrome measures `QUOTA_BYTES_PER_ITEM`.
60
+ * How many bytes sync counts for one item, as Chrome measures
61
+ * `QUOTA_BYTES_PER_ITEM`: the key plus the value's JSON as Chrome's own
62
+ * writer spells it, UTF-8 encoded. That writer (`base::WriteJson`) escapes
63
+ * `<` as `\u003C` and U+2028 / U+2029 as `\u2028` / `\u2029`, six bytes each,
64
+ * so text full of `<` (an HTML template) weighs far more in sync than its
65
+ * `JSON.stringify` length says. Checked against Chromium's
66
+ * `settings_storage_quota_enforcer.cc` and `base/json/string_escape.cc`.
59
67
  *
60
68
  * @param {string} key - The item's key.
61
69
  * @param {unknown} value - The item's value.
62
70
  * @returns {number} Bytes.
71
+ * @example
72
+ * syncItemBytes('k', 'a<b'); // 11: k + "a\u003Cb"
63
73
  */
64
74
  export function syncItemBytes(key, value) {
65
- return new TextEncoder().encode(key + JSON.stringify(value)).length;
75
+ const json = String(JSON.stringify(value));
76
+ let wider = 0;
77
+ for (const [character] of json.matchAll(WIDER_IN_CHROME)) wider += character === '<' ? 5 : 3;
78
+ return new TextEncoder().encode(key + json).length + wider;
79
+ }
80
+
81
+ /**
82
+ * A value as text that reads the same however its objects' keys are ordered:
83
+ * Chrome hands a stored object back with its keys sorted.
84
+ *
85
+ * @param {unknown} value - Anything JSON can hold.
86
+ * @returns {string} Its JSON, object keys sorted.
87
+ */
88
+ function canonical(value) {
89
+ return String(JSON.stringify(value, (_, inner) => (inner && typeof inner === 'object' && !Array.isArray(inner)
90
+ ? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
91
+ : inner)));
92
+ }
93
+
94
+ /**
95
+ * Whether two stored values are the same, whatever order their objects' keys come in.
96
+ *
97
+ * @param {unknown} a - One value.
98
+ * @param {unknown} b - The other.
99
+ * @returns {boolean} True when they'd be stored alike.
100
+ * @example
101
+ * sameValue({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
102
+ */
103
+ export function sameValue(a, b) {
104
+ return canonical(a) === canonical(b);
105
+ }
106
+
107
+ /** Where there are no Web Locks (an old Node): each name's last piece of work, so the next waits for it. @type {Map<string, Promise<unknown>>} */
108
+ const chains = new Map();
109
+
110
+ /**
111
+ * Run `work` while nothing else runs under the same name: in this page, the
112
+ * extension's other pages and its service worker alike (Web Locks, which an
113
+ * extension's contexts share), so a read-modify-write of one storage item
114
+ * can't interleave with another. `work` is told whether it had to wait.
115
+ *
116
+ * @template T
117
+ * @param {string} name - The lock's name.
118
+ * @param {(waited: boolean) => Promise<T>} work - What to do; `waited` is true when other work held the name first.
119
+ * @returns {Promise<T>} What `work` gives.
120
+ * @example
121
+ * await exclusive('unsyncedSettings', async () => storage.local.set({ held: { ...(await storage.local.get('held')).held, days } }));
122
+ */
123
+ export async function exclusive(name, work) {
124
+ const locks = /** @type {{navigator?: {locks?: LockManager}}} */ (globalThis).navigator?.locks;
125
+ if (locks) {
126
+ /** @type {{value: T} | null} */
127
+ const free = await locks.request(name, { ifAvailable: true }, async (lock) => (lock ? { value: await work(false) } : null));
128
+ return free ? free.value : locks.request(name, () => work(true));
129
+ }
130
+ const before = chains.get(name);
131
+ const run = (before ?? Promise.resolve()).then(() => work(before !== undefined));
132
+ const settled = run.catch(() => {});
133
+ chains.set(name, settled);
134
+ settled.then(() => { if (chains.get(name) === settled) chains.delete(name); });
135
+ return run;
66
136
  }
67
137
 
68
138
  /**