Files
silent-send/src/content/injector.js
T
Claude dd68361474 fix: move early fetch hook BEFORE async storage read
The early fetch hook was still inside the async init() function,
running AFTER await storage.local.get(). By the time storage
responded, ChatGPT's JS had already loaded and captured the
original fetch().

Moved the inline script injection to the TOP of the IIFE, before
any async operations. The sequence is now:

1. [synchronous] Inject inline <script> that captures fetch/XHR
2. [synchronous] Define mergeProfiles and other helpers
3. [async] Read storage for config
4. [async] Inject content.js with full substitution engine

This guarantees the fetch proxy is installed before any page
JavaScript runs, regardless of how long storage reads take.

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
2026-03-27 05:14:13 +00:00

198 lines
7.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;
// IMMEDIATELY inject a synchronous fetch hook into the page world
// BEFORE any async operations. This must run before any page JS
// (like ChatGPT's Next.js) can store a reference to the original fetch.
const earlyHook = document.createElement('script');
earlyHook.textContent = `(function(){
window.__ssOriginalFetch = window.fetch;
window.__ssOriginalXHROpen = XMLHttpRequest.prototype.open;
window.__ssOriginalXHRSend = XMLHttpRequest.prototype.send;
window.__ssReady = false;
window.fetch = function() {
if (window.__ssReady && window.__ssInterceptFetch) {
return window.__ssInterceptFetch.apply(this, arguments);
}
return window.__ssOriginalFetch.apply(this, arguments);
};
})();`;
(document.head || document.documentElement).appendChild(earlyHook);
earlyHook.remove();
// 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']);
const settings = result.ss_settings || { enabled: true };
// Check if data is encrypted (locked) — pass empty config
// The background will send decrypted data via vault:unlocked when ready
const isLocked = result.ss_mappings?._ssLocalEncrypted ||
result.ss_identity?._ssLocalEncrypted;
const mappings = isLocked ? [] : (result.ss_mappings || []);
const identityData = isLocked ? {} : (result.ss_identity || {});
// Merge active profiles into a flat identity object for the content script
const identity = mergeProfiles(identityData);
// Load the full content.js which will use __ssOriginalFetch
// (captured by the early hook above) and set __ssReady = true
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();
// 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, '*');
}
}
});
// Listen for settings updates and vault unlock from background
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 || {},
}, '*');
}
});
// 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 });
}
});
}
init();
})();