From 51b6d1ae2e34b687cd05408bc6ab355478dadb80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=87a=C4=9Fda=C5=9F=20Y=C3=BCrekli?= <25122236+cagdasyurekli@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:41:56 +0200 Subject: [PATCH 1/2] fix(gui): open chat links in external system browser (#607) - Wire tauri-plugin-opener into desktop Cargo.toml, capabilities, and Tauri builder - Expose native open_url command as fallback in Tauri shell - Route Markdown component link clicks through openExternal - Fallback to native open_url and window.open in openExternal --- surfaces/gui/src-tauri/Cargo.lock | 53 +++++++++++++++++++ surfaces/gui/src-tauri/Cargo.toml | 1 + .../gui/src-tauri/capabilities/default.json | 3 +- surfaces/gui/src-tauri/src/lib.rs | 9 ++++ surfaces/gui/src/components/Markdown.tsx | 14 ++++- surfaces/gui/src/tauri.ts | 18 +++++-- 6 files changed, 92 insertions(+), 6 deletions(-) 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..bbacccd681 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -346,6 +346,13 @@ fn start_keep_awake() -> Option { // -- native commands (invoked from the SPA via window.__TAURI__.core.invoke) ----------------- +/// Opens a URL in the user's default system browser. +#[tauri::command] +fn open_url(app: tauri::AppHandle, url: String) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + app.opener().open_url(&url, None::<&str>).map_err(|e| e.to_string()) +} + /// Native macOS folder picker for the workspace gate. #[tauri::command] async fn pick_folder(app: tauri::AppHandle) -> Option { @@ -717,12 +724,14 @@ 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, None, )) .invoke_handler(tauri::generate_handler![ + open_url, pick_folder, get_autostart, set_autostart, 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.ts b/surfaces/gui/src/tauri.ts index 95f3ccdba0..8a185804b3 100644 --- a/surfaces/gui/src/tauri.ts +++ b/surfaces/gui/src/tauri.ts @@ -125,13 +125,23 @@ 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). */ +/** Best-effort open a URL in the user's browser. Uses the Tauri opener plugin if present, + * native open_url command, or `window.open`. The caller should also render the raw URL so it stays copyable + * if both no-op. */ 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")); + opener.openUrl(url).catch(() => { + invoke("open_url", { url }).catch(() => { + window.open(url, "_blank", "noopener,noreferrer"); + }); + }); + return; + } + if (isTauri()) { + invoke("open_url", { url }).catch(() => { + window.open(url, "_blank", "noopener,noreferrer"); + }); return; } window.open(url, "_blank", "noopener,noreferrer"); From c7153491b89f01ce841047a9b7e911bb5f4d2b0f Mon Sep 17 00:00:00 2001 From: cyurekli Date: Tue, 15 Sep 2026 21:21:42 +0200 Subject: [PATCH 2/2] fix: honor desktop opener permission rejections --- surfaces/gui/src-tauri/src/lib.rs | 8 -------- surfaces/gui/src/tauri-opener.test.ts | 25 +++++++++++++++++++++++++ surfaces/gui/src/tauri.ts | 22 +++++++--------------- 3 files changed, 32 insertions(+), 23 deletions(-) create mode 100644 surfaces/gui/src/tauri-opener.test.ts diff --git a/surfaces/gui/src-tauri/src/lib.rs b/surfaces/gui/src-tauri/src/lib.rs index bbacccd681..5579a9f836 100644 --- a/surfaces/gui/src-tauri/src/lib.rs +++ b/surfaces/gui/src-tauri/src/lib.rs @@ -346,13 +346,6 @@ fn start_keep_awake() -> Option { // -- native commands (invoked from the SPA via window.__TAURI__.core.invoke) ----------------- -/// Opens a URL in the user's default system browser. -#[tauri::command] -fn open_url(app: tauri::AppHandle, url: String) -> Result<(), String> { - use tauri_plugin_opener::OpenerExt; - app.opener().open_url(&url, None::<&str>).map_err(|e| e.to_string()) -} - /// Native macOS folder picker for the workspace gate. #[tauri::command] async fn pick_folder(app: tauri::AppHandle) -> Option { @@ -731,7 +724,6 @@ pub fn run() { None, )) .invoke_handler(tauri::generate_handler![ - open_url, pick_folder, get_autostart, set_autostart, 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 8a185804b3..2035ff60b0 100644 --- a/surfaces/gui/src/tauri.ts +++ b/surfaces/gui/src/tauri.ts @@ -125,23 +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, - * native open_url command, or `window.open`. The caller should also render the raw URL so it stays copyable - * if both no-op. */ +/** 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(() => { - invoke("open_url", { url }).catch(() => { - window.open(url, "_blank", "noopener,noreferrer"); - }); - }); - return; - } if (isTauri()) { - invoke("open_url", { url }).catch(() => { - window.open(url, "_blank", "noopener,noreferrer"); - }); + 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");