Files
silent-send/src/options/options.js
T
Claude 84f6e675db fix: date false-positive, partial-word highlights, cross-browser sync
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
2026-03-26 14:13:22 +00:00

480 lines
15 KiB
JavaScript

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 = [];
let settings = {};
const $ = (sel) => document.querySelector(sel);
document.addEventListener('DOMContentLoaded', async () => {
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
// Apply settings to UI
$('#showHighlights').checked = settings.showHighlights || false;
$('#secretScanning').checked = settings.secretScanning !== false;
$('#autoDetect').checked = settings.autoDetect !== false;
$('#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);
$('#btnImportAll').addEventListener('click', () => $('#fileImportAll').click());
$('#fileImportAll').addEventListener('change', importAll);
// Custom domains
$('#btnAddDomain').addEventListener('click', addDomain);
$('#newDomain').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addDomain();
});
// Settings listeners
$('#showHighlights').addEventListener('change', async (e) => {
await Storage.saveSettings({ showHighlights: e.target.checked });
});
$('#secretScanning').addEventListener('change', async (e) => {
await Storage.saveSettings({ secretScanning: e.target.checked });
});
$('#autoDetect').addEventListener('change', async (e) => {
await Storage.saveSettings({ autoDetect: e.target.checked });
});
$('#autoRedactDetected').addEventListener('change', async (e) => {
await Storage.saveSettings({ autoRedactDetected: e.target.checked });
});
$('#autoAddDetected').addEventListener('change', async (e) => {
await Storage.saveSettings({ autoAddDetected: e.target.checked });
});
$('#maxLogEntries').addEventListener('change', async (e) => {
await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 });
});
// Add mapping
$('#btnAddMapping').addEventListener('click', addMapping);
$('#newSub').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addMapping();
});
// Export
$('#btnExport').addEventListener('click', () => {
const data = JSON.stringify(mappings, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'silent-send-mappings.json';
a.click();
URL.revokeObjectURL(url);
});
// Import
$('#btnImport').addEventListener('click', () => $('#fileImport').click());
$('#fileImport').addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const imported = JSON.parse(text);
if (!Array.isArray(imported)) throw new Error('Expected array');
// Merge: add IDs if missing
for (const item of imported) {
if (!item.id) item.id = crypto.randomUUID();
if (!item.createdAt) item.createdAt = Date.now();
if (item.enabled === undefined) item.enabled = true;
}
mappings = [...mappings, ...imported];
await Storage.saveMappings(mappings);
renderMappings();
} catch (err) {
alert('Failed to import: ' + err.message);
}
});
// Clear all
$('#btnClearAll').addEventListener('click', async () => {
if (!confirm('Delete all mappings? This cannot be undone.')) return;
mappings = [];
await Storage.saveMappings([]);
renderMappings();
});
// Clear log
$('#btnClearLog').addEventListener('click', async () => {
await Storage.clearLog();
renderLog();
});
// Reset all data
$('#btnResetAll').addEventListener('click', async () => {
if (!confirm('This will delete ALL your identities, mappings, settings, and logs.\n\nAre you sure?')) return;
if (!confirm('Really? This cannot be undone.')) return;
await api.storage.local.clear();
mappings = [];
settings = {};
renderMappings();
renderDomains();
renderLog();
alert('All data cleared. Reload the extension to start fresh.');
});
});
async function addMapping() {
const real = $('#newReal').value.trim();
const sub = $('#newSub').value.trim();
if (!real || !sub) return;
const mapping = await Storage.addMapping({
real,
substitute: sub,
category: $('#newCategory').value,
caseSensitive: $('#newCaseSensitive').checked,
});
mappings.push(mapping);
renderMappings();
$('#newReal').value = '';
$('#newSub').value = '';
$('#newCaseSensitive').checked = false;
$('#newReal').focus();
}
function renderMappings() {
const tbody = $('#mappingTableBody');
if (mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:24px">No mappings configured</td></tr>';
return;
}
tbody.innerHTML = mappings
.map(
(m) => `
<tr data-id="${m.id}">
<td class="real">${escapeHtml(m.real)}</td>
<td class="sub">${escapeHtml(m.substitute)}</td>
<td><span class="cat">${m.category || 'general'}</span></td>
<td>${m.caseSensitive ? 'Yes' : 'No'}</td>
<td>
<label class="toggle" style="width:32px;height:18px">
<input type="checkbox" class="toggle-enabled" ${m.enabled ? 'checked' : ''}>
<span class="toggle-slider" style="border-radius:18px"></span>
</label>
</td>
<td><button class="btn btn-sm btn-danger btn-delete">&times;</button></td>
</tr>
`
)
.join('');
// Bind
tbody.querySelectorAll('.btn-delete').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = btn.closest('tr').dataset.id;
await Storage.deleteMapping(id);
mappings = mappings.filter((m) => m.id !== id);
renderMappings();
});
});
tbody.querySelectorAll('.toggle-enabled').forEach((cb) => {
cb.addEventListener('change', async () => {
const id = cb.closest('tr').dataset.id;
await Storage.updateMapping(id, { enabled: cb.checked });
const m = mappings.find((m) => m.id === id);
if (m) m.enabled = cb.checked;
});
});
}
async function renderLog() {
const log = await Storage.getLog();
$('#logCount').textContent = `${log.length} entries`;
const list = $('#logList');
if (log.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:24px">No activity logged</div>';
return;
}
list.innerHTML = log
.slice(0, 100)
.map((entry) => {
const time = new Date(entry.timestamp).toLocaleString();
return `
<div class="log-item">
<span class="log-time">${time}</span>
<span class="log-original">${escapeHtml(entry.original || '')}</span>
<span>&rarr;</span>
<span class="log-replaced">${escapeHtml(entry.replaced || '')}</span>
</div>
`;
})
.join('');
}
// --- Custom Domains ---
async function addDomain() {
let domain = $('#newDomain').value.trim();
if (!domain) return;
// Normalize: ensure it has a protocol
if (!domain.startsWith('http://') && !domain.startsWith('https://')) {
domain = 'https://' + domain;
}
// Strip trailing slashes
domain = domain.replace(/\/+$/, '');
const domains = settings.customDomains || [];
if (domains.includes(domain)) {
alert('Domain already added.');
return;
}
// Request browser permission for this domain
try {
const granted = await api.permissions.request({
origins: [domain + '/*'],
});
if (!granted) {
alert('Permission denied. The extension needs access to this domain to work.');
return;
}
} catch (e) {
// Firefox or older Chrome may not support optional permissions this way
console.warn('[Silent Send] Could not request permission:', e);
}
domains.push(domain);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
renderDomains();
$('#newDomain').value = '';
}
function renderDomains() {
const list = $('#domainList');
const domains = settings.customDomains || [];
if (domains.length === 0) {
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px;font-size:13px">No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.</div>';
return;
}
list.innerHTML = domains
.map((d, i) => `
<div class="domain-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
<span style="font-size:13px;font-family:monospace">${escapeHtml(d)}</span>
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}">&times;</button>
</div>
`)
.join('');
list.querySelectorAll('.btn-remove-domain').forEach((btn) => {
btn.addEventListener('click', async () => {
const idx = parseInt(btn.dataset.index, 10);
const domains = settings.customDomains || [];
domains.splice(idx, 1);
settings.customDomains = domains;
await Storage.saveSettings({ customDomains: domains });
renderDomains();
});
});
}
// --- Transfer Data (Export/Import All) ---
async function getAllData() {
const result = await api.storage.local.get(null); // get everything
return {
version: '1',
exportedAt: new Date().toISOString(),
identity: result.ss_identity || {},
mappings: result.ss_mappings || [],
settings: result.ss_settings || {},
};
}
function downloadFile(content, filename) {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
async function exportAllPlain() {
const data = await getAllData();
downloadFile(JSON.stringify(data, null, 2), 'silent-send-backup.json');
}
async function exportAllEncrypted() {
const password = prompt('Set a password for this backup:');
if (!password) return;
const confirm = prompt('Confirm password:');
if (password !== confirm) {
alert('Passwords do not match.');
return;
}
const data = await getAllData();
try {
const encrypted = await SilentSendCrypto.encrypt(data, password);
const wrapper = JSON.stringify({ encrypted: true, data: encrypted });
downloadFile(wrapper, 'silent-send-backup.ssbackup');
alert('Encrypted backup saved. You will need the password to import it.');
} catch (e) {
alert('Encryption failed: ' + e.message);
}
}
async function importAll(e) {
const file = e.target.files[0];
if (!file) return;
try {
const text = await file.text();
const parsed = JSON.parse(text);
let data;
if (parsed.encrypted) {
// Encrypted backup
const password = prompt('Enter the password for this backup:');
if (!password) return;
try {
data = await SilentSendCrypto.decrypt(parsed.data, password);
} catch (err) {
alert('Wrong password or corrupted file.');
return;
}
} else {
// Plain backup
data = parsed;
}
if (!data.version) {
alert('Not a valid Silent Send backup file.');
return;
}
if (!confirm('This will replace all your current data. Continue?')) return;
// Restore
if (data.identity) await api.storage.local.set({ ss_identity: data.identity });
if (data.mappings) await api.storage.local.set({ ss_mappings: data.mappings });
if (data.settings) await api.storage.local.set({ ss_settings: data.settings });
// Refresh UI
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
renderLog();
alert('Import complete. Reload the extension for changes to take effect.');
} catch (err) {
alert('Failed to import: ' + err.message);
}
// Reset file input
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;
return div.innerHTML;
}