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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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/*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
+16
-8
@@ -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',
|
||||
|
||||
@@ -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
@@ -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 },
|
||||
});
|
||||
},
|
||||
|
||||
+4
-3
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user