From fc6e7934bd365ad1665dea68556dbfc80ac3b611 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Wed, 25 Feb 2026 07:39:58 +0100 Subject: [PATCH] feat(desktop): enhance Windows app resolution and UI loading states (#13320) Co-authored-by: Brendan Allan Co-authored-by: Brendan Allan --- .../src/components/session/session-header.tsx | 127 +++-- packages/desktop/src-tauri/Cargo.lock | 5 +- packages/desktop/src-tauri/Cargo.toml | 3 +- packages/desktop/src-tauri/src/cli.rs | 4 +- packages/desktop/src-tauri/src/lib.rs | 158 +------ packages/desktop/src-tauri/src/os/mod.rs | 2 + packages/desktop/src-tauri/src/os/windows.rs | 439 ++++++++++++++++++ 7 files changed, 556 insertions(+), 182 deletions(-) create mode 100644 packages/desktop/src-tauri/src/os/mod.rs create mode 100644 packages/desktop/src-tauri/src/os/windows.rs diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 825d1dab6..d531fa50a 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,28 +1,28 @@ +import { AppIcon } from "@opencode-ai/ui/app-icon" +import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Keybind } from "@opencode-ai/ui/keybind" +import { Popover } from "@opencode-ai/ui/popover" +import { Spinner } from "@opencode-ai/ui/spinner" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { getFilename } from "@opencode-ai/util/path" +import { useParams } from "@solidjs/router" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" -import { useParams } from "@solidjs/router" -import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" +import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" import { useSync } from "@/context/sync" -import { useGlobalSDK } from "@/context/global-sdk" -import { getFilename } from "@opencode-ai/util/path" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" - -import { Icon } from "@opencode-ai/ui/icon" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { Button } from "@opencode-ai/ui/button" -import { AppIcon } from "@opencode-ai/ui/app-icon" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { Popover } from "@opencode-ai/ui/popover" -import { TextField } from "@opencode-ai/ui/text-field" -import { Keybind } from "@opencode-ai/ui/keybind" -import { showToast } from "@opencode-ai/ui/toast" import { StatusPopover } from "../status-popover" const OPEN_APPS = [ @@ -45,32 +45,67 @@ type OpenApp = (typeof OPEN_APPS)[number] type OS = "macos" | "windows" | "linux" | "unknown" const MAC_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" }, + { + id: "vscode", + label: "VS Code", + icon: "vscode", + openWith: "Visual Studio Code", + }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, - { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" }, + { + id: "antigravity", + label: "Antigravity", + icon: "antigravity", + openWith: "Antigravity", + }, { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, - { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + { + id: "android-studio", + label: "Android Studio", + icon: "android-studio", + openWith: "Android Studio", + }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, ] as const const WINDOWS_APPS = [ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + { + id: "powershell", + label: "PowerShell", + icon: "powershell", + openWith: "powershell", + }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, ] as const const LINUX_APPS = [ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" }, + { + id: "sublime-text", + label: "Sublime Text", + icon: "sublime-text", + openWith: "Sublime Text", + }, ] as const type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number] @@ -213,7 +248,9 @@ export function SessionHeader() { const view = createMemo(() => layout.view(sessionKey)) const os = createMemo(() => detectOS(platform)) - const [exists, setExists] = createStore>>({ finder: true }) + const [exists, setExists] = createStore>>({ + finder: true, + }) const apps = createMemo(() => { if (os() === "macos") return MAC_APPS @@ -259,18 +296,34 @@ export function SessionHeader() { const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ + app: undefined as OpenApp | undefined, + }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) + const opening = createMemo(() => openRequest.app !== undefined) + + createEffect(() => { + const value = prefs.app + if (options().some((o) => o.id === value)) return + setPrefs("app", options()[0]?.id ?? "finder") + }) const openDir = (app: OpenApp) => { + if (opening() || !canOpen() || !platform.openPath) return const directory = projectDirectory() if (!directory) return - if (!canOpen()) return const item = options().find((o) => o.id === app) const openWith = item && "openWith" in item ? item.openWith : undefined - Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => showRequestError(language, err)) + setOpenRequest("app", app) + platform + .openPath(directory, openWith) + .catch((err: unknown) => showRequestError(language, err)) + .finally(() => { + setOpenRequest("app", undefined) + }) } const copyPath = () => { @@ -315,7 +368,9 @@ export function SessionHeader() {
- {language.t("session.header.search.placeholder", { project: name() })} + {language.t("session.header.search.placeholder", { + project: name(), + })}
@@ -357,12 +412,21 @@ export function SessionHeader() {
@@ -377,7 +441,11 @@ export function SessionHeader() { as={IconButton} icon="chevron-down" variant="ghost" - class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-hover" + disabled={opening()} + class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default" + classList={{ + "bg-surface-raised-base-active": opening(), + }} aria-label={language.t("session.header.open.menu")} /> @@ -395,6 +463,7 @@ export function SessionHeader() { {(o) => ( { setMenu("open", false) openDir(o.id) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index f9516350e..55f0d5f36 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -1988,7 +1988,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -3136,7 +3136,8 @@ dependencies = [ "tracing-subscriber", "uuid", "webkit2gtk", - "windows 0.62.2", + "windows-core 0.62.2", + "windows-sys 0.61.2", ] [[package]] diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index e98b8965c..b228c7b61 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -55,7 +55,8 @@ tokio-stream = { version = "0.1.18", features = ["sync"] } process-wrap = { version = "9.0.3", features = ["tokio1"] } [target.'cfg(windows)'.dependencies] -windows = { version = "0.62", features = ["Win32_System_Threading"] } +windows-sys = { version = "0.61", features = ["Win32_System_Threading", "Win32_System_Registry"] } +windows-core = "0.62" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index acab0fa70..0c5dfebaf 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -19,7 +19,7 @@ use tokio::{ use tokio_stream::wrappers::ReceiverStream; use tracing::Instrument; #[cfg(windows)] -use windows::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; +use windows_sys::Win32::System::Threading::{CREATE_NO_WINDOW, CREATE_SUSPENDED}; use crate::server::get_wsl_config; @@ -32,7 +32,7 @@ struct WinCreationFlags; #[cfg(windows)] impl CommandWrapper for WinCreationFlags { fn pre_spawn(&mut self, command: &mut Command, _core: &CommandWrap) -> std::io::Result<()> { - command.creation_flags((CREATE_NO_WINDOW | CREATE_SUSPENDED).0); + command.creation_flags(CREATE_NO_WINDOW | CREATE_SUSPENDED); Ok(()) } } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 7ea3aaa8a..71fe8407f 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod linux_display; pub mod linux_windowing; mod logging; mod markdown; +mod os; mod server; mod window_customizer; mod windows; @@ -42,7 +43,7 @@ struct ServerReadyData { url: String, username: Option, password: Option, - is_sidecar: bool + is_sidecar: bool, } #[derive(Clone, Copy, serde::Serialize, specta::Type, Debug)] @@ -148,7 +149,7 @@ async fn await_initialization( fn check_app_exists(app_name: &str) -> bool { #[cfg(target_os = "windows")] { - check_windows_app(app_name) + os::windows::check_windows_app(app_name) } #[cfg(target_os = "macos")] @@ -162,156 +163,12 @@ fn check_app_exists(app_name: &str) -> bool { } } -#[cfg(target_os = "windows")] -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) + os::windows::resolve_windows_app_path(app_name) } #[cfg(not(target_os = "windows"))] @@ -634,7 +491,12 @@ async fn initialize(app: AppHandle) { app.state::().set_child(Some(child)); - Ok(ServerReadyData { url, username,password, is_sidecar: true }) + Ok(ServerReadyData { + url, + username, + password, + is_sidecar: true, + }) } .map(move |res| { let _ = server_ready_tx.send(res); diff --git a/packages/desktop/src-tauri/src/os/mod.rs b/packages/desktop/src-tauri/src/os/mod.rs new file mode 100644 index 000000000..8c36e53f7 --- /dev/null +++ b/packages/desktop/src-tauri/src/os/mod.rs @@ -0,0 +1,2 @@ +#[cfg(windows)] +pub mod windows; diff --git a/packages/desktop/src-tauri/src/os/windows.rs b/packages/desktop/src-tauri/src/os/windows.rs new file mode 100644 index 000000000..cab265b62 --- /dev/null +++ b/packages/desktop/src-tauri/src/os/windows.rs @@ -0,0 +1,439 @@ +use std::{ + ffi::c_void, + os::windows::process::CommandExt, + path::{Path, PathBuf}, + process::Command, +}; +use windows_sys::Win32::{ + Foundation::ERROR_SUCCESS, + System::Registry::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, REG_EXPAND_SZ, REG_SZ, RRF_RT_REG_EXPAND_SZ, + RRF_RT_REG_SZ, RegGetValueW, + }, +}; + +pub fn check_windows_app(app_name: &str) -> bool { + resolve_windows_app_path(app_name).is_some() +} + +pub fn resolve_windows_app_path(app_name: &str) -> Option { + fn expand_env(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut index = 0; + + while let Some(start) = value[index..].find('%') { + let start = index + start; + out.push_str(&value[index..start]); + + let Some(end_rel) = value[start + 1..].find('%') else { + out.push_str(&value[start..]); + return out; + }; + + let end = start + 1 + end_rel; + let key = &value[start + 1..end]; + if key.is_empty() { + out.push('%'); + index = end + 1; + continue; + } + + if let Ok(v) = std::env::var(key) { + out.push_str(&v); + index = end + 1; + continue; + } + + out.push_str(&value[start..=end]); + index = end + 1; + } + + out.push_str(&value[index..]); + out + } + + fn extract_exe(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Some(rest) = value.strip_prefix('"') { + if let Some(end) = rest.find('"') { + let inner = rest[..end].trim(); + if inner.to_ascii_lowercase().contains(".exe") { + return Some(inner.to_string()); + } + } + } + + let lower = value.to_ascii_lowercase(); + let end = lower.find(".exe")?; + Some(value[..end + 4].trim().trim_matches('"').to_string()) + } + + fn candidates(app_name: &str) -> Vec { + let app_name = app_name.trim().trim_matches('"'); + if app_name.is_empty() { + return vec![]; + } + + let mut out = Vec::::new(); + let mut push = |value: String| { + let value = value.trim().trim_matches('"').to_string(); + if value.is_empty() { + return; + } + if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) { + return; + } + out.push(value); + }; + + push(app_name.to_string()); + + let lower = app_name.to_ascii_lowercase(); + if !lower.ends_with(".exe") { + push(format!("{app_name}.exe")); + } + + let snake = { + let mut s = String::new(); + let mut underscore = false; + for c in lower.chars() { + if c.is_ascii_alphanumeric() { + s.push(c); + underscore = false; + continue; + } + if underscore { + continue; + } + s.push('_'); + underscore = true; + } + s.trim_matches('_').to_string() + }; + + if !snake.is_empty() { + push(snake.clone()); + if !snake.ends_with(".exe") { + push(format!("{snake}.exe")); + } + } + + let alnum = lower + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(); + + if !alnum.is_empty() { + push(alnum.clone()); + push(format!("{alnum}.exe")); + } + + match lower.as_str() { + "sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => { + push("subl".to_string()); + push("subl.exe".to_string()); + push("sublime_text".to_string()); + push("sublime_text.exe".to_string()); + } + _ => {} + } + + out + } + + fn reg_app_path(exe: &str) -> Option { + let exe = exe.trim().trim_matches('"'); + if exe.is_empty() { + return None; + } + + let query = |root: *mut c_void, subkey: &str| -> Option { + let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ; + let mut kind: u32 = 0; + let mut size = 0u32; + + let mut key = subkey.encode_utf16().collect::>(); + key.push(0); + + let status = unsafe { + RegGetValueW( + root, + key.as_ptr(), + std::ptr::null(), + flags, + &mut kind, + std::ptr::null_mut(), + &mut size, + ) + }; + + if status != ERROR_SUCCESS || size == 0 { + return None; + } + + if kind != REG_SZ && kind != REG_EXPAND_SZ { + return None; + } + + let mut data = vec![0u8; size as usize]; + let status = unsafe { + RegGetValueW( + root, + key.as_ptr(), + std::ptr::null(), + flags, + &mut kind, + data.as_mut_ptr() as *mut c_void, + &mut size, + ) + }; + + if status != ERROR_SUCCESS || size < 2 { + return None; + } + + let words = unsafe { + std::slice::from_raw_parts(data.as_ptr().cast::(), (size as usize) / 2) + }; + let len = words.iter().position(|v| *v == 0).unwrap_or(words.len()); + let value = String::from_utf16_lossy(&words[..len]).trim().to_string(); + + if value.is_empty() { + return None; + } + + Some(value) + }; + + let keys = [ + ( + HKEY_CURRENT_USER, + format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"), + ), + ( + HKEY_LOCAL_MACHINE, + format!(r"Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"), + ), + ( + HKEY_LOCAL_MACHINE, + format!(r"Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"), + ), + ]; + + for (root, key) in keys { + let Some(value) = query(root, &key) else { + continue; + }; + + let Some(exe) = extract_exe(&value) else { + continue; + }; + + let exe = expand_env(&exe); + let path = Path::new(exe.trim().trim_matches('"')); + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + + None + } + + let app_name = app_name.trim().trim_matches('"'); + if app_name.is_empty() { + return None; + } + + let direct = Path::new(app_name); + if direct.is_absolute() && direct.exists() { + return Some(direct.to_string_lossy().to_string()); + } + + let key = app_name + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .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) + }; + + let resolve_cmd = |path: &Path| -> Option { + let bytes = std::fs::read(path).ok()?; + let content = String::from_utf8_lossy(&bytes); + + for token in content.split('"') { + let Some(exe) = extract_exe(token) else { + continue; + }; + + let lower = exe.to_ascii_lowercase(); + if let Some(index) = lower.find("%~dp0") { + let base = path.parent()?; + let suffix = &exe[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()); + } + + continue; + } + + let resolved = PathBuf::from(expand_env(&exe)); + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + None + }; + + let resolve_where = |query: &str| -> Option { + let output = Command::new("where") + .creation_flags(0x08000000) + .arg(query) + .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::>(); + + if paths.is_empty() { + return None; + } + + if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { + return Some(path.to_string_lossy().to_string()); + } + + 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); + } + } + } + } + + 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()) + }; + + let list = candidates(app_name); + for query in &list { + if let Some(path) = resolve_where(query) { + return Some(path); + } + } + + let mut exes = Vec::::new(); + for query in &list { + let query = query.trim().trim_matches('"'); + if query.is_empty() { + continue; + } + + let name = Path::new(query) + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or(query); + + let exe = if name.to_ascii_lowercase().ends_with(".exe") { + name.to_string() + } else { + format!("{name}.exe") + }; + + if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) { + continue; + } + + exes.push(exe); + } + + for exe in exes { + if let Some(path) = reg_app_path(&exe) { + return Some(path); + } + } + + None +}