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 { CustomProviderForm } from "./CustomProviderForm";
|
||||||
import { PluginSlot } from "./PluginSlot";
|
import { PluginSlot } from "./PluginSlot";
|
||||||
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
|
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
|
||||||
|
import { openExternalUrl } from "../utils/open-external";
|
||||||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
import { filterVisibleOnboardingAndSettingsProviders } from "./providerVisibility";
|
||||||
import { useShellConnection } from "../hooks/useShellConnection";
|
import { useShellConnection } from "../hooks/useShellConnection";
|
||||||
@@ -1441,7 +1442,7 @@ export function ModelOnboardingModal({
|
|||||||
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
|
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
|
||||||
}
|
}
|
||||||
if (providerId !== "github-copilot" || !deviceCode) {
|
if (providerId !== "github-copilot" || !deviceCode) {
|
||||||
window.open(appendTokenQuery(deviceCode?.verificationUri ?? url), "_blank");
|
openExternalUrl(appendTokenQuery(deviceCode?.verificationUri ?? url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll for auth completion
|
// Poll for auth completion
|
||||||
@@ -2395,7 +2396,7 @@ export function ModelOnboardingModal({
|
|||||||
>
|
>
|
||||||
{t("setup.copyCode", "Copy code")}
|
{t("setup.copyCode", "Copy code")}
|
||||||
</button>
|
</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")}
|
{t("setup.openGitHub", "Open GitHub")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { ProviderIcon } from "./ProviderIcon";
|
|||||||
import { generateUniquePresetId } from "../utils/modelPresets";
|
import { generateUniquePresetId } from "../utils/modelPresets";
|
||||||
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
import { copyTextToClipboard } from "../utils/copyToClipboard";
|
||||||
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
|
import { appendTokenQuery, OAUTH_RELOGIN_SUCCESS_EVENT } from "../auth";
|
||||||
|
import { openExternalUrl } from "../utils/open-external";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||||
@@ -2535,7 +2536,7 @@ export function SettingsModal({
|
|||||||
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
|
setDeviceCodes((prev) => ({ ...prev, [providerId]: deviceCode }));
|
||||||
}
|
}
|
||||||
if (providerId !== "github-copilot" || !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
|
// Poll for auth completion every 2 seconds
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { CustomProvidersSection } from "../../CustomProvidersSection";
|
|||||||
import { SettingsHelpTip } from "../SettingsHelpTip";
|
import { SettingsHelpTip } from "../SettingsHelpTip";
|
||||||
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
|
import { copyTextToClipboard } from "../../../utils/copyToClipboard";
|
||||||
import { appendTokenQuery } from "../../../auth";
|
import { appendTokenQuery } from "../../../auth";
|
||||||
|
import { openExternalUrl } from "../../../utils/open-external";
|
||||||
import { refreshModelsCache } from "../../../hooks/useModelsCache";
|
import { refreshModelsCache } from "../../../hooks/useModelsCache";
|
||||||
export interface AuthenticationSectionData {
|
export interface AuthenticationSectionData {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -194,7 +195,7 @@ export function AuthenticationSection({ auth }: AuthenticationSectionProps) {
|
|||||||
}}>
|
}}>
|
||||||
{t("settings.auth.copyCode", "Copy code")}
|
{t("settings.auth.copyCode", "Copy code")}
|
||||||
</button>
|
</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")}
|
{t("settings.auth.openGitHub", "Open GitHub")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 {
|
import {
|
||||||
showExportSettingsDialog,
|
showExportSettingsDialog,
|
||||||
showImportSettingsDialog,
|
showImportSettingsDialog,
|
||||||
@@ -109,6 +109,28 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("tray:updateStatus", (_event, status: EngineStatus) => updateTrayStatus(tray, status));
|
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:showExportDialog", () => showExportSettingsDialog(mainWindow));
|
||||||
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
|
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
|
||||||
ipcMain.handle("app:getServerPort", () => options.getServerPort?.());
|
ipcMain.handle("app:getServerPort", () => options.getServerPort?.());
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ const electronApi = {
|
|||||||
// Tray status
|
// Tray status
|
||||||
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", 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
|
// Native dialogs
|
||||||
showExportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showExportDialog"),
|
showExportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showExportDialog"),
|
||||||
showImportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showImportDialog"),
|
showImportDialog: (): Promise<string | null> => ipcRenderer.invoke("native:showImportDialog"),
|
||||||
|
|||||||
Reference in New Issue
Block a user