Files
2026-07-24 13:29:16 +00:00

139 lines
4.4 KiB
JavaScript

'use strict';
// IndexedDB-backed persistence for profiles and settings — the browser-side
// replacement for the old file store. IndexedDB (not localStorage) is used
// because uploaded .srs rule-set files can exceed localStorage's ~5MB limit.
//
// Shapes match the former Go store exactly:
// Profile { id, name, config, inputs, createdAt, updatedAt, ruleSetFiles }
// Settings { dohServer }
//
// Exposed as window.singvisStorage (app.js is a classic script).
(function () {
const DB_NAME = 'singvis';
const DB_VERSION = 1;
const STORE_PROFILES = 'profiles';
const STORE_META = 'meta';
const SETTINGS_KEY = 'settings';
const DEFAULT_SETTINGS = { dohServer: 'https://1.1.1.1/dns-query' };
let dbPromise = null;
function openDB() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(STORE_PROFILES)) {
db.createObjectStore(STORE_PROFILES, { keyPath: 'id' });
}
if (!db.objectStoreNames.contains(STORE_META)) {
db.createObjectStore(STORE_META);
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error || new Error('failed to open IndexedDB'));
});
return dbPromise;
}
// tx runs fn(store) inside a transaction and resolves with `out` (a value fn
// can set) once the transaction completes.
async function tx(storeName, mode, fn) {
const db = await openDB();
return new Promise((resolve, reject) => {
const t = db.transaction(storeName, mode);
const store = t.objectStore(storeName);
let out;
const ret = (v) => { out = v; };
Promise.resolve(fn(store, ret)).catch(reject);
t.oncomplete = () => resolve(out);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error || new Error('transaction aborted'));
});
}
function reqPromise(request) {
return new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function newId() {
// Millisecond timestamp plus a short random suffix to avoid collisions when
// several profiles are created within the same millisecond.
return Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
}
const api = {
async listProfiles() {
const list = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
ret(await reqPromise(store.getAll()));
});
const out = list || [];
out.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
return out;
},
async getProfile(id) {
const p = await tx(STORE_PROFILES, 'readonly', async (store, ret) => {
ret(await reqPromise(store.get(id)));
});
if (!p) throw new Error('profile not found');
return p;
},
// saveProfile creates (when profile.id is empty) or updates a profile,
// assigning id/createdAt/updatedAt the same way the old server did.
async saveProfile(profile) {
const now = Date.now();
const p = Object.assign({}, profile);
if (!p.id) {
p.id = newId();
p.createdAt = now;
}
if (!p.createdAt) p.createdAt = now;
p.updatedAt = now;
p.ruleSetFiles = p.ruleSetFiles || {};
await tx(STORE_PROFILES, 'readwrite', (store) => {
store.put(p);
});
return p;
},
async deleteProfile(id) {
await tx(STORE_PROFILES, 'readwrite', (store) => {
store.delete(id);
});
},
async getSettings() {
const s = await tx(STORE_META, 'readonly', async (store, ret) => {
ret(await reqPromise(store.get(SETTINGS_KEY)));
});
const merged = Object.assign({}, DEFAULT_SETTINGS, s || {});
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
return merged;
},
async saveSettings(settings) {
const merged = Object.assign({}, DEFAULT_SETTINGS, settings || {});
if (!merged.dohServer) merged.dohServer = DEFAULT_SETTINGS.dohServer;
await tx(STORE_META, 'readwrite', (store) => {
store.put(merged, SETTINGS_KEY);
});
return merged;
},
defaultSettings() {
return Object.assign({}, DEFAULT_SETTINGS);
},
};
window.singvisStorage = api;
})();