feat: custom domains for OpenWebUI + fix README issues

- Add custom domain support in Options page so users can add
  self-hosted AI services (e.g. https://ai.myserver.com)
- Background worker dynamically injects content scripts on
  custom domains using scripting.executeScript
- Add optional_host_permissions so Chrome can grant per-domain access
- Rewrite README: add clone step to Firefox instructions, clarify
  what "credentials" means in step 3, add Windows commands alongside
  Mac/Linux for every terminal step
- Bump version to 0.2.0

https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
Claude
2026-03-26 00:35:21 +00:00
parent 1fe813337b
commit 60b810b1b9
8 changed files with 286 additions and 118 deletions
+70 -13
View File
@@ -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, {
+59 -53
View File
@@ -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();
})();
+1
View File
@@ -19,6 +19,7 @@ const DEFAULT_SETTINGS = {
showHighlights: false,
revealMode: false,
maxLogEntries: 200,
customDomains: [],
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'general'],
};
+14 -1
View File
@@ -79,6 +79,19 @@
</div>
</section>
<section class="section">
<h2>Custom Domains</h2>
<p class="section-desc">Add domains for self-hosted AI services (like OpenWebUI). The extension will activate on these domains in addition to the built-in ones.</p>
<div class="domain-list" id="domainList"></div>
<div class="add-row">
<input type="text" id="newDomain" placeholder="https://ai.myserver.com" class="input" style="flex:2">
<button class="btn btn-primary" id="btnAddDomain">Add Domain</button>
</div>
<p class="section-desc" style="margin-top:8px;margin-bottom:0">
After adding a domain in Chrome, you also need to grant permission: <code>chrome://extensions/</code> → Silent Send → Details → Site access → add the domain.
</p>
</section>
<section class="section">
<h2>Activity Log</h2>
<div class="log-actions">
@@ -89,7 +102,7 @@
</section>
<footer>
<p>Silent Send v0.1.0</p>
<p>Silent Send v0.2.0</p>
</footer>
</div>
+62
View File
@@ -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 = '<div style="text-align:center;color:#9ca3af;padding:12px;font-size:13px">No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.</div>';
return;
}
list.innerHTML = domains
.map((d, i) => `
<div class="domain-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
<span style="font-size:13px;font-family:monospace">${escapeHtml(d)}</span>
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}">&times;</button>
</div>
`)
.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;