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
198 lines
7.4 KiB
JavaScript
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();
|
|
})();
|