diff --git a/README.md b/README.md index b22a213..42762fc 100644 --- a/README.md +++ b/README.md @@ -18,16 +18,29 @@ A Chrome extension that intercepts personal information and substitutes it with - **Reveal mode** (eye icon) toggles showing real vs substitute data in responses - **Browser DevTools** → Console shows `[Silent Send] Substituted N value(s)` messages -## Installation (Developer Mode) +## Installation +### Chrome (Developer Mode) 1. Clone this repo -2. Open `chrome://extensions/` in Chrome -3. Enable **Developer mode** (top right) -4. Click **Load unpacked** -5. Select this directory +2. Run `./build.sh chrome` (or just use the root directory directly) +3. Open `chrome://extensions/` +4. Enable **Developer mode** (top right) +5. Click **Load unpacked** → select `dist/chrome/` (or root dir) 6. Navigate to claude.ai — the extension is active -No build step needed. No Chrome Web Store required. +### Firefox +1. Clone this repo +2. Run `./build.sh firefox` +3. Open `about:debugging` → **This Firefox** +4. Click **Load Temporary Add-on** → select `dist/firefox/manifest.json` +5. Navigate to claude.ai — the extension is active + +For persistent Firefox installation, package and sign via `web-ext`: +``` +cd dist/firefox && npx web-ext sign --api-key=YOUR_KEY --api-secret=YOUR_SECRET +``` + +No store required for either browser in developer mode. ## Architecture @@ -46,11 +59,12 @@ src/ options.html/css/js — Full mapping management, import/export, settings lib/ substitution-engine.js — Core find/replace logic - storage.js — Chrome storage wrapper + storage.js — Browser storage wrapper + browser-polyfill.js — Chrome/Firefox API compatibility ``` ## Privacy -- All data stays local in Chrome storage +- All data stays local in browser storage - No external servers, no telemetry, no analytics - The extension only activates on claude.ai diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..cd6681e --- /dev/null +++ b/build.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# +# Build Silent Send for Chrome or Firefox. +# +# Usage: +# ./build.sh chrome → dist/chrome/ (load unpacked) +# ./build.sh firefox → dist/firefox/ (load as temporary add-on) +# ./build.sh both → builds both +# + +set -e + +TARGET="${1:-both}" + +build() { + local browser="$1" + local out="dist/$browser" + + echo "Building for $browser..." + rm -rf "$out" + mkdir -p "$out" + + # Copy all source files + cp -r src icons "$out/" + + # Copy the correct manifest + if [ "$browser" = "firefox" ]; then + cp manifest.firefox.json "$out/manifest.json" + else + cp manifest.json "$out/manifest.json" + fi + + # Copy other root files + cp README.md "$out/" 2>/dev/null || true + + echo " → $out/ ready" +} + +case "$TARGET" in + chrome) build chrome ;; + firefox) build firefox ;; + both) build chrome; build firefox ;; + *) + echo "Usage: $0 {chrome|firefox|both}" + exit 1 + ;; +esac + +echo "" +echo "Done! Load the extension:" +echo " Chrome: chrome://extensions → Load unpacked → dist/chrome/" +echo " Firefox: about:debugging → This Firefox → Load Temporary Add-on → dist/firefox/manifest.json" diff --git a/manifest.firefox.json b/manifest.firefox.json new file mode 100644 index 0000000..0b8ecab --- /dev/null +++ b/manifest.firefox.json @@ -0,0 +1,56 @@ +{ + "manifest_version": 3, + "name": "Silent Send", + "version": "0.1.0", + "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", + "browser_specific_settings": { + "gecko": { + "id": "silent-send@example.com", + "strict_min_version": "128.0" + } + }, + "permissions": [ + "storage", + "activeTab", + "scripting" + ], + "host_permissions": [ + "https://claude.ai/*" + ], + "background": { + "scripts": ["src/background/service-worker.js"], + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://claude.ai/*"], + "js": ["src/content/injector.js"], + "css": ["src/content/content.css"], + "run_at": "document_start", + "all_frames": true + } + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.svg", + "48": "icons/icon48.svg", + "128": "icons/icon128.svg" + } + }, + "options_ui": { + "page": "src/options/options.html", + "open_in_tab": true + }, + "icons": { + "16": "icons/icon16.svg", + "48": "icons/icon48.svg", + "128": "icons/icon128.svg" + }, + "web_accessible_resources": [ + { + "resources": ["src/content/content.js"], + "matches": ["https://claude.ai/*"] + } + ] +} diff --git a/src/background/service-worker.js b/src/background/service-worker.js index 18f559a..7986863 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -2,9 +2,11 @@ * Silent Send - Background Service Worker * * Manages badge count, coordinates between popup and content scripts. + * Uses `api` alias for cross-browser compatibility (Chrome + Firefox). */ import Storage from '../lib/storage.js'; +import api from '../lib/browser-polyfill.js'; // Track substitution counts per tab const tabCounts = new Map(); @@ -15,12 +17,12 @@ function updateBadge(tabId) { const count = tabCounts.get(tabId) || 0; const text = count > 0 ? String(count) : ''; - chrome.action.setBadgeText({ text, tabId }); - chrome.action.setBadgeBackgroundColor({ color: count > 0 ? '#10b981' : '#6b7280', tabId }); + api.action.setBadgeText({ text, tabId }); + api.action.setBadgeBackgroundColor({ color: count > 0 ? '#10b981' : '#6b7280', tabId }); } // Reset count when tab navigates -chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { +api.tabs.onUpdated.addListener((tabId, changeInfo) => { if (changeInfo.status === 'loading') { tabCounts.set(tabId, 0); updateBadge(tabId); @@ -28,13 +30,13 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { }); // Cleanup when tab closes -chrome.tabs.onRemoved.addListener((tabId) => { +api.tabs.onRemoved.addListener((tabId) => { tabCounts.delete(tabId); }); // --- Message Handling --- -chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { +api.runtime.onMessage.addListener((message, sender, sendResponse) => { const handler = messageHandlers[message.type]; if (handler) { handler(message, sender, sendResponse); @@ -87,9 +89,9 @@ const messageHandlers = { async 'update:settings'(message) { await Storage.saveSettings(message.settings); // Broadcast to content scripts - const tabs = await chrome.tabs.query({ url: 'https://claude.ai/*' }); + const tabs = await api.tabs.query({ url: 'https://claude.ai/*' }); for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, { + api.tabs.sendMessage(tab.id, { type: 'settings:updated', settings: message.settings, }).catch(() => {}); @@ -98,6 +100,6 @@ const messageHandlers = { }; // --- Set initial badge state --- -chrome.runtime.onInstalled.addListener(() => { - chrome.action.setBadgeBackgroundColor({ color: '#6b7280' }); +api.runtime.onInstalled.addListener(() => { + api.action.setBadgeBackgroundColor({ color: '#6b7280' }); }); diff --git a/src/content/injector.js b/src/content/injector.js index bad9492..500b5ac 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -1,25 +1,33 @@ /** * Silent Send - Content Script Injector * - * This runs in Chrome's ISOLATED content script world, where it has - * access to chrome.storage. It then injects the fetch-hooking code - * into the MAIN page world so it can intercept the actual fetch() calls. + * Runs in the ISOLATED content script world, where it has access to + * browser/chrome.storage. Injects the fetch-hooking code into the + * MAIN page world so it can intercept the actual fetch() calls. * * Communication: page script <-> content script via window.postMessage */ 'use strict'; +// Cross-browser API +const api = + typeof browser !== 'undefined' && browser.runtime + ? browser + : typeof chrome !== 'undefined' + ? chrome + : null; + // Load mappings and settings, then inject into page async function init() { - const result = await chrome.storage.local.get(['ss_mappings', 'ss_settings']); + const result = await api.storage.local.get(['ss_mappings', 'ss_settings']); const mappings = result.ss_mappings || []; const settings = result.ss_settings || { enabled: true }; // Inject the main interception script into the page's world const script = document.createElement('script'); script.setAttribute('data-ss-config', JSON.stringify({ mappings, settings })); - script.src = chrome.runtime.getURL('src/content/content.js'); + script.src = api.runtime.getURL('src/content/content.js'); (document.head || document.documentElement).appendChild(script); script.onload = () => script.remove(); @@ -27,7 +35,7 @@ async function init() { window.addEventListener('message', (event) => { if (event.source !== window) return; if (event.data?.type === 'ss:substitution-performed') { - chrome.runtime.sendMessage({ + api.runtime.sendMessage({ type: 'substitution:performed', count: event.data.count, replacements: event.data.replacements, @@ -36,7 +44,7 @@ async function init() { }); // Forward storage changes to the page script - chrome.storage.onChanged.addListener((changes) => { + api.storage.onChanged.addListener((changes) => { if (changes.ss_mappings || changes.ss_settings) { window.postMessage({ type: 'ss:config-updated', @@ -47,7 +55,7 @@ async function init() { }); // Listen for settings updates from popup via runtime messages - chrome.runtime.onMessage.addListener((message) => { + api.runtime.onMessage.addListener((message) => { if (message.type === 'settings:updated') { window.postMessage({ type: 'ss:config-updated', diff --git a/src/lib/browser-polyfill.js b/src/lib/browser-polyfill.js new file mode 100644 index 0000000..9182633 --- /dev/null +++ b/src/lib/browser-polyfill.js @@ -0,0 +1,20 @@ +/** + * Minimal browser API compatibility layer. + * + * Firefox exposes `browser.*` (Promise-based) and polyfills `chrome.*`. + * Chrome only has `chrome.*` (callback-based, but storage/runtime are + * Promise-based in MV3). This normalizes to whichever is available. + */ + +const api = + typeof browser !== 'undefined' && browser.runtime + ? browser + : typeof chrome !== 'undefined' + ? chrome + : null; + +if (!api) { + console.error('[Silent Send] No WebExtension API found'); +} + +export default api; diff --git a/src/lib/storage.js b/src/lib/storage.js index 94f813b..7974510 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -1,10 +1,12 @@ /** * Silent Send - Storage Manager * - * Wraps chrome.storage.local with typed helpers for mappings, + * Wraps browser/api.storage.local with typed helpers for mappings, * activity log, and settings. */ +import api from './browser-polyfill.js'; + const KEYS = { MAPPINGS: 'ss_mappings', LOG: 'ss_activity_log', @@ -23,12 +25,12 @@ const Storage = { // --- Mappings --- async getMappings() { - const result = await chrome.storage.local.get(KEYS.MAPPINGS); + const result = await api.storage.local.get(KEYS.MAPPINGS); return result[KEYS.MAPPINGS] || []; }, async saveMappings(mappings) { - await chrome.storage.local.set({ [KEYS.MAPPINGS]: mappings }); + await api.storage.local.set({ [KEYS.MAPPINGS]: mappings }); }, async addMapping(mapping) { @@ -65,7 +67,7 @@ const Storage = { // --- Activity Log --- async getLog() { - const result = await chrome.storage.local.get(KEYS.LOG); + const result = await api.storage.local.get(KEYS.LOG); return result[KEYS.LOG] || []; }, @@ -84,23 +86,23 @@ const Storage = { log.length = settings.maxLogEntries; } - await chrome.storage.local.set({ [KEYS.LOG]: log }); + await api.storage.local.set({ [KEYS.LOG]: log }); }, async clearLog() { - await chrome.storage.local.set({ [KEYS.LOG]: [] }); + await api.storage.local.set({ [KEYS.LOG]: [] }); }, // --- Settings --- async getSettings() { - const result = await chrome.storage.local.get(KEYS.SETTINGS); + const result = await api.storage.local.get(KEYS.SETTINGS); return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) }; }, async saveSettings(settings) { const current = await this.getSettings(); - await chrome.storage.local.set({ + await api.storage.local.set({ [KEYS.SETTINGS]: { ...current, ...settings }, }); }, diff --git a/src/popup/popup.js b/src/popup/popup.js index 23ec2fa..2e488d0 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,5 +1,6 @@ import SubstitutionEngine from '../lib/substitution-engine.js'; import Storage from '../lib/storage.js'; +import api from '../lib/browser-polyfill.js'; // --- State --- let mappings = []; @@ -37,7 +38,7 @@ document.addEventListener('DOMContentLoaded', async () => { settings.enabled = e.target.checked; await Storage.saveSettings(settings); updateStatusDot(); - chrome.runtime.sendMessage({ + api.runtime.sendMessage({ type: 'update:settings', settings, }); @@ -48,7 +49,7 @@ document.addEventListener('DOMContentLoaded', async () => { settings.revealMode = !settings.revealMode; await Storage.saveSettings(settings); $('#btnReveal').classList.toggle('active', settings.revealMode); - chrome.runtime.sendMessage({ + api.runtime.sendMessage({ type: 'update:settings', settings, }); @@ -74,7 +75,7 @@ document.addEventListener('DOMContentLoaded', async () => { // Options link $('#btnOptions').addEventListener('click', (e) => { e.preventDefault(); - chrome.runtime.openOptionsPage(); + api.runtime.openOptionsPage(); }); });