97 lines
3.9 KiB
JavaScript
97 lines
3.9 KiB
JavaScript
|
|
// Per-add-on key/value stores (<userData>/extensions-data/<id>.json), shared
|
||
|
|
// by the add-on's own API (addons-host.js) and its pages (main.js IPC).
|
||
|
|
//
|
||
|
|
// The store used to be read and parsed from disk on EVERY get, and read +
|
||
|
|
// parsed + rewritten on every set — synchronously, on the Electron main
|
||
|
|
// thread. With a 7.5 MB Aegis store that was ~60 ms per get, and Aegis does
|
||
|
|
// many per state update: measured 2026-10-03 on a real profile, 30 s of the
|
||
|
|
// first 35 s of the main thread went to storage.get, and the window sat in
|
||
|
|
// "Not Responding" for 16 s after launch.
|
||
|
|
//
|
||
|
|
// Now each store is read once and kept in memory. A get costs only the value
|
||
|
|
// it returns; values are copied in and out (structuredClone), so a caller
|
||
|
|
// that mutates what it got — or what it passed to set — never changes the
|
||
|
|
// store behind its back, exactly as with the old parse-per-call. Writes are
|
||
|
|
// coalesced (WRITE_DELAY_MS) and land as an atomic temp-file + rename, so a
|
||
|
|
// crash mid-write can't leave a truncated file; flushAll() runs on quit.
|
||
|
|
// A store file that exists but doesn't parse is moved aside before the
|
||
|
|
// first write instead of being silently replaced by {} (which lost every key).
|
||
|
|
"use strict";
|
||
|
|
const fs = require("fs");
|
||
|
|
const path = require("path");
|
||
|
|
|
||
|
|
const WRITE_DELAY_MS = 100;
|
||
|
|
const stores = new Map(); // file -> Store
|
||
|
|
|
||
|
|
class Store {
|
||
|
|
constructor(file, log) {
|
||
|
|
this.file = file;
|
||
|
|
this.log = log || (() => {});
|
||
|
|
this.data = null; // loaded lazily
|
||
|
|
this.timer = null;
|
||
|
|
this.dirty = false;
|
||
|
|
}
|
||
|
|
_load() {
|
||
|
|
if (this.data) return this.data;
|
||
|
|
let text = null;
|
||
|
|
try { text = fs.readFileSync(this.file, "utf8"); } catch (e) { if (e.code !== "ENOENT") this.log(`store read failed (${path.basename(this.file)}):`, e.message); }
|
||
|
|
if (text == null) { this.data = {}; return this.data; }
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(text);
|
||
|
|
this.data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
||
|
|
} catch (e) {
|
||
|
|
// Keep the unreadable file for recovery rather than overwriting it.
|
||
|
|
const aside = `${this.file}.corrupt-${Date.now()}`;
|
||
|
|
try { fs.renameSync(this.file, aside); } catch {}
|
||
|
|
this.log(`store ${path.basename(this.file)} did not parse (${e.message}); kept as ${path.basename(aside)}`);
|
||
|
|
this.data = {};
|
||
|
|
}
|
||
|
|
return this.data;
|
||
|
|
}
|
||
|
|
get(key, fallback = null) {
|
||
|
|
const d = this._load();
|
||
|
|
return key in d ? structuredClone(d[key]) : fallback;
|
||
|
|
}
|
||
|
|
set(key, value) {
|
||
|
|
const d = this._load();
|
||
|
|
// JSON drops undefined: the old write-then-reparse made such a key vanish,
|
||
|
|
// so a later get returned the fallback. Keep that.
|
||
|
|
if (value === undefined) delete d[key]; else d[key] = structuredClone(value);
|
||
|
|
this._schedule();
|
||
|
|
}
|
||
|
|
all() { return structuredClone(this._load()); }
|
||
|
|
_schedule() {
|
||
|
|
this.dirty = true;
|
||
|
|
if (this.timer) return;
|
||
|
|
this.timer = setTimeout(() => { this.timer = null; this.flush(); }, WRITE_DELAY_MS);
|
||
|
|
}
|
||
|
|
flush() {
|
||
|
|
if (this.timer) { clearTimeout(this.timer); this.timer = null; }
|
||
|
|
if (!this.dirty || !this.data) return true;
|
||
|
|
const tmp = `${this.file}.tmp`;
|
||
|
|
try {
|
||
|
|
fs.mkdirSync(path.dirname(this.file), { recursive: true });
|
||
|
|
fs.writeFileSync(tmp, JSON.stringify(this.data));
|
||
|
|
fs.renameSync(tmp, this.file);
|
||
|
|
this.dirty = false;
|
||
|
|
return true;
|
||
|
|
} catch (e) {
|
||
|
|
this.log(`store write failed (${path.basename(this.file)}):`, e.message);
|
||
|
|
try { fs.rmSync(tmp, { force: true }); } catch {}
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// The one Store for a file — every reader and writer of an add-on's store
|
||
|
|
// must go through this, or the in-memory copy and the file drift apart.
|
||
|
|
function storeFor(file, log) {
|
||
|
|
const key = path.resolve(file).toLowerCase();
|
||
|
|
let s = stores.get(key);
|
||
|
|
if (!s) { s = new Store(path.resolve(file), log); stores.set(key, s); }
|
||
|
|
return s;
|
||
|
|
}
|
||
|
|
function flushAll() { for (const s of stores.values()) s.flush(); }
|
||
|
|
|
||
|
|
module.exports = { storeFor, flushAll, WRITE_DELAY_MS };
|