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:
7
.changeset/desktop-oauth-open-external.md
Normal file
7
.changeset/desktop-oauth-open-external.md
Normal 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."
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
51
packages/dashboard/app/utils/__tests__/open-external.test.ts
Normal file
51
packages/dashboard/app/utils/__tests__/open-external.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
32
packages/dashboard/app/utils/open-external.ts
Normal file
32
packages/dashboard/app/utils/open-external.ts
Normal 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");
|
||||
}
|
||||
@@ -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?.());
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user