Kaynağa Gözat

feat(desktop): enhance Windows app resolution and UI loading states (#13320)

Co-authored-by: Brendan Allan <[email protected]>
Co-authored-by: Brendan Allan <[email protected]>
Filip 1 ay önce
ebeveyn
işleme
fc6e7934bd

+ 98 - 29
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<Partial<Record<OpenApp, boolean>>>({ finder: true })
+  const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
+    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() {
               <div class="flex min-w-0 flex-1 items-center gap-1.5 overflow-visible">
                 <Icon name="magnifying-glass" size="small" class="icon-base shrink-0 size-4" />
                 <span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
-                  {language.t("session.header.search.placeholder", { project: name() })}
+                  {language.t("session.header.search.placeholder", {
+                    project: name(),
+                  })}
                 </span>
               </div>
 
@@ -357,12 +412,21 @@ export function SessionHeader() {
                       <div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
                         <Button
                           variant="ghost"
-                          class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
+                          class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none disabled:!cursor-default"
+                          classList={{
+                            "bg-surface-raised-base-active": opening(),
+                          }}
                           onClick={() => openDir(current().id)}
+                          disabled={opening()}
                           aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
                         >
                           <div class="flex size-5 shrink-0 items-center justify-center">
-                            <AppIcon id={current().icon} class="size-4" />
+                            <Show
+                              when={opening()}
+                              fallback={<AppIcon id={current().icon} class={openIconSize(current().icon)} />}
+                            >
+                              <Spinner class="size-3.5 text-icon-base" />
+                            </Show>
                           </div>
                           <span class="text-12-regular text-text-strong">Open</span>
                         </Button>
@@ -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")}
                           />
                           <DropdownMenu.Portal>
@@ -395,6 +463,7 @@ export function SessionHeader() {
                                     {(o) => (
                                       <DropdownMenu.RadioItem
                                         value={o.id}
+                                        disabled={opening()}
                                         onSelect={() => {
                                           setMenu("open", false)
                                           openDir(o.id)

+ 3 - 2
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]]

+ 2 - 1
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"

+ 2 - 2
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(())
     }
 }

+ 10 - 148
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<String>,
     password: Option<String>,
-    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<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)
+        os::windows::resolve_windows_app_path(app_name)
     }
 
     #[cfg(not(target_os = "windows"))]
@@ -634,7 +491,12 @@ async fn initialize(app: AppHandle) {
 
                             app.state::<ServerState>().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);

+ 2 - 0
packages/desktop/src-tauri/src/os/mod.rs

@@ -0,0 +1,2 @@
+#[cfg(windows)]
+pub mod windows;

+ 439 - 0
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<String> {
+    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<String> {
+        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<String> {
+        let app_name = app_name.trim().trim_matches('"');
+        if app_name.is_empty() {
+            return vec![];
+        }
+
+        let mut out = Vec::<String>::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::<String>();
+
+        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<String> {
+        let exe = exe.trim().trim_matches('"');
+        if exe.is_empty() {
+            return None;
+        }
+
+        let query = |root: *mut c_void, subkey: &str| -> Option<String> {
+            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::<Vec<_>>();
+            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::<u16>(), (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::<String>();
+
+    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<String> {
+        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<String> {
+        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::<Vec<_>>();
+
+        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::<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())
+    };
+
+    let list = candidates(app_name);
+    for query in &list {
+        if let Some(path) = resolve_where(query) {
+            return Some(path);
+        }
+    }
+
+    let mut exes = Vec::<String>::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
+}