From 84f6e675db0617aa5739a61ea9a82e8292af3108 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 14:13:22 +0000 Subject: [PATCH] fix: date false-positive, partial-word highlights, cross-browser sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - Date (possible DOB) pattern now requires context words (born, birthday, dob, etc.) before firing — prevents spurious warnings on page-load API calls that happen to contain ISO dates in conversation history. - Highlight regex now uses word boundaries (\b) so short substitute values (e.g. "aud") no longer match inside unrelated words like "Claude". - Both TreeWalkers in content.js now skip the extension's own UI elements (.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge) to prevent the highlight API from marking text in the extension's banners. Settings sync: - New src/lib/sync.js: exportSyncCode / importSyncCode (base64 JSON) for manual copy-paste across any browser combination. Newest lastModified timestamp wins; force flag available to override. - browser.storage.sync support: when "Browser account sync" is enabled the extension automatically pushes/pulls via Firefox Sync or Chrome account, chunked to stay within per-item quota limits. - storage.js now writes ss_lastModified on every save so conflict resolution has an accurate timestamp. - service-worker.js listens for both local and sync storage changes to keep all copies in sync. - New "Sync Between Browsers" section in options.html with Generate/Copy/ Import Sync Code UI and the browser sync toggle. https://claude.ai/code/session_01TKpSR9M8JgHLXCp5CeDsQP --- src/background/service-worker.js | 23 +++- src/content/content.js | 18 +++- src/lib/storage.js | 6 +- src/lib/sync.js | 176 +++++++++++++++++++++++++++++++ src/options/options.html | 50 ++++++++- src/options/options.js | 79 ++++++++++++++ 6 files changed, 340 insertions(+), 12 deletions(-) create mode 100644 src/lib/sync.js diff --git a/src/background/service-worker.js b/src/background/service-worker.js index bf9fd66..7f04898 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -7,6 +7,7 @@ */ import Storage from '../lib/storage.js'; +import SilentSendSync from '../lib/sync.js'; import api from '../lib/browser-polyfill.js'; // Track substitution counts per tab @@ -296,11 +297,24 @@ async function updateIcon(settings) { } } -// Update icon when settings, identity, or mappings change -api.storage.onChanged.addListener(async (changes) => { +// Update icon when settings, identity, or mappings change; push to sync storage if enabled +api.storage.onChanged.addListener(async (changes, areaName) => { if (changes.ss_settings || changes.ss_identity || changes.ss_mappings) { const settings = await Storage.getSettings(); await updateIcon(settings); + + // Push to browser.storage.sync when local data changes (same-browser cross-device) + if (areaName === 'local' && settings.browserSync) { + await SilentSendSync.pushToSyncStorage(); + } + } + + // When sync storage changes (another device pushed new data), pull it into local + if (areaName === 'sync' && (changes.ss_sync_meta || Object.keys(changes).some(k => k.startsWith('ss_sync_chunk_')))) { + const settings = await Storage.getSettings(); + if (settings.browserSync) { + await SilentSendSync.pullFromSyncStorage(); + } } }); @@ -311,8 +325,11 @@ api.runtime.onInstalled.addListener(async () => { await updateIcon(settings); }); -// Also set icon on startup (service worker wake) +// Also set icon on startup (service worker wake) + pull any newer sync data (async () => { const settings = await Storage.getSettings(); await updateIcon(settings); + if (settings.browserSync) { + await SilentSendSync.pullFromSyncStorage(); + } })(); diff --git a/src/content/content.js b/src/content/content.js index d109ae7..ec9b49f 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -327,7 +327,7 @@ hint: 'GPS coordinates — pinpoints a location', cat: 'address' }, // Personal { name: 'Date (possible DOB)', re: /\b(?:(?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01])[-/](?:19|20)\d{2}|(?:19|20)\d{2}[-/](?:0[1-9]|1[0-2])[-/](?:0[1-9]|[12]\d|3[01]))\b/g, - hint: 'Date — could be a birthday', cat: 'personal' }, + hint: 'Date — could be a birthday', cat: 'personal', contextRequired: true }, { name: 'EIN / Tax ID', re: /\b\d{2}-\d{7}\b/g, hint: 'Could be a tax ID', cat: 'document' }, // Paths not caught by smart patterns @@ -344,8 +344,11 @@ hint: 'Env variable with personal data', cat: 'env' }, ]; + const CONTEXT_WORDS_RE = /\b(?:born|birthday|dob|birth|passport|license|driver|ssn|social\s*security|address|zip|postal|date\s+of\s+birth)\b/i; + function autoDetectPPI(text, ident) { if (!text || text.length < 5) return []; + const hasContext = CONTEXT_WORDS_RE.test(text); // Build skip set from configured values const configured = new Set(); @@ -360,6 +363,7 @@ const findings = []; for (const pat of PPI_PATTERNS) { + if (pat.contextRequired && !hasContext) continue; pat.re.lastIndex = 0; let m; while ((m = pat.re.exec(text)) !== null) { @@ -796,7 +800,7 @@ acceptNode(node) { const parent = node.parentElement; if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; - if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT; + if (parent?.closest?.('.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); @@ -846,7 +850,7 @@ acceptNode(node) { const parent = node.parentElement; if (parent && SKIP_REVEAL_TAGS.has(parent.tagName)) return NodeFilter.FILTER_REJECT; - if (parent?.classList?.contains('ss-reveal-badge')) return NodeFilter.FILTER_REJECT; + if (parent?.closest?.('.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge')) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); @@ -857,9 +861,13 @@ if (!text || text.length < MIN_STRING_LENGTH) continue; for (const p of pairs) { - const escaped = esc(settings.revealMode ? p.to : p.from); const searchTerm = settings.revealMode ? p.to : p.from; - const regex = new RegExp(escaped, p.caseSensitive ? 'g' : 'gi'); + const escaped = esc(searchTerm); + // Add word boundaries when the term starts/ends with word chars to + // prevent partial-word matches (e.g. "aud" inside "Claude") + const bStart = /^\w/.test(searchTerm) ? '\\b' : ''; + const bEnd = /\w$/.test(searchTerm) ? '\\b' : ''; + const regex = new RegExp(bStart + escaped + bEnd, p.caseSensitive ? 'g' : 'gi'); let match; while ((match = regex.exec(text)) !== null) { diff --git a/src/lib/storage.js b/src/lib/storage.js index 0e6664e..a0ed709 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -25,6 +25,7 @@ const DEFAULT_SETTINGS = { maxLogEntries: 200, customDomains: [], categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'], + browserSync: false, }; const Storage = { @@ -36,7 +37,7 @@ const Storage = { }, async saveMappings(mappings) { - await api.storage.local.set({ [KEYS.MAPPINGS]: mappings }); + await api.storage.local.set({ [KEYS.MAPPINGS]: mappings, ss_lastModified: Date.now() }); }, async addMapping(mapping) { @@ -96,7 +97,7 @@ const Storage = { }, async saveProfiles(profiles) { - await api.storage.local.set({ [KEYS.IDENTITY]: { profiles } }); + await api.storage.local.set({ [KEYS.IDENTITY]: { profiles }, ss_lastModified: Date.now() }); }, async addProfile(name) { @@ -210,6 +211,7 @@ const Storage = { const current = await this.getSettings(); await api.storage.local.set({ [KEYS.SETTINGS]: { ...current, ...settings }, + ss_lastModified: Date.now(), }); }, }; diff --git a/src/lib/sync.js b/src/lib/sync.js new file mode 100644 index 0000000..ccdea73 --- /dev/null +++ b/src/lib/sync.js @@ -0,0 +1,176 @@ +/** + * 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); + 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); + 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) { + const toSet = { ss_lastModified: data.lastModified }; + 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 []; + } + }, +}; + +export default SilentSendSync; diff --git a/src/options/options.html b/src/options/options.html index facd3e3..4589afa 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -96,13 +96,59 @@ +
+

Sync Between Browsers

+

Keep your identities, mappings, and settings in sync across browsers.

+ +
+
+ +

Automatically sync via your browser account (Firefox Sync / Chrome account). Works across devices using the same browser.

+
+ +
+ +
+

Sync Code — cross-browser copy-paste

+

Generate a code in one browser, paste it in another. Newest data wins automatically.

+
+ + +
+ + + + + +
+
+
+

Transfer Data

-

Export all your identities, mappings, and settings to move between browsers. Encrypted exports require a password to decrypt.

+

Export all your identities, mappings, and settings to a file. Encrypted exports require a password to decrypt.

- +
diff --git a/src/options/options.js b/src/options/options.js index 3ffe967..2bad103 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -1,5 +1,6 @@ import Storage from '../lib/storage.js'; import SilentSendCrypto from '../lib/crypto.js'; +import SilentSendSync from '../lib/sync.js'; import api from '../lib/browser-polyfill.js'; let mappings = []; @@ -18,11 +19,82 @@ document.addEventListener('DOMContentLoaded', async () => { $('#autoRedactDetected').checked = settings.autoRedactDetected !== false; $('#autoAddDetected').checked = settings.autoAddDetected !== false; $('#maxLogEntries').value = settings.maxLogEntries || 200; + $('#browserSync').checked = settings.browserSync === true; renderMappings(); renderDomains(); renderLog(); + // --- Sync section --- + $('#browserSync').addEventListener('change', async (e) => { + await Storage.saveSettings({ browserSync: e.target.checked }); + if (e.target.checked) { + await SilentSendSync.pushToSyncStorage(); + setSyncStatus('Browser account sync enabled. Your settings will sync automatically.', 'ok'); + } else { + setSyncStatus('Browser account sync disabled.', 'neutral'); + } + }); + + $('#btnGenerateSyncCode').addEventListener('click', async () => { + const code = await SilentSendSync.exportSyncCode(); + const data = await SilentSendSync._getAllData(); + $('#syncCodeText').value = code; + $('#syncCodeDisplay').style.display = 'block'; + $('#syncImportSection').style.display = 'none'; + $('#syncCodeTime').textContent = 'Generated: ' + new Date(data.lastModified).toLocaleString(); + setSyncStatus('', 'neutral'); + }); + + $('#btnCopySyncCode').addEventListener('click', async () => { + try { + await navigator.clipboard.writeText($('#syncCodeText').value); + $('#btnCopySyncCode').textContent = 'Copied!'; + setTimeout(() => { $('#btnCopySyncCode').textContent = 'Copy to Clipboard'; }, 2000); + } catch { + $('#syncCodeText').select(); + document.execCommand('copy'); + } + }); + + $('#btnImportSyncCode').addEventListener('click', () => { + $('#syncImportSection').style.display = 'block'; + $('#syncCodeDisplay').style.display = 'none'; + $('#syncImportText').focus(); + setSyncStatus('', 'neutral'); + }); + + $('#btnApplySyncCode').addEventListener('click', async () => { + const code = $('#syncImportText').value.trim(); + if (!code) return; + const force = $('#syncForce').checked; + const result = await SilentSendSync.importSyncCode(code, { force }); + if (result.success) { + setSyncStatus(`Imported successfully (data from ${result.importTime}).`, 'ok'); + $('#syncImportSection').style.display = 'none'; + $('#syncImportText').value = ''; + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + $('#browserSync').checked = settings.browserSync === true; + renderMappings(); + renderDomains(); + renderLog(); + } else if (result.skipped) { + setSyncStatus( + `Skipped: local data is newer (local: ${result.localTime} vs import: ${result.importTime}). Check "Force" to override.`, + 'warn' + ); + } else { + setSyncStatus(`Failed: ${result.reason}`, 'error'); + } + }); + + $('#btnCancelSyncImport').addEventListener('click', () => { + $('#syncImportSection').style.display = 'none'; + $('#syncImportText').value = ''; + setSyncStatus('', 'neutral'); + }); + // Transfer data $('#btnExportAll').addEventListener('click', exportAllPlain); $('#btnExportEncrypted').addEventListener('click', exportAllEncrypted); @@ -393,6 +465,13 @@ async function importAll(e) { e.target.value = ''; } +function setSyncStatus(msg, type) { + const el = $('#syncStatus'); + if (!el) return; + el.textContent = msg; + el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280'; +} + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str;