@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/settings/store.d.ts
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* `chrome.storage.local` instead. Local also holds any write sync refused
|
|
9
9
|
* (too many writes a minute, or an item over its size limit), kept under
|
|
10
10
|
* `unsyncedKey`, read as if synced, and retried, so a change is never lost.
|
|
11
|
+
* Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
|
|
12
|
+
* refused: once sync holds something else (the setting was changed since on
|
|
13
|
+
* another computer), the held change is the older one and is dropped, not
|
|
14
|
+
* read over the newer one or sent up over it.
|
|
15
|
+
*
|
|
16
|
+
* Holding a change is a read-modify-write of one local item, so it runs
|
|
17
|
+
* under a Web Lock the extension's pages and worker share: two refused
|
|
18
|
+
* writes at once keep both changes, and a retry can't put an older value
|
|
19
|
+
* back over a newer one.
|
|
11
20
|
*
|
|
12
21
|
* Each extension supplies its schema: the defaults and one normaliser that
|
|
13
22
|
* coerces anything read from storage or a form into valid settings.
|
|
@@ -72,6 +81,11 @@ export type SettingsStore<S extends Record<string, unknown>> = {
|
|
|
72
81
|
* Try again to put earlier refusals into sync.
|
|
73
82
|
*/
|
|
74
83
|
retryUnsynced: (storage: SyncedStorage) => Promise<boolean>;
|
|
84
|
+
/**
|
|
85
|
+
* Whether trying again could help: something held is still the newest and small enough for sync
|
|
86
|
+
* (an item over sync's size limit stays on this computer however often it's tried).
|
|
87
|
+
*/
|
|
88
|
+
retryable: (storage: SyncedStorage) => Promise<boolean>;
|
|
75
89
|
/**
|
|
76
90
|
* Save the settings `changes` names, normalised.
|
|
77
91
|
*/
|
|
@@ -116,6 +130,9 @@ export type SettingsStore<S extends Record<string, unknown>> = {
|
|
|
116
130
|
* everything reached sync.
|
|
117
131
|
* @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
|
|
118
132
|
* Try again to put earlier refusals into sync.
|
|
133
|
+
* @property {(storage: SyncedStorage) => Promise<boolean>} retryable
|
|
134
|
+
* Whether trying again could help: something held is still the newest and small enough for sync
|
|
135
|
+
* (an item over sync's size limit stays on this computer however often it's tried).
|
|
119
136
|
* @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
|
|
120
137
|
* Save the settings `changes` names, normalised.
|
|
121
138
|
* @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
|
package/src/settings/store.js
CHANGED
|
@@ -8,6 +8,15 @@
|
|
|
8
8
|
* `chrome.storage.local` instead. Local also holds any write sync refused
|
|
9
9
|
* (too many writes a minute, or an item over its size limit), kept under
|
|
10
10
|
* `unsyncedKey`, read as if synced, and retried, so a change is never lost.
|
|
11
|
+
* Beside it (`<unsyncedKey>Was`) is what sync held for each when it was
|
|
12
|
+
* refused: once sync holds something else (the setting was changed since on
|
|
13
|
+
* another computer), the held change is the older one and is dropped, not
|
|
14
|
+
* read over the newer one or sent up over it.
|
|
15
|
+
*
|
|
16
|
+
* Holding a change is a read-modify-write of one local item, so it runs
|
|
17
|
+
* under a Web Lock the extension's pages and worker share: two refused
|
|
18
|
+
* writes at once keep both changes, and a retry can't put an older value
|
|
19
|
+
* back over a newer one.
|
|
11
20
|
*
|
|
12
21
|
* Each extension supplies its schema: the defaults and one normaliser that
|
|
13
22
|
* coerces anything read from storage or a form into valid settings.
|
|
@@ -15,7 +24,7 @@
|
|
|
15
24
|
* @module settings/store
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
|
-
import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js';
|
|
27
|
+
import { MAX_SYNC_ITEM_BYTES, exclusive, isEmpty, pick, sameValue, syncItemBytes } from './storage.js';
|
|
19
28
|
|
|
20
29
|
/** @import { StorageChange, SyncedStorage } from './storage.js' */
|
|
21
30
|
|
|
@@ -51,6 +60,9 @@ import { MAX_SYNC_ITEM_BYTES, isEmpty, pick, syncItemBytes } from './storage.js'
|
|
|
51
60
|
* everything reached sync.
|
|
52
61
|
* @property {(storage: SyncedStorage) => Promise<boolean>} retryUnsynced
|
|
53
62
|
* Try again to put earlier refusals into sync.
|
|
63
|
+
* @property {(storage: SyncedStorage) => Promise<boolean>} retryable
|
|
64
|
+
* Whether trying again could help: something held is still the newest and small enough for sync
|
|
65
|
+
* (an item over sync's size limit stays on this computer however often it's tried).
|
|
54
66
|
* @property {(storage: SyncedStorage, changes: Partial<S>) => Promise<{settings: S, synced: boolean}>} save
|
|
55
67
|
* Save the settings `changes` names, normalised.
|
|
56
68
|
* @property {(changes: Record<string, StorageChange>, area: string) => (keyof S & string)[]} changed
|
|
@@ -78,9 +90,36 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
|
|
|
78
90
|
/** @param {Record<string, unknown> | null} [raw] @returns {S} */
|
|
79
91
|
const normalize = (raw) => normalizeRaw(raw ?? {});
|
|
80
92
|
|
|
93
|
+
/** The local item holding what sync held for each refused setting: `{key: [value]}`, `[]` for nothing. */
|
|
94
|
+
const wasKey = `${unsyncedKey}Was`;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* What an area holds for a key, in a form that tells nothing from `null`.
|
|
98
|
+
*
|
|
99
|
+
* @param {Record<string, unknown>} items - What a `get` gave.
|
|
100
|
+
* @param {string} key - The key.
|
|
101
|
+
* @returns {unknown[]} `[value]`, or `[]` when it holds nothing.
|
|
102
|
+
*/
|
|
103
|
+
const seen = (items, key) => (key in items ? [items[key]] : []);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The held changes that are still the newest: sync holds what it did when each was refused.
|
|
107
|
+
* One held before `wasKey` existed has nothing to compare, and counts as newest.
|
|
108
|
+
*
|
|
109
|
+
* @param {Record<string, unknown>} held - The held changes.
|
|
110
|
+
* @param {Record<string, unknown[]>} was - What sync held when each was refused.
|
|
111
|
+
* @param {Record<string, unknown>} synced - What sync holds now.
|
|
112
|
+
* @returns {Record<string, unknown>} The ones to keep.
|
|
113
|
+
*/
|
|
114
|
+
const newest = (held, was, synced) => Object.fromEntries(Object.entries(pick(held, syncedKeys))
|
|
115
|
+
.filter(([key]) => !(key in was) || sameValue(was[key], seen(synced, key))));
|
|
116
|
+
|
|
81
117
|
/**
|
|
82
118
|
* Start one sync write of `values` at once; resolves to what sync refused.
|
|
83
|
-
* An item too big for sync isn't even tried.
|
|
119
|
+
* An item too big for sync isn't even tried. When Chrome refuses several
|
|
120
|
+
* together for anything but its write rate, each is tried on its own, so
|
|
121
|
+
* one item Chrome won't take (the whole of sync is full, or it weighs more
|
|
122
|
+
* there than here) doesn't keep the others on this computer.
|
|
84
123
|
*
|
|
85
124
|
* @param {SyncedStorage} storage
|
|
86
125
|
* @param {Record<string, unknown>} values
|
|
@@ -91,10 +130,13 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
|
|
|
91
130
|
const fits = Object.fromEntries(entries.filter(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES));
|
|
92
131
|
const refused = Object.fromEntries(entries.filter(([key]) => !(key in fits)));
|
|
93
132
|
if (isEmpty(fits)) return Promise.resolve(refused);
|
|
94
|
-
return storage.sync.set(fits).then(() => refused, (/** @type {unknown} */ error) => {
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
133
|
+
return storage.sync.set(fits).then(() => refused, async (/** @type {unknown} */ error) => {
|
|
134
|
+
const message = String(/** @type {Error | undefined} */ (error)?.message ?? error);
|
|
135
|
+
console.warn('Settings: Chrome Sync refused the change; keeping it on this computer and trying again later.', message);
|
|
136
|
+
const keys = Object.keys(fits);
|
|
137
|
+
if (keys.length === 1 || /MAX_WRITE_OPERATIONS/.test(message)) return { ...refused, ...fits };
|
|
138
|
+
const alone = await Promise.all(keys.map((key) => storage.sync.set({ [key]: fits[key] }).then(() => [], () => [key])));
|
|
139
|
+
return { ...refused, ...pick(fits, alone.flat()) };
|
|
98
140
|
});
|
|
99
141
|
}
|
|
100
142
|
|
|
@@ -102,30 +144,54 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
|
|
|
102
144
|
async function load(storage) {
|
|
103
145
|
const [synced, local] = await Promise.all([
|
|
104
146
|
storage.sync.get([...syncedKeys]),
|
|
105
|
-
storage.local.get([...localKeys, unsyncedKey]),
|
|
147
|
+
storage.local.get([...localKeys, unsyncedKey, wasKey]),
|
|
106
148
|
]);
|
|
107
|
-
const { [unsyncedKey]: unsynced = {}, ...here } = local;
|
|
108
|
-
return normalize({ ...synced, ...
|
|
149
|
+
const { [unsyncedKey]: unsynced = {}, [wasKey]: was = {}, ...here } = local;
|
|
150
|
+
return normalize({ ...synced, ...newest(unsynced, was, synced), ...here });
|
|
109
151
|
}
|
|
110
152
|
|
|
111
153
|
/**
|
|
112
154
|
* One sync write for all of `values` (sync allows 120 a minute), then any
|
|
113
|
-
* that sync refused before
|
|
114
|
-
* `load` still reads it, and goes up with the next
|
|
115
|
-
* `retryUnsynced`.
|
|
155
|
+
* that sync refused before and that are still the newest. What sync refuses
|
|
156
|
+
* is kept in local, where `load` still reads it, and goes up with the next
|
|
157
|
+
* write or `retryUnsynced`.
|
|
116
158
|
*
|
|
117
159
|
* @type {SettingsStore<S>['writeSynced']}
|
|
118
160
|
*/
|
|
119
|
-
|
|
161
|
+
function writeSynced(storage, values) {
|
|
120
162
|
// Sent before anything is awaited, so a page that's closing still gets it out.
|
|
121
163
|
const sending = send(storage, values);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
164
|
+
return exclusive(`settings:${unsyncedKey}`, async (waited) => {
|
|
165
|
+
let refused = await sending;
|
|
166
|
+
const sent = Object.keys(values).filter((key) => !(key in refused));
|
|
167
|
+
if (waited && sent.length) {
|
|
168
|
+
// The work waited for may have been a retry that sent an older value after this one: put this one back.
|
|
169
|
+
const now = await storage.sync.get(sent);
|
|
170
|
+
const undone = pick(values, sent.filter((key) => !sameValue(seen(now, key), [values[key]])));
|
|
171
|
+
if (!isEmpty(undone)) refused = { ...refused, ...(await send(storage, undone)) };
|
|
172
|
+
}
|
|
173
|
+
const { [unsyncedKey]: held = {}, [wasKey]: was = {} } = await storage.local.get([unsyncedKey, wasKey]);
|
|
174
|
+
const heldKeys = Object.keys(pick(held, syncedKeys)).filter((key) => !(key in values));
|
|
175
|
+
const synced = await storage.sync.get([...heldKeys, ...Object.keys(refused)]);
|
|
176
|
+
const older = pick(newest(held, was, synced), heldKeys);
|
|
177
|
+
const stillOlder = isEmpty(older) ? {} : await send(storage, older);
|
|
178
|
+
const keep = { ...stillOlder, ...refused };
|
|
179
|
+
const keepWas = Object.fromEntries(Object.keys(keep).map((key) => [key, key in refused || !(key in was) ? seen(synced, key) : was[key]]));
|
|
180
|
+
if (isEmpty(keep)) {
|
|
181
|
+
if (!isEmpty(held) || !isEmpty(was)) await storage.local.remove([unsyncedKey, wasKey]);
|
|
182
|
+
} else if (!sameValue(keep, held) || !sameValue(keepWas, was)) {
|
|
183
|
+
await storage.local.set({ [unsyncedKey]: keep, [wasKey]: keepWas });
|
|
184
|
+
}
|
|
185
|
+
return isEmpty(keep);
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** @type {SettingsStore<S>['retryable']} */
|
|
190
|
+
async function retryable(storage) {
|
|
191
|
+
const { [unsyncedKey]: held = {}, [wasKey]: was = {} } = await storage.local.get([unsyncedKey, wasKey]);
|
|
192
|
+
const keys = Object.keys(pick(held, syncedKeys));
|
|
193
|
+
if (!keys.length) return false;
|
|
194
|
+
return Object.entries(newest(held, was, await storage.sync.get(keys))).some(([key, value]) => syncItemBytes(key, value) <= MAX_SYNC_ITEM_BYTES);
|
|
129
195
|
}
|
|
130
196
|
|
|
131
197
|
/**
|
|
@@ -166,6 +232,7 @@ export function createSettingsStore({ defaults, normalize: normalizeRaw, localKe
|
|
|
166
232
|
normalize,
|
|
167
233
|
load,
|
|
168
234
|
writeSynced,
|
|
235
|
+
retryable,
|
|
169
236
|
retryUnsynced: (/** @type {SyncedStorage} */ storage) => writeSynced(storage, {}),
|
|
170
237
|
save,
|
|
171
238
|
changed,
|
package/src/sites/sites.d.ts
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* taken out or edited. Pure.
|
|
8
8
|
*
|
|
9
9
|
* A site is a web host: http or https, with a dot in its name (or
|
|
10
|
-
* `localhost`). Text without a scheme is read as https.
|
|
10
|
+
* `localhost`). Text without a scheme is read as https. Only a real host
|
|
11
|
+
* name is a site: a `*` typed inside one would make a match pattern Chrome
|
|
12
|
+
* refuses (`*://*.*.example.com/*`), and with it the whole
|
|
13
|
+
* `registerContentScripts` call, so the extension would stop following its
|
|
14
|
+
* settings (checked in Chromium 156: "Invalid host wildcard").
|
|
11
15
|
*
|
|
12
16
|
* @module sites/sites
|
|
13
17
|
*/
|
|
@@ -35,6 +39,7 @@ export declare function siteOrigin(input: unknown): string | null;
|
|
|
35
39
|
* @example
|
|
36
40
|
* siteHost(' https://www.GitHub.com/a '); // 'github.com'
|
|
37
41
|
* siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
|
|
42
|
+
* siteHost('*.example.com'); // 'example.com': a list covers subdomains anyway
|
|
38
43
|
* siteHost('not a site'); // null
|
|
39
44
|
*/
|
|
40
45
|
export declare function siteHost(input: unknown): string | null;
|
package/src/sites/sites.js
CHANGED
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
* taken out or edited. Pure.
|
|
8
8
|
*
|
|
9
9
|
* A site is a web host: http or https, with a dot in its name (or
|
|
10
|
-
* `localhost`). Text without a scheme is read as https.
|
|
10
|
+
* `localhost`). Text without a scheme is read as https. Only a real host
|
|
11
|
+
* name is a site: a `*` typed inside one would make a match pattern Chrome
|
|
12
|
+
* refuses (`*://*.*.example.com/*`), and with it the whole
|
|
13
|
+
* `registerContentScripts` call, so the extension would stop following its
|
|
14
|
+
* settings (checked in Chromium 156: "Invalid host wildcard").
|
|
11
15
|
*
|
|
12
16
|
* @module sites/sites
|
|
13
17
|
*/
|
|
@@ -18,19 +22,31 @@ export const MAX_HOST_LENGTH = 253;
|
|
|
18
22
|
/** A scheme at the start of what was typed; `localhost:8080` is a host and port, not one. */
|
|
19
23
|
const SCHEME = /^[a-z][a-z\d+.-]*:(?!\d)/i;
|
|
20
24
|
|
|
25
|
+
/** A match pattern's wildcards at the start of what was typed: `*://` for any scheme, `*.` for subdomains. A listed site covers its subdomains anyway. */
|
|
26
|
+
const PATTERN_START = /^(?:\*:\/\/)?((?:[a-z][a-z\d+.-]*:\/\/)?)\*\./i;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A host name as the URL parser gives it (lower case, punycode), and nothing else it lets through:
|
|
30
|
+
* labels of letters, digits, `-` and `_`, none empty, a dot at the end allowed. `*`, commas and
|
|
31
|
+
* empty labels pass the parser, and a `*` goes on to make a match pattern Chrome refuses.
|
|
32
|
+
*/
|
|
33
|
+
const HOST_NAME = /^[a-z\d_](?:[a-z\d_-]*[a-z\d_])?(?:\.[a-z\d_](?:[a-z\d_-]*[a-z\d_])?)*\.?$/;
|
|
34
|
+
|
|
21
35
|
/**
|
|
22
|
-
* What was typed (or stored, or a page's address) as a web URL.
|
|
36
|
+
* What was typed (or stored, or a page's address) as a web URL. A match pattern's wildcards at
|
|
37
|
+
* the start (`*.example.com`, `*://*.example.com/*`) are dropped: the site is `example.com`.
|
|
23
38
|
*
|
|
24
39
|
* @param {unknown} input - Anything.
|
|
25
40
|
* @returns {URL | null} The URL, or null when it isn't a web site's.
|
|
26
41
|
*/
|
|
27
42
|
function webUrl(input) {
|
|
28
43
|
// Only text: a stray number would otherwise read as an IP address (3 → "0.0.0.3").
|
|
29
|
-
const text = typeof input === 'string' ? input.trim() : '';
|
|
44
|
+
const text = typeof input === 'string' ? input.trim().replace(PATTERN_START, '$1').replace(/^\*:\/\//, '') : '';
|
|
30
45
|
if (!text || /\s/.test(text)) return null;
|
|
31
46
|
try {
|
|
32
47
|
const url = new URL(SCHEME.test(text) ? text : `https://${text}`);
|
|
33
48
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
49
|
+
if (!HOST_NAME.test(url.hostname)) return null;
|
|
34
50
|
return url.hostname.includes('.') || url.hostname === 'localhost' ? url : null;
|
|
35
51
|
} catch {
|
|
36
52
|
return null;
|
|
@@ -63,6 +79,7 @@ export function siteOrigin(input) {
|
|
|
63
79
|
* @example
|
|
64
80
|
* siteHost(' https://www.GitHub.com/a '); // 'github.com'
|
|
65
81
|
* siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
|
|
82
|
+
* siteHost('*.example.com'); // 'example.com': a list covers subdomains anyway
|
|
66
83
|
* siteHost('not a site'); // null
|
|
67
84
|
*/
|
|
68
85
|
export function siteHost(input) {
|
package/src/styles/chrome-ui.css
CHANGED
|
@@ -307,7 +307,7 @@ button.link:hover { background: none; text-decoration: underline; }
|
|
|
307
307
|
|
|
308
308
|
/* settings-subpage's header, at the top of its card: the back button, then the subpage's title.
|
|
309
309
|
<div class="subpage-head"><button class="icon-button" aria-label="Back"><span class="icon icon-arrow-back"></span></button><h2>Cookies</h2></div> */
|
|
310
|
-
.subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding:
|
|
310
|
+
.subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding-inline: 8px 20px; }
|
|
311
311
|
.subpage-head h2 { margin: 0; font-size: 15px; font-weight: 400; color: var(--text); overflow-wrap: anywhere; }
|
|
312
312
|
.subpage-head .icon-button { color: var(--icon-muted); }
|
|
313
313
|
|
|
@@ -323,13 +323,17 @@ select {
|
|
|
323
323
|
width: 200px;
|
|
324
324
|
max-width: 100%;
|
|
325
325
|
height: 36px;
|
|
326
|
-
padding: 0
|
|
326
|
+
padding-block: 0;
|
|
327
|
+
padding-inline: 10px 20px;
|
|
327
328
|
border: 1px solid var(--field-border);
|
|
328
329
|
border-radius: var(--radius);
|
|
329
330
|
background: var(--select-arrow) calc(100% - 10px) center / 7px no-repeat transparent;
|
|
330
331
|
cursor: pointer;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
/* Right to left: the arrow at the other end, as Chrome mirrors its own. */
|
|
335
|
+
select:dir(rtl) { background-position: 10px center; }
|
|
336
|
+
|
|
333
337
|
select:hover { background-color: var(--hover-subtle); }
|
|
334
338
|
select:focus-visible { outline: 2px solid var(--primary); outline-offset: -1px; }
|
|
335
339
|
select:disabled { opacity: 0.38; cursor: default; }
|
|
@@ -414,13 +418,13 @@ input.switch::after {
|
|
|
414
418
|
content: "";
|
|
415
419
|
position: absolute;
|
|
416
420
|
top: 50%;
|
|
417
|
-
|
|
421
|
+
inset-inline-start: 7px;
|
|
418
422
|
width: 8px;
|
|
419
423
|
height: 8px;
|
|
420
424
|
border-radius: 50%;
|
|
421
425
|
background: var(--switch-outline-off);
|
|
422
426
|
transform: translate(-50%, -50%);
|
|
423
|
-
transition:
|
|
427
|
+
transition: inset-inline-start 80ms linear, width 80ms linear, height 80ms linear, background-color 80ms linear;
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
/* The 20px hover halo around the knob. */
|
|
@@ -428,7 +432,7 @@ input.switch:hover::before {
|
|
|
428
432
|
content: "";
|
|
429
433
|
position: absolute;
|
|
430
434
|
top: 50%;
|
|
431
|
-
|
|
435
|
+
inset-inline-start: 7px;
|
|
432
436
|
width: 20px;
|
|
433
437
|
height: 20px;
|
|
434
438
|
border-radius: 50%;
|
|
@@ -438,8 +442,10 @@ input.switch:hover::before {
|
|
|
438
442
|
|
|
439
443
|
input.switch:active::after { width: 10px; height: 10px; }
|
|
440
444
|
input.switch:checked { background: var(--primary); border-color: var(--primary); }
|
|
441
|
-
input.switch:checked::after {
|
|
442
|
-
input.switch:checked:hover::before {
|
|
445
|
+
input.switch:checked::after { inset-inline-start: 17px; width: 12px; height: 12px; background: var(--on-primary); }
|
|
446
|
+
input.switch:checked:hover::before { inset-inline-start: 17px; }
|
|
447
|
+
/* Right to left: the knob is measured from the right, so it's centred on its point the other way. */
|
|
448
|
+
input.switch:dir(rtl)::after, input.switch:dir(rtl):hover::before { transform: translate(50%, -50%); }
|
|
443
449
|
input.switch:checked:active::after { width: 14px; height: 14px; }
|
|
444
450
|
input.switch:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
|
445
451
|
input.switch:disabled { opacity: 0.38; cursor: default; }
|
|
@@ -458,10 +464,10 @@ input.switch:disabled::before { display: none; }
|
|
|
458
464
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr) var(--sidebar-width);
|
|
459
465
|
align-items: center;
|
|
460
466
|
height: var(--toolbar-height);
|
|
461
|
-
padding-
|
|
467
|
+
padding-inline-end: 12px;
|
|
462
468
|
}
|
|
463
469
|
|
|
464
|
-
.toolbar-start { display: flex; align-items: center; gap: 12px; padding-
|
|
470
|
+
.toolbar-start { display: flex; align-items: center; gap: 12px; padding-inline-start: 20px; min-width: 0; }
|
|
465
471
|
.toolbar-start h1, .toolbar > h1 {
|
|
466
472
|
font-size: 22px;
|
|
467
473
|
font-weight: 500;
|
|
@@ -481,7 +487,8 @@ input.switch:disabled::before { display: none; }
|
|
|
481
487
|
width: 100%;
|
|
482
488
|
max-width: 680px;
|
|
483
489
|
height: 36px;
|
|
484
|
-
padding: 0
|
|
490
|
+
padding-block: 0;
|
|
491
|
+
padding-inline: 10px 6px;
|
|
485
492
|
border-radius: 100px;
|
|
486
493
|
background: var(--search-bg);
|
|
487
494
|
color: var(--icon);
|
|
@@ -496,7 +503,8 @@ input.switch:disabled::before { display: none; }
|
|
|
496
503
|
min-width: 0;
|
|
497
504
|
height: 100%;
|
|
498
505
|
min-height: 0;
|
|
499
|
-
padding: 0
|
|
506
|
+
padding: 0;
|
|
507
|
+
padding-inline-start: 4px;
|
|
500
508
|
border: 0;
|
|
501
509
|
border-radius: 0;
|
|
502
510
|
background: none;
|
|
@@ -621,11 +629,13 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
621
629
|
flex: none;
|
|
622
630
|
width: 20px;
|
|
623
631
|
height: 20px;
|
|
624
|
-
margin-
|
|
632
|
+
margin-inline-end: -2px;
|
|
625
633
|
background: var(--icon-muted);
|
|
626
634
|
mask: var(--icon-arrow-right) center / 20px no-repeat;
|
|
627
635
|
}
|
|
628
636
|
|
|
637
|
+
.link-row:dir(rtl)::after { transform: scaleX(-1); }
|
|
638
|
+
|
|
629
639
|
.link-row.external::after { mask-image: var(--icon-open-in-new); }
|
|
630
640
|
|
|
631
641
|
/* A list inside a card: each item is a row. */
|
|
@@ -653,9 +663,13 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
653
663
|
align-items: center;
|
|
654
664
|
gap: 20px;
|
|
655
665
|
min-height: 40px;
|
|
656
|
-
margin: 0
|
|
657
|
-
|
|
658
|
-
|
|
666
|
+
margin-block: 0;
|
|
667
|
+
margin-inline: 1px 2px;
|
|
668
|
+
padding-block: 10px;
|
|
669
|
+
padding-inline: 23px 16px;
|
|
670
|
+
border-radius: 0;
|
|
671
|
+
border-start-end-radius: 100px;
|
|
672
|
+
border-end-end-radius: 100px;
|
|
659
673
|
color: var(--text);
|
|
660
674
|
font-size: 14px;
|
|
661
675
|
font-weight: 500;
|
|
@@ -678,7 +692,7 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
678
692
|
.manager { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
|
|
679
693
|
.layout { flex: 1; min-height: 0; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); }
|
|
680
694
|
.sidebar { overflow: auto; padding: 8px 0 24px; }
|
|
681
|
-
.main { overflow: auto; padding: 24px
|
|
695
|
+
.main { overflow: auto; padding-block: 24px 96px; padding-inline: 17px 32px; min-width: 0; }
|
|
682
696
|
|
|
683
697
|
/* A folder in the sidebar (bookmarks-folder-node): the same pill as the settings menu. */
|
|
684
698
|
.tree { list-style: none; margin: 0; padding: 0; }
|
|
@@ -689,9 +703,13 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
689
703
|
align-items: center;
|
|
690
704
|
gap: 8px;
|
|
691
705
|
min-height: 40px;
|
|
692
|
-
margin: 0
|
|
693
|
-
|
|
694
|
-
|
|
706
|
+
margin-block: 0;
|
|
707
|
+
margin-inline: 1px 3px;
|
|
708
|
+
padding-block: 0;
|
|
709
|
+
padding-inline: calc(8px + var(--depth) * 20px) 4px;
|
|
710
|
+
border-radius: 0;
|
|
711
|
+
border-start-end-radius: 100px;
|
|
712
|
+
border-end-end-radius: 100px;
|
|
695
713
|
color: var(--folder-text);
|
|
696
714
|
font-size: 14px;
|
|
697
715
|
font-weight: 500;
|
|
@@ -724,7 +742,8 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
724
742
|
align-items: center;
|
|
725
743
|
gap: 20px;
|
|
726
744
|
height: 40px;
|
|
727
|
-
padding: 0
|
|
745
|
+
padding-block: 0;
|
|
746
|
+
padding-inline: 20px 8px;
|
|
728
747
|
user-select: none;
|
|
729
748
|
}
|
|
730
749
|
|
|
@@ -798,7 +817,8 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
798
817
|
`hidden`: the popover defaults undone either way. */
|
|
799
818
|
.toast {
|
|
800
819
|
position: fixed;
|
|
801
|
-
inset: auto
|
|
820
|
+
inset-block: auto 0;
|
|
821
|
+
inset-inline: 0 auto;
|
|
802
822
|
display: flex;
|
|
803
823
|
align-items: center;
|
|
804
824
|
gap: 32px;
|
|
@@ -822,7 +842,7 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
822
842
|
|
|
823
843
|
.toast button {
|
|
824
844
|
height: auto;
|
|
825
|
-
margin-
|
|
845
|
+
margin-inline-end: -8px;
|
|
826
846
|
padding: 8px;
|
|
827
847
|
border: 0;
|
|
828
848
|
background: none;
|
|
@@ -836,7 +856,8 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
836
856
|
|
|
837
857
|
.drawer {
|
|
838
858
|
position: fixed;
|
|
839
|
-
inset: 0
|
|
859
|
+
inset-block: 0;
|
|
860
|
+
inset-inline: 0 auto;
|
|
840
861
|
width: 256px;
|
|
841
862
|
max-width: calc(100vw - 56px);
|
|
842
863
|
height: 100%;
|
|
@@ -851,9 +872,11 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
851
872
|
|
|
852
873
|
.drawer[open] { animation: cr-drawer-in 200ms ease; }
|
|
853
874
|
.drawer::backdrop { background: rgb(0 0 0 / 0.5); }
|
|
854
|
-
.drawer-head { display: flex; align-items: center; gap: 12px; height: var(--toolbar-height); padding-
|
|
875
|
+
.drawer-head { display: flex; align-items: center; gap: 12px; height: var(--toolbar-height); padding-inline-start: 24px; font-size: 22px; font-weight: 500; letter-spacing: 0.25px; }
|
|
855
876
|
|
|
856
877
|
@keyframes cr-drawer-in { from { transform: translateX(-100%); } }
|
|
878
|
+
@keyframes cr-drawer-in-rtl { from { transform: translateX(100%); } }
|
|
879
|
+
.drawer[open]:dir(rtl) { animation-name: cr-drawer-in-rtl; }
|
|
857
880
|
|
|
858
881
|
@media (prefers-reduced-motion: reduce) {
|
|
859
882
|
.drawer[open] { animation: none; }
|
|
@@ -942,7 +965,8 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
942
965
|
|
|
943
966
|
/* .sp-card: rows on a 12px-rounded card, 8px from the panel's edge. */
|
|
944
967
|
.sp-card {
|
|
945
|
-
margin: 0
|
|
968
|
+
margin: 0;
|
|
969
|
+
margin-inline-start: 8px;
|
|
946
970
|
padding: 8px 0;
|
|
947
971
|
border-radius: 12px;
|
|
948
972
|
background: var(--sp-card);
|
|
@@ -1027,7 +1051,7 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
1027
1051
|
place-items: center;
|
|
1028
1052
|
width: 40px;
|
|
1029
1053
|
height: 40px;
|
|
1030
|
-
margin-
|
|
1054
|
+
margin-inline-end: 16px;
|
|
1031
1055
|
border-radius: 8px;
|
|
1032
1056
|
background: var(--sp-icon-bg);
|
|
1033
1057
|
color: var(--text-secondary);
|
|
@@ -1057,12 +1081,20 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
1057
1081
|
display: none;
|
|
1058
1082
|
align-items: center;
|
|
1059
1083
|
gap: 4px;
|
|
1060
|
-
margin: 0
|
|
1084
|
+
margin-block: 0;
|
|
1085
|
+
margin-inline: 8px -4px;
|
|
1061
1086
|
}
|
|
1062
1087
|
|
|
1063
1088
|
.sp-item:is(:hover, :focus-within) .sp-suffix,
|
|
1064
1089
|
.sp-suffix.always { display: flex; }
|
|
1065
1090
|
|
|
1091
|
+
/* .sp-notice (ui/notice.js): a card saying what a list leaves out or can't do; a warning's icon in
|
|
1092
|
+
the error colour, an information's in the secondary text colour. */
|
|
1093
|
+
.sp-notice .sp-heading > .icon { flex: none; width: 18px; height: 18px; color: var(--error); }
|
|
1094
|
+
.sp-notice.info .sp-heading > .icon { color: var(--text-secondary); }
|
|
1095
|
+
.sp-notice-text { margin: 0; padding: 2px 16px 8px; color: var(--text-secondary); font-size: 12px; line-height: 16px; }
|
|
1096
|
+
.sp-notice-actions { display: flex; justify-content: flex-end; padding: 0 16px 8px; }
|
|
1097
|
+
|
|
1066
1098
|
/* sp-empty-state: a centred heading and a line of help. */
|
|
1067
1099
|
.sp-empty {
|
|
1068
1100
|
display: flex;
|
|
@@ -1089,7 +1121,8 @@ button.floating {
|
|
|
1089
1121
|
align-items: center;
|
|
1090
1122
|
gap: 8px;
|
|
1091
1123
|
height: 40px;
|
|
1092
|
-
padding: 8px
|
|
1124
|
+
padding-block: 8px;
|
|
1125
|
+
padding-inline: 12px 16px;
|
|
1093
1126
|
border: 0;
|
|
1094
1127
|
border-radius: 8px;
|
|
1095
1128
|
background: var(--tonal-bg);
|
|
@@ -1111,6 +1144,6 @@ button.floating .icon { width: 20px; height: 20px; }
|
|
|
1111
1144
|
/* ------------------------------------------------------------ Narrow */
|
|
1112
1145
|
|
|
1113
1146
|
@media (max-width: 720px) {
|
|
1114
|
-
.toolbar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; padding-
|
|
1115
|
-
.toolbar-start { padding-
|
|
1147
|
+
.toolbar { grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; padding-inline-end: 8px; }
|
|
1148
|
+
.toolbar-start { padding-inline-start: 16px; }
|
|
1116
1149
|
}
|
package/src/testing/e2e.d.ts
CHANGED
|
@@ -125,6 +125,33 @@ export declare function extensionProfile(chromium: BrowserType, dir: string, { p
|
|
|
125
125
|
* await page.goto(url('options/options.html'));
|
|
126
126
|
*/
|
|
127
127
|
export declare function launchExtension(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension>;
|
|
128
|
+
/**
|
|
129
|
+
* Stop the extension's service worker, as Chrome does after 30 seconds idle: its globals,
|
|
130
|
+
* timers and anything else kept in memory are gone; storage, alarms and registered listeners
|
|
131
|
+
* stay. Chrome never does this by itself in an e2e run (Playwright's debugger keeps the worker
|
|
132
|
+
* alive), so a check that state survives a restart has to ask for it. Done through the
|
|
133
|
+
* DevTools protocol from a page of the extension (`ServiceWorker.stopWorker`), and, unless
|
|
134
|
+
* `start` is false, the worker is started again with no event (`ServiceWorker.startWorker`).
|
|
135
|
+
*
|
|
136
|
+
* `launched.worker` stays the handle to use: Playwright keeps it across the restart. Anything a
|
|
137
|
+
* test put in the old worker (`recordIconCalls`, a patched `chrome.*` function) is gone with it.
|
|
138
|
+
*
|
|
139
|
+
* @param {Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>} launched - From {@link launchExtension}.
|
|
140
|
+
* @param {{start?: boolean, page?: string}} [options] - `start: false` leaves the worker stopped, for
|
|
141
|
+
* the next event (a message, an alarm, a tab) to start it as on a person's computer; `page`: a file
|
|
142
|
+
* of the extension to open for the protocol session (default `manifest.json`).
|
|
143
|
+
* @returns {Promise<void>} Once the worker has stopped (and, with `start`, is running again).
|
|
144
|
+
* @throws {Error} When the worker isn't running, or doesn't stop or start in 10 s.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* await worker.evaluate(() => { globalThis.cache = { tab: 7 }; });
|
|
148
|
+
* await stopWorker(launched);
|
|
149
|
+
* await worker.evaluate(() => globalThis.cache); // undefined: what wasn't stored is gone
|
|
150
|
+
*/
|
|
151
|
+
export declare function stopWorker({ context, worker, extensionId, url }: Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>, { start, page: file }?: {
|
|
152
|
+
start?: boolean;
|
|
153
|
+
page?: string;
|
|
154
|
+
}): Promise<void>;
|
|
128
155
|
/**
|
|
129
156
|
* Emulate colour schemes in the offscreen document: one CDP session attached
|
|
130
157
|
* to it, reused for every change.
|
|
@@ -137,10 +164,12 @@ export declare function launchExtension(chromium: BrowserType, dir: string, opti
|
|
|
137
164
|
export declare function offscreenEmulator(cdp: CDPSession, suffix?: string): Promise<(scheme: 'light' | 'dark') => Promise<unknown>>;
|
|
138
165
|
/**
|
|
139
166
|
* Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
|
|
140
|
-
* Chrome has taken it (a path Chrome can't load fails, and isn't recorded)
|
|
167
|
+
* Chrome has taken it (a path Chrome can't load fails, and isn't recorded):
|
|
168
|
+
* its `path`, or `'imageData'` for an icon given as pixels. The record lives
|
|
169
|
+
* in the worker: it's gone when the worker restarts ({@link stopWorker}).
|
|
141
170
|
*
|
|
142
171
|
* @param {Worker} worker - The extension's service worker.
|
|
143
|
-
* @returns {Promise<() => Promise<unknown[]>>} Reads the recorded
|
|
172
|
+
* @returns {Promise<() => Promise<unknown[]>>} Reads the recorded icons, oldest first.
|
|
144
173
|
*/
|
|
145
174
|
export declare function recordIconCalls(worker: Worker): Promise<() => Promise<unknown[]>>;
|
|
146
175
|
/** How long one check may take before it's failed, in ms: long enough for a slow real-browser check, short of a hang. */
|
|
@@ -159,7 +188,8 @@ export type CheckRunner = {
|
|
|
159
188
|
*/
|
|
160
189
|
fail: (name: string) => void;
|
|
161
190
|
/**
|
|
162
|
-
* Print the tally, and exit with 1 if anything failed
|
|
191
|
+
* Print the tally, and exit with 1 if anything failed or no check ran at all
|
|
192
|
+
* (a script that returned early, or whose filter matched nothing, hasn't passed).
|
|
163
193
|
*/
|
|
164
194
|
finish: () => void;
|
|
165
195
|
};
|
|
@@ -173,7 +203,8 @@ export type CheckRunner = {
|
|
|
173
203
|
* whether it passed.
|
|
174
204
|
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
175
205
|
* uncaught error, say): printed now, listed and counted at the end.
|
|
176
|
-
* @property {() => void} finish Print the tally, and exit with 1 if anything failed
|
|
206
|
+
* @property {() => void} finish Print the tally, and exit with 1 if anything failed or no check ran at all
|
|
207
|
+
* (a script that returned early, or whose filter matched nothing, hasn't passed).
|
|
177
208
|
*/
|
|
178
209
|
/**
|
|
179
210
|
* Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
|