fix: route every host-browser open through open_url_external

The Rust command existed but nothing called it. All four frontend call
sites still used `openUrl` from `@tauri-apps/plugin-opener`, so the
environment fix was inert and the three dialogs carried the same Linux bug
as the terminal: DockerInstallDialog's docs link, ClaudeAuthModal's sign-in
link and UpdateDialog's release link would all have reported success while
launching nothing.

`openUrlExternal` in tauri-commands.ts is now the single sink. There is no
platform branch: Linux gets the sanitized spawn, macOS and Windows reach
the same plugin as before but from Rust, and every platform picks up the
Rust-side re-validation, which matters because these URLs originate in an
untrusted container.

Comments in urlRelay.ts and urlDetector.ts that named `openUrl` as the sink
they guard are updated to match, and the two test files that mocked
`@tauri-apps/plugin-opener` now mock the command instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 10:09:57 -07:00
co-authored by Claude Opus 5
parent 9297020688
commit 5a09254538
9 changed files with 46 additions and 33 deletions
@@ -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();
});
});