Skip to content

Commit dce4c05

Browse files
authored
fix(desktop): open apps with executables on Windows (#13022)
1 parent 8c56571 commit dce4c05

File tree

4 files changed

+189
-11
lines changed

4 files changed

+189
-11
lines changed

packages/app/src/components/session/session-header.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,7 @@ export function SessionHeader() {
166166
})
167167

168168
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
169+
const [menu, setMenu] = createStore({ open: false })
169170

170171
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
171172
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -355,7 +356,12 @@ export function SessionHeader() {
355356
<span class="text-12-regular text-text-strong">Open</span>
356357
</Button>
357358
<div class="self-stretch w-px bg-border-base/70" />
358-
<DropdownMenu gutter={6} placement="bottom-end">
359+
<DropdownMenu
360+
gutter={6}
361+
placement="bottom-end"
362+
open={menu.open}
363+
onOpenChange={(open) => setMenu("open", open)}
364+
>
359365
<DropdownMenu.Trigger
360366
as={IconButton}
361367
icon="chevron-down"
@@ -375,7 +381,13 @@ export function SessionHeader() {
375381
}}
376382
>
377383
{options().map((o) => (
378-
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
384+
<DropdownMenu.RadioItem
385+
value={o.id}
386+
onSelect={() => {
387+
setMenu("open", false)
388+
openDir(o.id)
389+
}}
390+
>
379391
<div class="flex size-5 shrink-0 items-center justify-center">
380392
<AppIcon id={o.icon} class={size(o.icon)} />
381393
</div>
@@ -388,7 +400,12 @@ export function SessionHeader() {
388400
</DropdownMenu.RadioGroup>
389401
</DropdownMenu.Group>
390402
<DropdownMenu.Separator />
391-
<DropdownMenu.Item onSelect={copyPath}>
403+
<DropdownMenu.Item
404+
onSelect={() => {
405+
setMenu("open", false)
406+
copyPath()
407+
}}
408+
>
392409
<div class="flex size-5 shrink-0 items-center justify-center">
393410
<Icon name="copy" size="small" class="text-icon-weak" />
394411
</div>

packages/desktop/src-tauri/src/lib.rs

Lines changed: 162 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ use std::{
2020
env,
2121
net::TcpListener,
2222
path::PathBuf,
23+
process::Command,
2324
sync::{Arc, Mutex},
2425
time::Duration,
25-
process::Command,
2626
};
2727
use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
2828
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
@@ -152,24 +152,178 @@ fn check_app_exists(app_name: &str) -> bool {
152152
{
153153
check_windows_app(app_name)
154154
}
155-
155+
156156
#[cfg(target_os = "macos")]
157157
{
158158
check_macos_app(app_name)
159159
}
160-
160+
161161
#[cfg(target_os = "linux")]
162162
{
163163
check_linux_app(app_name)
164164
}
165165
}
166166

167167
#[cfg(target_os = "windows")]
168-
fn check_windows_app(app_name: &str) -> bool {
168+
fn check_windows_app(_app_name: &str) -> bool {
169169
// Check if command exists in PATH, including .exe
170170
return true;
171171
}
172172

173+
#[cfg(target_os = "windows")]
174+
fn resolve_windows_app_path(app_name: &str) -> Option<String> {
175+
use std::path::{Path, PathBuf};
176+
177+
// Try to find the command using 'where'
178+
let output = Command::new("where").arg(app_name).output().ok()?;
179+
180+
if !output.status.success() {
181+
return None;
182+
}
183+
184+
let paths = String::from_utf8_lossy(&output.stdout)
185+
.lines()
186+
.map(str::trim)
187+
.filter(|line| !line.is_empty())
188+
.map(PathBuf::from)
189+
.collect::<Vec<_>>();
190+
191+
let has_ext = |path: &Path, ext: &str| {
192+
path.extension()
193+
.and_then(|v| v.to_str())
194+
.map(|v| v.eq_ignore_ascii_case(ext))
195+
.unwrap_or(false)
196+
};
197+
198+
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
199+
return Some(path.to_string_lossy().to_string());
200+
}
201+
202+
let resolve_cmd = |path: &Path| -> Option<String> {
203+
let content = std::fs::read_to_string(path).ok()?;
204+
205+
for token in content.split('"') {
206+
let lower = token.to_ascii_lowercase();
207+
if !lower.contains(".exe") {
208+
continue;
209+
}
210+
211+
if let Some(index) = lower.find("%~dp0") {
212+
let base = path.parent()?;
213+
let suffix = &token[index + 5..];
214+
let mut resolved = PathBuf::from(base);
215+
216+
for part in suffix.replace('/', "\\").split('\\') {
217+
if part.is_empty() || part == "." {
218+
continue;
219+
}
220+
if part == ".." {
221+
let _ = resolved.pop();
222+
continue;
223+
}
224+
resolved.push(part);
225+
}
226+
227+
if resolved.exists() {
228+
return Some(resolved.to_string_lossy().to_string());
229+
}
230+
}
231+
232+
let resolved = PathBuf::from(token);
233+
if resolved.exists() {
234+
return Some(resolved.to_string_lossy().to_string());
235+
}
236+
}
237+
238+
None
239+
};
240+
241+
for path in &paths {
242+
if has_ext(path, "cmd") || has_ext(path, "bat") {
243+
if let Some(resolved) = resolve_cmd(path) {
244+
return Some(resolved);
245+
}
246+
}
247+
248+
if path.extension().is_none() {
249+
let cmd = path.with_extension("cmd");
250+
if cmd.exists() {
251+
if let Some(resolved) = resolve_cmd(&cmd) {
252+
return Some(resolved);
253+
}
254+
}
255+
256+
let bat = path.with_extension("bat");
257+
if bat.exists() {
258+
if let Some(resolved) = resolve_cmd(&bat) {
259+
return Some(resolved);
260+
}
261+
}
262+
}
263+
}
264+
265+
let key = app_name
266+
.chars()
267+
.filter(|v| v.is_ascii_alphanumeric())
268+
.flat_map(|v| v.to_lowercase())
269+
.collect::<String>();
270+
271+
if !key.is_empty() {
272+
for path in &paths {
273+
let dirs = [
274+
path.parent(),
275+
path.parent().and_then(|dir| dir.parent()),
276+
path.parent()
277+
.and_then(|dir| dir.parent())
278+
.and_then(|dir| dir.parent()),
279+
];
280+
281+
for dir in dirs.into_iter().flatten() {
282+
if let Ok(entries) = std::fs::read_dir(dir) {
283+
for entry in entries.flatten() {
284+
let candidate = entry.path();
285+
if !has_ext(&candidate, "exe") {
286+
continue;
287+
}
288+
289+
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
290+
continue;
291+
};
292+
293+
let name = stem
294+
.chars()
295+
.filter(|v| v.is_ascii_alphanumeric())
296+
.flat_map(|v| v.to_lowercase())
297+
.collect::<String>();
298+
299+
if name.contains(&key) || key.contains(&name) {
300+
return Some(candidate.to_string_lossy().to_string());
301+
}
302+
}
303+
}
304+
}
305+
}
306+
}
307+
308+
paths.first().map(|path| path.to_string_lossy().to_string())
309+
}
310+
311+
#[tauri::command]
312+
#[specta::specta]
313+
fn resolve_app_path(app_name: &str) -> Option<String> {
314+
#[cfg(target_os = "windows")]
315+
{
316+
resolve_windows_app_path(app_name)
317+
}
318+
319+
#[cfg(not(target_os = "windows"))]
320+
{
321+
// On macOS/Linux, just return the app_name as-is since
322+
// the opener plugin handles them correctly
323+
Some(app_name.to_string())
324+
}
325+
}
326+
173327
#[cfg(target_os = "macos")]
174328
fn check_macos_app(app_name: &str) -> bool {
175329
// Check common installation locations
@@ -181,13 +335,13 @@ fn check_macos_app(app_name: &str) -> bool {
181335
if let Ok(home) = std::env::var("HOME") {
182336
app_locations.push(format!("{}/Applications/{}.app", home, app_name));
183337
}
184-
338+
185339
for location in app_locations {
186340
if std::path::Path::new(&location).exists() {
187341
return true;
188342
}
189343
}
190-
344+
191345
// Also check if command exists in PATH
192346
Command::new("which")
193347
.arg(app_name)
@@ -251,7 +405,8 @@ pub fn run() {
251405
get_display_backend,
252406
set_display_backend,
253407
markdown::parse_markdown_command,
254-
check_app_exists
408+
check_app_exists,
409+
resolve_app_path
255410
])
256411
.events(tauri_specta::collect_events![LoadingWindowComplete])
257412
.error_handling(tauri_specta::ErrorHandlingMode::Throw);

packages/desktop/src/bindings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export const commands = {
1414
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
1515
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
1616
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
17+
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
1718
};
1819

1920
/** Events */

packages/desktop/src/index.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
9898
void shellOpen(url).catch(() => undefined)
9999
},
100100

101-
openPath(path: string, app?: string) {
101+
async openPath(path: string, app?: string) {
102+
const os = ostype()
103+
if (os === "windows" && app) {
104+
const resolvedApp = await commands.resolveAppPath(app)
105+
return openerOpenPath(path, resolvedApp || app)
106+
}
102107
return openerOpenPath(path, app)
103108
},
104109

0 commit comments

Comments
 (0)