@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.
- package/README.md +103 -21
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/highlight.d.ts +15 -6
- package/src/content/highlight.js +42 -17
- package/src/content/overlay.d.ts +19 -4
- package/src/content/overlay.js +44 -6
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/frame/window.d.ts +1 -1
- package/src/frame/window.js +8 -7
- package/src/index.d.ts +10 -3
- package/src/index.js +8 -3
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +17 -5
- package/src/scripting/tab.js +15 -7
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +59 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +3 -3
- package/src/testing/index.js +5 -3
- package/src/testing/live-run.d.ts +23 -2
- package/src/testing/live-run.js +64 -18
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +48 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.d.ts +3 -0
- 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
|
+
}
|
package/src/scripting/call.d.ts
CHANGED
|
@@ -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 (
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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>[]>;
|
package/src/scripting/call.js
CHANGED
|
@@ -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 (
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
56
|
-
|
|
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 })
|
package/src/scripting/tab.d.ts
CHANGED
|
@@ -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'),
|
|
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'
|
|
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[]
|
|
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>>;
|
package/src/scripting/tab.js
CHANGED
|
@@ -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'),
|
|
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'
|
|
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
|
-
|
|
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
|
}
|
package/src/settings/saver.js
CHANGED
|
@@ -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
|
},
|