diff --git a/app/src/components/DockerInstallDialog.tsx b/app/src/components/DockerInstallDialog.tsx index cb3d712..cffdea7 100644 --- a/app/src/components/DockerInstallDialog.tsx +++ b/app/src/components/DockerInstallDialog.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; import { useInstallHelper } from "../hooks/useInstallHelper"; +import { openUrlExternal } from "../lib/tauri-commands"; import { useDocker } from "../hooks/useDocker"; import Modal from "./ui/Modal"; import Button from "./ui/Button"; @@ -41,7 +41,7 @@ export default function DockerInstallDialog({ onClose }: Props) { const handleOpenDocs = async () => { if (!options) return; try { - await openUrl(options.docs_url); + await openUrlExternal(options.docs_url); } catch (e) { console.error("Failed to open docs URL:", e); } diff --git a/app/src/components/settings/ClaudeAuthModal.test.tsx b/app/src/components/settings/ClaudeAuthModal.test.tsx index 937cb1f..11c09ab 100644 --- a/app/src/components/settings/ClaudeAuthModal.test.tsx +++ b/app/src/components/settings/ClaudeAuthModal.test.tsx @@ -11,14 +11,12 @@ vi.mock("../../lib/tauri-commands", () => ({ hasClaudeToken: vi.fn(), clearClaudeToken: vi.fn(), cancelClaudeToken: (...args: unknown[]) => cancelClaudeToken(...args), + openUrlExternal: (...args: unknown[]) => openUrlExternal(...args), })); const cancelClaudeToken = vi.fn(() => Promise.resolve()); -const openUrl = vi.fn(); -vi.mock("@tauri-apps/plugin-opener", () => ({ - openUrl: (...args: unknown[]) => openUrl(...args), -})); +const openUrlExternal = vi.fn(); /** Captured event handlers, keyed by event name, so tests can emit. */ const handlers = new Map void>(); @@ -174,7 +172,7 @@ describe("ClaudeAuthModal", () => { const link = await screen.findByRole("link", { name: url }); fireEvent.click(link); - await waitFor(() => expect(openUrl).toHaveBeenCalledWith(url)); + await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(url)); }); it("ignores output belonging to a different project", async () => { @@ -259,8 +257,8 @@ describe("ClaudeAuthModal", () => { const link = await screen.findByRole("link", { name: FULL_URL }); fireEvent.click(link); - await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL)); - expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL); + await waitFor(() => expect(openUrlExternal).toHaveBeenCalledWith(FULL_URL)); + expect(openUrlExternal).not.toHaveBeenCalledWith(TRUNCATED_URL); }); it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => { @@ -270,7 +268,7 @@ describe("ClaudeAuthModal", () => { emitLink("https://evil.tld/cai/oauth/authorize?code=true"); expect(screen.queryByRole("link")).not.toBeInTheDocument(); - expect(openUrl).not.toHaveBeenCalled(); + expect(openUrlExternal).not.toHaveBeenCalled(); }); it("ignores a hyperlink belonging to a different project", async () => { diff --git a/app/src/components/settings/ClaudeAuthModal.tsx b/app/src/components/settings/ClaudeAuthModal.tsx index c58296b..ca5d081 100644 --- a/app/src/components/settings/ClaudeAuthModal.tsx +++ b/app/src/components/settings/ClaudeAuthModal.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { openUrl } from "@tauri-apps/plugin-opener"; -import { cancelClaudeToken } from "../../lib/tauri-commands"; +import { cancelClaudeToken, openUrlExternal } from "../../lib/tauri-commands"; import Modal from "../ui/Modal"; import Button from "../ui/Button"; import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator"; @@ -118,7 +117,7 @@ export default function ClaudeAuthModal({ return; } try { - await openUrl(target); + await openUrlExternal(target); } catch (e) { setLinkError( authErrorMessage( diff --git a/app/src/components/settings/UpdateDialog.tsx b/app/src/components/settings/UpdateDialog.tsx index 7e905c8..b798ee4 100644 --- a/app/src/components/settings/UpdateDialog.tsx +++ b/app/src/components/settings/UpdateDialog.tsx @@ -1,5 +1,5 @@ -import { openUrl } from "@tauri-apps/plugin-opener"; import type { UpdateInfo } from "../../lib/types"; +import { openUrlExternal } from "../../lib/tauri-commands"; import Modal from "../ui/Modal"; import Button from "../ui/Button"; import { formatBytes } from "../../lib/formatBytes"; @@ -19,7 +19,7 @@ export default function UpdateDialog({ }: Props) { const handleDownload = async (url: string) => { try { - await openUrl(url); + await openUrlExternal(url); } catch (e) { console.error("Failed to open URL:", e); } diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 093d471..56aa70d 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, fireEvent, cleanup, act } from "@testing-library/react"; import TerminalView, { supersedes } from "./TerminalView"; import { useAppState } from "../../store/appState"; -import { uploadHostFileToTerminal } from "../../lib/tauri-commands"; -import { openUrl } from "@tauri-apps/plugin-opener"; +import { + uploadHostFileToTerminal, + openUrlExternal, +} from "../../lib/tauri-commands"; import { chooseSignInTarget, resetBrowserSupportCache, @@ -65,6 +67,7 @@ vi.mock("../../lib/tauri-commands", () => ({ uploadHostFileToTerminal: vi.fn(async () => ""), getAuthBridgeStatus: vi.fn(async () => containerEnv.bridge), checkBrowserViewSupport: vi.fn(async () => containerEnv.detection), + openUrlExternal: vi.fn(async () => {}), })); vi.mock("@tauri-apps/api/event", () => ({ @@ -74,10 +77,6 @@ vi.mock("@tauri-apps/api/event", () => ({ }, })); -vi.mock("@tauri-apps/plugin-opener", () => ({ - openUrl: vi.fn(async () => {}), -})); - vi.mock("@tauri-apps/api/webview", () => ({ getCurrentWebview: () => ({ onDragDropEvent: async (cb: (event: unknown) => unknown) => { @@ -148,8 +147,8 @@ beforeEach(() => { vi.mocked(uploadHostFileToTerminal).mockResolvedValue("/workspace/api/dropped.txt"); dragDrop.handler = null; ptyOutput.listeners.clear(); - vi.mocked(openUrl).mockReset(); - vi.mocked(openUrl).mockResolvedValue(undefined); + vi.mocked(openUrlExternal).mockReset(); + vi.mocked(openUrlExternal).mockResolvedValue(undefined); containerEnv.bridge = { enabled: false, active_ports: [], conflicts: [] }; containerEnv.detection = null; // The Playwright probe is memoized across mounts (it is a container exec), so @@ -760,7 +759,7 @@ describe("TerminalView — a host open that fails says so", () => { } it("pushes a toast instead of a console line nobody reads", async () => { - vi.mocked(openUrl).mockRejectedValueOnce(new Error("no opener")); + vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener")); await mountWithPrompt(); await act(async () => { fireEvent.click(openButton()); @@ -775,7 +774,7 @@ describe("TerminalView — a host open that fails says so", () => { it("keeps the prompt on screen, so the other route is still one click away", async () => { // Dismissing first is what this replaced: the toast vanished, nothing // opened, and the URL only existed in the container's transcript. - vi.mocked(openUrl).mockRejectedValueOnce(new Error("no opener")); + vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener")); await mountWithPrompt(); await act(async () => { fireEvent.click(openButton()); @@ -790,7 +789,7 @@ describe("TerminalView — a host open that fails says so", () => { fireEvent.click(openButton()); await Promise.resolve(); }); - expect(openUrl).toHaveBeenCalledWith(URL); + expect(openUrlExternal).toHaveBeenCalledWith(URL); expect(document.querySelector(URL_TOAST_SELECTOR)).toBeNull(); }); }); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 7f5cba2..0ff1f4a 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -3,7 +3,6 @@ import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import { WebglAddon } from "@xterm/addon-webgl"; import { WebLinksAddon } from "@xterm/addon-web-links"; -import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; import { useAppState } from "../../store/appState"; @@ -11,6 +10,7 @@ import { CLAUDE_SOFT_NEWLINE } from "../../lib/claudeInput"; import { awsSsoRefresh, openPageInContainerBrowser, + openUrlExternal, uploadHostFileToTerminal, } from "../../lib/tauri-commands"; import { getCurrentWebview } from "@tauri-apps/api/webview"; @@ -413,7 +413,7 @@ export default function TerminalView({ sessionId, active }: Props) { // Same failure reporting as the toast's Open button — see the long note // on `handleOpenUrl`, including what this catch does *not* catch on // Linux. A click that appears to do nothing is the complaint either way. - openUrl(safe).catch((e) => + openUrlExternal(safe).catch((e) => useAppState.getState().pushToast({ kind: "error", message: "Could not open that link in your browser", @@ -822,14 +822,14 @@ export default function TerminalView({ sessionId, active }: Props) { if (!urlPrompt) return; // Validated again at the sink. `promptUrl` is the only writer and already // sanitizes, so this can only fail if that invariant is broken — which is - // precisely when it matters that the last thing before `openUrl` checks. + // precisely when it matters that the last thing before the opener checks. const safe = sanitizeRelayUrl(urlPrompt.url); if (!safe) { console.warn("Refusing to open a URL that failed validation"); dismissUrlPrompt(); return; } - openUrl(safe) + openUrlExternal(safe) .then(() => dismissUrlPrompt()) .catch((e) => useAppState.getState().pushToast({ diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index d6934f8..a3ea226 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -398,3 +398,18 @@ export const rollbackMigration = (projectId: string) => * app crash shows up here as phase "interrupted". */ export const getMigrationState = (projectId: string) => invoke("get_migration_state", { projectId }); + +/** Open a URL in the user's own browser. + * + * Replaces `openUrl` from `@tauri-apps/plugin-opener` at every call site. On + * Linux the app ships as an AppImage whose environment leaks into everything + * it spawns, which kills a *cold-launched* browser before it paints while + * `xdg-open` still exits 0 — so the plugin path reported success and did + * nothing (triple-c#34). The Rust side hands the child a repaired environment + * and re-validates the URL, which matters because these URLs originate in an + * untrusted container. macOS and Windows still reach the plugin, just from + * Rust, so there is no platform branch here. + * + * Rejects with a string already phrased for a toast. */ +export const openUrlExternal = (url: string) => + invoke("open_url_external", { url }); diff --git a/app/src/lib/urlDetector.ts b/app/src/lib/urlDetector.ts index 6bab70c..b2fbb22 100644 --- a/app/src/lib/urlDetector.ts +++ b/app/src/lib/urlDetector.ts @@ -109,7 +109,8 @@ export type UrlCallback = (url: string, source: UrlSource) => void; * A direct port of `usable_sign_in_link` in * `commands/auth_token_commands.rs`, and deliberately just as shallow: this is * a junk filter, not the security decision. `sanitizeRelayUrl` is still the - * only thing standing between any of this and `openUrl`, and duplicating its + * only thing standing between any of this and `openUrlExternal`, and + * duplicating its * rules here would be a second place for them to go stale. * * The one rule from the Rust that is not ported is its `sk-ant-` check: that @@ -293,7 +294,7 @@ export class UrlDetector { // include the *whole* C0 range and DEL, not just BEL: an escape or a NUL // swallowed into the middle of a match becomes a URL that renders as one // thing in the toast and resolves as another. Everything emitted here is - // still re-validated by `sanitizeRelayUrl` before it can reach `openUrl`; + // still re-validated by `sanitizeRelayUrl` before it can reach the opener; // stopping the match early only means the legitimate prefix survives // instead of the whole candidate being thrown away. // eslint-disable-next-line no-control-regex diff --git a/app/src/lib/urlRelay.ts b/app/src/lib/urlRelay.ts index 947adfd..32d238f 100644 --- a/app/src/lib/urlRelay.ts +++ b/app/src/lib/urlRelay.ts @@ -1,6 +1,7 @@ /** * URL relay — host side of `container/triple-c-open` — and the single URL - * validator every `openUrl` call site in the app is required to go through. + * validator every `openUrlExternal` call site in the app is required to go + * through. * * A CLI inside the container has no browser. When it wants to open a URL * (`gh auth login`, `aws sso login`, `gcloud auth login`, anything honouring