// Per-add-on key/value stores (/extensions-data/.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 };