@arraypress/extension-kit 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -18
- 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/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +63 -0
- package/src/content/highlight.js +117 -0
- package/src/content/overlay.d.ts +31 -6
- package/src/content/overlay.js +56 -8
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- 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/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +8 -1
- package/src/frame/window.js +11 -8
- package/src/index.d.ts +31 -6
- package/src/index.js +24 -6
- 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 +80 -0
- package/src/scripting/tab.js +89 -0
- 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 +63 -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 +8 -2
- package/src/testing/index.js +8 -2
- package/src/testing/live-run.d.ts +151 -0
- package/src/testing/live-run.js +206 -0
- 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/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- 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 +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
- 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.js +2 -0
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 })
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tab: one of the extension's page modules called in every frame
|
|
3
|
+
* Chrome lets it run in, the frames' answers top first; or why it can't be
|
|
4
|
+
* read: a page Chrome keeps to itself (its own pages, the Web Store, other
|
|
5
|
+
* extensions'), no access (the tab went to another site since the click
|
|
6
|
+
* that gave activeTab), the module failed in every frame, or no answer in
|
|
7
|
+
* time.
|
|
8
|
+
*
|
|
9
|
+
* @module scripting/tab
|
|
10
|
+
*/
|
|
11
|
+
import type { FrameResult } from './call.js';
|
|
12
|
+
/**
|
|
13
|
+
* Whether Chrome keeps a page from extensions.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} url - The page's address.
|
|
16
|
+
* @returns {boolean} It does.
|
|
17
|
+
* @example
|
|
18
|
+
* isBrowserPage('chrome://settings'); // true
|
|
19
|
+
* isBrowserPage('https://chromewebstore.google.com/detail/x'); // true
|
|
20
|
+
*/
|
|
21
|
+
export declare const isBrowserPage: (url: string) => boolean;
|
|
22
|
+
export type TabReading<T> = {
|
|
23
|
+
state: 'read';
|
|
24
|
+
frames: FrameResult<T>[];
|
|
25
|
+
} | {
|
|
26
|
+
state: 'browser';
|
|
27
|
+
} | {
|
|
28
|
+
state: 'no-access';
|
|
29
|
+
} | {
|
|
30
|
+
state: 'failed';
|
|
31
|
+
error: string;
|
|
32
|
+
} | {
|
|
33
|
+
state: 'timeout';
|
|
34
|
+
};
|
|
35
|
+
export type TabEdges = {
|
|
36
|
+
/**
|
|
37
|
+
* `chrome.tabs`.
|
|
38
|
+
*/
|
|
39
|
+
tabs: Pick<typeof chrome.tabs, 'get'>;
|
|
40
|
+
/**
|
|
41
|
+
* `chrome.scripting`.
|
|
42
|
+
*/
|
|
43
|
+
scripting: Pick<typeof chrome.scripting, 'executeScript'>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* What reading a tab gave.
|
|
47
|
+
*
|
|
48
|
+
* @template T
|
|
49
|
+
* @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'} | {state: 'failed', error: string} | {state: 'timeout'}} TabReading
|
|
50
|
+
* `frames`: each frame that answered (not null), the top one first, then in Chrome's frame order.
|
|
51
|
+
* `failed`: no frame answered and the call threw in at least one (`error`: the first one's reason),
|
|
52
|
+
* which Chrome itself reports as a bare null; `timeout`: `timeoutMs` passed first.
|
|
53
|
+
*/
|
|
54
|
+
/**
|
|
55
|
+
* The Chrome APIs a reading uses (fakes in unit tests).
|
|
56
|
+
*
|
|
57
|
+
* @typedef {object} TabEdges
|
|
58
|
+
* @property {Pick<typeof chrome.tabs, 'get'>} tabs `chrome.tabs`.
|
|
59
|
+
* @property {Pick<typeof chrome.scripting, 'executeScript'>} scripting `chrome.scripting`.
|
|
60
|
+
*/
|
|
61
|
+
/**
|
|
62
|
+
* Call a page module's export in every frame of a tab, or say why it can't be.
|
|
63
|
+
*
|
|
64
|
+
* @template [T=unknown]
|
|
65
|
+
* @param {TabEdges} edges - Chrome's tabs and scripting.
|
|
66
|
+
* @param {number} tabId - The tab.
|
|
67
|
+
* @param {string} module - The module (in `web_accessible_resources`).
|
|
68
|
+
* @param {string} name - The export.
|
|
69
|
+
* @param {unknown[]} [args] - Its arguments.
|
|
70
|
+
* @param {{timeoutMs?: number}} [options] - `timeoutMs`: the longest to wait (Chrome answers once every
|
|
71
|
+
* frame has, so one that never does would keep a panel waiting for ever); default none.
|
|
72
|
+
* @returns {Promise<TabReading<T>>} The frames' answers, or why there are none.
|
|
73
|
+
* @example
|
|
74
|
+
* const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
|
|
75
|
+
* if (reading.state === 'read') show(reading.frames);
|
|
76
|
+
* else showMessage(reading.state); // 'browser', 'no-access', 'failed' or 'timeout'
|
|
77
|
+
*/
|
|
78
|
+
export declare function callInTab<T = unknown>(edges: TabEdges, tabId: number, module: string, name: string, args?: unknown[], { timeoutMs }?: {
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
}): Promise<TabReading<T>>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a tab: one of the extension's page modules called in every frame
|
|
3
|
+
* Chrome lets it run in, the frames' answers top first; or why it can't be
|
|
4
|
+
* read: a page Chrome keeps to itself (its own pages, the Web Store, other
|
|
5
|
+
* extensions'), no access (the tab went to another site since the click
|
|
6
|
+
* that gave activeTab), the module failed in every frame, or no answer in
|
|
7
|
+
* time.
|
|
8
|
+
*
|
|
9
|
+
* @module scripting/tab
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { callInPage } from './call.js';
|
|
13
|
+
|
|
14
|
+
/** @import { FrameResult } from './call.js' */
|
|
15
|
+
|
|
16
|
+
/** Addresses Chrome keeps from every extension. */
|
|
17
|
+
const BROWSER_PAGE = /^(?:chrome|chrome-extension|chrome-untrusted|devtools|edge|about|view-source):|^https:\/\/(?:chromewebstore\.google\.com|chrome\.google\.com\/webstore)\//;
|
|
18
|
+
|
|
19
|
+
/** Chrome's refusals for its own pages, when the page's address isn't known (no access to the tab). */
|
|
20
|
+
const BROWSER_REFUSAL = /Cannot access a (?:chrome|chrome-extension|chrome-untrusted|devtools|edge):\/\/ URL|extensions gallery cannot be scripted/i;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Whether Chrome keeps a page from extensions.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} url - The page's address.
|
|
26
|
+
* @returns {boolean} It does.
|
|
27
|
+
* @example
|
|
28
|
+
* isBrowserPage('chrome://settings'); // true
|
|
29
|
+
* isBrowserPage('https://chromewebstore.google.com/detail/x'); // true
|
|
30
|
+
*/
|
|
31
|
+
export const isBrowserPage = (url) => BROWSER_PAGE.test(url);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What reading a tab gave.
|
|
35
|
+
*
|
|
36
|
+
* @template T
|
|
37
|
+
* @typedef {{state: 'read', frames: FrameResult<T>[]} | {state: 'browser'} | {state: 'no-access'} | {state: 'failed', error: string} | {state: 'timeout'}} TabReading
|
|
38
|
+
* `frames`: each frame that answered (not null), the top one first, then in Chrome's frame order.
|
|
39
|
+
* `failed`: no frame answered and the call threw in at least one (`error`: the first one's reason),
|
|
40
|
+
* which Chrome itself reports as a bare null; `timeout`: `timeoutMs` passed first.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The Chrome APIs a reading uses (fakes in unit tests).
|
|
45
|
+
*
|
|
46
|
+
* @typedef {object} TabEdges
|
|
47
|
+
* @property {Pick<typeof chrome.tabs, 'get'>} tabs `chrome.tabs`.
|
|
48
|
+
* @property {Pick<typeof chrome.scripting, 'executeScript'>} scripting `chrome.scripting`.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Call a page module's export in every frame of a tab, or say why it can't be.
|
|
53
|
+
*
|
|
54
|
+
* @template [T=unknown]
|
|
55
|
+
* @param {TabEdges} edges - Chrome's tabs and scripting.
|
|
56
|
+
* @param {number} tabId - The tab.
|
|
57
|
+
* @param {string} module - The module (in `web_accessible_resources`).
|
|
58
|
+
* @param {string} name - The export.
|
|
59
|
+
* @param {unknown[]} [args] - Its arguments.
|
|
60
|
+
* @param {{timeoutMs?: number}} [options] - `timeoutMs`: the longest to wait (Chrome answers once every
|
|
61
|
+
* frame has, so one that never does would keep a panel waiting for ever); default none.
|
|
62
|
+
* @returns {Promise<TabReading<T>>} The frames' answers, or why there are none.
|
|
63
|
+
* @example
|
|
64
|
+
* const reading = await callInTab(chrome, tabId, 'content/main.js', 'scan');
|
|
65
|
+
* if (reading.state === 'read') show(reading.frames);
|
|
66
|
+
* else showMessage(reading.state); // 'browser', 'no-access', 'failed' or 'timeout'
|
|
67
|
+
*/
|
|
68
|
+
export async function callInTab(edges, tabId, module, name, args = [], { timeoutMs } = {}) {
|
|
69
|
+
let url;
|
|
70
|
+
try {
|
|
71
|
+
url = (await edges.tabs.get(tabId)).url;
|
|
72
|
+
} catch {
|
|
73
|
+
return { state: 'no-access' };
|
|
74
|
+
}
|
|
75
|
+
if (url && isBrowserPage(url)) return { state: 'browser' };
|
|
76
|
+
/** @type {FrameResult<T>[]} */
|
|
77
|
+
let frames;
|
|
78
|
+
try {
|
|
79
|
+
frames = await callInPage(edges.scripting, { tabId, allFrames: true }, module, name, args, { timeoutMs });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (/** @type {Error} */ (error)?.name === 'TimeoutError') return { state: 'timeout' };
|
|
82
|
+
// Chrome's own page (said in its refusal), or no access: the tab went to another site since the click.
|
|
83
|
+
return { state: BROWSER_REFUSAL.test(String(/** @type {Error} */ (error)?.message)) ? 'browser' : 'no-access' };
|
|
84
|
+
}
|
|
85
|
+
const read = frames.filter((frame) => frame.result !== null && frame.result !== undefined).sort((a, b) => a.frameId - b.frameId);
|
|
86
|
+
if (read.length) return { state: 'read', frames: read };
|
|
87
|
+
const failed = frames.toSorted((a, b) => a.frameId - b.frameId).find((frame) => frame.error !== undefined);
|
|
88
|
+
return failed ? { state: 'failed', error: /** @type {string} */ (failed.error) } : { state: 'no-access' };
|
|
89
|
+
}
|
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
|
},
|
|
@@ -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
|
|
78
|
-
*
|
|
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
|
*
|
package/src/settings/storage.js
CHANGED
|
@@ -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
|
|
58
|
-
*
|
|
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
|
-
|
|
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
|
/**
|