diff --git a/surfaces/gui/src-tauri/Cargo.lock b/surfaces/gui/src-tauri/Cargo.lock index e1d348643b..23c7533d02 100644 --- a/surfaces/gui/src-tauri/Cargo.lock +++ b/surfaces/gui/src-tauri/Cargo.lock @@ -1936,6 +1936,25 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2664,6 +2683,17 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "open" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" +dependencies = [ + "dunce", + "is-wsl", + "libc", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -2681,6 +2711,7 @@ dependencies = [ "tauri-build", "tauri-plugin-autostart", "tauri-plugin-dialog", + "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", "uuid", @@ -4075,6 +4106,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows 0.61.3", + "zbus", +] + [[package]] name = "tauri-plugin-single-instance" version = "2.4.2" diff --git a/surfaces/gui/src-tauri/Cargo.toml b/surfaces/gui/src-tauri/Cargo.toml index e8e721abc9..c367107174 100644 --- a/surfaces/gui/src-tauri/Cargo.toml +++ b/surfaces/gui/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-dialog = "2" +tauri-plugin-opener = "2" tauri-plugin-autostart = "2" tauri-plugin-single-instance = "2" tauri-plugin-updater = "2" diff --git a/surfaces/gui/src-tauri/capabilities/default.json b/surfaces/gui/src-tauri/capabilities/default.json index b2548af66d..042b33da47 100644 --- a/surfaces/gui/src-tauri/capabilities/default.json +++ b/surfaces/gui/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-set-focus", "core:window:allow-unminimize", "dialog:default", - "autostart:default" + "autostart:default", + "opener:default" ] } diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index d96c3c6f4c..5579a9f836 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -717,6 +717,7 @@ pub fn run() { show_main(app); })) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, diff --git a/surfaces/gui/src/components/Markdown.tsx b/surfaces/gui/src/components/Markdown.tsx index e27a6627a3..a0587dea71 100644 --- a/surfaces/gui/src/components/Markdown.tsx +++ b/surfaces/gui/src/components/Markdown.tsx @@ -1,6 +1,7 @@ import ReactMarkdown, { defaultUrlTransform } from "react-markdown"; import { useTranslation } from "react-i18next"; import remarkGfm from "remark-gfm"; +import { openExternal } from "../tauri"; import { Icon } from "./Icon"; // §34 (UX-016): the agent ends a deliverable turn with plain markdown — @@ -77,7 +78,18 @@ export function Markdown({ text }: { text: string }) { return ; } return ( - + { + if (href) { + e.preventDefault(); + openExternal(href); + } + }} + > {children} ); diff --git a/surfaces/gui/src/tauri-opener.test.ts b/surfaces/gui/src/tauri-opener.test.ts new file mode 100644 index 0000000000..c212c81a97 --- /dev/null +++ b/surfaces/gui/src/tauri-opener.test.ts @@ -0,0 +1,25 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { openExternal } from "./tauri"; + +afterEach(() => vi.unstubAllGlobals()); + +it("does not bypass a rejected opener scope", async () => { + const invoke = vi.fn().mockResolvedValue(null); + vi.stubGlobal("__TAURI__", { + opener: { openUrl: vi.fn().mockRejectedValue(new Error("ForbiddenUrl")) }, + core: { invoke }, + }); + openExternal("irc://example.invalid/channel"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(invoke).not.toHaveBeenCalled(); +}); + +it("uses the scoped plugin when the desktop opener JS API is absent", async () => { + const invoke = vi.fn().mockRejectedValue(new Error("ForbiddenUrl")); + vi.stubGlobal("__TAURI__", { core: { invoke } }); + openExternal("irc://example.invalid/channel"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(invoke.mock.calls).toEqual([ + ["plugin:opener|open_url", { url: "irc://example.invalid/channel" }], + ]); +}); diff --git a/surfaces/gui/src/tauri.ts b/surfaces/gui/src/tauri.ts index 95f3ccdba0..2035ff60b0 100644 --- a/surfaces/gui/src/tauri.ts +++ b/surfaces/gui/src/tauri.ts @@ -125,13 +125,15 @@ export const clearPendingUpdate = () => invokeStrict("clear_pending_update * Windows hands off to the installer). */ export const installUpdate = () => invokeStrict("install_update"); -/** Best-effort open a URL in the user's browser. Uses the Tauri opener plugin if present, else - * `window.open`. The caller should also render the raw URL so it stays copyable if both no-op - * (the desktop webview has no opener plugin wired yet). */ +/** Open browser links through the scoped desktop plugin or the browser. */ export function openExternal(url: string): void { - const opener = (globalThis as any).__TAURI__?.opener; - if (opener?.openUrl) { - opener.openUrl(url).catch(() => window.open(url, "_blank", "noopener,noreferrer")); + if (isTauri()) { + const opener = (globalThis as any).__TAURI__?.opener; + // Both routes enforce the plugin's URL scope. A denied URL must stay denied. + const opened = opener?.openUrl + ? opener.openUrl(url) + : invokeStrict("plugin:opener|open_url", { url }); + void opened.catch(() => {}); return; } window.open(url, "_blank", "noopener,noreferrer");