feat: add Firefox support with cross-browser compatibility

Add manifest.firefox.json for Firefox MV3 (gecko ID, background
scripts instead of service_worker, options_ui). Introduce
browser-polyfill.js shim so all modules use whichever API is
available (browser.* or chrome.*). Add build.sh to target
chrome, firefox, or both.

https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
Claude
2026-03-25 23:36:59 +00:00
parent 4576597e64
commit 3ef5df7efb
8 changed files with 191 additions and 36 deletions
+22 -8
View File
@@ -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 - **Reveal mode** (eye icon) toggles showing real vs substitute data in responses
- **Browser DevTools** → Console shows `[Silent Send] Substituted N value(s)` messages - **Browser DevTools** → Console shows `[Silent Send] Substituted N value(s)` messages
## Installation (Developer Mode) ## Installation
### Chrome (Developer Mode)
1. Clone this repo 1. Clone this repo
2. Open `chrome://extensions/` in Chrome 2. Run `./build.sh chrome` (or just use the root directory directly)
3. Enable **Developer mode** (top right) 3. Open `chrome://extensions/`
4. Click **Load unpacked** 4. Enable **Developer mode** (top right)
5. Select this directory 5. Click **Load unpacked** → select `dist/chrome/` (or root dir)
6. Navigate to claude.ai — the extension is active 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 ## Architecture
@@ -46,11 +59,12 @@ src/
options.html/css/js — Full mapping management, import/export, settings options.html/css/js — Full mapping management, import/export, settings
lib/ lib/
substitution-engine.js — Core find/replace logic 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 ## Privacy
- All data stays local in Chrome storage - All data stays local in browser storage
- No external servers, no telemetry, no analytics - No external servers, no telemetry, no analytics
- The extension only activates on claude.ai - The extension only activates on claude.ai
Executable
+52
View File
@@ -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"
+56
View File
@@ -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/*"]
}
]
}
+11 -9
View File
@@ -2,9 +2,11 @@
* Silent Send - Background Service Worker * Silent Send - Background Service Worker
* *
* Manages badge count, coordinates between popup and content scripts. * 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 Storage from '../lib/storage.js';
import api from '../lib/browser-polyfill.js';
// Track substitution counts per tab // Track substitution counts per tab
const tabCounts = new Map(); const tabCounts = new Map();
@@ -15,12 +17,12 @@ function updateBadge(tabId) {
const count = tabCounts.get(tabId) || 0; const count = tabCounts.get(tabId) || 0;
const text = count > 0 ? String(count) : ''; const text = count > 0 ? String(count) : '';
chrome.action.setBadgeText({ text, tabId }); api.action.setBadgeText({ text, tabId });
chrome.action.setBadgeBackgroundColor({ color: count > 0 ? '#10b981' : '#6b7280', tabId }); api.action.setBadgeBackgroundColor({ color: count > 0 ? '#10b981' : '#6b7280', tabId });
} }
// Reset count when tab navigates // Reset count when tab navigates
chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { api.tabs.onUpdated.addListener((tabId, changeInfo) => {
if (changeInfo.status === 'loading') { if (changeInfo.status === 'loading') {
tabCounts.set(tabId, 0); tabCounts.set(tabId, 0);
updateBadge(tabId); updateBadge(tabId);
@@ -28,13 +30,13 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => {
}); });
// Cleanup when tab closes // Cleanup when tab closes
chrome.tabs.onRemoved.addListener((tabId) => { api.tabs.onRemoved.addListener((tabId) => {
tabCounts.delete(tabId); tabCounts.delete(tabId);
}); });
// --- Message Handling --- // --- Message Handling ---
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { api.runtime.onMessage.addListener((message, sender, sendResponse) => {
const handler = messageHandlers[message.type]; const handler = messageHandlers[message.type];
if (handler) { if (handler) {
handler(message, sender, sendResponse); handler(message, sender, sendResponse);
@@ -87,9 +89,9 @@ const messageHandlers = {
async 'update:settings'(message) { async 'update:settings'(message) {
await Storage.saveSettings(message.settings); await Storage.saveSettings(message.settings);
// Broadcast to content scripts // 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) { for (const tab of tabs) {
chrome.tabs.sendMessage(tab.id, { api.tabs.sendMessage(tab.id, {
type: 'settings:updated', type: 'settings:updated',
settings: message.settings, settings: message.settings,
}).catch(() => {}); }).catch(() => {});
@@ -98,6 +100,6 @@ const messageHandlers = {
}; };
// --- Set initial badge state --- // --- Set initial badge state ---
chrome.runtime.onInstalled.addListener(() => { api.runtime.onInstalled.addListener(() => {
chrome.action.setBadgeBackgroundColor({ color: '#6b7280' }); api.action.setBadgeBackgroundColor({ color: '#6b7280' });
}); });
+16 -8
View File
@@ -1,25 +1,33 @@
/** /**
* Silent Send - Content Script Injector * Silent Send - Content Script Injector
* *
* This runs in Chrome's ISOLATED content script world, where it has * Runs in the ISOLATED content script world, where it has access to
* access to chrome.storage. It then injects the fetch-hooking code * browser/chrome.storage. Injects the fetch-hooking code into the
* into the MAIN page world so it can intercept the actual fetch() calls. * MAIN page world so it can intercept the actual fetch() calls.
* *
* Communication: page script <-> content script via window.postMessage * Communication: page script <-> content script via window.postMessage
*/ */
'use strict'; '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 // Load mappings and settings, then inject into page
async function init() { 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 mappings = result.ss_mappings || [];
const settings = result.ss_settings || { enabled: true }; const settings = result.ss_settings || { enabled: true };
// Inject the main interception script into the page's world // Inject the main interception script into the page's world
const script = document.createElement('script'); const script = document.createElement('script');
script.setAttribute('data-ss-config', JSON.stringify({ mappings, settings })); 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); (document.head || document.documentElement).appendChild(script);
script.onload = () => script.remove(); script.onload = () => script.remove();
@@ -27,7 +35,7 @@ async function init() {
window.addEventListener('message', (event) => { window.addEventListener('message', (event) => {
if (event.source !== window) return; if (event.source !== window) return;
if (event.data?.type === 'ss:substitution-performed') { if (event.data?.type === 'ss:substitution-performed') {
chrome.runtime.sendMessage({ api.runtime.sendMessage({
type: 'substitution:performed', type: 'substitution:performed',
count: event.data.count, count: event.data.count,
replacements: event.data.replacements, replacements: event.data.replacements,
@@ -36,7 +44,7 @@ async function init() {
}); });
// Forward storage changes to the page script // Forward storage changes to the page script
chrome.storage.onChanged.addListener((changes) => { api.storage.onChanged.addListener((changes) => {
if (changes.ss_mappings || changes.ss_settings) { if (changes.ss_mappings || changes.ss_settings) {
window.postMessage({ window.postMessage({
type: 'ss:config-updated', type: 'ss:config-updated',
@@ -47,7 +55,7 @@ async function init() {
}); });
// Listen for settings updates from popup via runtime messages // Listen for settings updates from popup via runtime messages
chrome.runtime.onMessage.addListener((message) => { api.runtime.onMessage.addListener((message) => {
if (message.type === 'settings:updated') { if (message.type === 'settings:updated') {
window.postMessage({ window.postMessage({
type: 'ss:config-updated', type: 'ss:config-updated',
+20
View File
@@ -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;
+10 -8
View File
@@ -1,10 +1,12 @@
/** /**
* Silent Send - Storage Manager * 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. * activity log, and settings.
*/ */
import api from './browser-polyfill.js';
const KEYS = { const KEYS = {
MAPPINGS: 'ss_mappings', MAPPINGS: 'ss_mappings',
LOG: 'ss_activity_log', LOG: 'ss_activity_log',
@@ -23,12 +25,12 @@ const Storage = {
// --- Mappings --- // --- Mappings ---
async getMappings() { async getMappings() {
const result = await chrome.storage.local.get(KEYS.MAPPINGS); const result = await api.storage.local.get(KEYS.MAPPINGS);
return result[KEYS.MAPPINGS] || []; return result[KEYS.MAPPINGS] || [];
}, },
async saveMappings(mappings) { async saveMappings(mappings) {
await chrome.storage.local.set({ [KEYS.MAPPINGS]: mappings }); await api.storage.local.set({ [KEYS.MAPPINGS]: mappings });
}, },
async addMapping(mapping) { async addMapping(mapping) {
@@ -65,7 +67,7 @@ const Storage = {
// --- Activity Log --- // --- Activity Log ---
async getLog() { async getLog() {
const result = await chrome.storage.local.get(KEYS.LOG); const result = await api.storage.local.get(KEYS.LOG);
return result[KEYS.LOG] || []; return result[KEYS.LOG] || [];
}, },
@@ -84,23 +86,23 @@ const Storage = {
log.length = settings.maxLogEntries; log.length = settings.maxLogEntries;
} }
await chrome.storage.local.set({ [KEYS.LOG]: log }); await api.storage.local.set({ [KEYS.LOG]: log });
}, },
async clearLog() { async clearLog() {
await chrome.storage.local.set({ [KEYS.LOG]: [] }); await api.storage.local.set({ [KEYS.LOG]: [] });
}, },
// --- Settings --- // --- Settings ---
async getSettings() { 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] || {}) }; return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) };
}, },
async saveSettings(settings) { async saveSettings(settings) {
const current = await this.getSettings(); const current = await this.getSettings();
await chrome.storage.local.set({ await api.storage.local.set({
[KEYS.SETTINGS]: { ...current, ...settings }, [KEYS.SETTINGS]: { ...current, ...settings },
}); });
}, },
+4 -3
View File
@@ -1,5 +1,6 @@
import SubstitutionEngine from '../lib/substitution-engine.js'; import SubstitutionEngine from '../lib/substitution-engine.js';
import Storage from '../lib/storage.js'; import Storage from '../lib/storage.js';
import api from '../lib/browser-polyfill.js';
// --- State --- // --- State ---
let mappings = []; let mappings = [];
@@ -37,7 +38,7 @@ document.addEventListener('DOMContentLoaded', async () => {
settings.enabled = e.target.checked; settings.enabled = e.target.checked;
await Storage.saveSettings(settings); await Storage.saveSettings(settings);
updateStatusDot(); updateStatusDot();
chrome.runtime.sendMessage({ api.runtime.sendMessage({
type: 'update:settings', type: 'update:settings',
settings, settings,
}); });
@@ -48,7 +49,7 @@ document.addEventListener('DOMContentLoaded', async () => {
settings.revealMode = !settings.revealMode; settings.revealMode = !settings.revealMode;
await Storage.saveSettings(settings); await Storage.saveSettings(settings);
$('#btnReveal').classList.toggle('active', settings.revealMode); $('#btnReveal').classList.toggle('active', settings.revealMode);
chrome.runtime.sendMessage({ api.runtime.sendMessage({
type: 'update:settings', type: 'update:settings',
settings, settings,
}); });
@@ -74,7 +75,7 @@ document.addEventListener('DOMContentLoaded', async () => {
// Options link // Options link
$('#btnOptions').addEventListener('click', (e) => { $('#btnOptions').addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
chrome.runtime.openOptionsPage(); api.runtime.openOptionsPage();
}); });
}); });