@arraypress/extension-kit 1.10.0 → 1.11.1
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 +103 -21
- 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/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/highlight.d.ts +15 -6
- package/src/content/highlight.js +42 -17
- package/src/content/overlay.d.ts +19 -4
- package/src/content/overlay.js +44 -6
- 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/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/frame/window.d.ts +1 -1
- package/src/frame/window.js +8 -7
- package/src/index.d.ts +10 -3
- package/src/index.js +8 -3
- 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 +17 -5
- package/src/scripting/tab.js +15 -7
- 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 +59 -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 +3 -3
- package/src/testing/index.js +5 -3
- package/src/testing/live-run.d.ts +23 -2
- package/src/testing/live-run.js +64 -18
- 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/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 +48 -11
- package/src/ui/model-row.js +5 -1
- 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.d.ts +3 -0
- package/src/ui/toast.js +23 -1
|
@@ -74,14 +74,45 @@ export declare const MAX_SYNC_ITEM_BYTES = 8192;
|
|
|
74
74
|
*/
|
|
75
75
|
export declare function pick<T extends Record<string, unknown>>(object: T, keys: readonly string[]): Partial<T>;
|
|
76
76
|
/**
|
|
77
|
-
* How many bytes sync counts for one item
|
|
78
|
-
*
|
|
77
|
+
* How many bytes sync counts for one item, as Chrome measures
|
|
78
|
+
* `QUOTA_BYTES_PER_ITEM`: the key plus the value's JSON as Chrome's own
|
|
79
|
+
* writer spells it, UTF-8 encoded. That writer (`base::WriteJson`) escapes
|
|
80
|
+
* `<` as `\u003C` and U+2028 / U+2029 as `\u2028` / `\u2029`, six bytes each,
|
|
81
|
+
* so text full of `<` (an HTML template) weighs far more in sync than its
|
|
82
|
+
* `JSON.stringify` length says. Checked against Chromium's
|
|
83
|
+
* `settings_storage_quota_enforcer.cc` and `base/json/string_escape.cc`.
|
|
79
84
|
*
|
|
80
85
|
* @param {string} key - The item's key.
|
|
81
86
|
* @param {unknown} value - The item's value.
|
|
82
87
|
* @returns {number} Bytes.
|
|
88
|
+
* @example
|
|
89
|
+
* syncItemBytes('k', 'a<b'); // 11: k + "a\u003Cb"
|
|
83
90
|
*/
|
|
84
91
|
export declare function syncItemBytes(key: string, value: unknown): number;
|
|
92
|
+
/**
|
|
93
|
+
* Whether two stored values are the same, whatever order their objects' keys come in.
|
|
94
|
+
*
|
|
95
|
+
* @param {unknown} a - One value.
|
|
96
|
+
* @param {unknown} b - The other.
|
|
97
|
+
* @returns {boolean} True when they'd be stored alike.
|
|
98
|
+
* @example
|
|
99
|
+
* sameValue({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
|
|
100
|
+
*/
|
|
101
|
+
export declare function sameValue(a: unknown, b: unknown): boolean;
|
|
102
|
+
/**
|
|
103
|
+
* Run `work` while nothing else runs under the same name: in this page, the
|
|
104
|
+
* extension's other pages and its service worker alike (Web Locks, which an
|
|
105
|
+
* extension's contexts share), so a read-modify-write of one storage item
|
|
106
|
+
* can't interleave with another. `work` is told whether it had to wait.
|
|
107
|
+
*
|
|
108
|
+
* @template T
|
|
109
|
+
* @param {string} name - The lock's name.
|
|
110
|
+
* @param {(waited: boolean) => Promise<T>} work - What to do; `waited` is true when other work held the name first.
|
|
111
|
+
* @returns {Promise<T>} What `work` gives.
|
|
112
|
+
* @example
|
|
113
|
+
* await exclusive('unsyncedSettings', async () => storage.local.set({ held: { ...(await storage.local.get('held')).held, days } }));
|
|
114
|
+
*/
|
|
115
|
+
export declare function exclusive<T>(name: string, work: (waited: boolean) => Promise<T>): Promise<T>;
|
|
85
116
|
/**
|
|
86
117
|
* Whether an object has no own keys.
|
|
87
118
|
*
|
package/src/settings/storage.js
CHANGED
|
@@ -53,16 +53,86 @@ export function pick(object, keys) {
|
|
|
53
53
|
return /** @type {Partial<T>} */ (Object.fromEntries(keys.filter((key) => key in object).map((key) => [key, object[key]])));
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/** What Chrome's JSON writer spells as six bytes where `JSON.stringify` writes fewer: `<` (one byte), U+2028 and U+2029 (three each). */
|
|
57
|
+
const WIDER_IN_CHROME = /[<\u2028\u2029]/g;
|
|
58
|
+
|
|
56
59
|
/**
|
|
57
|
-
* How many bytes sync counts for one item
|
|
58
|
-
*
|
|
60
|
+
* How many bytes sync counts for one item, as Chrome measures
|
|
61
|
+
* `QUOTA_BYTES_PER_ITEM`: the key plus the value's JSON as Chrome's own
|
|
62
|
+
* writer spells it, UTF-8 encoded. That writer (`base::WriteJson`) escapes
|
|
63
|
+
* `<` as `\u003C` and U+2028 / U+2029 as `\u2028` / `\u2029`, six bytes each,
|
|
64
|
+
* so text full of `<` (an HTML template) weighs far more in sync than its
|
|
65
|
+
* `JSON.stringify` length says. Checked against Chromium's
|
|
66
|
+
* `settings_storage_quota_enforcer.cc` and `base/json/string_escape.cc`.
|
|
59
67
|
*
|
|
60
68
|
* @param {string} key - The item's key.
|
|
61
69
|
* @param {unknown} value - The item's value.
|
|
62
70
|
* @returns {number} Bytes.
|
|
71
|
+
* @example
|
|
72
|
+
* syncItemBytes('k', 'a<b'); // 11: k + "a\u003Cb"
|
|
63
73
|
*/
|
|
64
74
|
export function syncItemBytes(key, value) {
|
|
65
|
-
|
|
75
|
+
const json = String(JSON.stringify(value));
|
|
76
|
+
let wider = 0;
|
|
77
|
+
for (const [character] of json.matchAll(WIDER_IN_CHROME)) wider += character === '<' ? 5 : 3;
|
|
78
|
+
return new TextEncoder().encode(key + json).length + wider;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A value as text that reads the same however its objects' keys are ordered:
|
|
83
|
+
* Chrome hands a stored object back with its keys sorted.
|
|
84
|
+
*
|
|
85
|
+
* @param {unknown} value - Anything JSON can hold.
|
|
86
|
+
* @returns {string} Its JSON, object keys sorted.
|
|
87
|
+
*/
|
|
88
|
+
function canonical(value) {
|
|
89
|
+
return String(JSON.stringify(value, (_, inner) => (inner && typeof inner === 'object' && !Array.isArray(inner)
|
|
90
|
+
? Object.fromEntries(Object.keys(inner).sort().map((key) => [key, inner[key]]))
|
|
91
|
+
: inner)));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Whether two stored values are the same, whatever order their objects' keys come in.
|
|
96
|
+
*
|
|
97
|
+
* @param {unknown} a - One value.
|
|
98
|
+
* @param {unknown} b - The other.
|
|
99
|
+
* @returns {boolean} True when they'd be stored alike.
|
|
100
|
+
* @example
|
|
101
|
+
* sameValue({ a: 1, b: [2] }, { b: [2], a: 1 }); // true
|
|
102
|
+
*/
|
|
103
|
+
export function sameValue(a, b) {
|
|
104
|
+
return canonical(a) === canonical(b);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Where there are no Web Locks (an old Node): each name's last piece of work, so the next waits for it. @type {Map<string, Promise<unknown>>} */
|
|
108
|
+
const chains = new Map();
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run `work` while nothing else runs under the same name: in this page, the
|
|
112
|
+
* extension's other pages and its service worker alike (Web Locks, which an
|
|
113
|
+
* extension's contexts share), so a read-modify-write of one storage item
|
|
114
|
+
* can't interleave with another. `work` is told whether it had to wait.
|
|
115
|
+
*
|
|
116
|
+
* @template T
|
|
117
|
+
* @param {string} name - The lock's name.
|
|
118
|
+
* @param {(waited: boolean) => Promise<T>} work - What to do; `waited` is true when other work held the name first.
|
|
119
|
+
* @returns {Promise<T>} What `work` gives.
|
|
120
|
+
* @example
|
|
121
|
+
* await exclusive('unsyncedSettings', async () => storage.local.set({ held: { ...(await storage.local.get('held')).held, days } }));
|
|
122
|
+
*/
|
|
123
|
+
export async function exclusive(name, work) {
|
|
124
|
+
const locks = /** @type {{navigator?: {locks?: LockManager}}} */ (globalThis).navigator?.locks;
|
|
125
|
+
if (locks) {
|
|
126
|
+
/** @type {{value: T} | null} */
|
|
127
|
+
const free = await locks.request(name, { ifAvailable: true }, async (lock) => (lock ? { value: await work(false) } : null));
|
|
128
|
+
return free ? free.value : locks.request(name, () => work(true));
|
|
129
|
+
}
|
|
130
|
+
const before = chains.get(name);
|
|
131
|
+
const run = (before ?? Promise.resolve()).then(() => work(before !== undefined));
|
|
132
|
+
const settled = run.catch(() => {});
|
|
133
|
+
chains.set(name, settled);
|
|
134
|
+
settled.then(() => { if (chains.get(name) === settled) chains.delete(name); });
|
|
135
|
+
return run;
|
|
66
136
|
}
|
|
67
137
|
|
|
68
138
|
/**
|
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) {
|