diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 19a9fa303265..e067c3302a78 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -306,6 +306,8 @@ const ExtensionsRoute = () => { export function AppInner() { const [fatalError, setFatalError] = useState(null); + const nostrImportInFlight = useRef(null); + const navigate = useNavigate(); const setView = useNavigation(); @@ -397,15 +399,22 @@ export function AppInner() { const handleOpenSessionShare = async (_event: IpcRendererEvent, ...args: unknown[]) => { const link = args[0] as string; window.electron.logInfo('Opening session share link'); - try { - if (link.startsWith('goose://sessions/nostr')) { - await importNostrSessionFromDeepLink(link); - navigate('/sessions'); - return; - } + if (!link.startsWith('goose://sessions/nostr')) { toast.error('Unsupported session share link'); navigate('/sessions'); + return; + } + + if (nostrImportInFlight.current === link) { + window.electron.logInfo('Skipping duplicate Nostr deep link import'); + return; + } + nostrImportInFlight.current = link; + + try { + await importNostrSessionFromDeepLink(link); + navigate('/sessions'); } catch (error) { console.error('Unexpected error opening Nostr session share:', error); trackErrorWithContext(error, { @@ -415,6 +424,10 @@ export function AppInner() { }); toast.error(`Failed to import Nostr session: ${errorMessage(error, 'Unknown error')}`); navigate('/sessions'); + } finally { + if (nostrImportInFlight.current === link) { + nostrImportInFlight.current = null; + } } }; window.electron.on('open-shared-session', handleOpenSessionShare); diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 6f166939ea6a..854b89b1f696 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -487,7 +487,66 @@ if (process.platform !== 'darwin') { } } -const pendingDeepLinks = new Map(); // windowId -> deep link URL +const pendingDeepLinks = new Map(); + +function queuePendingDeepLink(windowId: number, url: string): void { + if (pendingDeepLinks.get(windowId) === url) { + return; + } + pendingDeepLinks.set(windowId, url); +} + +const reactReadyWindows = new Set(); + +const DEEPLINK_BURST_DEDUP_MS = 2000; +const recentSessionDeepLinkSends = new Map(); + +function pruneExpiredSessionDeepLinkSends(now: number): void { + for (const [url, sentAt] of recentSessionDeepLinkSends) { + if (now - sentAt >= DEEPLINK_BURST_DEDUP_MS) { + recentSessionDeepLinkSends.delete(url); + } + } +} + +function isBurstDuplicateSessionDeepLink(url: string): boolean { + const now = Date.now(); + pruneExpiredSessionDeepLinkSends(now); + const sentAt = recentSessionDeepLinkSends.get(url); + return sentAt !== undefined && now - sentAt < DEEPLINK_BURST_DEDUP_MS; +} + +function recordSessionDeepLinkSend(url: string): void { + const now = Date.now(); + recentSessionDeepLinkSends.set(url, now); + pruneExpiredSessionDeepLinkSends(now); +} + +function sendOpenSharedSession(window: BrowserWindow, url: string): void { + if (isBurstDuplicateSessionDeepLink(url)) { + log.info('[Main] Ignoring burst duplicate session deep link'); + return; + } + recordSessionDeepLinkSend(url); + window.webContents.send('open-shared-session', url); +} + +function deliverExtensionOrSessionDeepLink( + url: string, + parsedUrl: URL, + targetWindow: BrowserWindow +): void { + if (!reactReadyWindows.has(targetWindow.id) || targetWindow.webContents.isLoadingMainFrame()) { + queuePendingDeepLink(targetWindow.id, url); + return; + } + + if (parsedUrl.hostname === 'extension') { + targetWindow.webContents.send('add-extension', url); + } else if (parsedUrl.hostname === 'sessions') { + sendOpenSharedSession(targetWindow, url); + } +} function getResumeSessionId(parsedUrl: URL): string | null { try { @@ -547,7 +606,7 @@ async function handleProtocolUrl(url: string, parsedUrl: URL) { } if (targetWindow.webContents.isLoadingMainFrame()) { - pendingDeepLinks.set(targetWindow.id, url); + queuePendingDeepLink(targetWindow.id, url); } else { await processProtocolUrl(url, parsedUrl, targetWindow); } @@ -561,7 +620,7 @@ async function processProtocolUrl(url: string, parsedUrl: URL, window: BrowserWi if (parsedUrl.hostname === 'extension') { window.webContents.send('add-extension', url); } else if (parsedUrl.hostname === 'sessions') { - window.webContents.send('open-shared-session', url); + sendOpenSharedSession(window, url); } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { const deeplinkData = parseRecipeDeeplink(url); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); @@ -636,15 +695,13 @@ app.on('open-url', async (_event, url) => { const targetWindow = existingWindows[0]; if (targetWindow.isMinimized()) targetWindow.restore(); targetWindow.focus(); - if (parsedUrl.hostname === 'extension') { - targetWindow.webContents.send('add-extension', url); - } else if (parsedUrl.hostname === 'sessions') { - targetWindow.webContents.send('open-shared-session', url); + if (parsedUrl.hostname === 'extension' || parsedUrl.hostname === 'sessions') { + deliverExtensionOrSessionDeepLink(url, parsedUrl, targetWindow); } } else { openUrlHandledLaunch = true; const newWindow = await createChat(app, { dir: openDir || undefined }); - pendingDeepLinks.set(newWindow.id, url); + queuePendingDeepLink(newWindow.id, url); } } }); @@ -1275,6 +1332,7 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { pendingInitialMessages.delete(windowId); pendingDeepLinks.delete(windowId); + reactReadyWindows.delete(windowId); if (windowPowerSaveBlockers.has(windowId)) { const blockerId = windowPowerSaveBlockers.get(windowId)!; @@ -1350,6 +1408,7 @@ const createLauncher = () => { activeLauncherWindow = launcherWindow; launcherWindow.on('closed', () => { + reactReadyWindows.delete(launcherWindow.id); activeLauncherWindow = null; }); @@ -1634,6 +1693,10 @@ ipcMain.on('react-ready', (event) => { const window = BrowserWindow.fromWebContents(event.sender); const windowId = window?.id; + if (windowId !== undefined) { + reactReadyWindows.add(windowId); + } + // Send any pending initial message for this window if (windowId && pendingInitialMessages.has(windowId)) { const initialMessage = pendingInitialMessages.get(windowId)!; @@ -1653,7 +1716,7 @@ ipcMain.on('react-ready', (event) => { if (parsedUrl.hostname === 'extension') { window.webContents.send('add-extension', deepLinkUrl); } else if (parsedUrl.hostname === 'sessions') { - window.webContents.send('open-shared-session', deepLinkUrl); + sendOpenSharedSession(window, deepLinkUrl); } } catch (error) { log.error('Error processing pending deep link:', error);