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
+11 -9
View File
@@ -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
View File
@@ -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',
+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
*
* 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
View File
@@ -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();
});
});