Browse Source

fix(desktop): open apps with executables on Windows (#13022)

Filip 2 weeks ago
parent
commit
dce4c05fa9

+ 20 - 3
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() {
                           <span class="text-12-regular text-text-strong">Open</span>
                         </Button>
                         <div class="self-stretch w-px bg-border-base/70" />
-                        <DropdownMenu gutter={6} placement="bottom-end">
+                        <DropdownMenu
+                          gutter={6}
+                          placement="bottom-end"
+                          open={menu.open}
+                          onOpenChange={(open) => setMenu("open", open)}
+                        >
                           <DropdownMenu.Trigger
                             as={IconButton}
                             icon="chevron-down"
@@ -375,7 +381,13 @@ export function SessionHeader() {
                                   }}
                                 >
                                   {options().map((o) => (
-                                    <DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
+                                    <DropdownMenu.RadioItem
+                                      value={o.id}
+                                      onSelect={() => {
+                                        setMenu("open", false)
+                                        openDir(o.id)
+                                      }}
+                                    >
                                       <div class="flex size-5 shrink-0 items-center justify-center">
                                         <AppIcon id={o.icon} class={size(o.icon)} />
                                       </div>
@@ -388,7 +400,12 @@ export function SessionHeader() {
                                 </DropdownMenu.RadioGroup>
                               </DropdownMenu.Group>
                               <DropdownMenu.Separator />
-                              <DropdownMenu.Item onSelect={copyPath}>
+                              <DropdownMenu.Item
+                                onSelect={() => {
+                                  setMenu("open", false)
+                                  copyPath()
+                                }}
+                              >
                                 <div class="flex size-5 shrink-0 items-center justify-center">
                                   <Icon name="copy" size="small" class="text-icon-weak" />
                                 </div>

+ 162 - 7
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<String> {
+    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::<Vec<_>>();
+
+    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<String> {
+        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::<String>();
+
+    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::<String>();
+
+                        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<String> {
+    #[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);

+ 1 - 0
packages/desktop/src/bindings.ts

@@ -14,6 +14,7 @@ export const commands = {
 	setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
 	parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
 	checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
+	resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
 };
 
 /** Events */

+ 6 - 1
packages/desktop/src/index.tsx

@@ -98,7 +98,12 @@ const createPlatform = (password: Accessor<string | null>): 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)
   },