@arraypress/extension-kit 0.0.0-stage → 1.1.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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -2
  3. package/bin/extension-kit.js +35 -0
  4. package/package.json +67 -4
  5. package/src/about/links.d.ts +51 -0
  6. package/src/about/links.js +48 -0
  7. package/src/build/duplication.d.ts +116 -0
  8. package/src/build/duplication.js +119 -0
  9. package/src/build/index.d.ts +11 -0
  10. package/src/build/index.js +12 -0
  11. package/src/build/release-checks.d.ts +36 -0
  12. package/src/build/release-checks.js +61 -0
  13. package/src/build/toolbar-icons.d.ts +96 -0
  14. package/src/build/toolbar-icons.js +101 -0
  15. package/src/build/vendor.d.ts +26 -0
  16. package/src/build/vendor.js +43 -0
  17. package/src/build/zip.d.ts +31 -0
  18. package/src/build/zip.js +47 -0
  19. package/src/commands/shortcuts.d.ts +39 -0
  20. package/src/commands/shortcuts.js +31 -0
  21. package/src/dom/i18n.d.ts +39 -0
  22. package/src/dom/i18n.js +49 -0
  23. package/src/dom/page.d.ts +59 -0
  24. package/src/dom/page.js +93 -0
  25. package/src/index.d.ts +21 -0
  26. package/src/index.js +24 -0
  27. package/src/settings/saver.d.ts +76 -0
  28. package/src/settings/saver.js +98 -0
  29. package/src/settings/storage.d.ts +91 -0
  30. package/src/settings/storage.js +76 -0
  31. package/src/settings/store.d.ts +138 -0
  32. package/src/settings/store.js +173 -0
  33. package/src/styles/chrome-ui.css +1094 -0
  34. package/src/testing/e2e.d.ts +222 -0
  35. package/src/testing/e2e.js +327 -0
  36. package/src/testing/fake-storage.d.ts +71 -0
  37. package/src/testing/fake-storage.js +145 -0
  38. package/src/testing/index.d.ts +29 -0
  39. package/src/testing/index.js +24 -0
  40. package/src/testing/package.d.ts +39 -0
  41. package/src/testing/package.js +96 -0
  42. package/src/testing/server.d.ts +47 -0
  43. package/src/testing/server.js +90 -0
  44. package/src/toolbar/offscreen.d.ts +30 -0
  45. package/src/toolbar/offscreen.js +37 -0
  46. package/src/toolbar/scheme.d.ts +81 -0
  47. package/src/toolbar/scheme.js +87 -0
  48. package/src/toolbar/theme.d.ts +187 -0
  49. package/src/toolbar/theme.js +176 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * An in-memory `chrome.storage` for tests, behaving as Chrome does: values
3
+ * are stored as JSON, `onChanged` fires only for values that changed, and
4
+ * sync refuses an item over its 8,192-byte quota and more than 120 writes
5
+ * (set or remove) a minute. Tests then check outcomes (what's stored), not
6
+ * calls.
7
+ *
8
+ * @module testing/fake-storage
9
+ */
10
+
11
+ import { MAX_SYNC_ITEM_BYTES } from '../settings/storage.js';
12
+
13
+ /** @import { StorageChange } from '../settings/storage.js' */
14
+
15
+ /** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
16
+ export const SYNC_WRITES_PER_MINUTE = 120;
17
+
18
+ /**
19
+ * @typedef {(changes: Record<string, StorageChange>, area: string) => unknown} ChangeListener
20
+ */
21
+
22
+ /**
23
+ * One fake storage area.
24
+ *
25
+ * @typedef {object} FakeArea
26
+ * @property {Record<string, any>} data What it holds, to inspect (and seed) in tests.
27
+ * @property {number} writes How many writes it has taken.
28
+ * @property {(keys?: string | string[] | null) => Promise<Record<string, any>>} get
29
+ * @property {(items: Record<string, unknown>) => Promise<void>} set
30
+ * @property {(keys: string | string[]) => Promise<void>} remove
31
+ */
32
+
33
+ /**
34
+ * The fake `chrome.storage`.
35
+ *
36
+ * @typedef {object} FakeChromeStorage
37
+ * @property {FakeArea} local `chrome.storage.local`.
38
+ * @property {FakeArea} sync `chrome.storage.sync`, with Chrome's quotas.
39
+ * @property {FakeArea} session `chrome.storage.session`.
40
+ * @property {{addListener(listener: ChangeListener): void}} onChanged `chrome.storage.onChanged`.
41
+ * @property {() => Promise<void>} settled Waits for every listener, including the changes those listeners make.
42
+ */
43
+
44
+ /**
45
+ * Copy a value the way storage does, through JSON.
46
+ *
47
+ * @param {unknown} value - Anything JSON-able, or undefined.
48
+ * @returns {any} The copy.
49
+ */
50
+ const viaJson = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
51
+
52
+ /**
53
+ * Make a fake `chrome.storage`.
54
+ *
55
+ * @param {object} [initial] - Starting contents and the clock.
56
+ * @param {Record<string, unknown>} [initial.local] - What local holds.
57
+ * @param {Record<string, unknown>} [initial.sync] - What sync holds.
58
+ * @param {Record<string, unknown>} [initial.session] - What session holds.
59
+ * @param {() => number} [initial.now] - The clock sync's write limit reads. Default `Date.now`.
60
+ * @returns {FakeChromeStorage} The fake.
61
+ * @example
62
+ * const storage = fakeChromeStorage({ sync: { days: 3 } });
63
+ * await store.save(storage, { days: 5 });
64
+ * assert.equal(storage.sync.data.days, 5);
65
+ */
66
+ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = Date.now } = {}) {
67
+ /** @type {ChangeListener[]} */
68
+ const listeners = [];
69
+ /** @type {unknown[]} */
70
+ const running = [];
71
+
72
+ /**
73
+ * @param {string} name
74
+ * @param {Record<string, unknown>} initialData
75
+ * @param {{quotaPerItem?: number, writesPerMinute?: number}} [limits]
76
+ * @returns {FakeArea}
77
+ */
78
+ function area(name, initialData, { quotaPerItem, writesPerMinute } = {}) {
79
+ /** @type {Record<string, any>} */
80
+ const data = viaJson(initialData);
81
+ /** @type {number[]} */
82
+ const recent = [];
83
+ /** @param {Record<string, StorageChange>} changes */
84
+ const fire = (changes) => {
85
+ if (!Object.keys(changes).length) return;
86
+ for (const listener of listeners) running.push(listener(viaJson(changes), name));
87
+ };
88
+ /** A write, counted against the per-minute limit; refused over it, as Chrome does. */
89
+ const take = () => {
90
+ if (writesPerMinute) {
91
+ while (recent.length && recent[0] <= now() - 60_000) recent.shift();
92
+ if (recent.length >= writesPerMinute) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_MINUTE quota.');
93
+ recent.push(now());
94
+ }
95
+ fake.writes++;
96
+ };
97
+ /** @type {FakeArea} */
98
+ const fake = {
99
+ data,
100
+ writes: 0,
101
+ async get(keys) {
102
+ const wanted = keys == null ? Object.keys(data) : [keys].flat();
103
+ return Object.fromEntries(wanted.filter((key) => key in data).map((key) => [key, viaJson(data[key])]));
104
+ },
105
+ async set(items) {
106
+ for (const [key, value] of Object.entries(items)) {
107
+ if (quotaPerItem && key.length + JSON.stringify(value).length > quotaPerItem) {
108
+ throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
109
+ }
110
+ }
111
+ take();
112
+ /** @type {Record<string, StorageChange>} */
113
+ const changes = {};
114
+ for (const [key, value] of Object.entries(items)) {
115
+ if (JSON.stringify(data[key]) === JSON.stringify(value)) continue;
116
+ changes[key] = { oldValue: data[key], newValue: viaJson(value) };
117
+ data[key] = viaJson(value);
118
+ }
119
+ fire(changes);
120
+ },
121
+ async remove(keys) {
122
+ take();
123
+ /** @type {Record<string, StorageChange>} */
124
+ const changes = {};
125
+ for (const key of [keys].flat()) {
126
+ if (!(key in data)) continue;
127
+ changes[key] = { oldValue: data[key] };
128
+ delete data[key];
129
+ }
130
+ fire(changes);
131
+ },
132
+ };
133
+ return fake;
134
+ }
135
+
136
+ return {
137
+ local: area('local', local),
138
+ sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
139
+ session: area('session', session),
140
+ onChanged: { addListener: (listener) => listeners.push(listener) },
141
+ async settled() {
142
+ while (running.length) await Promise.all(running.splice(0));
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
+ * end-to-end tests and a server for their pages, and checks for a packaging
4
+ * test. Node only.
5
+ *
6
+ * @module testing
7
+ */
8
+ export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
9
+ export { serve } from './server.js';
10
+ export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
11
+ export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
12
+ export type CheckRunner = import('./e2e.js').CheckRunner;
13
+ export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
14
+ export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
15
+ export type LaunchOptions = import('./e2e.js').LaunchOptions;
16
+ export type Reply = import('./server.js').Reply;
17
+ export type Routes = import('./server.js').Routes;
18
+ export type TestServer = import('./server.js').TestServer;
19
+ /**
20
+ * The helpers' types, for a test's JSDoc.
21
+ *
22
+ * @typedef {import('./e2e.js').CheckRunner} CheckRunner
23
+ * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
24
+ * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
25
+ * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
26
+ * @typedef {import('./server.js').Reply} Reply
27
+ * @typedef {import('./server.js').Routes} Routes
28
+ * @typedef {import('./server.js').TestServer} TestServer
29
+ */
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
+ * end-to-end tests and a server for their pages, and checks for a packaging
4
+ * test. Node only.
5
+ *
6
+ * @module testing
7
+ */
8
+
9
+ export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
10
+ export { serve } from './server.js';
11
+ export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
12
+ export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
13
+
14
+ /**
15
+ * The helpers' types, for a test's JSDoc.
16
+ *
17
+ * @typedef {import('./e2e.js').CheckRunner} CheckRunner
18
+ * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
19
+ * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
20
+ * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
21
+ * @typedef {import('./server.js').Reply} Reply
22
+ * @typedef {import('./server.js').Routes} Routes
23
+ * @typedef {import('./server.js').TestServer} TestServer
24
+ */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Helpers for an extension's packaging test: list the folder, list the real
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).
5
+ *
6
+ * @module testing/package
7
+ */
8
+ /**
9
+ * Every file under `dir`.
10
+ *
11
+ * @param {string} dir - The folder.
12
+ * @returns {Promise<string[]>} Paths relative to it, forward slashes, sorted.
13
+ */
14
+ export declare function filesUnder(dir: string): Promise<string[]>;
15
+ /**
16
+ * The entries of the real zip of `from` (build/zip.js), listed by `unzip`.
17
+ *
18
+ * @param {string} from - The extension folder.
19
+ * @param {readonly string[]} [exclude] - `zip -x` patterns, as `zipExtension` takes.
20
+ * @returns {Promise<string[]>} Entry names, sorted.
21
+ */
22
+ export declare function zipEntries(from: string, exclude?: readonly string[]): Promise<string[]>;
23
+ /**
24
+ * The paths a manifest names, from the extension's root.
25
+ *
26
+ * @param {Record<string, any>} manifest - The parsed `manifest.json`.
27
+ * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources.
28
+ */
29
+ export declare function manifestReferences(manifest: Record<string, any>): string[];
30
+ /**
31
+ * Every path the extension names that isn't a file inside it: relative
32
+ * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
33
+ * in its pages, the manifest's entries, and any `extra` paths.
34
+ *
35
+ * @param {string} dir - The extension folder.
36
+ * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
37
+ * @returns {Promise<string[]>} "from → target" for each missing one; empty when all resolve.
38
+ */
39
+ export declare function missingReferences(dir: string, extra?: readonly string[]): Promise<string[]>;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Helpers for an extension's packaging test: list the folder, list the real
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).
5
+ *
6
+ * @module testing/package
7
+ */
8
+
9
+ import { execFileSync } from 'node:child_process';
10
+ import { existsSync } from 'node:fs';
11
+ import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
12
+ import { tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+ import { zipExtension } from '../build/zip.js';
15
+
16
+ /**
17
+ * Every file under `dir`.
18
+ *
19
+ * @param {string} dir - The folder.
20
+ * @returns {Promise<string[]>} Paths relative to it, forward slashes, sorted.
21
+ */
22
+ export async function filesUnder(dir) {
23
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
24
+ return entries.filter((entry) => entry.isFile())
25
+ .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'))
26
+ .sort();
27
+ }
28
+
29
+ /**
30
+ * The entries of the real zip of `from` (build/zip.js), listed by `unzip`.
31
+ *
32
+ * @param {string} from - The extension folder.
33
+ * @param {readonly string[]} [exclude] - `zip -x` patterns, as `zipExtension` takes.
34
+ * @returns {Promise<string[]>} Entry names, sorted.
35
+ */
36
+ export async function zipEntries(from, exclude) {
37
+ const tmp = await mkdtemp(path.join(tmpdir(), 'extension-zip-'));
38
+ try {
39
+ const zip = await zipExtension(from, path.join(tmp, 'out.zip'), exclude);
40
+ return execFileSync('unzip', ['-Z1', zip], { encoding: 'utf8' }).trim().split('\n').sort();
41
+ } finally {
42
+ await rm(tmp, { recursive: true, force: true });
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The paths a manifest names, from the extension's root.
48
+ *
49
+ * @param {Record<string, any>} manifest - The parsed `manifest.json`.
50
+ * @returns {string[]} Worker, pages, icons, side panel and web-accessible resources.
51
+ */
52
+ export function manifestReferences(manifest) {
53
+ return [
54
+ manifest.background?.service_worker,
55
+ manifest.options_ui?.page,
56
+ manifest.action?.default_popup,
57
+ manifest.side_panel?.default_path,
58
+ ...Object.values(manifest.icons ?? {}),
59
+ ...Object.values(manifest.action?.default_icon ?? {}),
60
+ ...(manifest.web_accessible_resources ?? []).flatMap((/** @type {{resources: string[]}} */ entry) => entry.resources),
61
+ ].filter((ref) => typeof ref === 'string' && !ref.includes('*'));
62
+ }
63
+
64
+ /**
65
+ * Every path the extension names that isn't a file inside it: relative
66
+ * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
67
+ * in its pages, the manifest's entries, and any `extra` paths.
68
+ *
69
+ * @param {string} dir - The extension folder.
70
+ * @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
71
+ * @returns {Promise<string[]>} "from → target" for each missing one; empty when all resolve.
72
+ */
73
+ export async function missingReferences(dir, extra = []) {
74
+ const root = path.resolve(dir);
75
+ /** @type {string[]} */
76
+ const missing = [];
77
+ /** @param {string} from @param {string} target */
78
+ const check = (from, target) => {
79
+ const file = path.resolve(root, from, target);
80
+ if (!file.startsWith(root) || !existsSync(file)) missing.push(`${from || 'root'} → ${target}`);
81
+ };
82
+ for (const file of await filesUnder(root)) {
83
+ const from = path.posix.dirname(file);
84
+ if (file.endsWith('.js')) {
85
+ const source = await readFile(path.join(root, file), 'utf8');
86
+ for (const [, spec] of source.matchAll(/(?:from|import\()\s*'(\.{1,2}\/[^']+)'/g)) check(from, spec);
87
+ for (const [, ref] of source.matchAll(/getURL\('([^']+)'\)/g)) check('', ref);
88
+ } else if (file.endsWith('.html')) {
89
+ const source = await readFile(path.join(root, file), 'utf8');
90
+ for (const [, ref] of source.matchAll(/(?:src|href)="([^"#:]+)"/g)) check(from, ref);
91
+ }
92
+ }
93
+ const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
94
+ for (const ref of [...manifestReferences(manifest), ...extra]) check('', ref);
95
+ return missing;
96
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A local web server for end-to-end test pages: a site for the content
3
+ * script to run on, answering each path from a table (or a function), on
4
+ * 127.0.0.1 so a test build's host permission can cover it.
5
+ *
6
+ * @module testing/server
7
+ */
8
+ import type { IncomingMessage, Server } from 'node:http';
9
+ export type Reply = string | Uint8Array | {
10
+ body: string | Uint8Array;
11
+ type?: string;
12
+ status?: number;
13
+ headers?: Record<string, string>;
14
+ };
15
+ export type Routes = Readonly<Record<string, Reply>> | ReadonlyMap<string, Reply> | ((pathname: string, request: IncomingMessage) => Reply | undefined | Promise<Reply | undefined>);
16
+ export type TestServer = {
17
+ /**
18
+ * Node's server.
19
+ */
20
+ server: Server;
21
+ /**
22
+ * The port it listens on.
23
+ */
24
+ port: number;
25
+ /**
26
+ * `http://127.0.0.1:<port>`.
27
+ */
28
+ origin: string;
29
+ /**
30
+ * Drop open connections and stop listening.
31
+ */
32
+ close: () => Promise<void>;
33
+ };
34
+ /**
35
+ * Serve `routes` on 127.0.0.1.
36
+ *
37
+ * @param {Routes} routes - Path → page, or a function of the path.
38
+ * @param {{port?: number}} [options] - `port`: a fixed port (default: any free one).
39
+ * @returns {Promise<TestServer>} The listening server.
40
+ * @example
41
+ * const site = await serve({'/': '<!doctype html><textarea></textarea>'});
42
+ * await page.goto(`${site.origin}/`);
43
+ * await site.close();
44
+ */
45
+ export declare function serve(routes: Routes, { port }?: {
46
+ port?: number;
47
+ }): Promise<TestServer>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * A local web server for end-to-end test pages: a site for the content
3
+ * script to run on, answering each path from a table (or a function), on
4
+ * 127.0.0.1 so a test build's host permission can cover it.
5
+ *
6
+ * @module testing/server
7
+ */
8
+
9
+ import { createServer } from 'node:http';
10
+
11
+ /** @import { IncomingMessage, Server } from 'node:http' */
12
+
13
+ /**
14
+ * What a path answers: a body (sent as UTF-8 HTML), or a body with its own
15
+ * type, status and headers.
16
+ *
17
+ * @typedef {string | Uint8Array | {body: string | Uint8Array, type?: string, status?: number, headers?: Record<string, string>}} Reply
18
+ */
19
+
20
+ /**
21
+ * The site: path (without the query) → reply, as an object or a `Map` (which
22
+ * a test may change as it goes), or a function of the path; no reply is a 404.
23
+ *
24
+ * @typedef {Readonly<Record<string, Reply>> | ReadonlyMap<string, Reply>
25
+ * | ((pathname: string, request: IncomingMessage) => Reply | undefined | Promise<Reply | undefined>)} Routes
26
+ */
27
+
28
+ /**
29
+ * A running test site.
30
+ *
31
+ * @typedef {object} TestServer
32
+ * @property {Server} server Node's server.
33
+ * @property {number} port The port it listens on.
34
+ * @property {string} origin `http://127.0.0.1:<port>`.
35
+ * @property {() => Promise<void>} close Drop open connections and stop listening.
36
+ */
37
+
38
+ const HTML = 'text/html; charset=utf-8';
39
+
40
+ /**
41
+ * The reply for a path.
42
+ *
43
+ * @param {Routes} routes - The site.
44
+ * @param {string} pathname - The request's path.
45
+ * @param {IncomingMessage} request - The request.
46
+ * @returns {Promise<Reply | undefined>} What to send; `undefined` for a 404.
47
+ */
48
+ async function replyFor(routes, pathname, request) {
49
+ if (typeof routes === 'function') return routes(pathname, request);
50
+ if (routes instanceof Map) return routes.get(pathname);
51
+ const table = /** @type {Readonly<Record<string, Reply>>} */ (routes);
52
+ return Object.hasOwn(table, pathname) ? table[pathname] : undefined;
53
+ }
54
+
55
+ /**
56
+ * Serve `routes` on 127.0.0.1.
57
+ *
58
+ * @param {Routes} routes - Path → page, or a function of the path.
59
+ * @param {{port?: number}} [options] - `port`: a fixed port (default: any free one).
60
+ * @returns {Promise<TestServer>} The listening server.
61
+ * @example
62
+ * const site = await serve({'/': '<!doctype html><textarea></textarea>'});
63
+ * await page.goto(`${site.origin}/`);
64
+ * await site.close();
65
+ */
66
+ export async function serve(routes, { port = 0 } = {}) {
67
+ const server = createServer(async (request, response) => {
68
+ const { pathname } = new URL(request.url ?? '/', 'http://127.0.0.1');
69
+ const reply = await replyFor(routes, pathname, request);
70
+ if (reply === undefined) {
71
+ response.writeHead(404, { 'Content-Type': HTML });
72
+ response.end('');
73
+ return;
74
+ }
75
+ const { body, type = HTML, status = 200, headers = {} } = typeof reply === 'string' || reply instanceof Uint8Array ? { body: reply } : reply;
76
+ response.writeHead(status, { 'Content-Type': type, ...headers });
77
+ response.end(body);
78
+ });
79
+ await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(undefined)));
80
+ const address = /** @type {import('node:net').AddressInfo} */ (server.address());
81
+ return {
82
+ server,
83
+ port: address.port,
84
+ origin: `http://127.0.0.1:${address.port}`,
85
+ close() {
86
+ server.closeAllConnections();
87
+ return new Promise((resolve) => server.close(() => resolve()));
88
+ },
89
+ };
90
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The offscreen scheme watcher (toolbar/theme.js `watchFromOffscreen`): the
3
+ * one job of an offscreen document kept open to hear light/dark changes.
4
+ * Offscreen documents get `chrome.runtime` and nothing else, so it can't read
5
+ * what's stored: it reports once on opening (the worker drops a repeat), then
6
+ * on each change.
7
+ *
8
+ * @module toolbar/offscreen
9
+ */
10
+ import type { MessageSender, SchemeMedia } from './scheme.js';
11
+ /** @import { MessageSender, SchemeMedia } from './scheme.js' */
12
+ /**
13
+ * How often the watcher looks, ms. An offscreen document never renders, and
14
+ * Chrome fires a media query's `change` event as part of rendering, so there
15
+ * it never comes (checked in Chromium 157: no animation frames either).
16
+ * `matches` itself stays current, so look every few seconds: one cached
17
+ * boolean read, and a message only when it differs.
18
+ */
19
+ export declare const POLL_MS = 2000;
20
+ /**
21
+ * Start watching. The offscreen document's script is just this call.
22
+ *
23
+ * @param {MessageSender} runtime - `chrome.runtime`.
24
+ * @param {SchemeMedia} [media] - Default `matchMedia(DARK_QUERY)`.
25
+ * @returns {ReturnType<typeof setInterval>} The poll, for `clearInterval` in tests.
26
+ * @example
27
+ * // offscreen/offscreen.js
28
+ * watchSchemeOffscreen(chrome.runtime);
29
+ */
30
+ export declare function watchSchemeOffscreen(runtime: MessageSender, media?: SchemeMedia): ReturnType<typeof setInterval>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The offscreen scheme watcher (toolbar/theme.js `watchFromOffscreen`): the
3
+ * one job of an offscreen document kept open to hear light/dark changes.
4
+ * Offscreen documents get `chrome.runtime` and nothing else, so it can't read
5
+ * what's stored: it reports once on opening (the worker drops a repeat), then
6
+ * on each change.
7
+ *
8
+ * @module toolbar/offscreen
9
+ */
10
+
11
+ import { DARK_QUERY, reportColorScheme, schemeSender } from './scheme.js';
12
+
13
+ /** @import { MessageSender, SchemeMedia } from './scheme.js' */
14
+
15
+ /**
16
+ * How often the watcher looks, ms. An offscreen document never renders, and
17
+ * Chrome fires a media query's `change` event as part of rendering, so there
18
+ * it never comes (checked in Chromium 157: no animation frames either).
19
+ * `matches` itself stays current, so look every few seconds: one cached
20
+ * boolean read, and a message only when it differs.
21
+ */
22
+ export const POLL_MS = 2000;
23
+
24
+ /**
25
+ * Start watching. The offscreen document's script is just this call.
26
+ *
27
+ * @param {MessageSender} runtime - `chrome.runtime`.
28
+ * @param {SchemeMedia} [media] - Default `matchMedia(DARK_QUERY)`.
29
+ * @returns {ReturnType<typeof setInterval>} The poll, for `clearInterval` in tests.
30
+ * @example
31
+ * // offscreen/offscreen.js
32
+ * watchSchemeOffscreen(chrome.runtime);
33
+ */
34
+ export function watchSchemeOffscreen(runtime, media = matchMedia(DARK_QUERY)) {
35
+ const check = reportColorScheme(media, undefined, schemeSender(runtime));
36
+ return setInterval(check, POLL_MS);
37
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The page side of the light/dark toolbar glyph: documents tell the service
3
+ * worker whether they see a light or dark colour scheme, so it can swap the
4
+ * glyph (toolbar/theme.js). The worker has no `matchMedia`, so documents
5
+ * report: an extension's pages, its offscreen watcher, or a content script.
6
+ * Kept tiny, as a content script may carry it.
7
+ *
8
+ * @module toolbar/scheme
9
+ */
10
+ /** `chrome.storage.local`: the scheme last reported. This computer's, never synced. */
11
+ export declare const TOOLBAR_DARK_KEY = "toolbarDark";
12
+ /** The report's message type: `{type: TOOLBAR_SCHEME, dark: boolean}`. */
13
+ export declare const TOOLBAR_SCHEME = "toolbar-scheme";
14
+ /** The media query that matches a dark colour scheme. */
15
+ export declare const DARK_QUERY = "(prefers-color-scheme: dark)";
16
+ export type MessageSender = {
17
+ sendMessage(message: unknown): Promise<unknown>;
18
+ };
19
+ export type SchemeApi = {
20
+ /**
21
+ * `chrome.runtime`.
22
+ */
23
+ runtime: MessageSender;
24
+ /**
25
+ * `chrome.storage`.
26
+ */
27
+ storage: {
28
+ local: {
29
+ get(keys: string): Promise<Record<string, any>>;
30
+ };
31
+ };
32
+ };
33
+ export type SchemeMedia = Pick<MediaQueryList, 'matches' | 'addEventListener'>;
34
+ /**
35
+ * What a document reports through: `chrome.runtime`, or a fake.
36
+ *
37
+ * @typedef {{sendMessage(message: unknown): Promise<unknown>}} MessageSender
38
+ */
39
+ /**
40
+ * What `watchColorScheme` needs: `chrome` itself will do.
41
+ *
42
+ * @typedef {object} SchemeApi
43
+ * @property {MessageSender} runtime `chrome.runtime`.
44
+ * @property {{local: {get(keys: string): Promise<Record<string, any>>}}} storage `chrome.storage`.
45
+ */
46
+ /**
47
+ * The smallest `MediaQueryList`: `matchMedia(DARK_QUERY)`.
48
+ *
49
+ * @typedef {Pick<MediaQueryList, 'matches' | 'addEventListener'>} SchemeMedia
50
+ */
51
+ /**
52
+ * Send `dark` now if it differs from `known` (what's stored), then on each
53
+ * change: a page load where nothing changed sends nothing.
54
+ *
55
+ * @param {SchemeMedia} media - `matchMedia(DARK_QUERY)`.
56
+ * @param {boolean | undefined} known - What the worker last stored;
57
+ * undefined (unknown) sends once.
58
+ * @param {(dark: boolean) => void} send - Delivers a report.
59
+ * @returns {() => void} The check itself, for a document that has to poll
60
+ * (an offscreen document never hears `change`).
61
+ */
62
+ export declare function reportColorScheme(media: SchemeMedia, known: boolean | undefined, send: (dark: boolean) => void): () => void;
63
+ /**
64
+ * A sender of scheme reports. The worker may be asleep or restarting: a lost
65
+ * report is retried by the next change or page, so failures are dropped.
66
+ *
67
+ * @param {MessageSender} runtime - `chrome.runtime`.
68
+ * @returns {(dark: boolean) => Promise<unknown>} Sends one report.
69
+ */
70
+ export declare function schemeSender(runtime: MessageSender): (dark: boolean) => Promise<unknown>;
71
+ /**
72
+ * For an extension page: read what's stored, then report now if it differs
73
+ * and on every change.
74
+ *
75
+ * @param {SchemeApi} api - `chrome`, or a fake with `runtime` and `storage.local`.
76
+ * @param {SchemeMedia} [media] - Default `matchMedia(DARK_QUERY)`.
77
+ * @returns {Promise<void>} Resolves once the first check has run.
78
+ * @example
79
+ * watchColorScheme(chrome).catch((error) => console.debug('Reporting the colour scheme:', error));
80
+ */
81
+ export declare function watchColorScheme(api: SchemeApi, media?: SchemeMedia): Promise<void>;