@arraypress/extension-kit 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/README.md +145 -18
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. package/src/ui/toast.js +2 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Helpers for an extension's packaging test: list the folder, list the real
3
3
  * zip of it, and find every path the extension names that doesn't resolve to
4
- * a file inside it (a broken import, a stylesheet link, a manifest entry).
4
+ * a file the zip holds (a broken import, a stylesheet link, a manifest entry).
5
5
  *
6
6
  * @module testing/package
7
7
  */
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
11
11
  import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
12
12
  import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
- import { zipExtension } from '../build/zip.js';
14
+ import { shipsFile, zipExtension } from '../build/zip.js';
15
15
 
16
16
  /**
17
17
  * Every file under `dir`.
@@ -49,27 +49,43 @@ export async function zipEntries(from, exclude) {
49
49
  * @typedef {object} ManifestFiles
50
50
  * @property {{service_worker?: string}} [background] The worker.
51
51
  * @property {{page?: string}} [options_ui] The options page.
52
+ * @property {string} [options_page] The options page, the older way.
52
53
  * @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
53
54
  * @property {{default_path?: string}} [side_panel] The side panel.
54
55
  * @property {Record<string, string>} [icons] The extension's icons.
55
56
  * @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
57
+ * @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
58
+ * @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
59
+ * @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
60
+ * @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
61
+ * @property {string} [devtools_page] The DevTools page.
62
+ * @property {{pages?: string[]}} [sandbox] Sandboxed pages.
56
63
  */
57
64
 
58
65
  /**
59
66
  * The paths a manifest names, from the extension's root.
60
67
  *
61
68
  * @param {ManifestFiles} manifest - The parsed `manifest.json`.
62
- * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
69
+ * @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
70
+ * locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
71
+ * (less patterns with `*`).
63
72
  */
64
73
  export function manifestReferences(manifest) {
65
74
  const icon = manifest.action?.default_icon;
66
75
  return [
67
76
  manifest.background?.service_worker,
68
77
  manifest.options_ui?.page,
78
+ manifest.options_page,
69
79
  manifest.action?.default_popup,
70
80
  manifest.side_panel?.default_path,
81
+ manifest.devtools_page,
71
82
  ...Object.values(manifest.icons ?? {}),
72
83
  ...(typeof icon === 'string' ? [icon] : Object.values(icon ?? {})),
84
+ ...(manifest.content_scripts ?? []).flatMap((entry) => [...(entry.js ?? []), ...(entry.css ?? [])]),
85
+ ...(manifest.default_locale ? [`_locales/${manifest.default_locale}/messages.json`] : []),
86
+ ...(manifest.declarative_net_request?.rule_resources ?? []).map((entry) => entry.path),
87
+ ...Object.values(manifest.chrome_url_overrides ?? {}),
88
+ ...(manifest.sandbox?.pages ?? []),
73
89
  ...(manifest.web_accessible_resources ?? []).flatMap((entry) => entry.resources),
74
90
  ].filter((ref) => typeof ref === 'string').filter((ref) => !ref.includes('*'));
75
91
  }
@@ -88,12 +104,27 @@ export function referencedPath(reference) {
88
104
  /** A reference with a scheme (`https:`, `data:`, `mailto:`) or protocol-relative: not a file of the extension. */
89
105
  const ELSEWHERE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
90
106
 
107
+ /** A module a script imports: `… from './x.js'`, `import('./x.js')`, or `import './x.js'` alone on its line; either quote. */
108
+ const IMPORTS = [/(?:from|import\()\s*(['"])(\.{1,2}\/[^'"]+)\1/g, /^\s*import\s*(['"])(\.{1,2}\/[^'"]+)\1/gm];
109
+
110
+ /** `getURL('…')`, either quote. */
111
+ const GET_URL = /getURL\(\s*(['"])([^'"]+)\1\s*\)/g;
112
+
113
+ /** A page's `src="…"` or `href="…"`, either quote. */
114
+ const ATTRIBUTES = /\s(?:src|href)\s*=\s*(['"])([^'"]+)\1/g;
115
+
116
+ /** A stylesheet's `url(…)` and `@import "…"`. */
117
+ const STYLE_URLS = [/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, /@import\s+(['"])([^'"]+)\1/g];
118
+
91
119
  /**
92
- * Every path the extension names that isn't a file inside it: relative
93
- * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
94
- * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
95
- * or `?query` is ignored: `getURL('options.html#workspaces')` names
96
- * `options.html`.
120
+ * Every path the extension names that isn't a file it ships: relative imports in its scripts
121
+ * (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
122
+ * `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
123
+ * ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
124
+ * zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
125
+ * that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
126
+ * `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
127
+ * extension's root. Files that don't ship aren't read.
97
128
  *
98
129
  * @param {string} dir - The extension folder.
99
130
  * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
@@ -107,18 +138,26 @@ export async function missingReferences(dir, extra = []) {
107
138
  const check = (from, target) => {
108
139
  const name = referencedPath(target);
109
140
  if (!name || ELSEWHERE.test(target)) return;
110
- const file = path.resolve(root, from, name);
111
- if (!file.startsWith(root) || !existsSync(file)) missing.push(`${from || 'root'} → ${target}`);
141
+ const file = name.startsWith('/') ? path.join(root, name) : path.resolve(root, from, name);
142
+ const inside = path.relative(root, file).split(path.sep).join('/');
143
+ if (inside.startsWith('..') || !existsSync(file) || !shipsFile(inside)) missing.push(`${from || 'root'} → ${target}`);
144
+ };
145
+ /** @param {string} source @param {readonly RegExp[]} patterns @param {string} from */
146
+ const each = (source, patterns, from) => {
147
+ for (const pattern of patterns) for (const match of source.matchAll(pattern)) check(from, match[2]);
112
148
  };
113
- for (const file of await filesUnder(root)) {
149
+ for (const file of (await filesUnder(root)).filter(shipsFile)) {
114
150
  const from = path.posix.dirname(file);
115
- if (file.endsWith('.js')) {
116
- const source = await readFile(path.join(root, file), 'utf8');
117
- for (const [, spec] of source.matchAll(/(?:from|import\()\s*'(\.{1,2}\/[^']+)'/g)) check(from, spec);
118
- for (const [, ref] of source.matchAll(/getURL\('([^']+)'\)/g)) check('', ref);
119
- } else if (file.endsWith('.html')) {
120
- const source = await readFile(path.join(root, file), 'utf8');
121
- for (const [, ref] of source.matchAll(/(?:src|href)="([^"]+)"/g)) check(from, ref);
151
+ const kind = path.posix.extname(file);
152
+ if (kind !== '.js' && kind !== '.html' && kind !== '.css') continue;
153
+ const source = await readFile(path.join(root, file), 'utf8');
154
+ if (kind === '.js') {
155
+ each(source, IMPORTS, from);
156
+ each(source, [GET_URL], '');
157
+ } else if (kind === '.html') {
158
+ each(source, [ATTRIBUTES], from);
159
+ } else {
160
+ each(source, STYLE_URLS, from);
122
161
  }
123
162
  }
124
163
  const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Reaching a tested extension's pages from an e2e test: a Playwright page's
3
+ * tab id (as the extension's worker sees it), a page module called in a
4
+ * tab's frames as the extension calls it, and a page's whole tree through
5
+ * the DevTools protocol, closed shadow roots and frames included (where an
6
+ * extension's overlay lives). Tests only: an extension never needs CDP.
7
+ *
8
+ * @module testing/pages
9
+ */
10
+ import type { CDPSession, Page, Worker } from 'playwright';
11
+ /** @import { CDPSession, Page, Worker } from 'playwright' */
12
+ /**
13
+ * The tab id of a page: the newest tab at its address (any tab for a `chrome:` page).
14
+ *
15
+ * @param {Worker} worker - The extension's service worker.
16
+ * @param {Page} page - The page.
17
+ * @returns {Promise<number>} Its tab's id.
18
+ * @example
19
+ * const tabId = await tabIdOf(worker, page);
20
+ */
21
+ export declare function tabIdOf(worker: Worker, page: Page): Promise<number>;
22
+ /**
23
+ * Call an export of the extension's page module in a tab's frames, as `callInPage` does from the
24
+ * worker.
25
+ *
26
+ * @param {Worker} worker - The extension's service worker.
27
+ * @param {number} tabId - The tab.
28
+ * @param {string} module - The module's path in the extension (`content/main.js`).
29
+ * @param {string} name - The export.
30
+ * @param {unknown[]} [args] - Its arguments.
31
+ * @returns {Promise<{frameId: number, result: any}[]>} Each frame's answer.
32
+ * @example
33
+ * const [{ result }] = await callTabModule(worker, tabId, 'content/main.js', 'scan');
34
+ */
35
+ export declare function callTabModule(worker: Worker, tabId: number, module: string, name: string, args?: unknown[]): Promise<{
36
+ frameId: number;
37
+ result: any;
38
+ }[]>;
39
+ export type CdpNode = {
40
+ /**
41
+ * Its id in the session.
42
+ */
43
+ nodeId: number;
44
+ /**
45
+ * Its lasting id.
46
+ */
47
+ backendNodeId: number;
48
+ /**
49
+ * Its DOM node type.
50
+ */
51
+ nodeType: number;
52
+ /**
53
+ * Its name.
54
+ */
55
+ nodeName: string;
56
+ /**
57
+ * A text node's text.
58
+ */
59
+ nodeValue?: string;
60
+ /**
61
+ * Its attributes, name and value in turn.
62
+ */
63
+ attributes?: string[];
64
+ /**
65
+ * Its children.
66
+ */
67
+ children?: CdpNode[];
68
+ /**
69
+ * Its shadow roots, closed ones too.
70
+ */
71
+ shadowRoots?: CdpNode[];
72
+ /**
73
+ * A frame's document.
74
+ */
75
+ contentDocument?: CdpNode;
76
+ };
77
+ /**
78
+ * A node as the DevTools protocol's `DOM.getDocument` gives it (the parts read here).
79
+ *
80
+ * @typedef {object} CdpNode
81
+ * @property {number} nodeId Its id in the session.
82
+ * @property {number} backendNodeId Its lasting id.
83
+ * @property {number} nodeType Its DOM node type.
84
+ * @property {string} nodeName Its name.
85
+ * @property {string} [nodeValue] A text node's text.
86
+ * @property {string[]} [attributes] Its attributes, name and value in turn.
87
+ * @property {CdpNode[]} [children] Its children.
88
+ * @property {CdpNode[]} [shadowRoots] Its shadow roots, closed ones too.
89
+ * @property {CdpNode} [contentDocument] A frame's document.
90
+ */
91
+ /**
92
+ * Every node of a page's tree in tree order, closed shadow roots and same-process frames included.
93
+ *
94
+ * @param {CDPSession} cdp - A session on the page.
95
+ * @returns {Promise<CdpNode[]>} The nodes; a node's shadow roots come before its children.
96
+ * @example
97
+ * const boxes = (await pierceNodes(cdp)).filter((node) => nodeAttribute(node, 'class') === 'box');
98
+ */
99
+ export declare function pierceNodes(cdp: CDPSession): Promise<CdpNode[]>;
100
+ /**
101
+ * A node's attribute.
102
+ *
103
+ * @param {CdpNode} node - The node.
104
+ * @param {string} name - The attribute.
105
+ * @returns {string} Its value, or ''.
106
+ */
107
+ export declare function nodeAttribute(node: CdpNode, name: string): string;
108
+ /**
109
+ * A node's text: a text node's own, an element's children's joined.
110
+ *
111
+ * @param {CdpNode} node - The node.
112
+ * @returns {string} Its text.
113
+ */
114
+ export declare const nodeText: (node: CdpNode) => string;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Reaching a tested extension's pages from an e2e test: a Playwright page's
3
+ * tab id (as the extension's worker sees it), a page module called in a
4
+ * tab's frames as the extension calls it, and a page's whole tree through
5
+ * the DevTools protocol, closed shadow roots and frames included (where an
6
+ * extension's overlay lives). Tests only: an extension never needs CDP.
7
+ *
8
+ * @module testing/pages
9
+ */
10
+
11
+ /** @import { CDPSession, Page, Worker } from 'playwright' */
12
+
13
+ /**
14
+ * The tab id of a page: the newest tab at its address (any tab for a `chrome:` page).
15
+ *
16
+ * @param {Worker} worker - The extension's service worker.
17
+ * @param {Page} page - The page.
18
+ * @returns {Promise<number>} Its tab's id.
19
+ * @example
20
+ * const tabId = await tabIdOf(worker, page);
21
+ */
22
+ export async function tabIdOf(worker, page) {
23
+ return worker.evaluate(async (url) => {
24
+ const query = url.startsWith('chrome:') ? {} : { url: url.replace(/#.*$/, '') };
25
+ const ids = (await chrome.tabs.query(query)).map((tab) => tab.id ?? -1);
26
+ if (!ids.length) throw new Error(`No tab for ${url}`);
27
+ return Math.max(...ids);
28
+ }, page.url());
29
+ }
30
+
31
+ /**
32
+ * Call an export of the extension's page module in a tab's frames, as `callInPage` does from the
33
+ * worker.
34
+ *
35
+ * @param {Worker} worker - The extension's service worker.
36
+ * @param {number} tabId - The tab.
37
+ * @param {string} module - The module's path in the extension (`content/main.js`).
38
+ * @param {string} name - The export.
39
+ * @param {unknown[]} [args] - Its arguments.
40
+ * @returns {Promise<{frameId: number, result: any}[]>} Each frame's answer.
41
+ * @example
42
+ * const [{ result }] = await callTabModule(worker, tabId, 'content/main.js', 'scan');
43
+ */
44
+ export function callTabModule(worker, tabId, module, name, args = []) {
45
+ return worker.evaluate(({ tabId, module, name, args }) => chrome.scripting.executeScript({
46
+ target: { tabId, allFrames: true },
47
+ injectImmediately: true,
48
+ // As the kit's importAndCall (a worker's evaluate takes no function from outside, and its CSP no eval).
49
+ func: (/** @type {string} */ module, /** @type {string} */ name, /** @type {unknown[]} */ args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args)),
50
+ args: [module, name, args],
51
+ }).then((frames) => frames.map(({ frameId, result }) => ({ frameId, result }))), { tabId, module, name, args });
52
+ }
53
+
54
+ /**
55
+ * A node as the DevTools protocol's `DOM.getDocument` gives it (the parts read here).
56
+ *
57
+ * @typedef {object} CdpNode
58
+ * @property {number} nodeId Its id in the session.
59
+ * @property {number} backendNodeId Its lasting id.
60
+ * @property {number} nodeType Its DOM node type.
61
+ * @property {string} nodeName Its name.
62
+ * @property {string} [nodeValue] A text node's text.
63
+ * @property {string[]} [attributes] Its attributes, name and value in turn.
64
+ * @property {CdpNode[]} [children] Its children.
65
+ * @property {CdpNode[]} [shadowRoots] Its shadow roots, closed ones too.
66
+ * @property {CdpNode} [contentDocument] A frame's document.
67
+ */
68
+
69
+ /**
70
+ * Every node of a page's tree in tree order, closed shadow roots and same-process frames included.
71
+ *
72
+ * @param {CDPSession} cdp - A session on the page.
73
+ * @returns {Promise<CdpNode[]>} The nodes; a node's shadow roots come before its children.
74
+ * @example
75
+ * const boxes = (await pierceNodes(cdp)).filter((node) => nodeAttribute(node, 'class') === 'box');
76
+ */
77
+ export async function pierceNodes(cdp) {
78
+ const { root } = /** @type {{root: CdpNode}} */ (await cdp.send('DOM.getDocument', { depth: -1, pierce: true }));
79
+ /** @type {CdpNode[]} */
80
+ const nodes = [];
81
+ /** @param {CdpNode} node */
82
+ const walk = (node) => {
83
+ nodes.push(node);
84
+ for (const child of [...(node.shadowRoots ?? []), ...(node.children ?? []), ...(node.contentDocument ? [node.contentDocument] : [])]) walk(child);
85
+ };
86
+ walk(root);
87
+ return nodes;
88
+ }
89
+
90
+ /**
91
+ * A node's attribute.
92
+ *
93
+ * @param {CdpNode} node - The node.
94
+ * @param {string} name - The attribute.
95
+ * @returns {string} Its value, or ''.
96
+ */
97
+ export function nodeAttribute(node, name) {
98
+ const attributes = node.attributes ?? [];
99
+ const at = attributes.findIndex((value, index) => index % 2 === 0 && value === name);
100
+ return at >= 0 ? attributes[at + 1] : '';
101
+ }
102
+
103
+ /**
104
+ * A node's text: a text node's own, an element's children's joined.
105
+ *
106
+ * @param {CdpNode} node - The node.
107
+ * @returns {string} Its text.
108
+ */
109
+ export const nodeText = (node) => (node.nodeType === 3 ? node.nodeValue ?? '' : (node.children ?? []).map(nodeText).join(''));
@@ -8,8 +8,10 @@
8
8
  * Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
9
9
  * recorded on `window.asked` (what was asked, and whether a click was behind it,
10
10
  * `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
11
- * kept), as a person clicking “Allow” or “Deny” would. Runs in the page: give it to
12
- * `page.evaluate` or `page.addInitScript`, so it takes no closures.
11
+ * kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
12
+ * refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
13
+ * no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
14
+ * no closures.
13
15
  *
14
16
  * @param {boolean[]} answers - The answers, in order.
15
17
  * @example
@@ -9,8 +9,10 @@
9
9
  * Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
10
10
  * recorded on `window.asked` (what was asked, and whether a click was behind it,
11
11
  * `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
12
- * kept), as a person clicking “Allow” or “Deny” would. Runs in the page: give it to
13
- * `page.evaluate` or `page.addInitScript`, so it takes no closures.
12
+ * kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
13
+ * refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
14
+ * no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
15
+ * no closures.
14
16
  *
15
17
  * @param {boolean[]} answers - The answers, in order.
16
18
  * @example
@@ -23,7 +25,9 @@ export function playPermissionPrompt(answers) {
23
25
  const asked = [];
24
26
  Object.assign(window, { asked });
25
27
  chrome.permissions.request = /** @type {typeof chrome.permissions.request} */ (/** @type {unknown} */ (async (/** @type {chrome.permissions.Permissions} */ permissions) => {
26
- asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture: navigator.userActivation.isActive });
28
+ const gesture = navigator.userActivation.isActive;
29
+ asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture });
30
+ if (!gesture) throw new Error('This function must be called during a user gesture');
27
31
  return answers.length > 1 ? answers.shift() : answers[0];
28
32
  }));
29
33
  }
@@ -13,7 +13,8 @@
13
13
  *
14
14
  * @template T
15
15
  * @param {readonly T[]} items - The items.
16
- * @param {number} limit - At most this many at once.
16
+ * @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
17
+ * that isn't a number, runs them one at a time rather than not at all).
17
18
  * @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
18
19
  * `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
19
20
  * (a browser each).
package/src/time/pool.js CHANGED
@@ -14,7 +14,8 @@
14
14
  *
15
15
  * @template T
16
16
  * @param {readonly T[]} items - The items.
17
- * @param {number} limit - At most this many at once.
17
+ * @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
18
+ * that isn't a number, runs them one at a time rather than not at all).
18
19
  * @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
19
20
  * `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
20
21
  * (a browser each).
@@ -24,7 +25,8 @@
24
25
  */
25
26
  export async function eachLimited(items, limit, work) {
26
27
  let next = 0;
27
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async (_, slot) => {
28
+ const slots = Math.max(1, Math.floor(limit) || 1);
29
+ await Promise.all(Array.from({ length: Math.min(slots, items.length) }, async (_, slot) => {
28
30
  while (next < items.length) {
29
31
  const index = next;
30
32
  next += 1;
@@ -132,9 +132,12 @@ export type OffscreenApi = {
132
132
  *
133
133
  * @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
134
134
  * @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
135
+ * @param {string} [justification] - What the document is for, when it does more than watch the
136
+ * scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
137
+ * with the document, so it has to be the whole truth.
135
138
  * @returns {Promise<boolean>} Whether it opened one (false: already open).
136
139
  */
137
- export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string): Promise<boolean>;
140
+ export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string): Promise<boolean>;
138
141
  export type ToolbarWorkerApi = {
139
142
  /**
140
143
  * `chrome.storage`.
@@ -179,9 +182,14 @@ export type ToolbarWorkerApi = {
179
182
  * worker wakes.
180
183
  *
181
184
  * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
185
+ * @param {{url?: string, justification?: string}} [document] - The offscreen document and what
186
+ * it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
182
187
  * @returns {ToolbarTheme} The theme, for tests.
183
188
  * @example
184
189
  * // background.js
185
190
  * startToolbarTheme(chrome);
186
191
  */
187
- export declare function startToolbarTheme(api: ToolbarWorkerApi): ToolbarTheme;
192
+ export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification }?: {
193
+ url?: string;
194
+ justification?: string;
195
+ }): ToolbarTheme;
@@ -116,6 +116,9 @@ export function createToolbarTheme({ storage, action, extensionId }) {
116
116
  /** @type {Promise<boolean> | null} */
117
117
  let opening = null;
118
118
 
119
+ /** Why the offscreen document is open, as Chrome is told. */
120
+ const SCHEME_JUSTIFICATION = 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.';
121
+
119
122
  /**
120
123
  * Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
121
124
  * unless it's open. It stays open: it's one small page in the extension's own
@@ -125,16 +128,19 @@ let opening = null;
125
128
  *
126
129
  * @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
127
130
  * @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
131
+ * @param {string} [justification] - What the document is for, when it does more than watch the
132
+ * scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
133
+ * with the document, so it has to be the whole truth.
128
134
  * @returns {Promise<boolean>} Whether it opened one (false: already open).
129
135
  */
130
- export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH) {
136
+ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION) {
131
137
  opening ??= (async () => {
132
138
  const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
133
139
  if (open.length) return false;
134
140
  await offscreen.createDocument({
135
141
  url,
136
142
  reasons: [offscreen.Reason.MATCH_MEDIA],
137
- justification: 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.',
143
+ justification,
138
144
  });
139
145
  return true;
140
146
  })().finally(() => { opening = null; });
@@ -159,15 +165,17 @@ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH)
159
165
  * worker wakes.
160
166
  *
161
167
  * @param {ToolbarWorkerApi} api - `chrome`, or a fake.
168
+ * @param {{url?: string, justification?: string}} [document] - The offscreen document and what
169
+ * it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
162
170
  * @returns {ToolbarTheme} The theme, for tests.
163
171
  * @example
164
172
  * // background.js
165
173
  * startToolbarTheme(chrome);
166
174
  */
167
- export function startToolbarTheme(api) {
175
+ export function startToolbarTheme(api, { url, justification } = {}) {
168
176
  const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
169
177
  theme.restore();
170
- watchFromOffscreen(api).catch((error) => console.error('Opening the colour scheme watcher:', error));
178
+ watchFromOffscreen(api, url, justification).catch((error) => console.error('Opening the colour scheme watcher:', error));
171
179
  api.runtime.onMessage.addListener((message, sender) => {
172
180
  theme.onMessage(message, sender);
173
181
  return false;
package/src/ui/menu.d.ts CHANGED
@@ -99,8 +99,10 @@ export type Box = {
99
99
  *
100
100
  * @param {{width: number, height: number}} size - The menu's size.
101
101
  * @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
102
- * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
103
- * box, or a point; `placement` says where it goes from an anchor (default `'over'`).
102
+ * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
103
+ * anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
104
+ * (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
105
+ * point, as Chrome mirrors its own.
104
106
  * @returns {{left: number, top: number}} The menu's top-left, in px.
105
107
  * @example
106
108
  * menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
@@ -112,11 +114,12 @@ export declare function menuPosition(size: {
112
114
  }, viewport: {
113
115
  width: number;
114
116
  height: number;
115
- }, { anchor, x, y, placement }: {
117
+ }, { anchor, x, y, placement, rtl }: {
116
118
  anchor?: Box;
117
119
  x?: number;
118
120
  y?: number;
119
121
  placement?: 'over' | 'below';
122
+ rtl?: boolean;
120
123
  }): {
121
124
  left: number;
122
125
  top: number;
@@ -163,6 +166,11 @@ export type ActionMenu = {
163
166
  * Close it, if open, and return the focus.
164
167
  */
165
168
  close: () => void;
169
+ /**
170
+ * Take it off the page and stop listening to the window (a content
171
+ * script's overlay being removed).
172
+ */
173
+ destroy: () => void;
166
174
  };
167
175
  /**
168
176
  * What {@link createMenu} takes.
@@ -182,6 +190,8 @@ export type ActionMenu = {
182
190
  * @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
183
191
  * and open it (closing it first if it's open).
184
192
  * @property {() => void} close Close it, if open, and return the focus.
193
+ * @property {() => void} destroy Take it off the page and stop listening to the window (a content
194
+ * script's overlay being removed).
185
195
  */
186
196
  /**
187
197
  * Make a page's action menu: one `<dialog class="menu">` that any number of