Repository navigation
Expand file tree
/
Copy pathstorage.js
More file actions
545 lines (491 loc) · 17.9 KB
/
Copy pathstorage.js
File metadata and controls
545 lines (491 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
/**
* storage.js — Dual-layer storage for instant reads + cross-device sync.
*
* Strategy:
* - chrome.storage.local → instant cache, read first on load
* - chrome.storage.sync → source of truth, syncs across devices
*
* On init: paint from local immediately, reconcile with sync in background.
* On write: update both layers atomically.
*
* Sync orchestration:
* - Each install gets a stable writerId (persisted in local storage).
* - Each local write gets a monotonic revision number.
* - Sync storage stores an envelope: { __sync: { writerId, revision }, ...state }.
* - Echo suppression uses (writerId, revision) pairs, not content equality.
* - All sync writes serialize through a single promise chain; remote updates
* invalidate pending local writes and trigger re-assertion after stale writes.
*/
const STORAGE_KEY = 'newtab_data';
const BACKGROUND_KEY = 'newtab_background';
const BACKGROUND_SIZE_KEY = 'newtab_background_size';
const WRITER_ID_KEY = 'sync_writer_id';
const SCHEMA_VERSION = 1;
/** @typedef {{ id: string, name: string, url: string }} Shortcut */
/** @typedef {{ id: string, name: string, shortcuts: Shortcut[] }} Group */
/** @typedef {{ version: number, groups: Group[], shortcuts: Shortcut[] }} AppState */
/** @typedef {{ writerId: string, revision: number }} SyncMeta */
/** Generate a unique ID (exported for use in newtab.js) */
function newId() {
return crypto.randomUUID();
}
/**
* Unwrap sync envelope if present, returning clean state for validation.
* @param {unknown} raw
* @returns {AppState}
*/
function validate(raw) {
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.groups)) {
return { version: SCHEMA_VERSION, groups: [], shortcuts: [] };
}
// Remove __sync envelope if present (from sync storage)
const source = raw.__sync ? Object.fromEntries(
Object.entries(raw).filter(([k]) => k !== '__sync')
) : raw;
const groups = source.groups
.filter(g => g && typeof g === 'object')
.map(g => ({
id: typeof g.id === 'string' ? g.id : newId(),
name: typeof g.name === 'string' ? g.name : 'Untitled',
shortcuts: Array.isArray(g.shortcuts)
? g.shortcuts
.filter(s => s && typeof s === 'object' && typeof s.url === 'string')
.map(s => ({
id: typeof s.id === 'string' ? s.id : newId(),
name: typeof s.name === 'string' ? s.name : s.url,
url: s.url,
}))
: [],
}));
const shortcuts = Array.isArray(source.shortcuts)
? source.shortcuts
.filter(s => s && typeof s === 'object' && typeof s.url === 'string')
.map(s => ({
id: typeof s.id === 'string' ? s.id : newId(),
name: typeof s.name === 'string' ? s.name : s.url,
url: s.url,
}))
: [];
return { version: SCHEMA_VERSION, groups, shortcuts };
}
/**
* Read from local cache (instant, synchronous-feeling).
* Returns validated state or null if nothing cached.
* @returns {Promise<AppState|null>}
*/
async function readLocal() {
return new Promise(resolve => {
chrome.storage.local.get(STORAGE_KEY, result => {
const raw = result[STORAGE_KEY];
resolve(raw ? validate(raw) : null);
});
});
}
/**
* Read from sync storage (cross-device, may be slower).
* Returns validated state and sync metadata (writerId, revision).
* @returns {Promise<{ state: AppState|null, meta: SyncMeta|null }>}
*/
async function readSync() {
return new Promise(resolve => {
chrome.storage.sync.get(STORAGE_KEY, result => {
const raw = result[STORAGE_KEY];
if (!raw) {
resolve({ state: null, meta: null });
return;
}
const meta = raw.__sync ?? null;
const state = validate(raw);
resolve({ state, meta });
});
});
}
/**
* Write state to both layers.
* If provenance is provided, writes envelope to sync; otherwise writes clean state to both.
* @param {AppState} state
* @param {SyncMeta|null} [provenance]
* @returns {Promise<AppState>}
*/
async function saveAll(state, provenance = null) {
const validated = validate(state);
const localPayload = { [STORAGE_KEY]: validated };
const syncPayload = provenance
? { [STORAGE_KEY]: { ...validated, __sync: provenance } }
: localPayload;
await Promise.all([
new Promise((resolve, reject) => chrome.storage.local.set(localPayload, () => {
const err = chrome.runtime?.lastError;
err ? reject(err) : resolve();
})),
new Promise((resolve, reject) => chrome.storage.sync.set(syncPayload, () => {
const err = chrome.runtime?.lastError;
err ? reject(err) : resolve();
})),
]);
return validated;
}
/**
* Load state with instant-first strategy:
* 1. Return local cache immediately if available (fast first paint).
* 2. Fetch sync in background; call onSyncReady(state, meta) when resolved.
*
* @param {function(AppState): void} onLocalReady — called immediately with cached data (or empty)
* @param {function(AppState, SyncMeta|null): void} onSyncReady — called when sync resolves
*/
async function init(onLocalReady, onSyncReady) {
const local = await readLocal();
onLocalReady(local ?? { version: SCHEMA_VERSION, groups: [], shortcuts: [] });
const { state: synced, meta } = await readSync();
if (synced) {
// Sync wins as source of truth; update local cache to match
await new Promise(resolve =>
chrome.storage.local.set({ [STORAGE_KEY]: synced }, resolve)
);
onSyncReady(synced, meta);
} else if (local) {
// No sync data yet; push local up to sync (first-time setup on new device)
// Write without provenance (will be overwritten by next proper persist)
await new Promise(resolve =>
chrome.storage.sync.set({ [STORAGE_KEY]: local }, resolve)
);
onSyncReady(local, null);
} else {
onSyncReady({ version: SCHEMA_VERSION, groups: [], shortcuts: [] }, null);
}
}
/**
* Subscribe to live sync changes (e.g. another device wrote while this tab is open).
* Callback receives (validatedState, syncMeta).
* @param {function(AppState, SyncMeta|null): void} callback
*/
function onChange(callback) {
chrome.storage.onChanged.addListener((changes, area) => {
if (area === 'sync' && changes[STORAGE_KEY]) {
const newVal = changes[STORAGE_KEY].newValue;
if (newVal) {
const meta = newVal.__sync ?? null;
const state = validate(newVal);
// Mirror to local cache (clean state, no envelope)
chrome.storage.local.set({ [STORAGE_KEY]: state });
callback(state, meta);
}
}
});
}
/**
* Debounce a function so it fires `ms` after the last call.
* Returns a promise that resolves when the debounced function completes.
* @param {function(...any): Promise<void>} fn
* @param {number} ms
* @returns {{ (...args: any[]): Promise<void>, cancel: () => void, flush: (...args: any[]) => Promise<void> }}
*/
function debounce(fn, ms) {
let timer = null;
let lastArgs = null;
let pendingPromise = null;
let pendingResolve = null;
let pendingReject = null;
const debounced = (...args) => {
lastArgs = args;
if (timer) {
clearTimeout(timer);
pendingResolve?.();
}
// Create a new promise for this debounced call
pendingPromise = new Promise((resolve, reject) => {
pendingResolve = resolve;
pendingReject = reject;
});
timer = setTimeout(async () => {
timer = null;
try {
await fn(...lastArgs);
pendingResolve?.();
} catch (e) {
pendingReject?.(e);
} finally {
pendingResolve = pendingReject = null;
pendingPromise = null;
}
}, ms);
return pendingPromise;
};
debounced.cancel = () => {
if (timer) {
clearTimeout(timer);
timer = null;
// Cancellation is not an error; resolve the pending promise
pendingResolve?.();
pendingResolve = pendingReject = null;
pendingPromise = null;
}
};
debounced.flush = (...args) => {
if (timer) {
clearTimeout(timer);
timer = null;
// Resolve the pending promise from the superseded debounced call
pendingResolve?.();
pendingResolve = pendingReject = null;
pendingPromise = null;
}
lastArgs = args.length ? args : lastArgs;
if (!lastArgs) return Promise.resolve();
return fn(...lastArgs);
};
return debounced;
}
/**
* Load or create the stable writer ID for this install.
* @returns {Promise<string>}
*/
async function getOrCreateWriterId() {
return new Promise(resolve => {
chrome.storage.local.get(WRITER_ID_KEY, result => {
let id = result[WRITER_ID_KEY];
if (!id) {
id = crypto.randomUUID();
chrome.storage.local.set({ [WRITER_ID_KEY]: id });
}
resolve(id);
});
});
}
/**
* Create a sync orchestrator that rate-limits writes to Chrome Sync
* and ignores chrome.storage.onChanged echoes of our own writes.
*
* Features:
* - Per-install writerId + monotonic revision for provenance.
* - Serialized write queue: local writes and remote reconciliations
* execute in order; remote updates invalidate pending local writes.
* - Echo suppression by (writerId, revision) — not content equality.
* - flush() for lifecycle boundaries; errors propagate to callers.
*
* @param {{ debounceMs?: number, historyLimit?: number, writerId?: string }} [options]
* @returns {Promise<{ persist: (state: AppState) => Promise<void>, onRemote: (state: AppState, meta: SyncMeta|null) => boolean, flush: () => Promise<void> }>}
*/
async function createSyncer({ debounceMs = 400, historyLimit = 10, writerId } = {}) {
// Writer identity (loaded once, or use provided for testing)
const resolvedWriterId = writerId ?? await getOrCreateWriterId();
// State
let localRevision = 0;
// Local writes whose revision is at or below this floor were superseded by a
// remote update (remote wins). Writes scheduled after the remote are unaffected.
let remoteRevisionFloor = 0;
// Whether a local write is currently executing on the wire. Only then does a
// remote update need to be re-asserted after the (potentially stale) write.
let writeInFlight = false;
const acknowledgedRevisions = new Set(); // revisions we've successfully written to sync
let latestRemoteState = null; // most recent remote state that arrived mid-write
let latestRemoteMeta = null;
// Serialized write queue
let writeChain = Promise.resolve();
function enqueueWrite(jobFn) {
const jobPromise = writeChain.then(jobFn);
// Chain continues even if job fails (errors handled by caller via returned promise)
writeChain = jobPromise.catch(() => {});
return jobPromise;
}
// Debounced local write
let debounceTimer = null;
let pendingDebouncedState = null;
let pendingDebouncedRevision = null;
let pendingDebouncedResolve = null;
let pendingDebouncedReject = null;
function scheduleDebouncedSave(state, revision) {
pendingDebouncedState = state;
pendingDebouncedRevision = revision;
if (debounceTimer) {
clearTimeout(debounceTimer);
// The previously scheduled write is superseded before it ever ran —
// settle its caller's promise instead of leaving it pending forever.
pendingDebouncedResolve?.();
}
const flushPromise = new Promise((resolve, reject) => {
pendingDebouncedResolve = resolve;
pendingDebouncedReject = reject;
});
debounceTimer = setTimeout(() => {
debounceTimer = null;
const resolve = pendingDebouncedResolve;
const reject = pendingDebouncedReject;
const job = flushDebouncedSave();
if (reject) {
job.then(resolve, reject);
} else {
job.then(resolve).catch(() => {}); // detached settle — swallow to avoid unhandled rejection
}
pendingDebouncedResolve = pendingDebouncedReject = null;
}, debounceMs);
return flushPromise;
}
function cancelDebouncedSave() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
pendingDebouncedState = null;
pendingDebouncedRevision = null;
pendingDebouncedResolve?.(); // cancellation is not an error
pendingDebouncedResolve = pendingDebouncedReject = null;
}
}
async function flushDebouncedSave() {
const state = pendingDebouncedState;
const revision = pendingDebouncedRevision;
pendingDebouncedState = null;
pendingDebouncedRevision = null;
if (!state || revision === undefined) return;
return enqueueWrite(async () => {
writeInFlight = true;
try {
// Invalidation: superseded by a newer local write?
if (revision !== localRevision) return;
// Invalidation: a remote was applied after this write was queued?
if (revision <= remoteRevisionFloor) return;
// Sync stores the envelope { __sync } so echoes can be attributed to us.
acknowledgedRevisions.add(revision);
// Prune old acknowledged revisions (evict oldest inserted)
if (acknowledgedRevisions.size > historyLimit) {
const oldest = acknowledgedRevisions.values().next().value;
acknowledgedRevisions.delete(oldest);
}
try {
await saveAll(state, { writerId: resolvedWriterId, revision });
} catch (err) {
acknowledgedRevisions.delete(revision);
throw err;
}
} finally {
writeInFlight = false;
// A remote arrived while this write was executing — the write may have
// clobbered sync, so re-assert the remote (with its own provenance).
if (latestRemoteState) {
const remoteState = latestRemoteState;
const remoteMeta = latestRemoteMeta;
latestRemoteState = null;
latestRemoteMeta = null;
await reconcileRemote(remoteState, remoteMeta);
}
}
});
}
async function reconcileRemote(state, meta) {
// Re-assert remote state to sync (with its provenance) in case a stale
// local write clobbered it. The remote already carries its provenance.
await saveAll(state, meta);
}
return {
/**
* Schedule a debounced write to both layers.
* Returns a promise that resolves when the write is acknowledged (or skipped).
* @param {AppState} state
* @returns {Promise<void>}
*/
persist(state) {
localRevision++;
const revision = localRevision;
return scheduleDebouncedSave(state, revision);
},
/**
* Decide whether an incoming sync state should be applied.
* Returns false for echoes of our own acknowledged writes; otherwise
* cancels pending local write, records remote as latest, and returns true.
* @param {AppState} state
* @param {SyncMeta|null} meta
* @returns {boolean}
*/
onRemote(state, meta) {
// Echo check: our writerId + acknowledged revision?
if (meta && meta.writerId === resolvedWriterId && acknowledgedRevisions.has(meta.revision)) {
return false;
}
// Remote wins — any local write queued before this point is stale.
remoteRevisionFloor = localRevision;
cancelDebouncedSave(); // cancel any pending local write
// If a local write is mid-flight it may have clobbered sync; hold the
// remote so that write's completion re-asserts it (see flushDebouncedSave).
// An idle device must NOT latch the remote — later local edits are valid writes.
if (writeInFlight) {
latestRemoteState = state;
latestRemoteMeta = meta;
}
return true;
},
/**
* Flush any pending debounced write and wait for the write queue to drain.
* @returns {Promise<void>}
*/
async flush() {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
// Resolve the pending persist promise since we're flushing it manually
pendingDebouncedResolve?.();
pendingDebouncedResolve = pendingDebouncedReject = null;
await flushDebouncedSave();
}
await writeChain;
},
};
}
// ---------------------------------------------------------------------------
// Background image — stored in local-only to avoid sync quota limits
// ---------------------------------------------------------------------------
/**
* Save a background image data URL to local storage.
* @param {string} dataUrl
* @returns {Promise<void>}
*/
async function saveBackground(dataUrl) {
return new Promise(resolve => {
chrome.storage.local.set({ [BACKGROUND_KEY]: dataUrl }, resolve);
});
}
/**
* Read the background image from local storage.
* @returns {Promise<string|null>}
*/
async function readBackground() {
return new Promise(resolve => {
chrome.storage.local.get(BACKGROUND_KEY, result => {
resolve(result[BACKGROUND_KEY] ?? null);
});
});
}
/**
* Remove the background image from local storage.
* @returns {Promise<void>}
*/
async function clearBackground() {
return new Promise(resolve => {
chrome.storage.local.remove(BACKGROUND_KEY, resolve);
});
}
// ---------------------------------------------------------------------------
// Background image size — stored in local-only
// ---------------------------------------------------------------------------
/**
* Save the background image size setting to local storage.
* @param {'cover'|'contain'|'auto'} size
* @returns {Promise<void>}
*/
async function saveBackgroundSize(size) {
return new Promise(resolve => {
chrome.storage.local.set({ [BACKGROUND_SIZE_KEY]: size }, resolve);
});
}
/**
* Read the background image size setting from local storage.
* @returns {Promise<'cover'|'contain'|'auto'>}
*/
async function readBackgroundSize() {
return new Promise(resolve => {
chrome.storage.local.get(BACKGROUND_SIZE_KEY, result => {
const size = result[BACKGROUND_SIZE_KEY];
resolve(size === 'contain' || size === 'auto' ? size : 'cover');
});
});
}
export { init, saveAll, onChange, newId, validate, debounce, createSyncer, saveBackground, readBackground, clearBackground, saveBackgroundSize, readBackgroundSize };