diff --git a/electron/main.js b/electron/main.js index 75cc5930..4ec1e582 100644 --- a/electron/main.js +++ b/electron/main.js @@ -404,15 +404,24 @@ if (process.defaultApp) { app.setAsDefaultProtocolClient('openswarm'); } -// Pending deep-link captured before mainWindow exists (cold-launch case). -// Flushed to renderer once mainWindow is ready. -let pendingDeepLink = null; +// Deep links queue here until the RENDERER drains them. A single slot + a live +// webContents.send lost links two ways that stranded a real user's sign-in +// (ENG-240): !isLoading() means the page loaded, not that React subscribed, so a +// link arriving in that gap was sent into the void; and a single slot dropped an +// earlier link when a second arrived. A queue the renderer drains on mount AND on +// a nudge has no window where a delivered link can be lost. +const pendingDeepLinks = []; + +function notifyDeeplinkAvailable() { + if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isDestroyed()) { + try { mainWindow.webContents.send('openswarm:deeplink-available'); } catch (_) {} + } +} function forwardDeepLinkToRenderer(url) { if (!url) return; - // openswarm:// URLs split by host: "auth" → subscription token, - // "oauth/{provider}/complete" → OAuth claim. Each goes to its own - // IPC channel so the renderer can route without parsing twice. + // openswarm:// URLs split by host: "auth" → sign-in / subscription token, + // "oauth/{provider}/complete" → OAuth claim. The renderer routes by host when it drains. let channel = 'openswarm:auth-url'; try { const u = new URL(url); @@ -420,16 +429,10 @@ function forwardDeepLinkToRenderer(url) { channel = 'openswarm:oauth-claim'; } } catch (_) { - // Malformed URL — fall back to legacy channel; renderer ignores anything - // it doesn't recognise. - } - if (mainWindow && mainWindow.webContents && !mainWindow.webContents.isLoading()) { - mainWindow.webContents.send(channel, url); - } else { - // Stash both URL and target channel so we can flush correctly when - // the renderer is ready. Replaces the simple string with a {channel,url}. - pendingDeepLink = { channel, url }; + // Malformed URL — default channel; the renderer ignores anything it can't parse. } + pendingDeepLinks.push({ channel, url }); + notifyDeeplinkAvailable(); } function extractOpenswarmUrl(argv) { @@ -1516,20 +1519,14 @@ function createWindow() { }); } - // Once the renderer has loaded, flush any deep-link URL we captured before - // the window existed (cold-launch via openswarm://). pendingDeepLink may - // be a string (legacy) OR a {channel, url} object (v1.0.26+ OAuth claims). + // Once the renderer has loaded, nudge it to drain any deep link captured + // before the window existed (cold-launch via openswarm://). The renderer also + // drains on its own mount, so this is a belt over the same queue, never the + // only delivery. mainWindow.webContents.once('did-finish-load', () => { perfMark('first-paint'); maybeSendBootBeacon(); - if (pendingDeepLink) { - if (typeof pendingDeepLink === 'string') { - mainWindow.webContents.send('openswarm:auth-url', pendingDeepLink); - } else { - mainWindow.webContents.send(pendingDeepLink.channel, pendingDeepLink.url); - } - pendingDeepLink = null; - } + if (pendingDeepLinks.length) notifyDeeplinkAvailable(); }); // Identity-checked: on crash recovery we recreate the window, which means BOTH the old and new BrowserWindow are alive briefly. The OLD window's closed handler must not clobber the NEW mainWindow reference when the old finally destroys. @@ -3423,6 +3420,11 @@ ipcMain.handle('get-auth-token', async () => { ipcMain.on('perf:first-agent-response', () => perfMark('first-agent-response')); ipcMain.handle('get-app-version', () => app.getVersion()); + +// The renderer drains the deep-link queue on useDeepLink mount and on every +// 'deeplink-available' nudge; returning + clearing here is the single consume +// point, so a link is delivered exactly once no matter the timing (ENG-240). +ipcMain.handle('drain-deeplinks', () => pendingDeepLinks.splice(0)); ipcMain.handle('set-window-buttons-visible', (_e, visible) => { if (process.platform !== 'darwin' || !mainWindow || mainWindow.isDestroyed()) return; try { mainWindow.setWindowButtonVisibility(!!visible); } catch (err) { console.warn('[main] setWindowButtonVisibility failed:', err.message); } diff --git a/electron/preload.js b/electron/preload.js index 79020487..a6c866ce 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -230,16 +230,23 @@ contextBridge.exposeInMainWorld('openswarm', { return () => ipcRenderer.removeListener('openswarm:browser-shortcut', listener); }, - // Deep-link callback: fires when the OS opens the app with an - // openswarm://auth?token=... URL (after Stripe-hosted checkout). + // Deep-link delivery is drain-based (ENG-240): main queues every openswarm:// + // URL and nudges here; the renderer drains the queue on mount and on each nudge, + // so a link is never lost to the send-before-subscribe race or a single-slot overwrite. + onDeeplinkAvailable: (cb) => { + const listener = () => cb(); + ipcRenderer.on('openswarm:deeplink-available', listener); + return () => ipcRenderer.removeListener('openswarm:deeplink-available', listener); + }, + drainDeeplinks: () => ipcRenderer.invoke('drain-deeplinks'), + + // Legacy push channels, kept so an old renderer bundle still receives links; main + // no longer pushes to them (it queues + nudges instead), so on current builds these never fire. onAuthUrl: (cb) => { const listener = (_event, url) => cb(url); ipcRenderer.on('openswarm:auth-url', listener); return () => ipcRenderer.removeListener('openswarm:auth-url', listener); }, - - // OAuth claim deep-link channel. Receives openswarm://oauth/{provider}/complete - // after the user finishes an OAuth flow in their browser. onOauthClaim: (cb) => { const listener = (_event, url) => cb(url); ipcRenderer.on('openswarm:oauth-claim', listener); diff --git a/frontend/src/shared/hooks/useDeepLink.ts b/frontend/src/shared/hooks/useDeepLink.ts index 66814d76..50bd0070 100644 --- a/frontend/src/shared/hooks/useDeepLink.ts +++ b/frontend/src/shared/hooks/useDeepLink.ts @@ -1,11 +1,77 @@ import { useEffect } from 'react'; import { useAppDispatch } from '@/shared/hooks'; -import { activateSubscription, activateSignin } from '@/shared/state/settingsSlice'; +import { activateSubscription, activateSignin, fetchSettings } from '@/shared/state/settingsSlice'; import { fetchModels } from '@/shared/state/modelsSlice'; import { fetchTools } from '@/shared/state/toolsSlice'; import { API_BASE } from '@/shared/config'; import { report } from '@/shared/serviceClient'; +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +// Sign-in must survive a backend that is booting or mid-restart (the exact state Massimo hit +// during the update ping-pong): one failed activate POST used to lose the token forever. We retry +// with backoff AND verify the bearer actually persisted, because a backend can accept the POST then +// die before flushing settings ("landed but didn't stick"). Both are ENG-240 failure modes. +async function durableSignin( + token: string, + email: string | null, + dispatch: ReturnType, +): Promise { + const DEADLINE_MS = 30000; + const backoff = [400, 800, 1500, 3000, 3000, 4000]; + const start = Date.now(); + let attempt = 0; + let lastError = ''; + while (Date.now() - start < DEADLINE_MS) { + try { + const res = await dispatch(activateSignin({ token, signin_method: 'google', email })).unwrap(); + // Verify on user_id, the exact signal the mandatory-sign-in gate reads (shouldRequireSignIn), + // not the bearer: the bearer is a secret that may be redacted from GET /settings, and user_id + // is what actually flips the wall down. Confirms the sign-in truly landed, not just that the POST returned. + const settings = await dispatch(fetchSettings()).unwrap(); + if (settings?.user_id) { + report('signin', 'activated', { method: res.signin_method, plan: res.plan, attempt: attempt + 1 }); + dispatch(fetchModels()); + return; + } + lastError = 'accepted but identity not persisted'; + } catch (err) { + lastError = String((err as { message?: unknown })?.message ?? err).slice(0, 120); + } + await sleep(backoff[Math.min(attempt, backoff.length - 1)]); + attempt++; + } + console.error('[deep-link] Sign-in did not persist after retries:', lastError); + report('signin', 'activation_failed', { message: lastError, attempts: attempt }); +} + +async function claimOauth(rawUrl: string, dispatch: ReturnType): Promise { + const url = new URL(rawUrl); + if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) { + console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl); + return; + } + const sessionId = url.searchParams.get('session_id'); + const toolId = url.searchParams.get('tool_id'); + if (!sessionId || !toolId) { + console.warn('[deep-link] Missing session_id or tool_id in', rawUrl); + return; + } + report('oauth', 'deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' }); + const resp = await fetch(`${API_BASE}/tools/oauth/claim`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ session_id: sessionId, tool_id: toolId }), + }); + if (!resp.ok) { + console.error('[deep-link] OAuth claim failed:', resp.status, await resp.text()); + report('oauth', 'claim_failed', { status: resp.status }); + return; + } + report('oauth', 'claim_succeeded'); + dispatch(fetchTools()); +} + /** Subscribe to openswarm:// auth/oauth deep-links from Electron main; no-op in browser. */ export function useDeepLink(): void { const dispatch = useAppDispatch(); @@ -14,10 +80,17 @@ export function useDeepLink(): void { const api = (window as any).openswarm as OpenSwarmAPI | undefined; if (!api) return; - const unsubscribe = api.onAuthUrl?.((rawUrl: string) => { + // Same token can arrive twice (user clicked twice, or a nudge races a mount-drain); the retry + // loop is idempotent server-side, but skip re-running it so we don't stack overlapping retries. + const handled = new Set(); + + const processDeepLink = (rawUrl: string): void => { try { - // openswarm://auth?token=...; signin=true => free sign-in, else Stripe activation. const url = new URL(rawUrl); + if (url.host === 'oauth') { + void claimOauth(rawUrl, dispatch); + return; + } if (url.host !== 'auth' && url.pathname !== '//auth' && url.pathname !== '/auth') { console.warn('[deep-link] Unknown openswarm:// host:', url.host); return; @@ -27,102 +100,60 @@ export function useDeepLink(): void { console.warn('[deep-link] Missing token in', rawUrl); return; } - const isSignin = url.searchParams.get('signin') === 'true'; - const signinMethodRaw = url.searchParams.get('signin_method'); - const email = url.searchParams.get('email'); - const plan = url.searchParams.get('plan'); - const expires = url.searchParams.get('expires'); + if (handled.has(token)) return; + handled.add(token); - if (isSignin) { - // 1.0.29 only ships Google sign-in; read for forward compat. - void signinMethodRaw; + if (url.searchParams.get('signin') === 'true') { report('signin', 'deep_link_received', { method: 'google' }); - - dispatch(activateSignin({ token, signin_method: 'google', email })) - .unwrap() - .then((res) => { - report('signin', 'activated', { method: res.signin_method, plan: res.plan }); - dispatch(fetchModels()); - }) - .catch((err) => { - console.error('[deep-link] Sign-in activation failed:', err); - // unwrap() rejects with a SerializedError object; String() of it is "[object Object]". - report('signin', 'activation_failed', { - message: String(err?.message ?? err).slice(0, 120), - }); - }); + void durableSignin(token, url.searchParams.get('email'), dispatch); return; } - - report('subscription', 'deep_link_received', { - plan: plan ?? 'unknown', - }); - + report('subscription', 'deep_link_received', { plan: url.searchParams.get('plan') ?? 'unknown' }); dispatch( activateSubscription({ token, - plan, - expires, + plan: url.searchParams.get('plan'), + expires: url.searchParams.get('expires'), }), ) .unwrap() .then((res) => { report('subscription', 'activated', { plan: res.plan }); - // Refresh models so Pro-proxy Claude models appear in the picker immediately. dispatch(fetchModels()); }) .catch((err) => { console.error('[deep-link] Activation failed:', err); - report('subscription', 'activation_failed', { - message: String(err?.message ?? err).slice(0, 120), - }); + report('subscription', 'activation_failed', { message: String(err?.message ?? err).slice(0, 120) }); }); } catch (e) { console.error('[deep-link] Failed to parse URL', rawUrl, e); } - }); + }; - let unsubscribeOauth: (() => void) | undefined; - if (api?.onOauthClaim) { - unsubscribeOauth = api.onOauthClaim(async (rawUrl: string) => { - try { - // openswarm://oauth/{provider}/complete?session_id=...&tool_id=... - const url = new URL(rawUrl); - if (url.host !== 'oauth' || !url.pathname.endsWith('/complete')) { - console.warn('[deep-link] Unexpected oauth-claim URL:', rawUrl); - return; - } - const sessionId = url.searchParams.get('session_id'); - const toolId = url.searchParams.get('tool_id'); - if (!sessionId || !toolId) { - console.warn('[deep-link] Missing session_id or tool_id in', rawUrl); - return; - } + // Drain the queue main holds. Called on mount (catches links delivered before this + // subscription existed) and on every nudge (live links). Draining clears the queue in main, + // so each link is consumed exactly once regardless of timing. + const drain = async (): Promise => { + if (!api.drainDeeplinks) return; + try { + const links = await api.drainDeeplinks(); + for (const link of links) processDeepLink(link.url); + } catch (e) { + console.warn('[deep-link] drain failed', e); + } + }; - report('oauth', 'deep_link_received', { provider: url.pathname.split('/')[1] || 'unknown' }); + const unsubscribe = api.onDeeplinkAvailable?.(() => { void drain(); }); + void drain(); - const resp = await fetch(`${API_BASE}/tools/oauth/claim`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ session_id: sessionId, tool_id: toolId }), - }); - if (!resp.ok) { - const text = await resp.text(); - console.error('[deep-link] OAuth claim failed:', resp.status, text); - report('oauth', 'claim_failed', { status: resp.status }); - return; - } - report('oauth', 'claim_succeeded'); - dispatch(fetchTools()); - } catch (e) { - console.error('[deep-link] OAuth claim threw:', e); - } - }); - } + // Legacy push channels (old main builds): still honored so a mixed-version install works. + const unsubAuth = api.onAuthUrl?.((url) => processDeepLink(url)); + const unsubOauth = api.onOauthClaim?.((url) => processDeepLink(url)); return () => { unsubscribe?.(); - unsubscribeOauth?.(); + unsubAuth?.(); + unsubOauth?.(); }; }, [dispatch]); } diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 105ad518..9fcbee47 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -97,6 +97,8 @@ declare global { onVoiceToggle?: (cb: () => void) => () => void; voiceHoldCapable?: () => Promise; voiceRequestHoldPermission?: () => Promise; + onDeeplinkAvailable?: (cb: () => void) => () => void; + drainDeeplinks?: () => Promise>; onAuthUrl?: (cb: (url: string) => void) => () => void; onOauthClaim?: (cb: (url: string) => void) => () => void; notify?: (payload: OpenSwarmNotifyRequest) => Promise;