Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 134 additions & 12 deletions extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,27 @@ function waitTabComplete(tabId, timeoutMs = 25000) {
});
}

/**
* True when the existing tab is already on the target AI app.
* Do NOT re-navigate merely because query/hash/conversation id differ — that
* forced cold=1 on every Meta/Gemini hotkey and re-hydrated the composer.
*
* Rules:
* - origin must match
* - want path "/" (meta.ai/, chatgpt.com/): any path on that origin is fine
* - otherwise accept exact path or prefix (gemini /app → /app/xyz)
*/
function isAlreadyOnApp(u, want) {
if (u.origin !== want.origin) return false;
let wantPath = want.pathname || '/';
if (wantPath.length > 1 && wantPath.endsWith('/')) wantPath = wantPath.slice(0, -1);
if (wantPath === '/' || wantPath === '') return true;
const path = u.pathname || '/';
if (path === wantPath) return true;
const prefix = wantPath.endsWith('/') ? wantPath : wantPath + '/';
return path.startsWith(prefix);
}

/**
* Focus or open a tab for url.
* Returns { tabId, cold } where cold=true if we created a tab or navigated.
Expand All @@ -110,7 +131,7 @@ async function ensureTab(url) {
}
}
let cold = false;
if (u.pathname !== want.pathname || u.search !== want.search || u.hash !== want.hash) {
if (!isAlreadyOnApp(u, want)) {
await chrome.tabs.update(t.id, { url });
cold = true;
await waitTabComplete(t.id);
Expand All @@ -125,9 +146,12 @@ async function ensureTab(url) {
return { tabId: created.id, cold: true };
}

async function sendToTab(tabId, message, attempts = 16) {
async function sendToTab(tabId, message, attempts = 16, deadlineMs = 0) {
let lastErr = 'no response';
for (let i = 0; i < attempts; ++i) {
if (deadlineMs && Date.now() >= deadlineMs) {
return { ok: false, error: 'timeout waiting for content script' };
}
try {
const resp = await chrome.tabs.sendMessage(tabId, message);
if (resp) return resp;
Expand All @@ -145,11 +169,46 @@ async function sendToTab(tabId, message, attempts = 16) {
}
}
}
await sleep(300);
const slice = deadlineMs ? Math.min(300, Math.max(50, deadlineMs - Date.now())) : 300;
if (slice <= 0) break;
await sleep(slice);
}
return { ok: false, error: lastErr };
}

async function tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch = true) {
try {
const t = await chrome.tabs.get(tabId);
if (!t) return { ok: false, error: 'tab gone' };
if (wantOrigin && t.url) {
try {
if (new URL(t.url).origin !== wantOrigin) {
return { ok: false, error: 'tab navigated off AI origin' };
}
} catch {
/* ignore bad url */
}
}
if (!cancelOnFocusSwitch) return { ok: true };

// Tab.active only means selected in its window — still true if another app has OS focus.
if (t.active === false) return { ok: false, error: 'tab inactive / focus switched' };
if (t.windowId != null) {
try {
const w = await chrome.windows.get(t.windowId);
if (w && w.focused === false) {
return { ok: false, error: 'browser window not focused' };
}
} catch {
/* ignore — some hosts deny windows.get */
}
}
return { ok: true };
} catch (e) {
return { ok: false, error: String(e) };
}
}

async function onNativeMessage(msg) {
log('native msg', msg && msg.cmd, msg && msg.id);
if (!msg || typeof msg !== 'object') return;
Expand All @@ -163,24 +222,62 @@ async function onNativeMessage(msg) {
if (msg.cmd === 'prepareAndPaste') {
const url = String(msg.url || '');
const text = String(msg.text || '');
const timeoutMs = Math.max(1000, Number(msg.timeoutMs) || 15000);
// Hard cap 10s for form wait — matches product rule: cancel rather than surprise-paste.
const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000));
// Default true; tray can pass cancelOnFocusSwitch:false to disable focus leave cancel.
const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false;
if (!url) {
reply(msg, { ok: false, error: 'missing url' });
return;
}
let wantOrigin = '';
try {
wantOrigin = new URL(url).origin;
} catch {
/* ignore */
}
const deadline = Date.now() + timeoutMs + 1500;
const { tabId, cold } = await ensureTab(url);
// Cold open / navigate: SPA shell is not ready at status=complete.
if (cold) {
await sleep(900);
} else {
await sleep(250);
}
const resp = await sendToTab(tabId, {
cmd: 'waitAndPaste',
text,
timeoutMs,
cold: !!cold,
});
const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch);
if (!focusCheck.ok) {
reply(msg, {
ok: false,
error: focusCheck.error || 'tab inactive / focus switched',
cold: !!cold,
});
return;
}
const remain = Math.max(500, deadline - Date.now());
const resp = await sendToTab(
tabId,
{
cmd: 'waitAndPaste',
text,
timeoutMs: Math.min(timeoutMs, remain),
cold: !!cold,
wantOrigin,
cancelOnFocusSwitch,
},
16,
deadline,
);
// Final guard: if user left while content script worked, still report cancel.
const after = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch);
if (resp && resp.ok && !after.ok) {
reply(msg, {
ok: false,
error: after.error || 'tab inactive after paste',
detail: resp.detail,
cold: !!cold,
});
return;
}
reply(msg, {
ok: !!(resp && resp.ok),
detail: (resp && (resp.detail || resp.error)) || '',
Expand All @@ -193,11 +290,36 @@ async function onNativeMessage(msg) {

if (msg.cmd === 'prepare') {
const url = String(msg.url || '');
const timeoutMs = Math.max(1000, Number(msg.timeoutMs) || 15000);
const timeoutMs = Math.min(10000, Math.max(1000, Number(msg.timeoutMs) || 10000));
const cancelOnFocusSwitch = msg.cancelOnFocusSwitch !== false;
let wantOrigin = '';
try {
wantOrigin = new URL(url).origin;
} catch {
/* ignore */
}
const deadline = Date.now() + timeoutMs + 1500;
const { tabId, cold } = await ensureTab(url);
if (cold) await sleep(900);
else await sleep(250);
const resp = await sendToTab(tabId, { cmd: 'findComposer', timeoutMs, cold: !!cold });
const focusCheck = await tabStillActiveOnOrigin(tabId, wantOrigin, cancelOnFocusSwitch);
if (!focusCheck.ok) {
reply(msg, { ok: false, error: focusCheck.error || 'tab inactive', cold: !!cold });
return;
}
const remain = Math.max(500, deadline - Date.now());
const resp = await sendToTab(
tabId,
{
cmd: 'findComposer',
timeoutMs: Math.min(timeoutMs, remain),
cold: !!cold,
wantOrigin,
cancelOnFocusSwitch,
},
16,
deadline,
);
reply(msg, {
ok: !!(resp && resp.ok),
detail: (resp && resp.detail) || '',
Expand Down
Loading
Loading