fix(desktop,dashboard): open OAuth URLs via activation-free IPC on desktop

window.open after awaiting /auth/login can outlive Chromium's transient
user activation and get silently popup-blocked in the desktop app — the
OpenAI Codex flow (method select + localhost callback server) crossed
that threshold while Anthropic's faster flow usually didn't, so Codex
login never opened the browser. Add a shell:openExternal IPC bridge
(http/https only), expose it in preload, and route every dashboard
auth-URL open through an openExternalUrl helper that falls back to
window.open on the web.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-18 00:03:48 -07:00
parent 26054de697
commit 7bf83bb458
8 changed files with 124 additions and 5 deletions

View File

@@ -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."

View File

@@ -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")}
</button>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
<button className="btn btn-sm" onClick={() => openExternalUrl(appendTokenQuery(deviceCodes[provider.id].verificationUri))}>
{t("setup.openGitHub", "Open GitHub")}
</button>
</div>

View File

@@ -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

View File

@@ -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")}
</button>
<button className="btn btn-sm" onClick={() => window.open(appendTokenQuery(deviceCodes[provider.id].verificationUri), "_blank")}>
<button className="btn btn-sm" onClick={() => openExternalUrl(appendTokenQuery(deviceCodes[provider.id].verificationUri))}>
{t("settings.auth.openGitHub", "Open GitHub")}
</button>
</div>

View File

@@ -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<boolean> } };
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");
});
});

View File

@@ -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<boolean>;
}
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");
}

View File

@@ -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?.());

View File

@@ -64,6 +64,10 @@ const electronApi = {
// Tray status
updateTrayStatus: (status: string): Promise<void> => 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<boolean> => ipcRenderer.invoke("shell:openExternal", url),
// Native dialogs
showExportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showExportDialog"),
showImportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showImportDialog"),