diff --git a/README.md b/README.md index c6049fe..72712df 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A browser extension (Chrome + Firefox) that intercepts personal information and | ChatGPT | chatgpt.com, chat.openai.com | Untested | | Grok | grok.x.ai, x.com/i/grok | Untested | | Gemini | gemini.google.com | Untested | -| OpenWebUI | localhost, 127.0.0.1 (self-hosted) | Untested | +| OpenWebUI | localhost, 127.0.0.1, or custom domain | Untested | > **Note:** Only Claude has been tested so far. The other services have API interception patterns defined but may need adjustments. PRs welcome. @@ -51,83 +51,123 @@ A browser extension (Chrome + Firefox) that intercepts personal information and ### Prerequisites -You need [Git](https://git-scm.com/downloads) and [Node.js](https://nodejs.org/) (v18+) installed. +- [Git](https://git-scm.com/downloads) +- [Node.js](https://nodejs.org/) v18 or newer (needed for Firefox signing only) + +### Step 1: Get the code + +Open a terminal (Terminal on Mac, Command Prompt or PowerShell on Windows, any terminal on Linux) and run: -Clone the repo: ```bash git clone https://github.com/outis1one/silent-send.git cd silent-send ``` -### Chrome (Developer Mode) +This downloads the extension code to a `silent-send` folder on your computer. -1. Run `./build.sh chrome` (or just use the root directory directly) -2. Open `chrome://extensions/` in your browser -3. Enable **Developer mode** (toggle in the top right corner) -4. Click **Load unpacked** → navigate to and select the `dist/chrome/` folder (or the repo root) +### Chrome + +1. Open `chrome://extensions/` in Chrome +2. Enable **Developer mode** (toggle in the top right corner) +3. Click **Load unpacked** +4. Navigate to the `silent-send` folder you cloned and select it 5. Navigate to any supported AI site — the extension icon appears in your toolbar -That's it for Chrome. No account, no store, no fees. +That's it for Chrome. No build step, no account, no store, no fees. ### Firefox (signed, persistent) -Firefox requires extensions to be cryptographically signed before it will install them permanently. Mozilla provides free self-hosted signing — no store listing, no review process, no fees. +Firefox requires extensions to be cryptographically signed before it will permanently install them. Mozilla provides free signing — no store listing, no review process, no fees. You just need a free Firefox account. #### Step 1: Create a free Firefox account -1. Go to https://accounts.firefox.com/ and create an account (or sign in if you have one) +1. Go to https://accounts.firefox.com/ and sign up (or sign in if you have one already) 2. This is the same account used for Firefox Sync — you may already have one -#### Step 2: Get your Mozilla API credentials +#### Step 2: Generate your signing keys 1. Go to https://addons.mozilla.org/developers/addon/api/key/ -2. Sign in with your Firefox account from step 1 -3. On that page you'll see two values: - - **JWT issuer** — this is your API key, looks like `user:12345678:901` - - **JWT secret** — a long alphanumeric string, this is your API secret -4. Keep this page open — you'll need both values in the next step +2. Sign in with your Firefox account +3. You'll see two values on that page: + - **JWT issuer** — looks like `user:12345678:901` + - **JWT secret** — a long string of random characters +4. You need both of these. Copy them or keep the page open. -#### Step 3: Configure your credentials +#### Step 3: Install dependencies and save your signing keys +In your terminal, inside the `silent-send` folder: + +**Mac / Linux:** ```bash -# Install dependencies npm install - -# Copy the env template cp .env.example .env ``` -Now open `.env` in any text editor and paste in your values from step 2: +**Windows (Command Prompt):** +```cmd +npm install +copy .env.example .env +``` + +Now open the `.env` file in any text editor (Notepad, VS Code, etc.) and replace the placeholder values with the two values from step 2: + ``` WEB_EXT_API_KEY="user:12345678:901" WEB_EXT_API_SECRET="your-jwt-secret-here" ``` -Save the file. +Save and close the file. #### Step 4: Build and sign +**Mac / Linux:** ```bash source .env && npm run sign:firefox ``` -This builds the Firefox version and submits it to Mozilla for signing. It takes 10-30 seconds. When done, you'll see a signed `.xpi` file in `dist/firefox-signed/`. +**Windows (Command Prompt):** +```cmd +set /p x= < nul & for /f "tokens=1,* delims==" %a in (.env) do @set %a=%~b +npm run sign:firefox +``` -#### Step 5: Install the signed extension +**Windows (PowerShell):** +```powershell +Get-Content .env | ForEach-Object { if ($_ -match '^(.+?)=(.*)$') { [Environment]::SetEnvironmentVariable($matches[1], $matches[2].Trim('"')) } } +npm run sign:firefox +``` + +This submits the extension to Mozilla for signing (takes 10-30 seconds). When done, you'll find a signed `.xpi` file in `dist/firefox-signed/`. + +#### Step 5: Install - Drag the `.xpi` file into any Firefox window, **or** - Firefox menu → File → Open File → select the `.xpi` - Click **Add** when prompted -The signed `.xpi` is **permanent** — it survives browser restarts, updates, everything. No store listing, no review process, no fees. You only need to re-sign when you update to a new version. +Done. The extension is **permanently installed** — survives restarts, updates, everything. You only need to re-sign if you update to a newer version of Silent Send. -### Firefox (temporary, for development) +### Firefox (temporary, no signing needed) -If you just want to try it out without signing: +If you just want to try it out quickly: + +**Mac / Linux:** ```bash npm install && npm run run:firefox ``` -This opens a fresh Firefox with the extension pre-loaded. Auto-reloads on file changes. Resets when Firefox closes — useful for testing, not for daily use. + +**Windows:** +```cmd +npm install && npm run run:firefox +``` + +This opens Firefox with the extension pre-loaded. Resets when Firefox closes — useful for testing. + +## Custom domains (OpenWebUI, etc.) + +If you run OpenWebUI or another AI service on a custom domain (not localhost), go to **Options** → **Custom Domains** and add your domain (e.g. `https://ai.myserver.com`). The extension will activate on those domains too. + +For Chrome, you'll need to also grant the extension permission to access the new domain via `chrome://extensions/` → Silent Send → Details → Site access. ## Architecture @@ -145,7 +185,7 @@ src/ popup/ popup.html/css/js — Quick access: identity, mappings, activity, test mode options/ - options.html/css/js — Full mapping management, import/export, settings + options.html/css/js — Full mapping management, import/export, settings, custom domains lib/ substitution-engine.js — Core explicit find/replace logic smart-patterns.js — Auto-detection of emails, names, usernames, hostnames, phones, paths @@ -157,5 +197,5 @@ src/ - All data stays local in browser storage - No external servers, no telemetry, no analytics -- The extension only activates on claude.ai +- The extension only activates on supported AI sites (and any custom domains you add) - Your real identity data never leaves your machine diff --git a/manifest.firefox.json b/manifest.firefox.json index c0dd7dd..b71637c 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -63,19 +63,13 @@ "48": "icons/icon48.svg", "128": "icons/icon128.svg" }, + "optional_permissions": [ + "" + ], "web_accessible_resources": [ { "resources": ["src/content/content.js"], - "matches": [ - "https://claude.ai/*", - "https://chatgpt.com/*", - "https://chat.openai.com/*", - "https://grok.x.ai/*", - "https://x.com/*", - "https://gemini.google.com/*", - "http://localhost/*", - "http://127.0.0.1/*" - ] + "matches": [""] } ] } diff --git a/manifest.json b/manifest.json index 9d2932c..d73537b 100644 --- a/manifest.json +++ b/manifest.json @@ -54,19 +54,14 @@ "48": "icons/icon48.svg", "128": "icons/icon128.svg" }, + "optional_host_permissions": [ + "https://*/*", + "http://*/*" + ], "web_accessible_resources": [ { "resources": ["src/content/content.js"], - "matches": [ - "https://claude.ai/*", - "https://chatgpt.com/*", - "https://chat.openai.com/*", - "https://grok.x.ai/*", - "https://x.com/*", - "https://gemini.google.com/*", - "http://localhost/*", - "http://127.0.0.1/*" - ] + "matches": [""] } ] } diff --git a/src/background/service-worker.js b/src/background/service-worker.js index f73342d..d619b18 100644 --- a/src/background/service-worker.js +++ b/src/background/service-worker.js @@ -2,6 +2,7 @@ * Silent Send - Background Service Worker * * Manages badge count, coordinates between popup and content scripts. + * Injects content scripts on custom domains dynamically. * Uses `api` alias for cross-browser compatibility (Chrome + Firefox). */ @@ -11,6 +12,18 @@ import api from '../lib/browser-polyfill.js'; // Track substitution counts per tab const tabCounts = new Map(); +// Built-in URL patterns +const BUILTIN_URL_PATTERNS = [ + 'https://claude.ai/*', + 'https://chatgpt.com/*', + 'https://chat.openai.com/*', + 'https://grok.x.ai/*', + 'https://x.com/i/grok*', + 'https://gemini.google.com/*', + 'http://localhost/*', + 'http://127.0.0.1/*', +]; + // --- Badge Management --- function updateBadge(tabId) { @@ -22,11 +35,16 @@ function updateBadge(tabId) { } // Reset count when tab navigates -api.tabs.onUpdated.addListener((tabId, changeInfo) => { +api.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (changeInfo.status === 'loading') { tabCounts.set(tabId, 0); updateBadge(tabId); } + + // Inject content script on custom domains when page loads + if (changeInfo.status === 'complete' && tab.url) { + await injectOnCustomDomain(tabId, tab.url); + } }); // Cleanup when tab closes @@ -34,6 +52,47 @@ api.tabs.onRemoved.addListener((tabId) => { tabCounts.delete(tabId); }); +// --- Dynamic injection for custom domains --- + +async function injectOnCustomDomain(tabId, tabUrl) { + const settings = await Storage.getSettings(); + const customDomains = settings.customDomains || []; + if (customDomains.length === 0) return; + + const matches = customDomains.some((domain) => tabUrl.startsWith(domain)); + if (!matches) return; + + // Check if already injected (avoid double-injection) + try { + const results = await api.scripting.executeScript({ + target: { tabId }, + func: () => !!window.__silentSendInjected, + }); + if (results?.[0]?.result) return; + } catch (e) { + // Permission denied — user hasn't granted access to this domain + return; + } + + // Inject CSS + try { + await api.scripting.insertCSS({ + target: { tabId }, + files: ['src/content/content.css'], + }); + } catch (e) { /* non-fatal */ } + + // Inject content script + try { + await api.scripting.executeScript({ + target: { tabId }, + files: ['src/content/injector.js'], + }); + } catch (e) { + console.warn('[Silent Send] Failed to inject on custom domain:', e); + } +} + // --- Message Handling --- api.runtime.onMessage.addListener((message, sender, sendResponse) => { @@ -88,18 +147,16 @@ const messageHandlers = { async 'update:settings'(message) { await Storage.saveSettings(message.settings); - // Broadcast to content scripts on all supported sites - const SUPPORTED_URLS = [ - 'https://claude.ai/*', - 'https://chatgpt.com/*', - 'https://chat.openai.com/*', - 'https://grok.x.ai/*', - 'https://x.com/i/grok*', - 'https://gemini.google.com/*', - 'http://localhost/*', - 'http://127.0.0.1/*', - ]; - for (const urlPattern of SUPPORTED_URLS) { + + // Build list of all URL patterns (built-in + custom) + const allPatterns = [...BUILTIN_URL_PATTERNS]; + const customDomains = message.settings.customDomains || []; + for (const domain of customDomains) { + allPatterns.push(domain + '/*'); + } + + // Broadcast to content scripts + for (const urlPattern of allPatterns) { const tabs = await api.tabs.query({ url: urlPattern }).catch(() => []); for (const tab of tabs) { api.tabs.sendMessage(tab.id, { diff --git a/src/content/injector.js b/src/content/injector.js index 4ea3515..680f2bc 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -8,63 +8,69 @@ * Communication: page script <-> content script via window.postMessage */ -'use strict'; +(function () { + 'use strict'; -// Cross-browser API -const api = - typeof browser !== 'undefined' && browser.runtime - ? browser - : typeof chrome !== 'undefined' - ? chrome - : null; + // Prevent double-injection on custom domains + if (window.__silentSendInjected) return; + window.__silentSendInjected = true; -// Load mappings and settings, then inject into page -async function init() { - const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']); - const mappings = result.ss_mappings || []; - const identity = result.ss_identity || {}; - const settings = result.ss_settings || { enabled: true }; + // Cross-browser API + const api = + typeof browser !== 'undefined' && browser.runtime + ? browser + : typeof chrome !== 'undefined' + ? chrome + : null; - // Inject the main interception script into the page's world - const script = document.createElement('script'); - script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings })); - script.src = api.runtime.getURL('src/content/content.js'); - (document.head || document.documentElement).appendChild(script); - script.onload = () => script.remove(); + // Load mappings and settings, then inject into page + async function init() { + const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']); + const mappings = result.ss_mappings || []; + const identity = result.ss_identity || {}; + const settings = result.ss_settings || { enabled: true }; - // Listen for substitution events from the page script - window.addEventListener('message', (event) => { - if (event.source !== window) return; - if (event.data?.type === 'ss:substitution-performed') { - api.runtime.sendMessage({ - type: 'substitution:performed', - count: event.data.count, - replacements: event.data.replacements, - }).catch(() => {}); - } - }); + // Inject the main interception script into the page's world + const script = document.createElement('script'); + script.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings })); + script.src = api.runtime.getURL('src/content/content.js'); + (document.head || document.documentElement).appendChild(script); + script.onload = () => script.remove(); - // Forward storage changes to the page script - api.storage.onChanged.addListener((changes) => { - if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) { - window.postMessage({ - type: 'ss:config-updated', - mappings: changes.ss_mappings?.newValue, - identity: changes.ss_identity?.newValue, - settings: changes.ss_settings?.newValue, - }, '*'); - } - }); + // Listen for substitution events from the page script + window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.type === 'ss:substitution-performed') { + api.runtime.sendMessage({ + type: 'substitution:performed', + count: event.data.count, + replacements: event.data.replacements, + }).catch(() => {}); + } + }); - // Listen for settings updates from popup via runtime messages - api.runtime.onMessage.addListener((message) => { - if (message.type === 'settings:updated') { - window.postMessage({ - type: 'ss:config-updated', - settings: message.settings, - }, '*'); - } - }); -} + // Forward storage changes to the page script + api.storage.onChanged.addListener((changes) => { + if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) { + window.postMessage({ + type: 'ss:config-updated', + mappings: changes.ss_mappings?.newValue, + identity: changes.ss_identity?.newValue, + settings: changes.ss_settings?.newValue, + }, '*'); + } + }); -init(); + // Listen for settings updates from popup via runtime messages + api.runtime.onMessage.addListener((message) => { + if (message.type === 'settings:updated') { + window.postMessage({ + type: 'ss:config-updated', + settings: message.settings, + }, '*'); + } + }); + } + + init(); +})(); diff --git a/src/lib/storage.js b/src/lib/storage.js index 64923b4..57bb2b3 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -19,6 +19,7 @@ const DEFAULT_SETTINGS = { showHighlights: false, revealMode: false, maxLogEntries: 200, + customDomains: [], categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'general'], }; diff --git a/src/options/options.html b/src/options/options.html index 7e3beff..43a2bc3 100644 --- a/src/options/options.html +++ b/src/options/options.html @@ -79,6 +79,19 @@ +
+

Custom Domains

+

Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.

+
+
+ + +
+

+ After adding a domain in Chrome, you also need to grant permission: chrome://extensions/ → Silent Send → Details → Site access → add the domain. +

+
+

Activity Log

@@ -89,7 +102,7 @@
-

Silent Send v0.1.0

+

Silent Send v0.2.0

diff --git a/src/options/options.js b/src/options/options.js index 651fad4..83f04d9 100644 --- a/src/options/options.js +++ b/src/options/options.js @@ -14,8 +14,15 @@ document.addEventListener('DOMContentLoaded', async () => { $('#maxLogEntries').value = settings.maxLogEntries || 200; renderMappings(); + renderDomains(); renderLog(); + // Custom domains + $('#btnAddDomain').addEventListener('click', addDomain); + $('#newDomain').addEventListener('keydown', (e) => { + if (e.key === 'Enter') addDomain(); + }); + // Settings listeners $('#showHighlights').addEventListener('change', async (e) => { await Storage.saveSettings({ showHighlights: e.target.checked }); @@ -176,6 +183,61 @@ async function renderLog() { .join(''); } +// --- Custom Domains --- +async function addDomain() { + let domain = $('#newDomain').value.trim(); + if (!domain) return; + + // Normalize: ensure it has a protocol + if (!domain.startsWith('http://') && !domain.startsWith('https://')) { + domain = 'https://' + domain; + } + // Strip trailing slashes + domain = domain.replace(/\/+$/, ''); + + const domains = settings.customDomains || []; + if (domains.includes(domain)) { + alert('Domain already added.'); + return; + } + + domains.push(domain); + settings.customDomains = domains; + await Storage.saveSettings({ customDomains: domains }); + renderDomains(); + $('#newDomain').value = ''; +} + +function renderDomains() { + const list = $('#domainList'); + const domains = settings.customDomains || []; + + if (domains.length === 0) { + list.innerHTML = '
No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.
'; + return; + } + + list.innerHTML = domains + .map((d, i) => ` +
+ ${escapeHtml(d)} + +
+ `) + .join(''); + + list.querySelectorAll('.btn-remove-domain').forEach((btn) => { + btn.addEventListener('click', async () => { + const idx = parseInt(btn.dataset.index, 10); + const domains = settings.customDomains || []; + domains.splice(idx, 1); + settings.customDomains = domains; + await Storage.saveSettings({ customDomains: domains }); + renderDomains(); + }); + }); +} + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str;