From dce4c05fa9168ca78029142b06363c910cedd06c Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:10:58 +0100 Subject: [PATCH] fix(desktop): open apps with executables on Windows (#13022) --- .../src/components/session/session-header.tsx | 23 ++- packages/desktop/src-tauri/src/lib.rs | 169 +++++++++++++++++- packages/desktop/src/bindings.ts | 1 + packages/desktop/src/index.tsx | 7 +- 4 files changed, 189 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 18b607b96..94b843666 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -166,6 +166,7 @@ export function SessionHeader() { }) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + const [menu, setMenu] = createStore({ open: false }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) @@ -355,7 +356,12 @@ export function SessionHeader() { Open
- + setMenu("open", open)} + > {options().map((o) => ( - openDir(o.id)}> + { + setMenu("open", false) + openDir(o.id) + }} + >
@@ -388,7 +400,12 @@ export function SessionHeader() { - + { + setMenu("open", false) + copyPath() + }} + >
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 84dd1859b..82f0441ad 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -20,9 +20,9 @@ use std::{ env, net::TcpListener, path::PathBuf, + process::Command, sync::{Arc, Mutex}, time::Duration, - process::Command, }; use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] @@ -152,12 +152,12 @@ fn check_app_exists(app_name: &str) -> bool { { check_windows_app(app_name) } - + #[cfg(target_os = "macos")] { check_macos_app(app_name) } - + #[cfg(target_os = "linux")] { check_linux_app(app_name) @@ -165,11 +165,165 @@ fn check_app_exists(app_name: &str) -> bool { } #[cfg(target_os = "windows")] -fn check_windows_app(app_name: &str) -> bool { +fn check_windows_app(_app_name: &str) -> bool { // Check if command exists in PATH, including .exe return true; } +#[cfg(target_os = "windows")] +fn resolve_windows_app_path(app_name: &str) -> Option { + use std::path::{Path, PathBuf}; + + // Try to find the command using 'where' + let output = Command::new("where").arg(app_name).output().ok()?; + + if !output.status.success() { + return None; + } + + let paths = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect::>(); + + let has_ext = |path: &Path, ext: &str| { + path.extension() + .and_then(|v| v.to_str()) + .map(|v| v.eq_ignore_ascii_case(ext)) + .unwrap_or(false) + }; + + if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { + return Some(path.to_string_lossy().to_string()); + } + + let resolve_cmd = |path: &Path| -> Option { + let content = std::fs::read_to_string(path).ok()?; + + for token in content.split('"') { + let lower = token.to_ascii_lowercase(); + if !lower.contains(".exe") { + continue; + } + + if let Some(index) = lower.find("%~dp0") { + let base = path.parent()?; + let suffix = &token[index + 5..]; + let mut resolved = PathBuf::from(base); + + for part in suffix.replace('/', "\\").split('\\') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + let _ = resolved.pop(); + continue; + } + resolved.push(part); + } + + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + let resolved = PathBuf::from(token); + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + None + }; + + for path in &paths { + if has_ext(path, "cmd") || has_ext(path, "bat") { + if let Some(resolved) = resolve_cmd(path) { + return Some(resolved); + } + } + + if path.extension().is_none() { + let cmd = path.with_extension("cmd"); + if cmd.exists() { + if let Some(resolved) = resolve_cmd(&cmd) { + return Some(resolved); + } + } + + let bat = path.with_extension("bat"); + if bat.exists() { + if let Some(resolved) = resolve_cmd(&bat) { + return Some(resolved); + } + } + } + } + + let key = app_name + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + if !key.is_empty() { + for path in &paths { + let dirs = [ + path.parent(), + path.parent().and_then(|dir| dir.parent()), + path.parent() + .and_then(|dir| dir.parent()) + .and_then(|dir| dir.parent()), + ]; + + for dir in dirs.into_iter().flatten() { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let candidate = entry.path(); + if !has_ext(&candidate, "exe") { + continue; + } + + let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else { + continue; + }; + + let name = stem + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + if name.contains(&key) || key.contains(&name) { + return Some(candidate.to_string_lossy().to_string()); + } + } + } + } + } + } + + paths.first().map(|path| path.to_string_lossy().to_string()) +} + +#[tauri::command] +#[specta::specta] +fn resolve_app_path(app_name: &str) -> Option { + #[cfg(target_os = "windows")] + { + resolve_windows_app_path(app_name) + } + + #[cfg(not(target_os = "windows"))] + { + // On macOS/Linux, just return the app_name as-is since + // the opener plugin handles them correctly + Some(app_name.to_string()) + } +} + #[cfg(target_os = "macos")] fn check_macos_app(app_name: &str) -> bool { // Check common installation locations @@ -181,13 +335,13 @@ fn check_macos_app(app_name: &str) -> bool { if let Ok(home) = std::env::var("HOME") { app_locations.push(format!("{}/Applications/{}.app", home, app_name)); } - + for location in app_locations { if std::path::Path::new(&location).exists() { return true; } } - + // Also check if command exists in PATH Command::new("which") .arg(app_name) @@ -251,7 +405,8 @@ pub fn run() { get_display_backend, set_display_backend, markdown::parse_markdown_command, - check_app_exists + check_app_exists, + resolve_app_path ]) .events(tauri_specta::collect_events![LoadingWindowComplete]) .error_handling(tauri_specta::ErrorHandlingMode::Throw); diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 2db1a624c..c7bcaba9c 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -14,6 +14,7 @@ export const commands = { setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE("set_display_backend", { backend }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), + resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }), }; /** Events */ diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 25e9f825c..0e2fcb7fe 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -98,7 +98,12 @@ const createPlatform = (password: Accessor): Platform => ({ void shellOpen(url).catch(() => undefined) }, - openPath(path: string, app?: string) { + async openPath(path: string, app?: string) { + const os = ostype() + if (os === "windows" && app) { + const resolvedApp = await commands.resolveAppPath(app) + return openerOpenPath(path, resolvedApp || app) + } return openerOpenPath(path, app) },