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
|
- **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
|
||||||
|
|||||||
@@ -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
|
* 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
@@ -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',
|
||||||
|
|||||||
@@ -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
|
* 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
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user