feat: auto-sync, version history, merge, org policy, tamper guard
New modules: - version-history.js: IndexedDB snapshot storage with pruning/rollback - merge.js: three-way field-level merge for sync conflict resolution - org-policy.js: team/org policy enforcement, compliance checking, invite codes, required mappings that can't be disabled - tamper-guard.js: admin password to protect disable/clear/export actions, org policy can prevent disabling Auto background sync: - Gist/URL sync via chrome.alarms (MV3-safe, survives SW restarts) - Configurable interval (5/15/30 min), push on local changes - performAutoSync() orchestrates pull-then-conditional-push Multi-device dashboard: - Device auto-registration with UUID + browser/platform detection - Device list embedded in sync data for cross-device visibility - getDeviceInfo(), setDeviceName(), getDevices(), removeDevice() Manifest changes: - Added "alarms" permission for background polling Service worker: - Alarm listeners for auto-sync and org policy polling (hourly) - Tamper guard message handlers - Alarms set up on install and startup WIP: Options UI integration pending for all new features. https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
This commit is contained in:
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Silent Send - Version History Manager
|
||||
*
|
||||
* Manages sync version history using IndexedDB. Stores snapshots of the
|
||||
* extension's data (identity, mappings, settings) so the user can
|
||||
* rollback to a previous state after a sync overwrites local data.
|
||||
*
|
||||
* Each snapshot records the source that triggered it (e.g. 'gist',
|
||||
* 'browser-sync', 'rollback') and a full copy of the data at that
|
||||
* point in time. Old snapshots are automatically pruned to stay within
|
||||
* a configurable maximum (default 10).
|
||||
*
|
||||
* Database: IndexedDB 'ss_version_history', version 1
|
||||
* Object store: 'snapshots' (autoIncrement keyPath 'id', index on 'timestamp')
|
||||
*/
|
||||
|
||||
const DB_NAME = 'ss_version_history';
|
||||
const DB_VERSION = 1;
|
||||
const STORE_NAME = 'snapshots';
|
||||
const DEFAULT_MAX_SNAPSHOTS = 10;
|
||||
|
||||
const VersionHistory = {
|
||||
// ----------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Open (or create/upgrade) the IndexedDB database.
|
||||
* @returns {Promise<IDBDatabase>}
|
||||
*/
|
||||
async _openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result;
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = db.createObjectStore(STORE_NAME, {
|
||||
keyPath: 'id',
|
||||
autoIncrement: true,
|
||||
});
|
||||
store.createIndex('timestamp', 'timestamp', { unique: false });
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
},
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
// Public API
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Save a snapshot of the current extension data.
|
||||
*
|
||||
* @param {Object} data - The data payload to snapshot.
|
||||
* @param {string} data.version - Data schema version.
|
||||
* @param {number} data.lastModified - Epoch ms of the data.
|
||||
* @param {Object} data.identity - Identity fields.
|
||||
* @param {Array} data.mappings - Substitution mappings.
|
||||
* @param {Object} data.settings - Extension settings.
|
||||
* @param {string} source - Origin of the snapshot, e.g.
|
||||
* 'local'|'gist'|'url'|'browser-sync'|'file'|'code'|'rollback'.
|
||||
* @returns {Promise<number>} The auto-generated snapshot id.
|
||||
*/
|
||||
async saveSnapshot(data, source) {
|
||||
const db = await this._openDB();
|
||||
const snapshot = {
|
||||
timestamp: Date.now(),
|
||||
source,
|
||||
data,
|
||||
};
|
||||
|
||||
const id = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.add(snapshot);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
// Prune old snapshots beyond the configured maximum
|
||||
const maxVersions = data?.settings?.maxVersionHistory ?? DEFAULT_MAX_SNAPSHOTS;
|
||||
await this.pruneToMax(maxVersions);
|
||||
|
||||
return id;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve all snapshots, sorted newest first.
|
||||
* @returns {Promise<Array>} Array of snapshot objects.
|
||||
*/
|
||||
async getSnapshots() {
|
||||
const db = await this._openDB();
|
||||
const snapshots = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
|
||||
// Sort newest first by timestamp (fallback to id descending)
|
||||
snapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
return snapshots;
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve a single snapshot by id.
|
||||
* @param {number} id - The snapshot id.
|
||||
* @returns {Promise<Object|undefined>} The snapshot, or undefined.
|
||||
*/
|
||||
async getSnapshot(id) {
|
||||
const db = await this._openDB();
|
||||
const snapshot = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
return snapshot;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a single snapshot by id.
|
||||
* @param {number} id - The snapshot id to remove.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async deleteSnapshot(id) {
|
||||
const db = await this._openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.delete(id);
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Keep only the N newest snapshots, deleting the rest.
|
||||
* @param {number} [maxVersions] - Maximum snapshots to retain
|
||||
* (defaults to DEFAULT_MAX_SNAPSHOTS).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async pruneToMax(maxVersions) {
|
||||
const max = maxVersions ?? DEFAULT_MAX_SNAPSHOTS;
|
||||
if (max < 1) return;
|
||||
|
||||
const db = await this._openDB();
|
||||
const allSnapshots = await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readonly');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.getAll();
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
// Sort newest first, then mark everything beyond `max` for deletion
|
||||
allSnapshots.sort((a, b) => b.timestamp - a.timestamp || b.id - a.id);
|
||||
const toDelete = allSnapshots.slice(max);
|
||||
|
||||
if (toDelete.length > 0) {
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
for (const snap of toDelete) {
|
||||
store.delete(snap.id);
|
||||
}
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
db.close();
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete all snapshots (wipe version history).
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async clearAll() {
|
||||
const db = await this._openDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE_NAME, 'readwrite');
|
||||
const store = tx.objectStore(STORE_NAME);
|
||||
const request = store.clear();
|
||||
request.onsuccess = () => resolve();
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
|
||||
export default VersionHistory;
|
||||
Reference in New Issue
Block a user