FN-7473: open desktop Anthropic OAuth in browser
Route desktop OAuth popups to the system browser while preserving Anthropic subscription login polling.\n\n- Add Electron window-open handling that keeps internal Fusion navigation in-app and external http(s) OAuth URLs in the system browser.\n- Cover Anthropic Subscription polling so login-in-progress states do not show a false incomplete-login toast.\n- Document desktop OAuth browser handoff behavior and add a patch changeset.\n\nFiles changed:\n .../fn-7473-desktop-anthropic-oauth-browser.md | 7 ++\n docs/dashboard-guide.md | 2 +-\n docs/settings-reference.md | 2 +-\n .../__tests__/SettingsModal.models-auth.test.tsx | 76 ++++++++++++++++++++++\n .../desktop/src/__tests__/main-integration.test.ts | 2 +\n .../desktop/src/__tests__/main-local-mode.test.ts | 3 +-\n .../desktop/src/__tests__/main.integration.test.ts | 2 +\n packages/desktop/src/__tests__/main.test.ts | 54 ++++++++++++++-\n packages/desktop/src/main.ts | 60 +++++++++++++++++-\n 9 files changed, 203 insertions(+), 5 deletions(-) Fusion-Task-Id: FN-7473 Fusion-Task-Lineage: 3d6b0acd-536c-4f7d-8c6b-35eb519b46bb Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7473-desktop-anthropic-oauth-browser.md
Normal file
7
.changeset/fn-7473-desktop-anthropic-oauth-browser.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Open desktop Anthropic Subscription OAuth logins in the system browser.
|
||||||
|
category: fix
|
||||||
|
dev: Adds Electron window-open policy coverage and preserves Settings auth polling completion paths.
|
||||||
@@ -638,7 +638,7 @@ The global OAuth re-login banner clears a provider row immediately after that pr
|
|||||||
|
|
||||||
For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request.
|
For Claude/Anthropic OAuth credentials, the same `/auth/status` poll also attempts an automatic refresh when the stored OAuth credential has a refresh token and the access token is expired or within the refresh buffer. Anthropic banner state is keyed to `anthropic-subscription` (including legacy Anthropic OAuth rows), not Claude CLI state. When that refresh succeeds, the banner clears for the subscription provider without manual re-login and without waiting for a separate model request.
|
||||||
|
|
||||||
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding.
|
If the OAuth credential has no refresh token, the refresh request fails, or the provider is not Anthropic, the provider stays expired and the banner remains visible. Re-authenticate with manual re-login from **Settings → Authentication** or Model Onboarding. On Fusion desktop, OAuth login URLs open in the operating system browser rather than an in-app Electron child window; the Settings/Onboarding UI keeps polling until the provider authenticates or the login truly stops.
|
||||||
|
|
||||||
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. When Anthropic Subscription is expired but Anthropic API Key or Anthropic — via Claude CLI is already authenticated, the global banner suppresses only the urgent subscription re-login entry so it does not imply agents are blocked; Settings still shows the subscription OAuth card as expired/not connected and re-login remains available. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
|
Anthropic also supports a raw `ANTHROPIC_API_KEY` from a separate **Anthropic API Key** card in **Settings → Authentication** and Model Onboarding. Claude subscription OAuth remains on the **Anthropic Subscription** card for auth status, usage/subscription checks, and banner clearing; it also drives direct agent execution on the `anthropic` provider — a subscription/OAuth token runs `anthropic/*` selections against `https://api.anthropic.com/v1` with Claude Code identity headers, no API key required. CLI-backed execution remains the distinct, explicit **Claude CLI** provider (`pi-claude-cli`); subscription OAuth does not require it. When Anthropic Subscription is expired but Anthropic API Key or Anthropic — via Claude CLI is already authenticated, the global banner suppresses only the urgent subscription re-login entry so it does not imply agents are blocked; Settings still shows the subscription OAuth card as expired/not connected and re-login remains available. A configured API key takes precedence over OAuth on the direct provider. Saving or clearing an API key does not affect the OAuth sign-in path or turn OAuth tokens into raw API-key material. The dashboard only displays masked key hints after a key is saved.
|
||||||
|
|
||||||
|
|||||||
@@ -755,7 +755,7 @@ Anthropic has three independent authentication/routing paths:
|
|||||||
- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows.
|
- **Claude CLI** (`pi-claude-cli`) is the CLI-backed execution provider. Use it when you want sessions to run through the local `claude` CLI; CLI availability does not prove the subscription OAuth status is valid. It is a separate, explicit choice — subscription OAuth does not require or reroute to it. When Claude CLI is enabled, model selectors show the registered `pi-claude-cli/*` rows (for example `pi-claude-cli/claude-sonnet-5`) in addition to the direct `anthropic/*` rows.
|
||||||
- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider.
|
- **Anthropic API Key** (`anthropic` direct `/v1`) is raw API-key auth. It accepts `ANTHROPIC_API_KEY`, a `models.json` `apiKey`, or an `api_key` auth credential, uses `x-api-key`, and takes precedence over subscription OAuth when both are configured for the direct `https://api.anthropic.com/v1` provider.
|
||||||
|
|
||||||
Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. `/api/auth/status` returns only masked key hints for the API-key card.
|
Anthropic can be connected with a raw API key from both Model Onboarding and **Settings → Authentication**. Anthropic API-key auth appears as a separate **Anthropic API Key** card, while Claude subscription OAuth appears as **Anthropic Subscription** with Login/Logout controls. On Fusion desktop, Anthropic Subscription OAuth login URLs are delegated to the operating system browser instead of an Electron child window so the existing polling/callback flow can complete. `/api/auth/status` returns only masked key hints for the API-key card.
|
||||||
|
|
||||||
### Authentication troubleshooting (mobile OAuth fallback)
|
### Authentication troubleshooting (mobile OAuth fallback)
|
||||||
|
|
||||||
|
|||||||
@@ -1133,6 +1133,82 @@ describe("SettingsModal", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps polling Anthropic Subscription OAuth until authenticated without false incomplete toast", async () => {
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchAuthStatus
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth", loginInProgress: true }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: true, type: "oauth", loginInProgress: false }],
|
||||||
|
});
|
||||||
|
mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" });
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={noop} addToast={addToast} />);
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await settingsModalUser.click(screen.getByRole("button", { name: "Authentication" }));
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement;
|
||||||
|
fireEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank");
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Login successful", "success");
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith("Login did not complete. Please try again.", "error");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows incomplete toast when Anthropic Subscription OAuth stops without authentication", async () => {
|
||||||
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
|
const addToast = vi.fn();
|
||||||
|
mockFetchAuthStatus
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth" }],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
providers: [{ id: "anthropic-subscription", name: "Anthropic Subscription", authenticated: false, type: "oauth", loginInProgress: false }],
|
||||||
|
});
|
||||||
|
mockLoginProvider.mockResolvedValueOnce({ url: "https://claude.ai/oauth/authorize" });
|
||||||
|
|
||||||
|
render(<SettingsModal onClose={noop} addToast={addToast} />);
|
||||||
|
await waitForSettingsModalReady();
|
||||||
|
await settingsModalUser.click(screen.getByRole("button", { name: "Authentication" }));
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic-subscription").closest(".auth-provider-card") as HTMLElement;
|
||||||
|
fireEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(openSpy).toHaveBeenCalledWith("https://claude.ai/oauth/authorize", "_blank");
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(addToast).toHaveBeenCalledWith("Login did not complete. Please try again.", "error");
|
||||||
|
expect(addToast).not.toHaveBeenCalledWith("Login successful", "success");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("renders Anthropic pasted-code form when login response includes manualCode", async () => {
|
it("renders Anthropic pasted-code form when login response includes manualCode", async () => {
|
||||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
show: vi.fn(),
|
show: vi.fn(),
|
||||||
focus: vi.fn(),
|
focus: vi.fn(),
|
||||||
maximize: vi.fn(),
|
maximize: vi.fn(),
|
||||||
|
webContents: { setWindowOpenHandler: vi.fn() },
|
||||||
isDestroyed: vi.fn(() => false),
|
isDestroyed: vi.fn(() => false),
|
||||||
getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })),
|
getBounds: vi.fn(() => ({ x: 50, y: 80, width: 1280, height: 900 })),
|
||||||
isMaximized: vi.fn(() => false),
|
isMaximized: vi.fn(() => false),
|
||||||
@@ -169,6 +170,7 @@ vi.mock("electron", () => ({
|
|||||||
Tray: mocks.Tray,
|
Tray: mocks.Tray,
|
||||||
nativeImage: mocks.nativeImage,
|
nativeImage: mocks.nativeImage,
|
||||||
screen: mocks.screen,
|
screen: mocks.screen,
|
||||||
|
shell: { openExternal: vi.fn(() => Promise.resolve()) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../menu.js", () => ({
|
vi.mock("../menu.js", () => ({
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
show: vi.fn(),
|
show: vi.fn(),
|
||||||
focus: vi.fn(),
|
focus: vi.fn(),
|
||||||
maximize: vi.fn(),
|
maximize: vi.fn(),
|
||||||
webContents: { send: vi.fn() },
|
webContents: { send: vi.fn(), setWindowOpenHandler: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
const BrowserWindow = vi.fn(function () {
|
const BrowserWindow = vi.fn(function () {
|
||||||
@@ -70,6 +70,7 @@ vi.mock("electron", () => ({
|
|||||||
Tray: mocks.Tray,
|
Tray: mocks.Tray,
|
||||||
nativeImage: { createEmpty: vi.fn(() => ({})) },
|
nativeImage: { createEmpty: vi.fn(() => ({})) },
|
||||||
screen: mocks.screen,
|
screen: mocks.screen,
|
||||||
|
shell: { openExternal: vi.fn(() => Promise.resolve()) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") }));
|
vi.mock("../renderer.js", () => ({ isUrlRenderer: vi.fn(() => true), getRendererUrl: vi.fn(() => "http://localhost"), getRendererFilePath: vi.fn(() => "index.html") }));
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => {
|
|||||||
show: vi.fn(),
|
show: vi.fn(),
|
||||||
focus: vi.fn(),
|
focus: vi.fn(),
|
||||||
maximize: vi.fn(),
|
maximize: vi.fn(),
|
||||||
|
webContents: { setWindowOpenHandler: vi.fn() },
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -59,6 +60,7 @@ vi.mock("electron", () => ({
|
|||||||
Tray: mocks.Tray,
|
Tray: mocks.Tray,
|
||||||
nativeImage: mocks.nativeImage,
|
nativeImage: mocks.nativeImage,
|
||||||
screen: mocks.screen,
|
screen: mocks.screen,
|
||||||
|
shell: { openExternal: vi.fn(() => Promise.resolve()) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../menu.js", () => ({
|
vi.mock("../menu.js", () => ({
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ const mocks = vi.hoisted(() => {
|
|||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
close: vi.fn(),
|
close: vi.fn(),
|
||||||
maximize: vi.fn(),
|
maximize: vi.fn(),
|
||||||
webContents: { reload: vi.fn() },
|
webContents: {
|
||||||
|
reload: vi.fn(),
|
||||||
|
setWindowOpenHandler: vi.fn((handler: (details: { url: string }) => unknown) => {
|
||||||
|
browserWindowHandlers.set("window-open", handler as (...args: unknown[]) => void);
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const BrowserWindow = vi.fn(function () {
|
const BrowserWindow = vi.fn(function () {
|
||||||
@@ -326,6 +331,53 @@ describe("main process", () => {
|
|||||||
expect(mocks.browserWindowInstance.loadURL).not.toHaveBeenCalled();
|
expect(mocks.browserWindowInstance.loadURL).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("macOS Anthropic Subscription OAuth denies Claude app-like popup and opens system browser", async () => {
|
||||||
|
const authUrl = "https://claude.ai/oauth/authorize?client_id=fusion&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fcallback";
|
||||||
|
const { createMainWindow } = await importMainModule();
|
||||||
|
|
||||||
|
createMainWindow();
|
||||||
|
const handler = mocks.browserWindowHandlers.get("window-open") as
|
||||||
|
| ((details: { url: string }) => { action: "allow" | "deny" })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(handler).toBeTypeOf("function");
|
||||||
|
const result = handler?.({ url: authUrl });
|
||||||
|
|
||||||
|
expect(result).toEqual({ action: "deny" });
|
||||||
|
expect(mocks.shell.openExternal).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.shell.openExternal).toHaveBeenCalledWith(authUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps same-origin Fusion renderer popups inside the desktop app", async () => {
|
||||||
|
rendererMocks.isUrlRenderer.mockReturnValue(true);
|
||||||
|
rendererMocks.getRendererUrl.mockReturnValue("http://localhost:5173");
|
||||||
|
rendererMocks.getRendererFilePath.mockReturnValue("");
|
||||||
|
const { createMainWindow } = await importMainModule();
|
||||||
|
|
||||||
|
createMainWindow();
|
||||||
|
const handler = mocks.browserWindowHandlers.get("window-open") as
|
||||||
|
| ((details: { url: string }) => { action: "allow" | "deny" })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const result = handler?.({ url: "http://localhost:5173/settings?section=auth" });
|
||||||
|
|
||||||
|
expect(result).toEqual({ action: "allow" });
|
||||||
|
expect(mocks.shell.openExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not externalize Fusion deep links or unsafe custom window-open schemes", async () => {
|
||||||
|
const { createMainWindow } = await importMainModule();
|
||||||
|
|
||||||
|
createMainWindow();
|
||||||
|
const handler = mocks.browserWindowHandlers.get("window-open") as
|
||||||
|
| ((details: { url: string }) => { action: "allow" | "deny" })
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
expect(handler?.({ url: "fusion://task/FN-7473" })).toEqual({ action: "deny" });
|
||||||
|
expect(handler?.({ url: "claude://oauth/callback?code=abc" })).toEqual({ action: "deny" });
|
||||||
|
expect(mocks.shell.openExternal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("exports initializeApp for lifecycle orchestration", async () => {
|
it("exports initializeApp for lifecycle orchestration", async () => {
|
||||||
const mainModule = await importMainModule();
|
const mainModule = await importMainModule();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { app, BrowserWindow, nativeImage, screen, Tray } from "electron";
|
import { app, BrowserWindow, nativeImage, screen, shell, Tray } from "electron";
|
||||||
import { join, resolve } from "node:path";
|
import { join, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@@ -99,6 +99,37 @@ export function getCurrentDesktopLaunchMode(): DesktopLaunchMode {
|
|||||||
return currentDesktopLaunchMode;
|
return currentDesktopLaunchMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
const normalized = hostname.toLowerCase();
|
||||||
|
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDesktopRendererOrigin(launchTargetUrl?: string): string | null {
|
||||||
|
const candidateUrl = launchTargetUrl ?? (isUrlRenderer() ? getRendererUrl() : `file://${getRendererFilePath()}`);
|
||||||
|
try {
|
||||||
|
return new URL(candidateUrl).origin;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInternalDesktopNavigation(url: URL, rendererOrigin: string | null): boolean {
|
||||||
|
if (url.protocol === "file:") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((url.protocol === "http:" || url.protocol === "https:") && rendererOrigin && url.origin === rendererOrigin) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local runtime/dev dashboard URLs represent Fusion app navigation, not external OAuth destinations.
|
||||||
|
if ((url.protocol === "http:" || url.protocol === "https:") && isLoopbackHost(url.hostname)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
async function resetLaunchModeAndReload(window: BrowserWindow): Promise<void> {
|
async function resetLaunchModeAndReload(window: BrowserWindow): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const settings = await readShellSettings();
|
const settings = await readShellSettings();
|
||||||
@@ -148,6 +179,33 @@ export function createMainWindow(state?: WindowState, launchTargetUrl?: string):
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rendererOrigin = getDesktopRendererOrigin(launchTargetUrl);
|
||||||
|
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||||
|
let parsedUrl: URL;
|
||||||
|
try {
|
||||||
|
parsedUrl = new URL(url);
|
||||||
|
} catch {
|
||||||
|
return { action: "deny" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInternalDesktopNavigation(parsedUrl, rendererOrigin)) {
|
||||||
|
return { action: "allow" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") {
|
||||||
|
/*
|
||||||
|
FNXC:DesktopOAuth 2026-07-03-15:55:
|
||||||
|
macOS Anthropic Subscription login must leave the Electron popup path and open in the user's system browser instead of a child BrowserWindow or Claude Code app handoff. Keep Fusion polling in the renderer while denying the popup so the OAuth callback can complete the existing login state.
|
||||||
|
*/
|
||||||
|
void shell.openExternal(url).catch((error: unknown) => {
|
||||||
|
console.error(`[desktop/main] Failed to open external URL: ${url}`, error);
|
||||||
|
});
|
||||||
|
return { action: "deny" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { action: "deny" };
|
||||||
|
});
|
||||||
|
|
||||||
if (launchTargetUrl) {
|
if (launchTargetUrl) {
|
||||||
void window.loadURL(launchTargetUrl);
|
void window.loadURL(launchTargetUrl);
|
||||||
} else if (isUrlRenderer()) {
|
} else if (isUrlRenderer()) {
|
||||||
|
|||||||
Reference in New Issue
Block a user