@arraypress/extension-kit 1.10.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 (68) hide show
  1. package/README.md +97 -20
  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/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +56 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +31 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. 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 })
@@ -2,8 +2,9 @@
2
2
  * Reading a tab: one of the extension's page modules called in every frame
3
3
  * Chrome lets it run in, the frames' answers top first; or why it can't be
4
4
  * read: a page Chrome keeps to itself (its own pages, the Web Store, other
5
- * extensions'), or no access (the tab went to another site since the click
6
- * that gave activeTab).
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.
7
8
  *
8
9
  * @module scripting/tab
9
10
  */
@@ -25,6 +26,11 @@ export type TabReading<T> = {
25
26
  state: 'browser';
26
27
  } | {
27
28
  state: 'no-access';
29
+ } | {
30
+ state: 'failed';
31
+ error: string;
32
+ } | {
33
+ state: 'timeout';
28
34
  };
29
35
  export type TabEdges = {
30
36
  /**
@@ -40,8 +46,10 @@ export type TabEdges = {
40
46
  * What reading a tab gave.
41
47
  *
42
48
  * @template T
43
- * @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'}} TabReading
49
+ * @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'} | {state: 'failed', error: string} | {state: 'timeout'}} TabReading
44
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.
45
53
  */
46
54
  /**
47
55
  * The Chrome APIs a reading uses (fakes in unit tests).
@@ -59,10 +67,14 @@ export type TabEdges = {
59
67
  * @param {string} module - The module (in `web_accessible_resources`).
60
68
  * @param {string} name - The export.
61
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.
62
72
  * @returns {Promise<TabReading<T>>} The frames' answers, or why there are none.
63
73
  * @example
64
74
  * const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
65
75
  * if (reading.state === 'read') show(reading.frames);
66
- * else showMessage(reading.state); // 'browser' or 'no-access'
76
+ * else showMessage(reading.state); // 'browser', 'no-access', 'failed' or 'timeout'
67
77
  */
68
- export declare function callInTab<T = unknown>(edges: TabEdges, tabId: number, module: string, name: string, args?: unknown[]): Promise<TabReading<T>>;
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>>;
@@ -2,8 +2,9 @@
2
2
  * Reading a tab: one of the extension's page modules called in every frame
3
3
  * Chrome lets it run in, the frames' answers top first; or why it can't be
4
4
  * read: a page Chrome keeps to itself (its own pages, the Web Store, other
5
- * extensions'), or no access (the tab went to another site since the click
6
- * that gave activeTab).
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.
7
8
  *
8
9
  * @module scripting/tab
9
10
  */
@@ -33,8 +34,10 @@ export const isBrowserPage = (url) => BROWSER_PAGE.test(url);
33
34
  * What reading a tab gave.
34
35
  *
35
36
  * @template T
36
- * @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'}} TabReading
37
+ * @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'} | {state: 'failed', error: string} | {state: 'timeout'}} TabReading
37
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.
38
41
  */
39
42
 
40
43
  /**
@@ -54,13 +57,15 @@ export const isBrowserPage = (url) => BROWSER_PAGE.test(url);
54
57
  * @param {string} module - The module (in `web_accessible_resources`).
55
58
  * @param {string} name - The export.
56
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.
57
62
  * @returns {Promise<TabReading<T>>} The frames' answers, or why there are none.
58
63
  * @example
59
64
  * const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
60
65
  * if (reading.state === 'read') show(reading.frames);
61
- * else showMessage(reading.state); // 'browser' or 'no-access'
66
+ * else showMessage(reading.state); // 'browser', 'no-access', 'failed' or 'timeout'
62
67
  */
63
- export async function callInTab(edges, tabId, module, name, args = []) {
68
+ export async function callInTab(edges, tabId, module, name, args = [], { timeoutMs } = {}) {
64
69
  let url;
65
70
  try {
66
71
  url = (await edges.tabs.get(tabId)).url;
@@ -71,11 +76,14 @@ export async function callInTab(edges, tabId, module, name, args = []) {
71
76
  /** @type {FrameResult<T>[]} */
72
77
  let frames;
73
78
  try {
74
- frames = await callInPage(edges.scripting, { tabId, allFrames: true }, module, name, args);
79
+ frames = await callInPage(edges.scripting, { tabId, allFrames: true }, module, name, args, { timeoutMs });
75
80
  } catch (error) {
81
+ if (/** @type {Error} */ (error)?.name === 'TimeoutError') return { state: 'timeout' };
76
82
  // Chrome's own page (said in its refusal), or no access: the tab went to another site since the click.
77
83
  return { state: BROWSER_REFUSAL.test(String(/** @type {Error} */ (error)?.message)) ? 'browser' : 'no-access' };
78
84
  }
79
85
  const read = frames.filter((frame) => frame.result !== null && frame.result !== undefined).sort((a, b) => a.frameId - b.frameId);
80
- return read.length ? { state: 'read', frames: read } : { state: 'no-access' };
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' };
81
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
  /**
@@ -8,6 +8,15 @@
8
8
  * `chrome.storage.local` instead. Local also holds any write sync refused
9
9
  * (too many writes a minute, or an item over its size limit), kept under
10
10
  * `unsyncedKey`, read as if synced, and retried, so a change is never lost.
11
+ * Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
12
+ * refused: once sync holds something else (the setting was changed since on
13
+ * another computer), the held change is the older one and is dropped, not
14
+ * read over the newer one or sent up over it.
15
+ *
16
+ * Holding a change is a read-modify-write of one local item, so it runs
17
+ * under a Web Lock the extension's pages and worker share: two refused
18
+ * writes at once keep both changes, and a retry can't put an older value
19
+ * back over a newer one.
11
20
  *
12
21
  * Each extension supplies its schema: the defaults and one normaliser that
13
22
  * coerces anything read from storage or a form into valid settings.
@@ -72,6 +81,11 @@ export type SettingsStore<S extends Record<string, unknown>> = {
72
81
  * Try again to put earlier refusals into sync.
73
82
  */
74
83
  retryUnsynced: (storage: SyncedStorage) => Promise<boolean>;
84
+ /**
85
+ * Whether trying again could help: something held is still the newest and small enough for sync
86
+ * (an item over sync's size limit stays on this computer however often it's tried).
87
+ */
88
+ retryable: (storage: SyncedStorage) => Promise<boolean>;
75
89
  /**
76
90
  * Save the settings `changes` names, normalised.
77
91
  */
@@ -116,6 +130,9 @@ export type SettingsStore<S extends Record<string, unknown>> = {
116
130
  * everything reached sync.
117
131
  * @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
118
132
  * Try again to put earlier refusals into sync.
133
+ * @property {(storage: SyncedStorage) => Promise<boolean>} retryable
134
+ * Whether trying again could help: something held is still the newest and small enough for sync
135
+ * (an item over sync's size limit stays on this computer however often it's tried).
119
136
  * @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
120
137
  * Save the settings `changes` names, normalised.
121
138
  * @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed