Notification system:
- When sync applies data (file folder, browser account, or sync code),
ss_sync_notification is written to local storage with the source.
- Service worker catches it via storage.onChanged, shows a purple 'SYN'
badge on the extension icon that persists until Options is opened, and
fires a desktop notification ('Settings updated via sync folder — open
Options to review').
- Clicking the desktop notification opens the Options page directly.
- On service worker wake, SYN badge is restored if the notification was
not yet dismissed.
- Opening Options clears ss_sync_notification, resets the badge, and
sends a sync:notification-seen message to the service worker.
- Added 'notifications' permission to both manifests.
Cloud storage clarity:
- Options page now explicitly lists that the folder sync works with any
cloud storage that has a desktop sync client: Dropbox, OneDrive, Google
Drive, iCloud Drive, Box, pCloud, Nextcloud, Synology Drive, etc.
https://claude.ai/code/session_01TKpSR9M8JgHLXCp5CeDsQP
236 lines
7.9 KiB
JavaScript
236 lines
7.9 KiB
JavaScript
/**
|
|
* Silent Send - Cross-Browser Settings Sync
|
|
*
|
|
* Two sync mechanisms:
|
|
*
|
|
* 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste between
|
|
* browsers (works across any browser/device combination).
|
|
*
|
|
* 2. browser.storage.sync — automatic sync within the same browser family
|
|
* (Firefox ↔ Firefox via Firefox Sync, Chrome ↔ Chrome via Google account).
|
|
* Data is chunked to stay within per-item size limits.
|
|
*
|
|
* Conflict resolution: newest `lastModified` timestamp wins.
|
|
*/
|
|
|
|
import api from './browser-polyfill.js';
|
|
|
|
// browser.storage.sync per-item limit (leave generous headroom)
|
|
const SYNC_CHUNK_SIZE = 5000;
|
|
const SYNC_KEY_PREFIX = 'ss_sync_chunk_';
|
|
const SYNC_META_KEY = 'ss_sync_meta';
|
|
|
|
const SilentSendSync = {
|
|
// ----------------------------------------------------------------
|
|
// Sync Code — manual cross-browser copy-paste
|
|
// ----------------------------------------------------------------
|
|
|
|
/**
|
|
* Export all settings/mappings/identity as a compact base64 sync code.
|
|
* Share this code with another browser to import.
|
|
*/
|
|
async exportSyncCode() {
|
|
const data = await this._getAllData();
|
|
const json = JSON.stringify(data);
|
|
// btoa requires ASCII; use encodeURIComponent to handle Unicode
|
|
return btoa(unescape(encodeURIComponent(json)));
|
|
},
|
|
|
|
/**
|
|
* Import from a sync code string.
|
|
* Returns { success, importTime } or { success: false, skipped?, reason, localTime?, importTime? }
|
|
*
|
|
* Conflict resolution: newest timestamp wins.
|
|
* Pass force=true to override even if local is newer.
|
|
*/
|
|
async importSyncCode(code, { force = false } = {}) {
|
|
try {
|
|
const json = decodeURIComponent(escape(atob(code.trim())));
|
|
const data = JSON.parse(json);
|
|
|
|
if (!data.version || !data.lastModified) {
|
|
return { success: false, reason: 'Invalid sync code — missing version or timestamp.' };
|
|
}
|
|
|
|
if (!force) {
|
|
const local = await this._getAllData();
|
|
if (local.lastModified && local.lastModified >= data.lastModified) {
|
|
return {
|
|
success: false,
|
|
skipped: true,
|
|
reason: 'Local data is the same age or newer.',
|
|
localTime: new Date(local.lastModified).toLocaleString(),
|
|
importTime: new Date(data.lastModified).toLocaleString(),
|
|
};
|
|
}
|
|
}
|
|
|
|
await this._applyData(data, 'code');
|
|
return { success: true, importTime: new Date(data.lastModified).toLocaleString() };
|
|
} catch (e) {
|
|
return { success: false, reason: e.message };
|
|
}
|
|
},
|
|
|
|
// ----------------------------------------------------------------
|
|
// browser.storage.sync — automatic within-browser-family sync
|
|
// ----------------------------------------------------------------
|
|
|
|
/**
|
|
* Push current local data to browser.storage.sync (chunked).
|
|
* Called automatically whenever settings, mappings, or identity change
|
|
* and browserSync setting is enabled.
|
|
*/
|
|
async pushToSyncStorage() {
|
|
if (!api.storage?.sync) return;
|
|
try {
|
|
const data = await this._getAllData();
|
|
const json = JSON.stringify(data);
|
|
|
|
// Split into chunks to stay under per-item quota
|
|
const chunks = [];
|
|
for (let i = 0; i < json.length; i += SYNC_CHUNK_SIZE) {
|
|
chunks.push(json.slice(i, i + SYNC_CHUNK_SIZE));
|
|
}
|
|
|
|
// Remove stale chunks
|
|
const staleKeys = await this._getSyncChunkKeys();
|
|
if (staleKeys.length > 0) {
|
|
await api.storage.sync.remove(staleKeys);
|
|
}
|
|
|
|
// Write new chunks + metadata in one shot
|
|
const toWrite = {
|
|
[SYNC_META_KEY]: { chunks: chunks.length, lastModified: data.lastModified },
|
|
};
|
|
chunks.forEach((chunk, i) => { toWrite[SYNC_KEY_PREFIX + i] = chunk; });
|
|
await api.storage.sync.set(toWrite);
|
|
} catch (e) {
|
|
console.warn('[Silent Send] pushToSyncStorage failed:', e);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Pull from browser.storage.sync and apply if newer than local.
|
|
* Returns { imported: true, time } if data was applied, null otherwise.
|
|
*/
|
|
async pullFromSyncStorage() {
|
|
if (!api.storage?.sync) return null;
|
|
try {
|
|
const metaResult = await api.storage.sync.get(SYNC_META_KEY);
|
|
const syncMeta = metaResult[SYNC_META_KEY];
|
|
if (!syncMeta?.chunks) return null;
|
|
|
|
// Skip if local is already up to date
|
|
const local = await this._getAllData();
|
|
if (local.lastModified && local.lastModified >= syncMeta.lastModified) return null;
|
|
|
|
// Reassemble chunks
|
|
const chunkKeys = Array.from({ length: syncMeta.chunks }, (_, i) => SYNC_KEY_PREFIX + i);
|
|
const chunkResult = await api.storage.sync.get(chunkKeys);
|
|
const json = chunkKeys.map(k => chunkResult[k] || '').join('');
|
|
const data = JSON.parse(json);
|
|
|
|
await this._applyData(data, 'browser-sync');
|
|
return { imported: true, time: new Date(data.lastModified).toLocaleString() };
|
|
} catch (e) {
|
|
console.warn('[Silent Send] pullFromSyncStorage failed:', e);
|
|
return null;
|
|
}
|
|
},
|
|
|
|
// ----------------------------------------------------------------
|
|
// Internal helpers
|
|
// ----------------------------------------------------------------
|
|
|
|
async _getAllData() {
|
|
const result = await api.storage.local.get(null);
|
|
return {
|
|
version: '1',
|
|
lastModified: result.ss_lastModified || Date.now(),
|
|
identity: result.ss_identity || {},
|
|
mappings: result.ss_mappings || [],
|
|
settings: result.ss_settings || {},
|
|
};
|
|
},
|
|
|
|
async _applyData(data, source = 'unknown') {
|
|
const toSet = {
|
|
ss_lastModified: data.lastModified,
|
|
// Signal the service worker to show a badge/notification
|
|
ss_sync_notification: { source, time: Date.now() },
|
|
};
|
|
if (data.identity !== undefined) toSet.ss_identity = data.identity;
|
|
if (data.mappings !== undefined) toSet.ss_mappings = data.mappings;
|
|
if (data.settings !== undefined) toSet.ss_settings = data.settings;
|
|
await api.storage.local.set(toSet);
|
|
},
|
|
|
|
async _getSyncChunkKeys() {
|
|
if (!api.storage?.sync) return [];
|
|
try {
|
|
const all = await api.storage.sync.get(null);
|
|
return Object.keys(all).filter(k => k.startsWith(SYNC_KEY_PREFIX));
|
|
} catch {
|
|
return [];
|
|
}
|
|
},
|
|
|
|
// ----------------------------------------------------------------
|
|
// File System Access API helpers — folder-based sync
|
|
// The directory handle is stored in IndexedDB so the user only
|
|
// needs to grant access once per browser session.
|
|
// ----------------------------------------------------------------
|
|
|
|
_dbPromise: null,
|
|
|
|
_openDB() {
|
|
if (this._dbPromise) return this._dbPromise;
|
|
this._dbPromise = new Promise((resolve, reject) => {
|
|
const req = indexedDB.open('ss_sync_handles', 1);
|
|
req.onupgradeneeded = () => req.result.createObjectStore('handles');
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
return this._dbPromise;
|
|
},
|
|
|
|
async saveSyncDirHandle(handle) {
|
|
const db = await this._openDB();
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction('handles', 'readwrite');
|
|
tx.objectStore('handles').put(handle, 'syncDir');
|
|
tx.oncomplete = resolve;
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
},
|
|
|
|
async loadSyncDirHandle() {
|
|
try {
|
|
const db = await this._openDB();
|
|
return new Promise((resolve) => {
|
|
const tx = db.transaction('handles', 'readonly');
|
|
const req = tx.objectStore('handles').get('syncDir');
|
|
req.onsuccess = () => resolve(req.result || null);
|
|
req.onerror = () => resolve(null);
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
},
|
|
|
|
async clearSyncDirHandle() {
|
|
try {
|
|
const db = await this._openDB();
|
|
return new Promise((resolve) => {
|
|
const tx = db.transaction('handles', 'readwrite');
|
|
tx.objectStore('handles').delete('syncDir');
|
|
tx.oncomplete = resolve;
|
|
tx.onerror = resolve;
|
|
});
|
|
} catch { /* ignore */ }
|
|
},
|
|
};
|
|
|
|
export default SilentSendSync;
|