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
221 lines
8.4 KiB
JavaScript
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();
|
|
})();
|