@arraypress/extension-kit 1.10.0 → 1.11.1

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 (69) hide show
  1. package/README.md +103 -21
  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 +59 -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 +48 -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.d.ts +3 -0
  69. package/src/ui/toast.js +23 -1
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A side panel's “Show on page”: which of the panel's items (a font, a
3
+ * colour) is boxed on the page, kept true whatever ends it. A click on the
4
+ * item's button shows it, a second click takes it away, another item's
5
+ * replaces it; a rescan or the panel closing clears the page's boxes
6
+ * (`reset`); and when the page clears them itself (Esc there, or the page
7
+ * left: the highlighter's `onClear` reasons `'escape'` and `'pagehide'`,
8
+ * sent to the panel as a message) the panel's button is released without
9
+ * calling the page back (`cleared`).
10
+ *
11
+ * @module panel/show-on-page
12
+ */
13
+ export type ShowOnPageSetup = {
14
+ /**
15
+ * Boxes these on
16
+ * the page (the panel's own call into the tab's frames), scrolling the first into view in
17
+ * `revealFrame`; null takes the boxes away.
18
+ */
19
+ show: (ids: string[] | null, revealFrame?: number) => Promise<unknown>;
20
+ /**
21
+ * Hears which item is shown now (press its
22
+ * button, release the others).
23
+ */
24
+ onChange: (shown: string | null) => void;
25
+ };
26
+ export type ShowOnPage = {
27
+ /**
28
+ * The item shown, or null.
29
+ */
30
+ shown: () => string | null;
31
+ /**
32
+ * Show the item
33
+ * `id` (the page's `ids`), or take it away if it's the one shown. Rejects when the page can't be
34
+ * reached, with nothing shown.
35
+ */
36
+ toggle: (id: string, ids: string[], revealFrame?: number) => Promise<void>;
37
+ /**
38
+ * A rescan started, or the panel is closing: take the page's boxes
39
+ * away if any are shown. The call to the page starts at once (a closing panel still gets it out)
40
+ * and a failure is ignored (the tab is gone).
41
+ */
42
+ reset: () => Promise<void>;
43
+ /**
44
+ * The page says its boxes went: nothing is shown, and the page isn't called.
45
+ */
46
+ cleared: () => void;
47
+ };
48
+ /**
49
+ * @typedef {object} ShowOnPageSetup
50
+ * @property {(ids: string[] | null, revealFrame?: number) => Promise<unknown>} show Boxes these on
51
+ * the page (the panel's own call into the tab's frames), scrolling the first into view in
52
+ * `revealFrame`; null takes the boxes away.
53
+ * @property {(shown: string | null) => void} onChange Hears which item is shown now (press its
54
+ * button, release the others).
55
+ */
56
+ /**
57
+ * @typedef {object} ShowOnPage
58
+ * @property {() => string | null} shown The item shown, or null.
59
+ * @property {(id: string, ids: string[], revealFrame?: number) => Promise<void>} toggle Show the item
60
+ * `id` (the page's `ids`), or take it away if it's the one shown. Rejects when the page can't be
61
+ * reached, with nothing shown.
62
+ * @property {() => Promise<void>} reset A rescan started, or the panel is closing: take the page's boxes
63
+ * away if any are shown. The call to the page starts at once (a closing panel still gets it out)
64
+ * and a failure is ignored (the tab is gone).
65
+ * @property {() => void} cleared The page says its boxes went: nothing is shown, and the page isn't called.
66
+ */
67
+ /**
68
+ * A panel's “Show on page” state.
69
+ *
70
+ * @param {ShowOnPageSetup} setup - How to box items on the page, and who hears what's shown.
71
+ * @returns {ShowOnPage} The controller.
72
+ * @example
73
+ * const onPage = createShowOnPage({
74
+ * show: (ids, frame) => callPage('highlight', [ids, frame]),
75
+ * onChange: (shown) => { for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.id === shown)); },
76
+ * });
77
+ * button.addEventListener('click', () => onPage.toggle(font.id, [font.id], font.frameId));
78
+ * chrome.runtime.onMessage.addListener((message) => { if (message.type === 'boxes-cleared') onPage.cleared(); });
79
+ * addEventListener('pagehide', () => onPage.reset());
80
+ * await onPage.reset(); // before a rescan
81
+ */
82
+ export declare function createShowOnPage({ show, onChange }: ShowOnPageSetup): ShowOnPage;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A side panel's “Show on page”: which of the panel's items (a font, a
3
+ * colour) is boxed on the page, kept true whatever ends it. A click on the
4
+ * item's button shows it, a second click takes it away, another item's
5
+ * replaces it; a rescan or the panel closing clears the page's boxes
6
+ * (`reset`); and when the page clears them itself (Esc there, or the page
7
+ * left: the highlighter's `onClear` reasons `'escape'` and `'pagehide'`,
8
+ * sent to the panel as a message) the panel's button is released without
9
+ * calling the page back (`cleared`).
10
+ *
11
+ * @module panel/show-on-page
12
+ */
13
+
14
+ /**
15
+ * @typedef {object} ShowOnPageSetup
16
+ * @property {(ids: string[] | null, revealFrame?: number) => Promise<unknown>} show Boxes these on
17
+ * the page (the panel's own call into the tab's frames), scrolling the first into view in
18
+ * `revealFrame`; null takes the boxes away.
19
+ * @property {(shown: string | null) => void} onChange Hears which item is shown now (press its
20
+ * button, release the others).
21
+ */
22
+
23
+ /**
24
+ * @typedef {object} ShowOnPage
25
+ * @property {() => string | null} shown The item shown, or null.
26
+ * @property {(id: string, ids: string[], revealFrame?: number) => Promise<void>} toggle Show the item
27
+ * `id` (the page's `ids`), or take it away if it's the one shown. Rejects when the page can't be
28
+ * reached, with nothing shown.
29
+ * @property {() => Promise<void>} reset A rescan started, or the panel is closing: take the page's boxes
30
+ * away if any are shown. The call to the page starts at once (a closing panel still gets it out)
31
+ * and a failure is ignored (the tab is gone).
32
+ * @property {() => void} cleared The page says its boxes went: nothing is shown, and the page isn't called.
33
+ */
34
+
35
+ /**
36
+ * A panel's “Show on page” state.
37
+ *
38
+ * @param {ShowOnPageSetup} setup - How to box items on the page, and who hears what's shown.
39
+ * @returns {ShowOnPage} The controller.
40
+ * @example
41
+ * const onPage = createShowOnPage({
42
+ * show: (ids, frame) => callPage('highlight', [ids, frame]),
43
+ * onChange: (shown) => { for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.id === shown)); },
44
+ * });
45
+ * button.addEventListener('click', () => onPage.toggle(font.id, [font.id], font.frameId));
46
+ * chrome.runtime.onMessage.addListener((message) => { if (message.type === 'boxes-cleared') onPage.cleared(); });
47
+ * addEventListener('pagehide', () => onPage.reset());
48
+ * await onPage.reset(); // before a rescan
49
+ */
50
+ export function createShowOnPage({ show, onChange }) {
51
+ /** @type {string | null} */
52
+ let shown = null;
53
+ /** @param {string | null} id */
54
+ const set = (id) => {
55
+ if (shown === id) return;
56
+ shown = id;
57
+ onChange(id);
58
+ };
59
+
60
+ return {
61
+ shown: () => shown,
62
+ async toggle(id, ids, revealFrame) {
63
+ if (shown === id) {
64
+ set(null);
65
+ await show(null);
66
+ return;
67
+ }
68
+ set(id);
69
+ try {
70
+ await show(ids, revealFrame);
71
+ } catch (error) {
72
+ if (shown === id) set(null);
73
+ throw error;
74
+ }
75
+ },
76
+ async reset() {
77
+ if (shown === null) return;
78
+ set(null);
79
+ await show(null).catch(() => {});
80
+ },
81
+ cleared: () => set(null),
82
+ };
83
+ }
@@ -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
  },