Files
silent-send/src/content/injector.js
T
Claude b9acd6d677 Remove claude.ai/code skip
The extension needs to work on claude.ai/code like every other site;
the user toggles it off manually when needed. Dropping the host-level
skip leaves the word-boundary mapping fix as the only behavior change
on this branch.

https://claude.ai/code/session_01Y2YprLMx348eWD5C9Z4zpV
2026-04-16 18:10:04 +00:00

221 lines
8.4 KiB
JavaScript

/**
* Silent Send - Content Script Injector
*
* 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
*/
(function () {
'use strict';
// Prevent double-injection on custom domains
if (window.__silentSendInjected) return;
window.__silentSendInjected = true;
// Merge active profiles into flat identity object
function mergeProfiles(data) {
const profiles = data?.profiles || [];
const active = profiles.filter(p => p.active);
if (active.length === 0) {
// Legacy format: data IS the flat identity (pre-profile migration)
if (data && (data.names || data.emails || data.usernames)) return data;
return { emails: [], names: [], usernames: [], hostnames: [], phones: [],
catchAllEmail: '', emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true } };
}
const merged = {
emails: [], names: [], usernames: [], hostnames: [], phones: [],
catchAllEmail: '', emailDomains: [],
enabled: { emails: true, names: true, usernames: true, phones: true, paths: true },
};
for (const p of active) {
merged.emails.push(...(p.emails || []));
merged.names.push(...(p.names || []));
merged.usernames.push(...(p.usernames || []));
merged.hostnames.push(...(p.hostnames || []));
merged.phones.push(...(p.phones || []));
if (p.catchAllEmail && !merged.catchAllEmail) merged.catchAllEmail = p.catchAllEmail;
merged.emailDomains.push(...(p.emailDomains || []));
}
return merged;
}
// 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 api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']);
// Check if data is encrypted — pass empty config; decrypted data arrives via vault:unlocked
const isLocked = result.ss_mappings?._ssLocalEncrypted ||
result.ss_identity?._ssLocalEncrypted ||
result.ss_settings?._ssLocalEncrypted;
const mappings = isLocked ? [] : (result.ss_mappings || []);
const identityData = isLocked ? {} : (result.ss_identity || {});
const settings = isLocked ? { enabled: true } : (result.ss_settings || { enabled: true });
// Merge active profiles into a flat identity object for the content script
const identity = mergeProfiles(identityData);
// Inject the document scanner into the page's world first (sets globalThis.DocumentScanner)
const docScannerScript = document.createElement('script');
docScannerScript.type = 'module';
docScannerScript.src = api.runtime.getURL('src/lib/document-scanner.js');
(document.head || document.documentElement).appendChild(docScannerScript);
await new Promise(resolve => { docScannerScript.onload = resolve; docScannerScript.onerror = resolve; });
docScannerScript.remove();
// Register the runtime message listener BEFORE injecting content.js and BEFORE
// sending vault:request-unlock. This prevents two races:
// 1. The background could respond to vault:request-unlock before the listener
// is registered (if the service worker is already warm), dropping the message.
// 2. window.postMessage from the listener must reach content.js's message handler,
// which is only registered after content.js finishes executing.
// Solution: register the listener here, but send vault:request-unlock only inside
// script.onload (after content.js has fully executed).
api.runtime.onMessage.addListener(async (message) => {
if (message.type === 'settings:updated') {
window.postMessage({
type: 'ss:config-updated',
settings: message.settings,
}, '*');
}
// Vault unlocked — background sends pre-decrypted data
if (message.type === 'vault:unlocked') {
window.postMessage({
type: 'ss:config-updated',
mappings: message.mappings || [],
identity: message.identity || {},
settings: message.settings || {},
}, '*');
}
});
// 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();
// content.js has fully executed — its window.message listener is now live.
// Safe to request decrypted data; the vault:unlocked response will be
// delivered to content.js without a race.
if (isLocked) {
api.runtime.sendMessage({ type: 'vault:request-unlock' }).catch(() => {});
}
};
// Listen for substitution events from the page script
window.addEventListener('message', async (event) => {
if (event.source !== window) return;
if (event.data?.type === 'ss:substitution-performed') {
// Try to notify background for badge update
api.runtime.sendMessage({
type: 'substitution:performed',
count: event.data.count,
replacements: event.data.replacements,
}).catch(() => {});
// Also log directly from the injector (content script world)
// in case the background worker is asleep
const replacements = event.data.replacements || [];
for (const r of replacements) {
const log = (await api.storage.local.get('ss_activity_log')).ss_activity_log || [];
log.unshift({
id: crypto.randomUUID(),
timestamp: Date.now(),
type: 'substitution',
direction: 'outbound',
original: r.original,
replaced: r.replaced,
category: r.category || 'general',
pattern: r.pattern || '',
url: location.href,
});
// Trim
if (log.length > 200) log.length = 200;
await api.storage.local.set({ ss_activity_log: log });
}
}
});
// Forward storage changes to the page script (merge profiles before sending)
// Skip encrypted blobs — background will send decrypted data via vault:unlocked
api.storage.onChanged.addListener((changes) => {
if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) {
const msg = { type: 'ss:config-updated' };
if (changes.ss_mappings) {
const val = changes.ss_mappings.newValue;
if (!val?._ssLocalEncrypted) msg.mappings = val;
}
if (changes.ss_identity) {
const val = changes.ss_identity.newValue;
if (!val?._ssLocalEncrypted) msg.identity = mergeProfiles(val);
}
if (changes.ss_settings) msg.settings = changes.ss_settings.newValue;
// Only post if we have something meaningful to send
if (msg.mappings || msg.identity || msg.settings) {
window.postMessage(msg, '*');
}
}
});
// Storage bridge — lets page world script read/write storage
window.addEventListener('message', async (event) => {
if (event.source !== window) return;
if (event.data?.type === 'ss:storage-get') {
const result = await api.storage.local.get(event.data.key);
window.postMessage({
type: 'ss:storage-result',
id: event.data.id,
value: result[event.data.key] || null,
}, '*');
}
if (event.data?.type === 'ss:storage-set') {
await api.storage.local.set({ [event.data.key]: event.data.value });
}
if (event.data?.type === 'ss:add-mapping') {
try {
const response = await api.runtime.sendMessage({
type: 'add:mapping',
mapping: event.data.mapping,
});
window.postMessage({
type: 'ss:add-mapping-result',
id: event.data.id,
mappings: response?.mappings || [],
}, '*');
} catch {
window.postMessage({
type: 'ss:add-mapping-result',
id: event.data.id,
mappings: [],
}, '*');
}
}
});
}
init();
})();