@arraypress/extension-kit 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +214 -2
- package/bin/extension-kit.js +35 -0
- package/package.json +67 -4
- package/src/about/links.d.ts +51 -0
- package/src/about/links.js +48 -0
- package/src/build/duplication.d.ts +116 -0
- package/src/build/duplication.js +119 -0
- package/src/build/index.d.ts +11 -0
- package/src/build/index.js +12 -0
- package/src/build/release-checks.d.ts +36 -0
- package/src/build/release-checks.js +61 -0
- package/src/build/toolbar-icons.d.ts +96 -0
- package/src/build/toolbar-icons.js +101 -0
- package/src/build/vendor.d.ts +26 -0
- package/src/build/vendor.js +43 -0
- package/src/build/zip.d.ts +31 -0
- package/src/build/zip.js +47 -0
- package/src/commands/shortcuts.d.ts +39 -0
- package/src/commands/shortcuts.js +31 -0
- package/src/dom/i18n.d.ts +39 -0
- package/src/dom/i18n.js +49 -0
- package/src/dom/page.d.ts +59 -0
- package/src/dom/page.js +93 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +24 -0
- package/src/settings/saver.d.ts +76 -0
- package/src/settings/saver.js +98 -0
- package/src/settings/storage.d.ts +91 -0
- package/src/settings/storage.js +76 -0
- package/src/settings/store.d.ts +138 -0
- package/src/settings/store.js +173 -0
- package/src/styles/chrome-ui.css +1094 -0
- package/src/testing/e2e.d.ts +165 -0
- package/src/testing/e2e.js +272 -0
- package/src/testing/fake-storage.d.ts +71 -0
- package/src/testing/fake-storage.js +145 -0
- package/src/testing/index.d.ts +9 -0
- package/src/testing/index.js +10 -0
- package/src/testing/package.d.ts +39 -0
- package/src/testing/package.js +96 -0
- package/src/toolbar/offscreen.d.ts +30 -0
- package/src/toolbar/offscreen.js +37 -0
- package/src/toolbar/scheme.d.ts +81 -0
- package/src/toolbar/scheme.js +87 -0
- package/src/toolbar/theme.d.ts +187 -0
- package/src/toolbar/theme.js +176 -0
|
@@ -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,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>;
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
|
|
11
|
+
/** `chrome.storage.local`: the scheme last reported. This computer's, never synced. */
|
|
12
|
+
export const TOOLBAR_DARK_KEY = 'toolbarDark';
|
|
13
|
+
|
|
14
|
+
/** The report's message type: `{type: TOOLBAR_SCHEME, dark: boolean}`. */
|
|
15
|
+
export const TOOLBAR_SCHEME = 'toolbar-scheme';
|
|
16
|
+
|
|
17
|
+
/** The media query that matches a dark colour scheme. */
|
|
18
|
+
export const DARK_QUERY = '(prefers-color-scheme: dark)';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What a document reports through: `chrome.runtime`, or a fake.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {{sendMessage(message: unknown): Promise<unknown>}} MessageSender
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What `watchColorScheme` needs: `chrome` itself will do.
|
|
28
|
+
*
|
|
29
|
+
* @typedef {object} SchemeApi
|
|
30
|
+
* @property {MessageSender} runtime `chrome.runtime`.
|
|
31
|
+
* @property {{local: {get(keys: string): Promise<Record<string, any>>}}} storage `chrome.storage`.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The smallest `MediaQueryList`: `matchMedia(DARK_QUERY)`.
|
|
36
|
+
*
|
|
37
|
+
* @typedef {Pick<MediaQueryList, 'matches' | 'addEventListener'>} SchemeMedia
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Send `dark` now if it differs from `known` (what's stored), then on each
|
|
42
|
+
* change: a page load where nothing changed sends nothing.
|
|
43
|
+
*
|
|
44
|
+
* @param {SchemeMedia} media - `matchMedia(DARK_QUERY)`.
|
|
45
|
+
* @param {boolean | undefined} known - What the worker last stored;
|
|
46
|
+
* undefined (unknown) sends once.
|
|
47
|
+
* @param {(dark: boolean) => void} send - Delivers a report.
|
|
48
|
+
* @returns {() => void} The check itself, for a document that has to poll
|
|
49
|
+
* (an offscreen document never hears `change`).
|
|
50
|
+
*/
|
|
51
|
+
export function reportColorScheme(media, known, send) {
|
|
52
|
+
let last = known;
|
|
53
|
+
const report = () => {
|
|
54
|
+
if (media.matches === last) return;
|
|
55
|
+
last = media.matches;
|
|
56
|
+
send(last);
|
|
57
|
+
};
|
|
58
|
+
report();
|
|
59
|
+
media.addEventListener('change', report);
|
|
60
|
+
return report;
|
|
61
|
+
}
|
|
62
|
+
|
|
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 function schemeSender(runtime) {
|
|
71
|
+
return (dark) => runtime.sendMessage({ type: TOOLBAR_SCHEME, dark }).catch(() => {});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* For an extension page: read what's stored, then report now if it differs
|
|
76
|
+
* and on every change.
|
|
77
|
+
*
|
|
78
|
+
* @param {SchemeApi} api - `chrome`, or a fake with `runtime` and `storage.local`.
|
|
79
|
+
* @param {SchemeMedia} [media] - Default `matchMedia(DARK_QUERY)`.
|
|
80
|
+
* @returns {Promise<void>} Resolves once the first check has run.
|
|
81
|
+
* @example
|
|
82
|
+
* watchColorScheme(chrome).catch((error) => console.debug('Reporting the colour scheme:', error));
|
|
83
|
+
*/
|
|
84
|
+
export async function watchColorScheme(api, media = matchMedia(DARK_QUERY)) {
|
|
85
|
+
const { [TOOLBAR_DARK_KEY]: stored } = await api.storage.local.get(TOOLBAR_DARK_KEY);
|
|
86
|
+
reportColorScheme(media, stored === true, schemeSender(api.runtime));
|
|
87
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker side of the light/dark toolbar glyph, as Chrome's own toolbar
|
|
3
|
+
* icons do. The service worker can't see the scheme (no `matchMedia`), so
|
|
4
|
+
* documents report it (toolbar/scheme.js); this stores the last report per
|
|
5
|
+
* computer and shows the matching glyph. `setIcon` changes only the image: a
|
|
6
|
+
* badge still draws over it.
|
|
7
|
+
*
|
|
8
|
+
* An extension with nothing of its own on every page can keep an offscreen
|
|
9
|
+
* document open to hear a theme change (`watchFromOffscreen`,
|
|
10
|
+
* toolbar/offscreen.js).
|
|
11
|
+
*
|
|
12
|
+
* @module toolbar/theme
|
|
13
|
+
*/
|
|
14
|
+
/** Where the offscreen scheme watcher lives, from the extension's root. */
|
|
15
|
+
export declare const OFFSCREEN_PATH = "offscreen/offscreen.html";
|
|
16
|
+
/** Where the rendered glyphs live (build/toolbar-icons.js writes them). */
|
|
17
|
+
export declare const TOOLBAR_ICON_DIR = "/icons/toolbar";
|
|
18
|
+
export type ToolbarIconPaths = {
|
|
19
|
+
16: string;
|
|
20
|
+
32: string;
|
|
21
|
+
48: string;
|
|
22
|
+
};
|
|
23
|
+
export type ToolbarThemeApi = {
|
|
24
|
+
/**
|
|
25
|
+
* `chrome.storage.local`.
|
|
26
|
+
*/
|
|
27
|
+
storage: {
|
|
28
|
+
get(keys: string): Promise<Record<string, any>>;
|
|
29
|
+
set(items: Record<string, unknown>): Promise<void>;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* `chrome.action`.
|
|
33
|
+
*/
|
|
34
|
+
action: {
|
|
35
|
+
setIcon(details: {
|
|
36
|
+
path: ToolbarIconPaths;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* `chrome.runtime.id`: reports from anyone else are ignored.
|
|
41
|
+
*/
|
|
42
|
+
extensionId: string;
|
|
43
|
+
};
|
|
44
|
+
export type ToolbarTheme = {
|
|
45
|
+
/**
|
|
46
|
+
* Show the dark glyph again if that's what was
|
|
47
|
+
* seen last: after a restart or re-enable Chrome shows the manifest's light one.
|
|
48
|
+
*/
|
|
49
|
+
restore: () => Promise<void>;
|
|
50
|
+
/**
|
|
51
|
+
* Handle a
|
|
52
|
+
* scheme report; returns whether it was one (handled or ignored), so a worker's
|
|
53
|
+
* message listener can stop there.
|
|
54
|
+
*/
|
|
55
|
+
onMessage: (message: unknown, sender?: {
|
|
56
|
+
id?: string;
|
|
57
|
+
}) => boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Resolves once every report so far is handled.
|
|
60
|
+
*/
|
|
61
|
+
settled: () => Promise<void>;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {{16: string, 32: string, 48: string}} ToolbarIconPaths
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* The worker APIs the toolbar theme uses: pieces of `chrome`, or fakes.
|
|
68
|
+
*
|
|
69
|
+
* @typedef {object} ToolbarThemeApi
|
|
70
|
+
* @property {{get(keys: string): Promise<Record<string, any>>, set(items: Record<string, unknown>): Promise<void>}} storage
|
|
71
|
+
* `chrome.storage.local`.
|
|
72
|
+
* @property {{setIcon(details: {path: ToolbarIconPaths}): Promise<void>}} action `chrome.action`.
|
|
73
|
+
* @property {string} extensionId `chrome.runtime.id`: reports from anyone else are ignored.
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {object} ToolbarTheme
|
|
77
|
+
* @property {() => Promise<void>} restore Show the dark glyph again if that's what was
|
|
78
|
+
* seen last: after a restart or re-enable Chrome shows the manifest's light one.
|
|
79
|
+
* @property {(message: unknown, sender?: {id?: string}) => boolean} onMessage Handle a
|
|
80
|
+
* scheme report; returns whether it was one (handled or ignored), so a worker's
|
|
81
|
+
* message listener can stop there.
|
|
82
|
+
* @property {() => Promise<void>} settled Resolves once every report so far is handled.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* The glyph for a scheme; the manifest's `default_icon` is the light one.
|
|
86
|
+
* Paths start with "/": Chrome resolves `setIcon` paths against the worker's
|
|
87
|
+
* own address, where a relative one fails to load.
|
|
88
|
+
*
|
|
89
|
+
* @param {boolean} dark - The dark scheme's glyph, else the light one's.
|
|
90
|
+
* @returns {ToolbarIconPaths} Paths by size, for `chrome.action.setIcon`.
|
|
91
|
+
* @example
|
|
92
|
+
* toolbarIcon(true); // { 16: '/icons/toolbar/dark-16.png', 32: …, 48: … }
|
|
93
|
+
*/
|
|
94
|
+
export declare function toolbarIcon(dark: boolean): ToolbarIconPaths;
|
|
95
|
+
/**
|
|
96
|
+
* The worker's toolbar theme: one report at a time, so two quick ones can't
|
|
97
|
+
* store one scheme and show the other.
|
|
98
|
+
*
|
|
99
|
+
* @param {ToolbarThemeApi} api - Storage, the action and this extension's id.
|
|
100
|
+
* @returns {ToolbarTheme} The theme's handlers.
|
|
101
|
+
*/
|
|
102
|
+
export declare function createToolbarTheme({ storage, action, extensionId }: ToolbarThemeApi): ToolbarTheme;
|
|
103
|
+
export type OffscreenApi = {
|
|
104
|
+
/**
|
|
105
|
+
* `chrome.offscreen`.
|
|
106
|
+
*/
|
|
107
|
+
offscreen: {
|
|
108
|
+
createDocument(details: {
|
|
109
|
+
url: string;
|
|
110
|
+
reasons: string[];
|
|
111
|
+
justification: string;
|
|
112
|
+
}): Promise<void>;
|
|
113
|
+
Reason: {
|
|
114
|
+
MATCH_MEDIA: string;
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* `chrome.runtime`.
|
|
119
|
+
*/
|
|
120
|
+
runtime: {
|
|
121
|
+
getContexts(filter: {
|
|
122
|
+
contextTypes: 'OFFSCREEN_DOCUMENT'[];
|
|
123
|
+
}): Promise<unknown[]>;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
|
|
128
|
+
* unless it's open. It stays open: it's one small page in the extension's own
|
|
129
|
+
* process, and the only way to hear a theme change while no page of ours is
|
|
130
|
+
* open. Chrome closes it when the browser quits, so call this each time the
|
|
131
|
+
* worker starts. Concurrent calls share one attempt.
|
|
132
|
+
*
|
|
133
|
+
* @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
|
|
134
|
+
* @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
|
|
135
|
+
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
136
|
+
*/
|
|
137
|
+
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string): Promise<boolean>;
|
|
138
|
+
export type ToolbarWorkerApi = {
|
|
139
|
+
/**
|
|
140
|
+
* `chrome.storage`.
|
|
141
|
+
*/
|
|
142
|
+
storage: {
|
|
143
|
+
local: ToolbarThemeApi['storage'];
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* `chrome.action`.
|
|
147
|
+
*/
|
|
148
|
+
action: ToolbarThemeApi['action'];
|
|
149
|
+
/**
|
|
150
|
+
* `chrome.offscreen`.
|
|
151
|
+
*/
|
|
152
|
+
offscreen: OffscreenApi['offscreen'];
|
|
153
|
+
/**
|
|
154
|
+
* `chrome.runtime`.
|
|
155
|
+
*/
|
|
156
|
+
runtime: OffscreenApi['runtime'] & {
|
|
157
|
+
id: string;
|
|
158
|
+
onMessage: {
|
|
159
|
+
addListener(listener: (message: unknown, sender: {
|
|
160
|
+
id?: string;
|
|
161
|
+
}) => boolean): void;
|
|
162
|
+
};
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Everything `startToolbarTheme` wires up: `chrome` itself will do.
|
|
167
|
+
*
|
|
168
|
+
* @typedef {object} ToolbarWorkerApi
|
|
169
|
+
* @property {{local: ToolbarThemeApi['storage']}} storage `chrome.storage`.
|
|
170
|
+
* @property {ToolbarThemeApi['action']} action `chrome.action`.
|
|
171
|
+
* @property {OffscreenApi['offscreen']} offscreen `chrome.offscreen`.
|
|
172
|
+
* @property {OffscreenApi['runtime'] & {id: string, onMessage: {addListener(listener: (message: unknown, sender: {id?: string}) => boolean): void}}} runtime
|
|
173
|
+
* `chrome.runtime`.
|
|
174
|
+
*/
|
|
175
|
+
/**
|
|
176
|
+
* The whole worker side for an extension that watches from an offscreen
|
|
177
|
+
* document: restore the last glyph, open the watcher, and handle its reports.
|
|
178
|
+
* Call it at the worker's top level, so the listener attaches every time the
|
|
179
|
+
* worker wakes.
|
|
180
|
+
*
|
|
181
|
+
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
182
|
+
* @returns {ToolbarTheme} The theme, for tests.
|
|
183
|
+
* @example
|
|
184
|
+
* // background.js
|
|
185
|
+
* startToolbarTheme(chrome);
|
|
186
|
+
*/
|
|
187
|
+
export declare function startToolbarTheme(api: ToolbarWorkerApi): ToolbarTheme;
|