diff --git a/.changeset/desktop-oauth-open-external.md b/.changeset/desktop-oauth-open-external.md new file mode 100644 index 0000000000..c5a1b10d9c --- /dev/null +++ b/.changeset/desktop-oauth-open-external.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: OAuth sign-ins (OpenAI Codex and others) now reliably open the system browser from the desktop app. +category: fix +dev: "window.open after the /auth/login await can outlive Chromium's transient user activation (~5s) and get silently popup-blocked on desktop — Codex's slower flow (method select + localhost callback server) hit this while Anthropic's usually didn't. New shell:openExternal IPC (http/https-validated) + preload openExternal + dashboard openExternalUrl helper used by all auth-URL opens, with window.open fallback on web." diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 9a60e92caf..d7ed7e06bd 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -35,6 +35,7 @@ import { OnboardingDisclosure } from "./OnboardingDisclosure"; import { CustomProviderForm } from "./CustomProviderForm"; import { PluginSlot } from "./PluginSlot"; import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth"; +import { openExternalUrl } from "../utils/open-external"; import { copyTextToClipboard } from "../utils/copyToClipboard"; import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility"; import { useShellConnection } from "../hooks/useShellConnection"; @@ -1441,7 +1442,7 @@ export function ModelOnboardingModal({ setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode })); } if (providerId !== "github-copilot" || !deviceCode) { - window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank"); + openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url)); } // Poll for auth completion @@ -2395,7 +2396,7 @@ export function ModelOnboardingModal({ > {t("setup.copyCode", "Copy code")} - diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index fc3fd3cd2b..862c6ccc14 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -76,6 +76,7 @@ import { ProviderIcon } from "./ProviderIcon"; import { generateUniquePresetId } from "../utils/modelPresets"; import { copyTextToClipboard } from "../utils/copyToClipboard"; import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth"; +import { openExternalUrl } from "../utils/open-external"; import { useConfirm } from "../hooks/useConfirm"; import { useMobileKeyboard } from "../hooks/useMobileKeyboard"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; @@ -2535,7 +2536,7 @@ export function SettingsModal({ setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode })); } if (providerId !== "github-copilot" || !deviceCode) { - window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank"); + openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url)); } // Poll for auth completion every 2 seconds diff --git a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx index 0edc2fc5c3..12095ebc6f 100644 --- a/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx +++ b/packages/dashboard/app/components/settings/sections/AuthenticationSection.tsx @@ -16,6 +16,7 @@ import { CustomProvidersSection } from "../../CustomProvidersSection"; import { SettingsHelpTip } from "../SettingsHelpTip"; import { copyTextToClipboard } from "../../../utils/copyToClipboard"; import { appendTokenQuery } from "../../../auth"; +import { openExternalUrl } from "../../../utils/open-external"; import { refreshModelsCache } from "../../../hooks/useModelsCache"; export interface AuthenticationSectionData { projectId?: string; @@ -194,7 +195,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) { }}> {t("settings.auth.copyCode", "Copy code")} - diff --git a/packages/dashboard/app/utils/__tests__/open-external.test.ts b/packages/dashboard/app/utils/__tests__/open-external.test.ts new file mode 100644 index 0000000000..f7e1a614be --- /dev/null +++ b/packages/dashboard/app/utils/__tests__/open-external.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { openExternalUrl } from "../open-external"; + +/* +FNXC:DesktopOAuth 2026-07-18-04:00: +OAuth window.open after the /auth/login await can outlive Chromium's transient +user activation and get silently popup-blocked on desktop (observed with the +OpenAI Codex flow). The helper must prefer the activation-free desktop IPC +bridge and only fall back to window.open on the web (or when the bridge +declines the URL). +*/ +describe("openExternalUrl", () => { + const w = window as unknown as { fusionAPI?: { openExternal?: (url: string) => Promise } }; + + afterEach(() => { + delete w.fusionAPI; + vi.restoreAllMocks(); + }); + + it("prefers the desktop openExternal bridge over window.open", async () => { + const openExternal = vi.fn().mockResolvedValue(true); + w.fusionAPI = { openExternal }; + const windowOpen = vi.spyOn(window, "open").mockReturnValue(null); + + openExternalUrl("https://auth.openai.com/oauth/authorize?x=1"); + await Promise.resolve(); + + expect(openExternal).toHaveBeenCalledWith("https://auth.openai.com/oauth/authorize?x=1"); + expect(windowOpen).not.toHaveBeenCalled(); + }); + + it("falls back to window.open when the bridge declines the URL", async () => { + const openExternal = vi.fn().mockResolvedValue(false); + w.fusionAPI = { openExternal }; + const windowOpen = vi.spyOn(window, "open").mockReturnValue(null); + + openExternalUrl("https://example.com/auth"); + await Promise.resolve(); + await Promise.resolve(); + + expect(windowOpen).toHaveBeenCalledWith("https://example.com/auth", "_blank"); + }); + + it("uses window.open when no desktop bridge exists", () => { + const windowOpen = vi.spyOn(window, "open").mockReturnValue(null); + + openExternalUrl("https://example.com/auth"); + + expect(windowOpen).toHaveBeenCalledWith("https://example.com/auth", "_blank"); + }); +}); diff --git a/packages/dashboard/app/utils/open-external.ts b/packages/dashboard/app/utils/open-external.ts new file mode 100644 index 0000000000..af1c999d77 --- /dev/null +++ b/packages/dashboard/app/utils/open-external.ts @@ -0,0 +1,32 @@ +/* +FNXC:DesktopOAuth 2026-07-18-04:00: +OAuth login handlers call window.open AFTER awaiting POST /auth/login. When +the round trip outlives Chromium's transient user activation (~5s — observed +with the OpenAI Codex flow while the Anthropic flow, being faster, worked), +the popup is silently blocked in the desktop app and the system browser never +opens. On desktop, prefer the activation-free shell:openExternal IPC bridge; +in the web app fall back to window.open. +*/ + +interface DesktopShellApi { + openExternal?: (url: string) => Promise; +} + +function desktopShellApi(): DesktopShellApi | undefined { + const w = window as unknown as { fusionAPI?: DesktopShellApi; electronAPI?: DesktopShellApi }; + return w.fusionAPI ?? w.electronAPI; +} + +/** Open a URL in the user's browser: desktop IPC when available, window.open otherwise. */ +export function openExternalUrl(url: string): void { + const api = desktopShellApi(); + if (typeof api?.openExternal === "function") { + void api.openExternal(url).then((opened) => { + if (!opened) window.open(url, "_blank"); + }).catch(() => { + window.open(url, "_blank"); + }); + return; + } + window.open(url, "_blank"); +} diff --git a/packages/desktop/src/ipc.ts b/packages/desktop/src/ipc.ts index 64f823a2a6..cc2a370de6 100644 --- a/packages/desktop/src/ipc.ts +++ b/packages/desktop/src/ipc.ts @@ -1,4 +1,4 @@ -import { app, type BrowserWindow, ipcMain, type Tray } from "electron"; +import { app, type BrowserWindow, ipcMain, shell, type Tray } from "electron"; import { showExportSettingsDialog, showImportSettingsDialog, @@ -109,6 +109,28 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio }); ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => updateTrayStatus(tray, status)); + /* + FNXC:DesktopOAuth 2026-07-18-04:00: + OAuth flows call window.open AFTER awaiting POST /auth/login; when the round + trip outlives Chromium's transient user activation (~5s — observed with the + OpenAI Codex flow: method-select round-trip + localhost callback server + + auth-lock retries), the popup is silently blocked and setWindowOpenHandler + never fires, so the system browser never opens. This explicit IPC needs no + user activation. http/https only — reject everything else. + */ + ipcMain.handle("shell:openExternal", async (_event, url: unknown) => { + if (typeof url !== "string") return false; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false; + await shell.openExternal(url); + return true; + }); + ipcMain.handle("native:showExportDialog", () => showExportSettingsDialog(mainWindow)); ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow)); ipcMain.handle("app:getServerPort", () => options.getServerPort?.()); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index a2d7362560..54fa146220 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -64,6 +64,10 @@ const electronApi = { // Tray status updateTrayStatus: (status: string): Promise => ipcRenderer.invoke("tray:updateStatus", status), + // FNXC:DesktopOAuth 2026-07-18-04:00: + // Activation-free system-browser open for OAuth URLs (see shell:openExternal in ipc.ts). + openExternal: (url: string): Promise => ipcRenderer.invoke("shell:openExternal", url), + // Native dialogs showExportDialog: (): Promise => ipcRenderer.invoke("native:showExportDialog"), showImportDialog: (): Promise => ipcRenderer.invoke("native:showImportDialog"),