From 7439e4c0c54169c66d5b6413f37d1e8916aca086 Mon Sep 17 00:00:00 2001 From: jack Date: Fri, 31 Jul 2026 00:39:26 +0800 Subject: [PATCH] feat(desktop): support native launchers cross-platform --- desktop/src-tauri/Cargo.lock | 13 +- desktop/src-tauri/Cargo.toml | 18 + desktop/src-tauri/src/desktop_apps.rs | 378 +++------ desktop/src-tauri/src/desktop_apps/linux.rs | 778 ++++++++++++++++++ desktop/src-tauri/src/desktop_apps/macos.rs | 276 +++++++ .../src-tauri/src/desktop_apps/unsupported.rs | 10 + desktop/src-tauri/src/desktop_apps/windows.rs | 599 ++++++++++++++ web/src/App.tsx | 16 +- web/src/components/DesktopTitlebar.test.tsx | 6 +- web/src/components/DesktopTitlebar.tsx | 18 +- web/src/components/TopBar.tsx | 14 +- web/src/lib/useDesktop.test.ts | 4 + web/src/lib/useDesktop.ts | 15 +- 13 files changed, 1839 insertions(+), 306 deletions(-) create mode 100644 desktop/src-tauri/src/desktop_apps/linux.rs create mode 100644 desktop/src-tauri/src/desktop_apps/macos.rs create mode 100644 desktop/src-tauri/src/desktop_apps/unsupported.rs create mode 100644 desktop/src-tauri/src/desktop_apps/windows.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 20a30a7..ccfd100 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -423,7 +423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -1825,8 +1825,10 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "plist", + "png 0.18.1", "serde", "serde_json", + "shlex 1.3.0", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -1836,6 +1838,9 @@ dependencies = [ "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-window-state", + "tempfile", + "windows-sys 0.61.2", + "winreg", ] [[package]] @@ -3233,6 +3238,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 0004cd3..34a7e4b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -35,6 +35,24 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString", "NSURL"] } plist = "1" +[target.'cfg(target_os = "linux")'.dependencies] +base64 = "0.22" +shlex = "1.3" + +[target.'cfg(target_os = "windows")'.dependencies] +base64 = "0.22" +png = "0.18" +windows-sys = { version = "0.61", features = [ + "Win32_Foundation", + "Win32_Graphics_Gdi", + "Win32_UI_Shell", + "Win32_UI_WindowsAndMessaging", +] } +winreg = "0.55" + +[dev-dependencies] +tempfile = "3" + [profile.release] panic = "abort" codegen-units = 1 diff --git a/desktop/src-tauri/src/desktop_apps.rs b/desktop/src-tauri/src/desktop_apps.rs index ecca458..cb1a280 100644 --- a/desktop/src-tauri/src/desktop_apps.rs +++ b/desktop/src-tauri/src/desktop_apps.rs @@ -1,112 +1,69 @@ -//! Native macOS application discovery for the title-bar "Open in" menu. +//! Native application discovery for the desktop "Open in" menu. //! -//! The frontend never guesses whether an editor is installed and never sends -//! an executable path. This module resolves a curated set of developer apps by -//! bundle identifier through Launch Services, extracts each installed app's -//! own icon, and accepts only the opaque IDs declared below when opening a -//! workspace. +//! Each platform owns its discovery and launch details: +//! - macOS resolves bundle identifiers through Launch Services. +//! - Windows validates installed applications through the uninstall registry +//! and well-known executable locations. +//! - Linux reads freedesktop `.desktop` entries and icon themes. +//! +//! The webview only receives opaque application IDs and can never provide an +//! executable or command line. Workspace paths are canonicalized and checked +//! again in this native boundary before an application is started. use serde::Serialize; use std::path::{Path, PathBuf}; -#[derive(Clone, Copy)] -struct WorkspaceAppCandidate { - id: &'static str, - label: &'static str, - bundle_ids: &'static [&'static str], - group: &'static str, - reveal_in_finder: bool, +#[cfg(target_os = "linux")] +#[path = "desktop_apps/linux.rs"] +mod imp; +#[cfg(target_os = "macos")] +#[path = "desktop_apps/macos.rs"] +mod imp; +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +#[path = "desktop_apps/unsupported.rs"] +mod imp; +#[cfg(target_os = "windows")] +#[path = "desktop_apps/windows.rs"] +mod imp; + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub(super) enum WorkspaceApplicationKind { + Editor, + FileManager, + Terminal, } -const WORKSPACE_APP_CANDIDATES: &[WorkspaceAppCandidate] = &[ - WorkspaceAppCandidate { - id: "vscode", - label: "VS Code", - bundle_ids: &["com.microsoft.VSCode"], - group: "editor", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "cursor", - label: "Cursor", - bundle_ids: &["com.todesktop.230313mzl4w4u92"], - group: "editor", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "zed", - label: "Zed", - bundle_ids: &["dev.zed.Zed", "dev.zed.Zed-Preview"], - group: "editor", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "antigravity", - label: "Antigravity", - bundle_ids: &["com.google.antigravity"], - group: "editor", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "finder", - label: "Finder", - bundle_ids: &["com.apple.finder"], - group: "system", - reveal_in_finder: true, - }, - WorkspaceAppCandidate { - id: "terminal", - label: "Terminal", - bundle_ids: &["com.apple.Terminal"], - group: "system", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "iterm", - label: "iTerm2", - bundle_ids: &["com.googlecode.iterm2"], - group: "system", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "ghostty", - label: "Ghostty", - bundle_ids: &["com.mitchellh.ghostty"], - group: "system", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "warp", - label: "Warp", - bundle_ids: &["dev.warp.Warp-Stable", "dev.warp.Warp"], - group: "system", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "xcode", - label: "Xcode", - bundle_ids: &["com.apple.dt.Xcode"], - group: "system", - reveal_in_finder: false, - }, - WorkspaceAppCandidate { - id: "goland", - label: "GoLand", - bundle_ids: &["com.jetbrains.goland"], - group: "system", - reveal_in_finder: false, - }, -]; +#[derive(Clone, Copy, Debug)] +pub(super) struct WorkspaceAppCandidate { + pub id: &'static str, + pub label: &'static str, + pub group: &'static str, + pub kind: WorkspaceApplicationKind, +} -#[derive(Serialize)] +#[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct WorkspaceApplication { id: &'static str, label: &'static str, group: &'static str, + kind: WorkspaceApplicationKind, icon_data_url: Option, } +impl WorkspaceApplication { + pub(super) fn new(candidate: WorkspaceAppCandidate, icon_data_url: Option) -> Self { + Self { + id: candidate.id, + label: candidate.label, + group: candidate.group, + kind: candidate.kind, + icon_data_url, + } + } +} + #[tauri::command] pub fn list_workspace_applications() -> Vec { imp::list_workspace_applications() @@ -115,15 +72,7 @@ pub fn list_workspace_applications() -> Vec { #[tauri::command] pub fn open_workspace_in_application(path: String, app_id: String) -> Result<(), String> { let workspace = validate_workspace_path(&path)?; - let candidate = candidate_by_id(&app_id) - .ok_or_else(|| format!("unsupported workspace application: {app_id}"))?; - imp::open_workspace(&workspace, candidate) -} - -fn candidate_by_id(id: &str) -> Option<&'static WorkspaceAppCandidate> { - WORKSPACE_APP_CANDIDATES - .iter() - .find(|candidate| candidate.id == id) + imp::open_workspace(&workspace, &app_id) } fn validate_workspace_path(path: &str) -> Result { @@ -139,9 +88,7 @@ fn validate_workspace_path(path: &str) -> Result { return Err("workspace path must be a directory".to_string()); } - let home = std::env::var_os("HOME") - .map(PathBuf::from) - .ok_or_else(|| "HOME is unavailable".to_string())?; + let home = local_home().ok_or_else(|| "the local home directory is unavailable".to_string())?; let canonical_home = home.canonicalize().unwrap_or(home); if !is_allowed_workspace_root(&canonical, &canonical_home) { return Err("workspace path is outside the allowed local roots".to_string()); @@ -150,181 +97,55 @@ fn validate_workspace_path(path: &str) -> Result { Ok(canonical) } -fn is_allowed_workspace_root(path: &Path, home: &Path) -> bool { - path.starts_with(home) || path.starts_with("/Volumes") -} - -#[cfg(target_os = "macos")] -mod imp { - use super::{WorkspaceAppCandidate, WorkspaceApplication, WORKSPACE_APP_CANDIDATES}; - use base64::{engine::general_purpose::STANDARD, Engine as _}; - use icns::IconFamily; - use objc2_app_kit::NSWorkspace; - use objc2_foundation::{NSArray, NSString, NSURL}; - use plist::Value; - use std::fs::File; - use std::io::BufReader; - use std::path::{Path, PathBuf}; - use std::process::{Command, Stdio}; - - pub fn list_workspace_applications() -> Vec { - WORKSPACE_APP_CANDIDATES - .iter() - .filter_map(|candidate| { - let app_path = resolve_application(candidate)?; - Some(WorkspaceApplication { - id: candidate.id, - label: candidate.label, - group: candidate.group, - icon_data_url: app_icon_data_url(&app_path), - }) - }) - .collect() +fn local_home() -> Option { + #[cfg(target_os = "windows")] + { + std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) } - - pub fn open_workspace( - workspace_path: &Path, - candidate: &WorkspaceAppCandidate, - ) -> Result<(), String> { - let app_path = resolve_application(candidate) - .ok_or_else(|| format!("{} is not installed", candidate.label))?; - - if candidate.reveal_in_finder { - reveal_in_finder(workspace_path); - return Ok(()); - } - - let output = Command::new("/usr/bin/open") - .arg("-a") - .arg(&app_path) - .arg("--") - .arg(workspace_path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .output() - .map_err(|error| format!("could not start {}: {error}", candidate.label))?; - - if output.status.success() { - return Ok(()); - } - - let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); - if detail.is_empty() { - Err(format!( - "{} exited with status {}", - candidate.label, output.status - )) - } else { - Err(format!("could not open {}: {detail}", candidate.label)) - } - } - - fn resolve_application(candidate: &WorkspaceAppCandidate) -> Option { - let workspace = NSWorkspace::sharedWorkspace(); - for bundle_id in candidate.bundle_ids { - let identifier = NSString::from_str(bundle_id); - let Some(url) = workspace.URLForApplicationWithBundleIdentifier(&identifier) else { - continue; - }; - let Some(path) = url.path() else { - continue; - }; - let app_path = PathBuf::from(path.to_string()); - if app_path.is_dir() { - return Some(app_path); - } - } - None + #[cfg(not(target_os = "windows"))] + { + std::env::var_os("HOME").map(PathBuf::from) } +} - fn reveal_in_finder(path: &Path) { - let workspace = NSWorkspace::sharedWorkspace(); - let path = NSString::from_str(&path.to_string_lossy()); - let url = NSURL::fileURLWithPath(&path); - let urls = NSArray::from_retained_slice(&[url]); - workspace.activateFileViewerSelectingURLs(&urls); +fn is_allowed_workspace_root(path: &Path, home: &Path) -> bool { + if path.starts_with(home) { + return true; } - fn app_icon_data_url(app_path: &Path) -> Option { - let icon_path = app_icon_path(app_path)?; - let file = BufReader::new(File::open(icon_path).ok()?); - let family = IconFamily::read(file).ok()?; - let mut icon_types = family.available_icons().to_vec(); - icon_types.sort_by_key(|icon_type| { - let width = icon_type.pixel_width(); - if width >= 64 { - width - 64 - } else { - 10_000 + 64 - width - } - }); - - for icon_type in icon_types { - let Ok(image) = family.get_icon_with_type(icon_type) else { - continue; - }; - let mut png = Vec::new(); - if image.write_png(&mut png).is_ok() { - return Some(format!("data:image/png;base64,{}", STANDARD.encode(png))); - } - } - None + #[cfg(target_os = "macos")] + { + path.starts_with("/Volumes") } - - fn app_icon_path(app_path: &Path) -> Option { - let info = Value::from_file(app_path.join("Contents/Info.plist")).ok()?; - let dictionary = info.as_dictionary()?; - let icon_name = dictionary - .get("CFBundleIconFile") - .and_then(Value::as_string) - .or_else(|| { - dictionary - .get("CFBundleIconName") - .and_then(Value::as_string) - })?; - - let mut icon_path = app_path.join("Contents/Resources").join(icon_name); - if icon_path.extension().is_none() { - icon_path.set_extension("icns"); - } - icon_path.is_file().then_some(icon_path) + #[cfg(target_os = "linux")] + { + ["/media", "/mnt", "/run/media", "/workspace", "/workspaces"] + .iter() + .any(|root| path.starts_with(root)) } -} - -#[cfg(not(target_os = "macos"))] -mod imp { - use super::{WorkspaceAppCandidate, WorkspaceApplication}; - use std::path::Path; - - pub fn list_workspace_applications() -> Vec { - Vec::new() + #[cfg(target_os = "windows")] + { + // Developer repositories commonly live on a non-system drive. Keep + // network shares out of scope, but permit canonical drive-rooted paths. + let value = path.to_string_lossy(); + path.has_root() && !value.starts_with(r"\\") } - - pub fn open_workspace( - _workspace_path: &Path, - _candidate: &WorkspaceAppCandidate, - ) -> Result<(), String> { - Err("workspace applications are only available on macOS".to_string()) + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + false } } -#[cfg(test)] +#[cfg(all(test, any(target_os = "linux", target_os = "macos")))] mod tests { - use super::{candidate_by_id, is_allowed_workspace_root}; + use super::is_allowed_workspace_root; use std::path::Path; + #[cfg(target_os = "macos")] #[test] - fn candidate_lookup_accepts_only_declared_ids() { - assert_eq!( - candidate_by_id("vscode").map(|app| app.label), - Some("VS Code") - ); - assert!(candidate_by_id("../../Applications/Calculator.app").is_none()); - } - - #[test] - fn workspace_roots_are_scoped_to_home_and_volumes() { + fn macos_workspace_roots_are_scoped_to_home_and_volumes() { let home = Path::new("/Users/tester"); assert!(is_allowed_workspace_root( Path::new("/Users/tester/work/jcode"), @@ -341,17 +162,22 @@ mod tests { )); } - #[cfg(target_os = "macos")] + #[cfg(target_os = "linux")] #[test] - fn macos_discovery_returns_finder_with_its_native_icon() { - let applications = super::imp::list_workspace_applications(); - let finder = applications - .iter() - .find(|application| application.id == "finder") - .expect("Finder should be registered with Launch Services"); - assert!(finder - .icon_data_url - .as_deref() - .is_some_and(|icon| icon.starts_with("data:image/png;base64,"))); + fn linux_workspace_roots_include_common_mount_locations() { + let home = Path::new("/home/tester"); + assert!(is_allowed_workspace_root( + Path::new("/home/tester/work/jcode"), + home + )); + assert!(is_allowed_workspace_root( + Path::new("/mnt/source/jcode"), + home + )); + assert!(is_allowed_workspace_root( + Path::new("/workspaces/jcode"), + home + )); + assert!(!is_allowed_workspace_root(Path::new("/etc/jcode"), home)); } } diff --git a/desktop/src-tauri/src/desktop_apps/linux.rs b/desktop/src-tauri/src/desktop_apps/linux.rs new file mode 100644 index 0000000..d2f2612 --- /dev/null +++ b/desktop/src-tauri/src/desktop_apps/linux.rs @@ -0,0 +1,778 @@ +use super::{WorkspaceAppCandidate, WorkspaceApplication, WorkspaceApplicationKind}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use std::collections::HashSet; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +#[derive(Clone, Copy)] +struct LinuxCandidate { + app: WorkspaceAppCandidate, + desktop_ids: &'static [&'static str], + executable_names: &'static [&'static str], +} + +#[derive(Clone, Debug)] +struct DesktopEntry { + path: PathBuf, + name: String, + icon: Option, + exec: String, + try_exec: Option, +} + +#[derive(Clone)] +struct ResolvedApplication { + entry: Option, + executable: Option, +} + +const APPLICATIONS: &[LinuxCandidate] = &[ + candidate( + "vscode", + "VS Code", + "editor", + WorkspaceApplicationKind::Editor, + &[ + "code.desktop", + "visual-studio-code.desktop", + "com.visualstudio.code.desktop", + ], + &["code"], + ), + candidate( + "cursor", + "Cursor", + "editor", + WorkspaceApplicationKind::Editor, + &["cursor.desktop", "com.todesktop.230313mzl4w4u92.desktop"], + &["cursor"], + ), + candidate( + "zed", + "Zed", + "editor", + WorkspaceApplicationKind::Editor, + &["dev.zed.zed.desktop", "zed.desktop", "zed-editor.desktop"], + &["zed", "zeditor", "zed-editor"], + ), + candidate( + "antigravity", + "Antigravity", + "editor", + WorkspaceApplicationKind::Editor, + &["antigravity.desktop", "com.google.antigravity.desktop"], + &["antigravity"], + ), + candidate( + "goland", + "GoLand", + "editor", + WorkspaceApplicationKind::Editor, + &[ + "goland.desktop", + "jetbrains-goland.desktop", + "com.jetbrains.goland.desktop", + ], + &["goland"], + ), + candidate( + "nautilus", + "Files", + "system", + WorkspaceApplicationKind::FileManager, + &["org.gnome.nautilus.desktop", "nautilus.desktop"], + &["nautilus"], + ), + candidate( + "dolphin", + "Dolphin", + "system", + WorkspaceApplicationKind::FileManager, + &["org.kde.dolphin.desktop"], + &["dolphin"], + ), + candidate( + "thunar", + "Thunar", + "system", + WorkspaceApplicationKind::FileManager, + &["thunar.desktop"], + &["thunar"], + ), + candidate( + "nemo", + "Nemo", + "system", + WorkspaceApplicationKind::FileManager, + &["nemo.desktop"], + &["nemo"], + ), + candidate( + "ptyxis", + "Ptyxis", + "system", + WorkspaceApplicationKind::Terminal, + &["org.gnome.ptyxis.desktop"], + &["ptyxis"], + ), + candidate( + "gnome-terminal", + "Terminal", + "system", + WorkspaceApplicationKind::Terminal, + &["org.gnome.terminal.desktop", "gnome-terminal.desktop"], + &["gnome-terminal"], + ), + candidate( + "konsole", + "Konsole", + "system", + WorkspaceApplicationKind::Terminal, + &["org.kde.konsole.desktop"], + &["konsole"], + ), + candidate( + "xfce-terminal", + "Xfce Terminal", + "system", + WorkspaceApplicationKind::Terminal, + &["xfce4-terminal.desktop"], + &["xfce4-terminal"], + ), + candidate( + "ghostty", + "Ghostty", + "system", + WorkspaceApplicationKind::Terminal, + &["com.mitchellh.ghostty.desktop"], + &["ghostty"], + ), + candidate( + "warp", + "Warp", + "system", + WorkspaceApplicationKind::Terminal, + &["dev.warp.warp.desktop", "warp-terminal.desktop"], + &["warp-terminal", "warp"], + ), + candidate( + "alacritty", + "Alacritty", + "system", + WorkspaceApplicationKind::Terminal, + &["alacritty.desktop", "org.alacritty.alacritty.desktop"], + &["alacritty"], + ), + candidate( + "kitty", + "kitty", + "system", + WorkspaceApplicationKind::Terminal, + &["kitty.desktop"], + &["kitty"], + ), +]; + +const fn candidate( + id: &'static str, + label: &'static str, + group: &'static str, + kind: WorkspaceApplicationKind, + desktop_ids: &'static [&'static str], + executable_names: &'static [&'static str], +) -> LinuxCandidate { + LinuxCandidate { + app: WorkspaceAppCandidate { + id, + label, + group, + kind, + }, + desktop_ids, + executable_names, + } +} + +pub fn list_workspace_applications() -> Vec { + let entries = read_desktop_entries(); + APPLICATIONS + .iter() + .filter_map(|candidate| { + let resolved = resolve_application(candidate, &entries)?; + let icon = resolved + .entry + .as_ref() + .and_then(|entry| entry.icon.as_deref()) + .and_then(icon_data_url); + Some(WorkspaceApplication::new(candidate.app, icon)) + }) + .collect() +} + +pub fn open_workspace(workspace_path: &Path, app_id: &str) -> Result<(), String> { + let candidate = APPLICATIONS + .iter() + .find(|candidate| candidate.app.id == app_id) + .ok_or_else(|| format!("unsupported workspace application: {app_id}"))?; + let entries = read_desktop_entries(); + let resolved = resolve_application(candidate, &entries) + .ok_or_else(|| format!("{} is not installed", candidate.app.label))?; + + match candidate.app.kind { + WorkspaceApplicationKind::FileManager => { + if let Some(entry) = resolved.entry.as_ref() { + if launch_desktop_entry(entry, workspace_path, candidate).is_ok() { + return Ok(()); + } + } + launch_executable(&resolved, workspace_path, candidate, true) + } + WorkspaceApplicationKind::Editor => { + if let Some(entry) = resolved.entry.as_ref() { + if launch_desktop_entry(entry, workspace_path, candidate).is_ok() { + return Ok(()); + } + } + launch_executable(&resolved, workspace_path, candidate, true) + } + WorkspaceApplicationKind::Terminal => { + launch_executable(&resolved, workspace_path, candidate, false) + } + } +} + +fn resolve_application( + candidate: &LinuxCandidate, + entries: &[DesktopEntry], +) -> Option { + let entry = candidate + .desktop_ids + .iter() + .find_map(|expected| { + entries.iter().find(|entry| { + desktop_entry_id(entry).eq_ignore_ascii_case(expected) + && desktop_entry_available(entry) + }) + }) + .or_else(|| { + entries.iter().find(|entry| { + !desktop_entry_id(entry).contains("url-handler") + && desktop_executable_matches(entry, candidate) + && desktop_entry_available(entry) + }) + }) + .cloned(); + let executable = entry + .as_ref() + .and_then(desktop_entry_executable) + .or_else(|| find_named_executable(candidate.executable_names)); + + if entry.is_some() || executable.is_some() { + Some(ResolvedApplication { entry, executable }) + } else { + None + } +} + +fn desktop_entry_id(entry: &DesktopEntry) -> String { + entry + .path + .file_name() + .and_then(OsStr::to_str) + .unwrap_or_default() + .to_ascii_lowercase() +} + +fn desktop_executable_matches(entry: &DesktopEntry, candidate: &LinuxCandidate) -> bool { + let executable = desktop_entry_command(entry) + .and_then(|command| command.file_name().map(OsStr::to_owned)) + .and_then(|name| name.to_str().map(str::to_ascii_lowercase)); + executable.is_some_and(|name| { + candidate + .executable_names + .iter() + .any(|expected| name == expected.to_ascii_lowercase()) + }) +} + +fn desktop_entry_available(entry: &DesktopEntry) -> bool { + entry + .try_exec + .as_deref() + .map(resolve_executable) + .map(|path| path.is_some()) + .unwrap_or(true) +} + +fn desktop_entry_executable(entry: &DesktopEntry) -> Option { + entry + .try_exec + .as_deref() + .and_then(resolve_executable) + .or_else(|| desktop_entry_command(entry)) +} + +fn desktop_entry_command(entry: &DesktopEntry) -> Option { + let words = shlex::split(&entry.exec)?; + let command = words + .iter() + .skip_while(|word| word.as_str() == "env" || is_environment_assignment(word)) + .find(|word| !word.starts_with('%'))?; + resolve_executable(command) +} + +fn is_environment_assignment(word: &str) -> bool { + let Some((name, _)) = word.split_once('=') else { + return false; + }; + !name.is_empty() + && name + .bytes() + .all(|byte| byte == b'_' || byte.is_ascii_alphanumeric()) +} + +fn launch_desktop_entry( + entry: &DesktopEntry, + workspace_path: &Path, + candidate: &LinuxCandidate, +) -> Result<(), String> { + let gio = resolve_executable("gio").ok_or_else(|| "gio is unavailable".to_string())?; + spawn_detached( + Command::new(gio) + .arg("launch") + .arg(&entry.path) + .arg(workspace_path), + candidate.app.label, + ) +} + +fn launch_executable( + resolved: &ResolvedApplication, + workspace_path: &Path, + candidate: &LinuxCandidate, + pass_workspace: bool, +) -> Result<(), String> { + let (executable, mut arguments, includes_workspace) = + if let Some(entry) = resolved.entry.as_ref() { + desktop_command(entry, workspace_path, pass_workspace)? + } else { + let executable = resolved + .executable + .clone() + .ok_or_else(|| format!("{} executable is unavailable", candidate.app.label))?; + (executable, Vec::new(), false) + }; + + if pass_workspace && !includes_workspace { + arguments.push(workspace_path.as_os_str().to_owned()); + } + + let mut command = Command::new(executable); + command.args(arguments).current_dir(workspace_path); + spawn_detached(&mut command, candidate.app.label) +} + +fn desktop_command( + entry: &DesktopEntry, + workspace_path: &Path, + pass_workspace: bool, +) -> Result<(PathBuf, Vec, bool), String> { + let words = shlex::split(&entry.exec) + .ok_or_else(|| format!("invalid desktop command for {}", entry.name))?; + let mut words = words.into_iter(); + let command = words + .next() + .ok_or_else(|| format!("missing desktop command for {}", entry.name))?; + let executable = resolve_executable(&command) + .ok_or_else(|| format!("desktop executable is unavailable: {command}"))?; + let workspace = workspace_path.to_string_lossy(); + let mut includes_workspace = false; + let mut arguments = Vec::new(); + + for word in words { + if matches!(word.as_str(), "%i" | "%c" | "%k") { + continue; + } + if matches!(word.as_str(), "%f" | "%F" | "%u" | "%U") { + if pass_workspace { + arguments.push(workspace_path.as_os_str().to_owned()); + includes_workspace = true; + } + continue; + } + + let mut expanded = word.replace("%%", "%"); + for field in ["%f", "%F", "%u", "%U"] { + if expanded.contains(field) { + if pass_workspace { + expanded = expanded.replace(field, &workspace); + includes_workspace = true; + } else { + expanded = expanded.replace(field, ""); + } + } + } + if !expanded.is_empty() { + arguments.push(expanded.into()); + } + } + + Ok((executable, arguments, includes_workspace)) +} + +fn spawn_detached(command: &mut Command, label: &str) -> Result<(), String> { + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|error| format!("could not start {label}: {error}")) +} + +fn read_desktop_entries() -> Vec { + let mut entries = Vec::new(); + let mut seen = HashSet::new(); + for directory in application_directories() { + let Ok(children) = fs::read_dir(directory) else { + continue; + }; + for child in children.flatten() { + let path = child.path(); + if path.extension().and_then(OsStr::to_str) != Some("desktop") { + continue; + } + let Some(id) = path + .file_name() + .and_then(OsStr::to_str) + .map(str::to_ascii_lowercase) + else { + continue; + }; + if !seen.insert(id) { + continue; + } + if let Some(entry) = parse_desktop_entry(&path) { + entries.push(entry); + } + } + } + entries +} + +fn parse_desktop_entry(path: &Path) -> Option { + let source = fs::read_to_string(path).ok()?; + let mut in_desktop_entry = false; + let mut entry_type = String::new(); + let mut name = String::new(); + let mut icon = None; + let mut exec = String::new(); + let mut try_exec = None; + let mut hidden = false; + + for raw_line in source.lines() { + let line = raw_line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with('[') && line.ends_with(']') { + in_desktop_entry = line == "[Desktop Entry]"; + continue; + } + if !in_desktop_entry { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + match key { + "Type" => entry_type = value.trim().to_string(), + "Name" => name = value.trim().to_string(), + "Icon" => icon = non_empty(value), + "Exec" => exec = value.trim().to_string(), + "TryExec" => try_exec = non_empty(value), + "Hidden" => hidden = value.trim().eq_ignore_ascii_case("true"), + _ => {} + } + } + + if hidden || entry_type != "Application" || name.is_empty() || exec.is_empty() { + return None; + } + Some(DesktopEntry { + path: path.to_path_buf(), + name, + icon, + exec, + try_exec, + }) +} + +fn non_empty(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn application_directories() -> Vec { + let mut directories = Vec::new(); + let home = std::env::var_os("HOME").map(PathBuf::from); + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + directories.push(PathBuf::from(data_home).join("applications")); + } else if let Some(home) = home.as_ref() { + directories.push(home.join(".local/share/applications")); + } + for directory in system_data_directories() { + directories.push(directory.join("applications")); + } + directories.push(PathBuf::from("/var/lib/snapd/desktop/applications")); + deduplicate_paths(directories) +} + +fn system_data_directories() -> Vec { + let configured = std::env::var("XDG_DATA_DIRS") + .unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string()); + let mut directories: Vec = configured + .split(':') + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .collect(); + directories.push(PathBuf::from("/var/lib/flatpak/exports/share")); + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + directories.push(home.join(".local/share/flatpak/exports/share")); + } + deduplicate_paths(directories) +} + +fn deduplicate_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + paths + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() +} + +fn find_named_executable(names: &[&str]) -> Option { + names.iter().find_map(|name| resolve_executable(name)) +} + +fn resolve_executable(value: &str) -> Option { + let expanded = if let Some(relative) = value.strip_prefix("~/") { + std::env::var_os("HOME").map(PathBuf::from)?.join(relative) + } else { + PathBuf::from(value) + }; + if expanded.components().count() > 1 { + return executable_file(&expanded).then_some(expanded); + } + + executable_search_directories() + .into_iter() + .map(|directory| directory.join(value)) + .find(|path| executable_file(path)) +} + +fn executable_search_directories() -> Vec { + let mut paths: Vec = std::env::var_os("PATH") + .map(|value| std::env::split_paths(&value).collect()) + .unwrap_or_default(); + paths.extend([ + PathBuf::from("/usr/local/bin"), + PathBuf::from("/usr/bin"), + PathBuf::from("/snap/bin"), + ]); + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + paths.push(home.join(".local/bin")); + paths.push(home.join(".local/share/JetBrains/Toolbox/scripts")); + } + deduplicate_paths(paths) +} + +fn executable_file(path: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + + fs::metadata(path) + .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +fn icon_data_url(icon: &str) -> Option { + let icon_path = resolve_icon(icon)?; + let metadata = fs::metadata(&icon_path).ok()?; + if !metadata.is_file() || metadata.len() > 4 * 1024 * 1024 { + return None; + } + let mime = match icon_path + .extension() + .and_then(OsStr::to_str)? + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "svg" => "image/svg+xml", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => return None, + }; + let bytes = fs::read(icon_path).ok()?; + Some(format!("data:{mime};base64,{}", STANDARD.encode(bytes))) +} + +fn resolve_icon(icon: &str) -> Option { + let direct = PathBuf::from(icon); + if direct.is_absolute() && direct.is_file() { + return Some(direct); + } + + let icon_name = direct.file_stem().and_then(OsStr::to_str).unwrap_or(icon); + let requested_extension = direct.extension().and_then(OsStr::to_str); + let extensions = match requested_extension { + Some(extension) => vec![extension], + None => vec!["png", "svg", "webp"], + }; + + for data_dir in icon_data_directories() { + for theme in ["hicolor", "Adwaita", "breeze", "HighContrast"] { + for size in ["64x64", "48x48", "128x128", "256x256", "32x32", "scalable"] { + for context in ["apps", "applications"] { + for extension in &extensions { + let path = data_dir + .join("icons") + .join(theme) + .join(size) + .join(context) + .join(format!("{icon_name}.{extension}")); + if path.is_file() { + return Some(path); + } + } + } + } + } + for extension in &extensions { + let path = data_dir + .join("pixmaps") + .join(format!("{icon_name}.{extension}")); + if path.is_file() { + return Some(path); + } + } + } + None +} + +fn icon_data_directories() -> Vec { + let mut directories = Vec::new(); + if let Some(data_home) = std::env::var_os("XDG_DATA_HOME") { + directories.push(PathBuf::from(data_home)); + } else if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) { + directories.push(home.join(".local/share")); + directories.push(home.join(".icons")); + } + directories.extend(system_data_directories()); + deduplicate_paths(directories) +} + +#[cfg(test)] +mod tests { + use super::{desktop_command, parse_desktop_entry, resolve_application, DesktopEntry}; + use std::fs; + + #[test] + fn parses_a_visible_application_entry() { + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join("code.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Code\nExec=code %F\nIcon=code\n", + ) + .expect("desktop entry"); + let entry = parse_desktop_entry(&path).expect("valid entry"); + assert_eq!(entry.name, "Code"); + assert_eq!(entry.icon.as_deref(), Some("code")); + assert_eq!(entry.exec, "code %F"); + } + + #[test] + fn ignores_hidden_desktop_entries() { + let directory = tempfile::tempdir().expect("temp directory"); + let path = directory.path().join("hidden.desktop"); + fs::write( + &path, + "[Desktop Entry]\nType=Application\nName=Hidden\nExec=hidden\nHidden=true\n", + ) + .expect("desktop entry"); + assert!(parse_desktop_entry(&path).is_none()); + } + + #[test] + fn desktop_field_code_receives_the_workspace_once() { + let directory = tempfile::tempdir().expect("temp directory"); + let executable = directory.path().join("editor"); + fs::write(&executable, "#!/bin/sh\n").expect("executable"); + let mut permissions = fs::metadata(&executable).expect("metadata").permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + fs::set_permissions(&executable, permissions).expect("permissions"); + let entry = super::DesktopEntry { + path: directory.path().join("editor.desktop"), + name: "Editor".to_string(), + icon: None, + exec: format!("{} --new-window %F", executable.display()), + try_exec: None, + }; + let workspace = directory.path().join("workspace"); + let (_, arguments, included) = + desktop_command(&entry, &workspace, true).expect("desktop command"); + assert!(included); + assert_eq!( + arguments + .iter() + .filter(|argument| argument.as_os_str() == workspace.as_os_str()) + .count(), + 1 + ); + } + + #[test] + fn prefers_the_primary_desktop_entry_over_a_url_handler() { + let directory = tempfile::tempdir().expect("temp directory"); + let executable = directory.path().join("cursor"); + fs::write(&executable, "#!/bin/sh\n").expect("executable"); + let mut permissions = fs::metadata(&executable).expect("metadata").permissions(); + use std::os::unix::fs::PermissionsExt; + permissions.set_mode(0o755); + fs::set_permissions(&executable, permissions).expect("permissions"); + let entries = vec![ + DesktopEntry { + path: directory.path().join("cursor-url-handler.desktop"), + name: "Cursor URL Handler".to_string(), + icon: None, + exec: format!("{} --open-url %U", executable.display()), + try_exec: None, + }, + DesktopEntry { + path: directory.path().join("cursor.desktop"), + name: "Cursor".to_string(), + icon: None, + exec: format!("{} %F", executable.display()), + try_exec: None, + }, + ]; + let cursor = super::APPLICATIONS + .iter() + .find(|candidate| candidate.app.id == "cursor") + .expect("Cursor candidate"); + let resolved = resolve_application(cursor, &entries).expect("resolved Cursor"); + assert_eq!( + resolved + .entry + .expect("desktop entry") + .path + .file_name() + .and_then(std::ffi::OsStr::to_str), + Some("cursor.desktop") + ); + } +} diff --git a/desktop/src-tauri/src/desktop_apps/macos.rs b/desktop/src-tauri/src/desktop_apps/macos.rs new file mode 100644 index 0000000..eeafdf5 --- /dev/null +++ b/desktop/src-tauri/src/desktop_apps/macos.rs @@ -0,0 +1,276 @@ +use super::{WorkspaceAppCandidate, WorkspaceApplication, WorkspaceApplicationKind}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use icns::IconFamily; +use objc2_app_kit::NSWorkspace; +use objc2_foundation::{NSArray, NSString, NSURL}; +use plist::Value; +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +#[derive(Clone, Copy)] +struct MacOSCandidate { + app: WorkspaceAppCandidate, + bundle_ids: &'static [&'static str], + reveal_in_finder: bool, +} + +const APPLICATIONS: &[MacOSCandidate] = &[ + candidate( + "vscode", + "VS Code", + "editor", + WorkspaceApplicationKind::Editor, + &["com.microsoft.VSCode"], + false, + ), + candidate( + "cursor", + "Cursor", + "editor", + WorkspaceApplicationKind::Editor, + &["com.todesktop.230313mzl4w4u92"], + false, + ), + candidate( + "zed", + "Zed", + "editor", + WorkspaceApplicationKind::Editor, + &["dev.zed.Zed", "dev.zed.Zed-Preview"], + false, + ), + candidate( + "antigravity", + "Antigravity", + "editor", + WorkspaceApplicationKind::Editor, + &["com.google.antigravity"], + false, + ), + candidate( + "xcode", + "Xcode", + "editor", + WorkspaceApplicationKind::Editor, + &["com.apple.dt.Xcode"], + false, + ), + candidate( + "goland", + "GoLand", + "editor", + WorkspaceApplicationKind::Editor, + &["com.jetbrains.goland"], + false, + ), + candidate( + "finder", + "Finder", + "system", + WorkspaceApplicationKind::FileManager, + &["com.apple.finder"], + true, + ), + candidate( + "terminal", + "Terminal", + "system", + WorkspaceApplicationKind::Terminal, + &["com.apple.Terminal"], + false, + ), + candidate( + "iterm", + "iTerm2", + "system", + WorkspaceApplicationKind::Terminal, + &["com.googlecode.iterm2"], + false, + ), + candidate( + "ghostty", + "Ghostty", + "system", + WorkspaceApplicationKind::Terminal, + &["com.mitchellh.ghostty"], + false, + ), + candidate( + "warp", + "Warp", + "system", + WorkspaceApplicationKind::Terminal, + &["dev.warp.Warp-Stable", "dev.warp.Warp"], + false, + ), +]; + +const fn candidate( + id: &'static str, + label: &'static str, + group: &'static str, + kind: WorkspaceApplicationKind, + bundle_ids: &'static [&'static str], + reveal_in_finder: bool, +) -> MacOSCandidate { + MacOSCandidate { + app: WorkspaceAppCandidate { + id, + label, + group, + kind, + }, + bundle_ids, + reveal_in_finder, + } +} + +pub fn list_workspace_applications() -> Vec { + APPLICATIONS + .iter() + .filter_map(|candidate| { + let app_path = resolve_application(candidate)?; + Some(WorkspaceApplication::new( + candidate.app, + app_icon_data_url(&app_path), + )) + }) + .collect() +} + +pub fn open_workspace(workspace_path: &Path, app_id: &str) -> Result<(), String> { + let candidate = APPLICATIONS + .iter() + .find(|candidate| candidate.app.id == app_id) + .ok_or_else(|| format!("unsupported workspace application: {app_id}"))?; + let app_path = resolve_application(candidate) + .ok_or_else(|| format!("{} is not installed", candidate.app.label))?; + + if candidate.reveal_in_finder { + reveal_in_finder(workspace_path); + return Ok(()); + } + + let output = Command::new("/usr/bin/open") + .arg("-a") + .arg(&app_path) + .arg("--") + .arg(workspace_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .map_err(|error| format!("could not start {}: {error}", candidate.app.label))?; + + if output.status.success() { + return Ok(()); + } + + let detail = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if detail.is_empty() { + Err(format!( + "{} exited with status {}", + candidate.app.label, output.status + )) + } else { + Err(format!("could not open {}: {detail}", candidate.app.label)) + } +} + +fn resolve_application(candidate: &MacOSCandidate) -> Option { + let workspace = NSWorkspace::sharedWorkspace(); + for bundle_id in candidate.bundle_ids { + let identifier = NSString::from_str(bundle_id); + let Some(url) = workspace.URLForApplicationWithBundleIdentifier(&identifier) else { + continue; + }; + let Some(path) = url.path() else { + continue; + }; + let app_path = PathBuf::from(path.to_string()); + if app_path.is_dir() { + return Some(app_path); + } + } + None +} + +fn reveal_in_finder(path: &Path) { + let workspace = NSWorkspace::sharedWorkspace(); + let path = NSString::from_str(&path.to_string_lossy()); + let url = NSURL::fileURLWithPath(&path); + let urls = NSArray::from_retained_slice(&[url]); + workspace.activateFileViewerSelectingURLs(&urls); +} + +fn app_icon_data_url(app_path: &Path) -> Option { + let icon_path = app_icon_path(app_path)?; + let file = BufReader::new(File::open(icon_path).ok()?); + let family = IconFamily::read(file).ok()?; + let mut icon_types = family.available_icons().to_vec(); + icon_types.sort_by_key(|icon_type| { + let width = icon_type.pixel_width(); + if width >= 64 { + width - 64 + } else { + 10_000 + 64 - width + } + }); + + for icon_type in icon_types { + let Ok(image) = family.get_icon_with_type(icon_type) else { + continue; + }; + let mut png = Vec::new(); + if image.write_png(&mut png).is_ok() { + return Some(format!("data:image/png;base64,{}", STANDARD.encode(png))); + } + } + None +} + +fn app_icon_path(app_path: &Path) -> Option { + let info = Value::from_file(app_path.join("Contents/Info.plist")).ok()?; + let dictionary = info.as_dictionary()?; + let icon_name = dictionary + .get("CFBundleIconFile") + .and_then(Value::as_string) + .or_else(|| { + dictionary + .get("CFBundleIconName") + .and_then(Value::as_string) + })?; + + let mut icon_path = app_path.join("Contents/Resources").join(icon_name); + if icon_path.extension().is_none() { + icon_path.set_extension("icns"); + } + icon_path.is_file().then_some(icon_path) +} + +#[cfg(test)] +mod tests { + #[test] + fn discovery_returns_finder_with_its_native_icon() { + let applications = super::list_workspace_applications(); + let finder = applications + .iter() + .find(|application| application.id == "finder") + .expect("Finder should be registered with Launch Services"); + assert!(finder + .icon_data_url + .as_deref() + .is_some_and(|icon| icon.starts_with("data:image/png;base64,"))); + } + + #[test] + fn application_ids_are_opaque() { + let result = super::open_workspace( + std::path::Path::new("/"), + "../../Applications/Calculator.app", + ); + assert!(result.is_err()); + } +} diff --git a/desktop/src-tauri/src/desktop_apps/unsupported.rs b/desktop/src-tauri/src/desktop_apps/unsupported.rs new file mode 100644 index 0000000..f2c23dc --- /dev/null +++ b/desktop/src-tauri/src/desktop_apps/unsupported.rs @@ -0,0 +1,10 @@ +use super::WorkspaceApplication; +use std::path::Path; + +pub fn list_workspace_applications() -> Vec { + Vec::new() +} + +pub fn open_workspace(_workspace_path: &Path, _app_id: &str) -> Result<(), String> { + Err("workspace applications are unavailable on this platform".to_string()) +} diff --git a/desktop/src-tauri/src/desktop_apps/windows.rs b/desktop/src-tauri/src/desktop_apps/windows.rs new file mode 100644 index 0000000..4fc3ade --- /dev/null +++ b/desktop/src-tauri/src/desktop_apps/windows.rs @@ -0,0 +1,599 @@ +use super::{WorkspaceAppCandidate, WorkspaceApplication, WorkspaceApplicationKind}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use std::ffi::OsStr; +use std::mem; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::ptr; +use windows_sys::Win32::Graphics::Gdi::{ + DeleteObject, GetDC, GetDIBits, GetObjectW, ReleaseDC, BITMAP, BITMAPINFO, BITMAPINFOHEADER, + BI_RGB, DIB_RGB_COLORS, HBITMAP, HDC, +}; +use windows_sys::Win32::UI::Shell::ExtractIconExW; +use windows_sys::Win32::UI::WindowsAndMessaging::{DestroyIcon, GetIconInfo, HICON, ICONINFO}; +use winreg::enums::{ + HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY, +}; +use winreg::RegKey; + +#[derive(Clone, Copy)] +struct WindowsCandidate { + app: WorkspaceAppCandidate, + display_name_prefixes: &'static [&'static str], + publishers: &'static [&'static str], + executable_names: &'static [&'static str], + install_relative_paths: &'static [&'static str], +} + +#[derive(Debug)] +struct RegistryApplication { + display_name: String, + publisher: String, + display_icon: Option, + install_location: Option, +} + +const APPLICATIONS: &[WindowsCandidate] = &[ + candidate( + "vscode", + "VS Code", + WorkspaceApplicationKind::Editor, + &["Microsoft Visual Studio Code"], + &["Microsoft Corporation"], + &["Code.exe"], + &["Code.exe"], + ), + candidate( + "cursor", + "Cursor", + WorkspaceApplicationKind::Editor, + &["Cursor", "Cursor (User)"], + &["Anysphere", "Cursor AI"], + &["Cursor.exe"], + &["Cursor.exe"], + ), + candidate( + "zed", + "Zed", + WorkspaceApplicationKind::Editor, + &["Zed"], + &["Zed Industries"], + &["Zed.exe"], + &["Zed.exe"], + ), + candidate( + "antigravity", + "Antigravity", + WorkspaceApplicationKind::Editor, + &["Antigravity"], + &["Google"], + &["Antigravity.exe", "antigravity.exe"], + &["Antigravity.exe", "antigravity.exe"], + ), + candidate( + "goland", + "GoLand", + WorkspaceApplicationKind::Editor, + &["GoLand", "JetBrains GoLand", "JetBrains Toolbox (GoLand"], + &["JetBrains"], + &["goland64.exe", "goland.exe"], + &["bin\\goland64.exe", "bin\\goland.exe"], + ), + candidate( + "explorer", + "File Explorer", + WorkspaceApplicationKind::FileManager, + &[], + &[], + &["explorer.exe"], + &[], + ), + candidate( + "windows-terminal", + "Windows Terminal", + WorkspaceApplicationKind::Terminal, + &["Windows Terminal"], + &["Microsoft Corporation"], + &["wt.exe"], + &["wt.exe"], + ), + candidate( + "ghostty", + "Ghostty", + WorkspaceApplicationKind::Terminal, + &["Ghostty"], + &[], + &["ghostty.exe"], + &["ghostty.exe", "bin\\ghostty.exe"], + ), + candidate( + "warp", + "Warp", + WorkspaceApplicationKind::Terminal, + &["Warp"], + &[], + &["Warp.exe", "warp.exe"], + &["Warp.exe", "warp.exe"], + ), +]; + +const fn candidate( + id: &'static str, + label: &'static str, + kind: WorkspaceApplicationKind, + display_name_prefixes: &'static [&'static str], + publishers: &'static [&'static str], + executable_names: &'static [&'static str], + install_relative_paths: &'static [&'static str], +) -> WindowsCandidate { + let group = match kind { + WorkspaceApplicationKind::Editor => "editor", + WorkspaceApplicationKind::FileManager | WorkspaceApplicationKind::Terminal => "system", + }; + WindowsCandidate { + app: WorkspaceAppCandidate { + id, + label, + group, + kind, + }, + display_name_prefixes, + publishers, + executable_names, + install_relative_paths, + } +} + +pub fn list_workspace_applications() -> Vec { + let registry = installed_registry_applications(); + APPLICATIONS + .iter() + .filter_map(|candidate| { + let executable = resolve_application(candidate, ®istry)?; + let icon = app_icon_data_url(&executable); + Some(WorkspaceApplication::new(candidate.app, icon)) + }) + .collect() +} + +pub fn open_workspace(workspace_path: &Path, app_id: &str) -> Result<(), String> { + let candidate = APPLICATIONS + .iter() + .find(|candidate| candidate.app.id == app_id) + .ok_or_else(|| format!("unsupported workspace application: {app_id}"))?; + let registry = installed_registry_applications(); + let executable = resolve_application(candidate, ®istry) + .ok_or_else(|| format!("{} is not installed", candidate.app.label))?; + let mut command = Command::new(executable); + + match candidate.app.kind { + WorkspaceApplicationKind::Editor | WorkspaceApplicationKind::FileManager => { + command.arg(workspace_path); + } + WorkspaceApplicationKind::Terminal => { + if candidate.app.id == "windows-terminal" { + command.arg("-d").arg(workspace_path); + } + command.current_dir(workspace_path); + } + } + + command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|error| format!("could not start {}: {error}", candidate.app.label)) +} + +fn resolve_application( + candidate: &WindowsCandidate, + registry: &[RegistryApplication], +) -> Option { + if candidate.app.id == "explorer" { + return std::env::var_os("WINDIR") + .map(PathBuf::from) + .map(|path| path.join("explorer.exe")) + .filter(|path| path.is_file()) + .or_else(|| find_on_path(candidate.executable_names)); + } + + for application in registry { + if !registry_application_matches(application, candidate) { + continue; + } + if let Some(icon_path) = application + .display_icon + .as_ref() + .filter(|path| executable_matches(path, candidate)) + { + return Some(icon_path.clone()); + } + if let Some(install_location) = application.install_location.as_ref() { + for relative in candidate.install_relative_paths { + let path = install_location.join(relative); + if path.is_file() { + return Some(path); + } + } + } + } + + known_application_paths(candidate) + .into_iter() + .find(|path| path.is_file()) + .or_else(|| find_on_path(candidate.executable_names)) +} + +fn registry_application_matches( + application: &RegistryApplication, + candidate: &WindowsCandidate, +) -> bool { + if candidate.display_name_prefixes.is_empty() + || !candidate + .display_name_prefixes + .iter() + .any(|prefix| starts_with_ignore_ascii_case(&application.display_name, prefix)) + { + return false; + } + candidate.publishers.is_empty() + || candidate + .publishers + .iter() + .any(|publisher| starts_with_ignore_ascii_case(&application.publisher, publisher)) +} + +fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { + value + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) +} + +fn executable_matches(path: &Path, candidate: &WindowsCandidate) -> bool { + path.is_file() + && path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| { + candidate + .executable_names + .iter() + .any(|expected| name.eq_ignore_ascii_case(expected)) + }) +} + +fn installed_registry_applications() -> Vec { + const UNINSTALL: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall"; + let mut applications = Vec::new(); + for root in [HKEY_CURRENT_USER, HKEY_LOCAL_MACHINE] { + for view in [KEY_WOW64_64KEY, KEY_WOW64_32KEY] { + let root = RegKey::predef(root); + let Ok(uninstall) = root.open_subkey_with_flags(UNINSTALL, KEY_READ | view) else { + continue; + }; + for subkey_name in uninstall.enum_keys().flatten() { + let Ok(subkey) = uninstall.open_subkey_with_flags(subkey_name, KEY_READ) else { + continue; + }; + let display_name: String = subkey.get_value("DisplayName").unwrap_or_default(); + if display_name.is_empty() { + continue; + } + let publisher = subkey.get_value("Publisher").unwrap_or_default(); + let display_icon = subkey + .get_value::("DisplayIcon") + .ok() + .and_then(|value| clean_display_icon(&value)); + let install_location = subkey + .get_value::("InstallLocation") + .ok() + .map(PathBuf::from) + .filter(|path| path.is_dir()); + applications.push(RegistryApplication { + display_name, + publisher, + display_icon, + install_location, + }); + } + } + } + applications +} + +fn clean_display_icon(value: &str) -> Option { + let trimmed = value.trim(); + let without_index = trimmed + .rsplit_once(',') + .filter(|(_, suffix)| suffix.trim().parse::().is_ok()) + .map(|(path, _)| path) + .unwrap_or(trimmed) + .trim() + .trim_matches('"'); + (!without_index.is_empty()).then(|| PathBuf::from(without_index)) +} + +fn known_application_paths(candidate: &WindowsCandidate) -> Vec { + let mut paths = Vec::new(); + let local_app_data = std::env::var_os("LOCALAPPDATA").map(PathBuf::from); + let program_files = std::env::var_os("ProgramFiles").map(PathBuf::from); + let program_files_x86 = std::env::var_os("ProgramFiles(x86)").map(PathBuf::from); + + match candidate.app.id { + "vscode" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Programs\Microsoft VS Code\Code.exe")); + } + for root in [program_files.as_ref(), program_files_x86.as_ref()] + .into_iter() + .flatten() + { + paths.push(root.join(r"Microsoft VS Code\Code.exe")); + } + } + "cursor" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Programs\Cursor\Cursor.exe")); + } + } + "zed" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Programs\Zed\Zed.exe")); + paths.push(root.join(r"Zed\Zed.exe")); + } + } + "antigravity" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Programs\Antigravity\Antigravity.exe")); + } + } + "windows-terminal" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Microsoft\WindowsApps\wt.exe")); + } + } + "ghostty" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Programs\ghostty\bin\ghostty.exe")); + paths.push(root.join(r"Programs\Ghostty\ghostty.exe")); + } + } + "warp" => { + if let Some(root) = local_app_data.as_ref() { + paths.push(root.join(r"Programs\Warp\Warp.exe")); + } + } + _ => {} + } + paths +} + +fn find_on_path(names: &[&str]) -> Option { + names.iter().find_map(|name| { + let output = Command::new("where.exe") + .arg(name) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .find(|path| path.is_file()) + }) +} + +fn app_icon_data_url(executable: &Path) -> Option { + let rgba = executable_icon_rgba(executable)?; + let mut png_bytes = Vec::new(); + { + let mut encoder = png::Encoder::new(&mut png_bytes, rgba.width, rgba.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().ok()?; + writer.write_image_data(&rgba.pixels).ok()?; + } + Some(format!( + "data:image/png;base64,{}", + STANDARD.encode(png_bytes) + )) +} + +struct IconPixels { + width: u32, + height: u32, + pixels: Vec, +} + +struct OwnedIcon(HICON); + +impl Drop for OwnedIcon { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: this guard exclusively owns the HICON returned by + // SHGetFileInfoW and releases it exactly once. + unsafe { + DestroyIcon(self.0); + } + } + } +} + +struct OwnedBitmap(HBITMAP); + +impl Drop for OwnedBitmap { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: ICONINFO transfers bitmap handles to the caller. This + // guard owns one handle and releases it exactly once. + unsafe { + DeleteObject(self.0); + } + } + } +} + +struct OwnedDc(HDC); + +impl Drop for OwnedDc { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: the DC was acquired with GetDC for the null window and is + // paired with ReleaseDC using the same null window. + unsafe { + ReleaseDC(ptr::null_mut(), self.0); + } + } + } +} + +fn executable_icon_rgba(executable: &Path) -> Option { + let wide_path: Vec = executable + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect(); + let mut extracted_icon: HICON = ptr::null_mut(); + // SAFETY: wide_path is NUL-terminated and extracted_icon points to writable + // storage for the single large icon requested from the executable. + let result = unsafe { + ExtractIconExW( + wide_path.as_ptr(), + 0, + &mut extracted_icon, + ptr::null_mut(), + 1, + ) + }; + if result == 0 || extracted_icon.is_null() { + return None; + } + let icon = OwnedIcon(extracted_icon); + + let mut info = ICONINFO::default(); + // SAFETY: icon is valid for the lifetime of this call and info is writable. + if unsafe { GetIconInfo(icon.0, &mut info) } == 0 { + return None; + } + let mask = OwnedBitmap(info.hbmMask); + let color = OwnedBitmap(info.hbmColor); + if color.0.is_null() { + return None; + } + + let mut bitmap = BITMAP::default(); + // SAFETY: color is a valid bitmap handle and bitmap has sufficient space + // for the metadata copied by GetObjectW. + let copied = unsafe { + GetObjectW( + color.0, + mem::size_of::() as i32, + (&mut bitmap as *mut BITMAP).cast(), + ) + }; + if copied != mem::size_of::() as i32 { + return None; + } + + let width = bitmap.bmWidth.unsigned_abs(); + let height = bitmap.bmHeight.unsigned_abs(); + let pixel_count = usize::try_from(width) + .ok()? + .checked_mul(usize::try_from(height).ok()?)?; + let mut bgra = vec![0_u32; pixel_count]; + let dc = OwnedDc( + // SAFETY: GetDC accepts a null window to acquire the screen DC. + unsafe { GetDC(ptr::null_mut()) }, + ); + if dc.0.is_null() { + return None; + } + let mut bitmap_info = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: mem::size_of::() as u32, + biWidth: bitmap.bmWidth, + biHeight: -bitmap.bmHeight, + biPlanes: 1, + biBitCount: 32, + biCompression: BI_RGB, + ..BITMAPINFOHEADER::default() + }, + ..BITMAPINFO::default() + }; + // SAFETY: bgra owns enough initialized space for width*height 32-bit + // pixels, and the handles remain alive for the duration of the call. + let scan_lines = unsafe { + GetDIBits( + dc.0, + color.0, + 0, + height, + bgra.as_mut_ptr().cast(), + &mut bitmap_info, + DIB_RGB_COLORS, + ) + }; + if scan_lines <= 0 || scan_lines as u32 != height { + return None; + } + + let mut pixels = Vec::with_capacity(pixel_count.checked_mul(4)?); + for pixel in bgra { + let [blue, green, red, alpha] = pixel.to_le_bytes(); + pixels.extend_from_slice(&[red, green, blue, alpha]); + } + if pixels.chunks_exact(4).all(|pixel| pixel[3] == 0) { + for pixel in pixels.chunks_exact_mut(4) { + pixel[3] = 255; + } + } + drop(mask); + Some(IconPixels { + width, + height, + pixels, + }) +} + +#[cfg(test)] +mod tests { + use super::{clean_display_icon, registry_application_matches, RegistryApplication}; + use std::path::PathBuf; + + #[test] + fn cleans_quotes_and_icon_index() { + assert_eq!( + clean_display_icon(r#""C:\Program Files\Code\Code.exe",0"#), + Some(PathBuf::from(r"C:\Program Files\Code\Code.exe")) + ); + } + + #[test] + fn validates_registry_identity_before_using_its_path() { + let application = RegistryApplication { + display_name: "Microsoft Visual Studio Code (User)".to_string(), + publisher: "Microsoft Corporation".to_string(), + display_icon: None, + install_location: None, + }; + let vscode = super::APPLICATIONS + .iter() + .find(|candidate| candidate.app.id == "vscode") + .expect("VS Code candidate"); + assert!(registry_application_matches(&application, vscode)); + + let spoofed = RegistryApplication { + publisher: "Unknown Publisher".to_string(), + ..application + }; + assert!(!registry_application_matches(&spoofed, vscode)); + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 81b0dd1..5ea7d00 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -60,7 +60,7 @@ import { RightPanel } from './components/RightPanel' import { TerminalPanel } from './components/TerminalPanel' import { RemoteConnectWizard } from './components/RemoteConnectWizard' import type { RemotePrefill } from './lib/remote' -import { isMacOSDesktop } from './lib/useDesktop' +import { isTauri } from './lib/useDesktop' export default function App() { const dispatch = useAppDispatch() @@ -277,10 +277,10 @@ function Shell({ activeView }: { activeView: 'chat' | 'automations' | 'cloud-mob
{/* Native title-bar drag strip — hidden in browser, shown in Tauri (CSS). */}