From d0cab51eca63ae0c805ce1c9f0dc81c522b08187 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Sun, 7 Dec 2025 01:18:26 +0000 Subject: [PATCH] open external links in native shells --- packages/electron-app/electron/main/main.ts | 54 ++++- packages/tauri-app/Cargo.lock | 207 ++++++++++++++++++ packages/tauri-app/src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/main-window.json | 3 +- packages/tauri-app/src-tauri/src/main.rs | 71 ++++-- 5 files changed, 314 insertions(+), 22 deletions(-) diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 47163616..f4e8be1d 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserView, BrowserWindow, nativeImage, session } from "electron" +import { app, BrowserView, BrowserWindow, nativeImage, session, shell } from "electron" import { existsSync } from "fs" import { dirname, join } from "path" import { fileURLToPath } from "url" @@ -89,6 +89,56 @@ function loadLoadingScreen(window: BrowserWindow) { }) } +function getAllowedRendererOrigins(): string[] { + const origins = new Set() + const rendererCandidates = [currentCliUrl, process.env.VITE_DEV_SERVER_URL, process.env.ELECTRON_RENDERER_URL] + for (const candidate of rendererCandidates) { + if (!candidate) { + continue + } + try { + origins.add(new URL(candidate).origin) + } catch (error) { + console.warn("[cli] failed to parse origin for", candidate, error) + } + } + return Array.from(origins) +} + +function shouldOpenExternally(url: string): boolean { + try { + const parsed = new URL(url) + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return true + } + const allowedOrigins = getAllowedRendererOrigins() + return !allowedOrigins.includes(parsed.origin) + } catch { + return false + } +} + +function setupNavigationGuards(window: BrowserWindow) { + const handleExternal = (url: string) => { + shell.openExternal(url).catch((error) => console.error("[cli] failed to open external URL", url, error)) + } + + window.webContents.setWindowOpenHandler(({ url }) => { + if (shouldOpenExternally(url)) { + handleExternal(url) + return { action: "deny" } + } + return { action: "allow" } + }) + + window.webContents.on("will-navigate", (event, url) => { + if (shouldOpenExternally(url)) { + event.preventDefault() + handleExternal(url) + } + }) +} + let cachedPreloadPath: string | null = null function getPreloadPath() { if (cachedPreloadPath && existsSync(cachedPreloadPath)) { @@ -153,6 +203,8 @@ function createWindow() { }, }) + setupNavigationGuards(mainWindow) + if (isMac) { mainWindow.webContents.session.setSpellCheckerEnabled(false) } diff --git a/packages/tauri-app/Cargo.lock b/packages/tauri-app/Cargo.lock index 970330da..2d918e8c 100644 --- a/packages/tauri-app/Cargo.lock +++ b/packages/tauri-app/Cargo.lock @@ -80,6 +80,79 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + [[package]] name = "async-recursion" version = "1.1.1" @@ -91,6 +164,30 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -191,6 +288,19 @@ dependencies = [ "objc2 0.6.3", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.2" @@ -382,6 +492,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-dialog", + "tauri-plugin-opener", "thiserror 1.0.69", "which", ] @@ -1357,6 +1468,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1659,6 +1776,25 @@ dependencies = [ "serde", ] +[[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 = "itoa" version = "1.0.15" @@ -2316,6 +2452,18 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2386,6 +2534,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2538,6 +2692,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2570,6 +2735,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.2", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3725,6 +3904,28 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.9.1" @@ -5216,8 +5417,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" dependencies = [ "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", "async-recursion", + "async-task", "async-trait", + "blocking", "enumflags2", "event-listener", "futures-core", diff --git a/packages/tauri-app/src-tauri/Cargo.toml b/packages/tauri-app/src-tauri/Cargo.toml index f020bd0c..e19c2867 100644 --- a/packages/tauri-app/src-tauri/Cargo.toml +++ b/packages/tauri-app/src-tauri/Cargo.toml @@ -19,3 +19,4 @@ which = "4" libc = "0.2" tauri-plugin-dialog = "2" dirs = "5" +tauri-plugin-opener = "2" diff --git a/packages/tauri-app/src-tauri/capabilities/main-window.json b/packages/tauri-app/src-tauri/capabilities/main-window.json index 8c789da3..c51d1f33 100644 --- a/packages/tauri-app/src-tauri/capabilities/main-window.json +++ b/packages/tauri-app/src-tauri/capabilities/main-window.json @@ -11,6 +11,7 @@ "windows": ["main"], "permissions": [ "core:default", - "dialog:allow-open" + "dialog:allow-open", + "opener:allow-default-urls" ] } diff --git a/packages/tauri-app/src-tauri/src/main.rs b/packages/tauri-app/src-tauri/src/main.rs index b1ab9f43..48352fcf 100644 --- a/packages/tauri-app/src-tauri/src/main.rs +++ b/packages/tauri-app/src-tauri/src/main.rs @@ -5,7 +5,11 @@ mod cli_manager; use cli_manager::{CliProcessManager, CliStatus}; use serde_json::json; use tauri::menu::Menu; -use tauri::{AppHandle, Emitter, Manager}; +use tauri::plugin::Builder as PluginBuilder; +use tauri::webview::Webview; +use tauri::{AppHandle, Emitter, Manager, Runtime}; +use tauri_plugin_opener::OpenerExt; +use url::Url; #[derive(Clone)] pub struct AppState { @@ -32,9 +36,38 @@ fn is_dev_mode() -> bool { cfg!(debug_assertions) || std::env::var("TAURI_DEV").is_ok() } +fn should_allow_internal(url: &Url) -> bool { + match url.scheme() { + "tauri" | "asset" | "file" => true, + "http" | "https" => matches!(url.host_str(), Some("127.0.0.1" | "localhost")), + _ => false, + } +} + +fn intercept_navigation(webview: &Webview, url: &Url) -> bool { + if should_allow_internal(url) { + return true; + } + + if let Err(err) = webview + .app_handle() + .opener() + .open_url(url.as_str(), None::<&str>) + { + eprintln!("[tauri] failed to open external link {}: {}", url, err); + } + false +} + fn main() { + let navigation_guard = PluginBuilder::new("external-link-guard") + .on_navigation(|webview, url| intercept_navigation(webview, url)) + .build(); + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_opener::init()) + .plugin(navigation_guard) .manage(AppState { manager: CliProcessManager::new(), }) @@ -45,10 +78,7 @@ fn main() { let manager = app.state::().manager.clone(); std::thread::spawn(move || { if let Err(err) = manager.start(app_handle.clone(), dev_mode) { - let _ = app_handle.emit( - "cli:error", - json!({"message": err.to_string()}), - ); + let _ = app_handle.emit("cli:error", json!({"message": err.to_string()})); } }); Ok(()) @@ -59,9 +89,21 @@ fn main() { }) .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|app_handle, event| { - match event { - tauri::RunEvent::ExitRequested { .. } => { + .run(|app_handle, event| match event { + tauri::RunEvent::ExitRequested { .. } => { + let app = app_handle.clone(); + std::thread::spawn(move || { + if let Some(state) = app.try_state::() { + let _ = state.manager.stop(); + } + app.exit(0); + }); + } + tauri::RunEvent::WindowEvent { + event: tauri::WindowEvent::Destroyed, + .. + } => { + if app_handle.webview_windows().len() <= 1 { let app = app_handle.clone(); std::thread::spawn(move || { if let Some(state) = app.try_state::() { @@ -70,19 +112,8 @@ fn main() { app.exit(0); }); } - tauri::RunEvent::WindowEvent { event: tauri::WindowEvent::Destroyed, .. } => { - if app_handle.webview_windows().len() <= 1 { - let app = app_handle.clone(); - std::thread::spawn(move || { - if let Some(state) = app.try_state::() { - let _ = state.manager.stop(); - } - app.exit(0); - }); - } - } - _ => {} } + _ => {} }); }