diff --git a/libs/cua-driver-rs/Cargo.lock b/libs/cua-driver-rs/Cargo.lock index 1779741749..9ab992e5be 100644 --- a/libs/cua-driver-rs/Cargo.lock +++ b/libs/cua-driver-rs/Cargo.lock @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "cua-driver" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -227,7 +227,7 @@ dependencies = [ [[package]] name = "cursor-overlay" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "image", @@ -325,7 +325,7 @@ checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" [[package]] name = "focus-monitor-win" -version = "0.1.2" +version = "0.1.3" dependencies = [ "windows", ] @@ -626,7 +626,7 @@ dependencies = [ [[package]] name = "mcp-server" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -900,7 +900,7 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "platform-linux" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -919,7 +919,7 @@ dependencies = [ [[package]] name = "platform-macos" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", @@ -947,7 +947,7 @@ dependencies = [ [[package]] name = "platform-windows" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-trait", diff --git a/libs/cua-driver-rs/PARITY.md b/libs/cua-driver-rs/PARITY.md index 9ff1dc1f6a..12e0c6ab81 100644 --- a/libs/cua-driver-rs/PARITY.md +++ b/libs/cua-driver-rs/PARITY.md @@ -415,15 +415,65 @@ Windows's `click` takes `{button: enum}` instead. Rationale: ## MCP tool: `launch_app` - Swift: `libs/cua-driver/Sources/CuaDriverServer/Tools/LaunchAppTool.swift:6-490` + + Name resolution: `libs/cua-driver/Sources/CuaDriverCore/Apps/AppLauncher.swift` + `AppLauncher.locate(bundleId:name:)` - Rust: - windows=`crates/platform-windows/src/tools/impl_.rs` (LaunchAppTool) - - macos=`crates/platform-macos/src/tools/launch_app.rs` (TBD audit) + - macos=`crates/platform-macos/src/tools/launch_app.rs` + + Name resolution: `crates/platform-macos/src/apps.rs::locate_app_by_name` - linux=`crates/platform-linux/src/tools/impl_.rs` (TBD audit) - Status: - windows: VERIFIED - - macos: OPEN + - macos: VERIFIED (name resolution parity per Swift PR #1492 ported) - linux: OPEN -- Test: `crates/platform-windows/examples/launch_app_parity.rs` +- Test: + - `crates/platform-windows/examples/launch_app_parity.rs` (Windows) + - `tests/integration/test_api_parity.py::test_mcp_launch_app_by_name_*` + and `::test_mcp_launch_app_unknown_name_raises_error` (macOS) + +### Fixed (macOS) — ported from Swift PR trycua/cua#1492 + +`launch_app` previously accepted only the exact on-disk bundle filename +when a `name` parameter was used (`open -g -a `). Calls with a +bundle identifier as `name` (`com.apple.calculator`) or a locale-specific +display name (`計算機` on JP macOS) or a case-mismatched stem +(`CALCULATOR`) all failed with "Could not locate app". + +`apps::locate_app_by_name` now mirrors Swift `AppLauncher.locate` with +the same three-pass chain: + +1. **Filesystem lookup** — `.app` in canonical roots + (`/Applications`, `/System/Applications[/Utilities]`, + `/Applications/Utilities`, `~/Applications`, + `~/Applications/Chrome Apps.localized`). Fast, locale-independent for + English display names whose on-disk bundle name matches. +2. **LaunchServices bundle-ID lookup** — + `NSWorkspace.URLForApplicationWithBundleIdentifier:` via `objc2`. + Lets callers pass a bundle identifier as `name` without switching to + the `bundle_id` parameter. +3. **Fuzzy scan, case-insensitive**: + a. Locale-aware `localizedName` from `NSRunningApplication` + (covers e.g. `計算機` for Calculator on a JP-locale system). + b. `CFBundleDisplayName` → `CFBundleName` from each candidate + bundle's Info.plist (via `plutil -extract`, matching the existing + `scan_installed_apps` pattern in this file). + c. Bundle URL stem (filename minus `.app`). + +When a match is found, the resolver prefers launching by bundle ID +(`open -g -b`) — unambiguous and avoids a second LaunchServices lookup +in `open`. Falls back to `open ` when no bundle ID is recovered, +and finally to a raw `open -g -a ` if every resolver pass fails +(preserves the pre-fix behavior for unusual installs LaunchServices +already knows about). + +Verified on macOS with the three new parity tests against both binaries: + +```text +launch_app name="com.apple.calculator" → pid=…, bundle_id=com.apple.calculator +launch_app name="CALCULATOR" → pid=…, bundle_id=com.apple.calculator +launch_app name="Calculator" → pid=…, (regression guard) +launch_app name="no_such_app_xyzzy" → MCP error +``` ### Fixed (Windows) diff --git a/libs/cua-driver-rs/crates/platform-macos/src/apps.rs b/libs/cua-driver-rs/crates/platform-macos/src/apps.rs index 3c1b4cfc59..39642d99bd 100644 --- a/libs/cua-driver-rs/crates/platform-macos/src/apps.rs +++ b/libs/cua-driver-rs/crates/platform-macos/src/apps.rs @@ -99,12 +99,55 @@ pub fn launch_app(bundle_id: &str) -> anyhow::Result { /// Launch an app by display name using `open -g -a AppName` (background, no activation). /// Returns the pid on success. +/// +/// Tries `locate_app_by_name` first so callers can pass bundle IDs +/// (`com.apple.calculator`), localized display names (`計算機`), or +/// case-insensitive variants (`CALCULATOR`). Falls back to a raw +/// `open -g -a ` if the resolver finds nothing, preserving the +/// previous behavior for inputs LaunchServices already recognizes. pub fn launch_app_by_name(name: &str) -> anyhow::Result { + // Try the 3-pass resolver first (mirrors Swift's AppLauncher.locate). + if let Some(resolved) = locate_app_by_name(name) { + // Prefer launching by bundle ID when we have one — it's unambiguous + // and matches what the Swift implementation does via + // NSWorkspace.open(URL, configuration:). + if let Some(bundle_id) = resolved.bundle_id.as_deref() { + return launch_app(bundle_id); + } + // Fall back to launching by path if no bundle ID is available. + let status = Command::new("open") + .args(["-g", &resolved.path]) + .status()?; + if !status.success() { + anyhow::bail!("Failed to launch app at '{}'", resolved.path); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + let apps = list_running_apps(); + for app in &apps { + // Bundle-ID match must be a *both-Some* equality — comparing + // two `None`s would silently match an unrelated running app + // whose bundle_id we also failed to resolve, returning a wrong + // pid for the launch we just performed. + let bundle_id_match = matches!( + (app.bundle_id.as_deref(), resolved.bundle_id.as_deref()), + (Some(a), Some(b)) if a == b + ); + if app.name.eq_ignore_ascii_case(&resolved.display_name) || bundle_id_match { + return Ok(app.pid); + } + } + anyhow::bail!("Launched '{name}' but could not find its pid"); + } + + // Resolver came up empty — let `open -g -a` make its own attempt. + // It can succeed for installed apps whose Info.plist metadata wasn't + // visible to our scan (e.g. inside Chrome Apps.localized when the + // bundle layout is non-standard). let status = Command::new("open") .args(["-g", "-a", name]) .status()?; if !status.success() { - anyhow::bail!("Failed to launch app '{name}'"); + anyhow::bail!("Could not locate app (name '{name}')"); } std::thread::sleep(std::time::Duration::from_millis(500)); let apps = list_running_apps(); @@ -116,6 +159,224 @@ pub fn launch_app_by_name(name: &str) -> anyhow::Result { anyhow::bail!("Launched '{name}' but could not find its pid") } +/// Result of resolving a user-supplied app `name` to a concrete bundle. +#[derive(Debug, Clone)] +pub struct ResolvedApp { + /// Absolute filesystem path to the `.app` bundle. + pub path: String, + /// Bundle identifier (e.g. `com.apple.calculator`) when discoverable. + pub bundle_id: Option, + /// Best-effort display name (running localized name, CFBundleDisplayName, + /// CFBundleName, or stem — in that priority order). + pub display_name: String, +} + +/// Resolve a user-supplied app `name` to an installed bundle using the +/// same three-pass chain as Swift `AppLauncher.locate`: +/// +/// 1. Filesystem `.app` lookup in the canonical roots +/// (fast, locale-independent for English app names). +/// 2. LaunchServices bundle-ID lookup — lets callers pass +/// `com.apple.calculator` as `name` without switching parameters. +/// 3. Fuzzy scan: +/// a) Locale-aware `localizedName` from `NSRunningApplication` +/// (covers e.g. `計算機` on JP macOS for Calculator). +/// b) `CFBundleDisplayName` / `CFBundleName` from Info.plist +/// (case-insensitive English variants like `CALCULATOR`). +/// c) Bundle URL stem (filename minus `.app`). +/// +/// Matching is case-insensitive throughout. Returns `None` if every pass +/// fails — callers should treat that as "not found" and surface an error. +/// +/// Ports `libs/cua-driver/Sources/CuaDriverCore/Apps/AppLauncher.swift` +/// `AppLauncher.locate(bundleId:name:)` (PR trycua/cua#1492). +pub fn locate_app_by_name(name: &str) -> Option { + let name = name.trim(); + if name.is_empty() { + return None; + } + + // ── Pass 1 — Filesystem lookup by `.app` ──────────────────────── + let app_filename = if name.to_lowercase().ends_with(".app") { + name.to_string() + } else { + format!("{name}.app") + }; + for root in app_search_roots().iter() { + let path = format!("{root}/{app_filename}"); + if std::path::Path::new(&path).is_dir() { + let (display_name, bundle_id) = read_bundle_metadata(&path); + return Some(ResolvedApp { + path, + bundle_id, + display_name, + }); + } + } + + // ── Pass 2 — LaunchServices bundle-ID lookup ────────────────────────── + if let Some(path) = url_for_application_with_bundle_identifier(name) { + let (display_name, bundle_id) = read_bundle_metadata(&path); + // If we asked for `name` as a bundle ID and the bundle has no + // CFBundleIdentifier in its Info.plist (unlikely but possible), + // fall back to the queried string. + let bundle_id = bundle_id.or_else(|| Some(name.to_string())); + return Some(ResolvedApp { + path, + bundle_id, + display_name, + }); + } + + // ── Pass 3 — Fuzzy scan ────────────────────────────────────────────── + let needle = name.to_lowercase(); + + // 3a) Running apps: locale-aware `localizedName` from + // `NSRunningApplication`. Covers non-English locales without + // touching disk. + if let Some(resolved) = find_running_app_by_localized_name(&needle) { + return Some(resolved); + } + + // 3b/c) Scan installed bundles in the same roots; match against + // CFBundleDisplayName → CFBundleName → stem (case-insensitive). + for root in app_search_roots().iter() { + let Ok(entries) = std::fs::read_dir(root) else { continue }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("app") { + continue; + } + let path_str = path.to_string_lossy().to_string(); + let (display_name, bundle_id) = read_bundle_metadata(&path_str); + if display_name.to_lowercase() == needle { + return Some(ResolvedApp { + path: path_str, + bundle_id, + display_name, + }); + } + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default(); + if stem.to_lowercase() == needle { + return Some(ResolvedApp { + path: path_str, + bundle_id, + display_name, + }); + } + } + } + + None +} + +/// The same set of canonical directories Swift's `AppLauncher.locate` +/// searches: system roots first (so `/Applications` wins over a same-name +/// copy in `~/Applications`), then user-local paths, then the +/// Chrome-PWA subfolder. No recursion. +fn app_search_roots() -> Vec { + let home = std::env::var("HOME").unwrap_or_default(); + vec![ + "/Applications".to_string(), + "/System/Applications".to_string(), + "/System/Applications/Utilities".to_string(), + "/Applications/Utilities".to_string(), + format!("{home}/Applications"), + format!("{home}/Applications/Chrome Apps.localized"), + ] +} + +/// Read CFBundleDisplayName / CFBundleName / CFBundleIdentifier from a +/// bundle's Info.plist. Returns `(display_name, bundle_id)` where +/// `display_name` falls back to the on-disk stem when no plist keys exist. +fn read_bundle_metadata(app_path: &str) -> (String, Option) { + let plist_path = format!("{app_path}/Contents/Info.plist"); + let display_name = plutil_extract(&plist_path, "CFBundleDisplayName") + .or_else(|| plutil_extract(&plist_path, "CFBundleName")) + .unwrap_or_else(|| { + std::path::Path::new(app_path) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_string() + }); + let bundle_id = plutil_extract(&plist_path, "CFBundleIdentifier"); + (display_name, bundle_id) +} + +fn plutil_extract(plist_path: &str, key: &str) -> Option { + let out = Command::new("plutil") + .args(["-extract", key, "raw", "-o", "-", plist_path]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if s.is_empty() { None } else { Some(s) } +} + +/// Ask LaunchServices (via `NSWorkspace.URLForApplicationWithBundleIdentifier`) +/// for the path to the app registered for `bundle_id`. Returns `None` if +/// no such bundle is registered. +#[cfg(target_os = "macos")] +fn url_for_application_with_bundle_identifier(bundle_id: &str) -> Option { + use objc2_app_kit::NSWorkspace; + use objc2_foundation::NSString; + + // SAFETY: `NSWorkspace.sharedWorkspace()` and + // `URLForApplicationWithBundleIdentifier:` are thread-safe AppKit + // APIs documented as callable from any thread. + unsafe { + let workspace = NSWorkspace::sharedWorkspace(); + let ns_id = NSString::from_str(bundle_id); + let url = workspace.URLForApplicationWithBundleIdentifier(&ns_id)?; + let ns_path = url.path()?; + Some(ns_path.to_string()) + } +} + +/// Iterate running applications and return one whose +/// `localizedName` (locale-aware) matches `needle` case-insensitively. +/// `needle` must already be lowercased by the caller. +#[cfg(target_os = "macos")] +fn find_running_app_by_localized_name(needle: &str) -> Option { + use objc2_app_kit::NSWorkspace; + + // SAFETY: NSWorkspace.runningApplications snapshots the array on call; + // accessing element properties is safe from any thread. + unsafe { + let workspace = NSWorkspace::sharedWorkspace(); + let running = workspace.runningApplications(); + for i in 0..running.count() { + let app = running.objectAtIndex(i); + let Some(url) = app.bundleURL() else { continue }; + let Some(path) = url.path() else { continue }; + let Some(localized) = app.localizedName() else { continue }; + if localized.to_string().to_lowercase() != needle { + continue; + } + let path_str = path.to_string(); + let (display_name, bundle_id) = read_bundle_metadata(&path_str); + // Prefer the running app's bundleIdentifier when available — + // it's authoritative and doesn't require a plist read. + let bundle_id = app + .bundleIdentifier() + .map(|s| s.to_string()) + .or(bundle_id); + return Some(ResolvedApp { + path: path_str, + bundle_id, + display_name, + }); + } + } + None +} + /// Return all apps: running apps merged with installed-but-not-running apps. pub fn list_all_apps() -> Vec { let running = list_running_apps(); diff --git a/libs/cua-driver-rs/crates/platform-macos/src/tools/launch_app.rs b/libs/cua-driver-rs/crates/platform-macos/src/tools/launch_app.rs index f15099bc0a..a30022a7ef 100644 --- a/libs/cua-driver-rs/crates/platform-macos/src/tools/launch_app.rs +++ b/libs/cua-driver-rs/crates/platform-macos/src/tools/launch_app.rs @@ -227,6 +227,11 @@ fn launch_with_urls_by_bundle( } /// Launch by name with optional URLs, extra args, env vars, and new-instance flag. +/// +/// Resolves `name` via `crate::apps::locate_app_by_name` (the 3-pass chain +/// that handles bundle IDs and locale-specific display names) before +/// delegating to the bundle-ID variant when possible. Falls back to a raw +/// `open -g -a ` if the resolver finds nothing. fn launch_with_urls_by_name( name: &str, urls: &[String], @@ -234,6 +239,34 @@ fn launch_with_urls_by_name( env: &std::collections::HashMap, new_instance: bool, ) -> anyhow::Result { + if let Some(resolved) = crate::apps::locate_app_by_name(name) { + if let Some(bundle_id) = resolved.bundle_id.as_deref() { + return launch_with_urls_by_bundle( + bundle_id, urls, additional_args, env, new_instance, + ); + } + // No bundle ID — fall through to `open `. + let mut cmd = std::process::Command::new("open"); + if new_instance { cmd.arg("-n"); } + cmd.args(["-g", &resolved.path]); + for url in urls { cmd.arg(url); } + if !additional_args.is_empty() { + cmd.arg("--args"); + for arg in additional_args { cmd.arg(arg); } + } + for (k, v) in env { cmd.env(k, v); } + let status = cmd.status()?; + if !status.success() { + anyhow::bail!("Failed to launch '{}'", resolved.path); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + let apps = crate::apps::list_running_apps(); + return apps.into_iter() + .find(|a| a.name.eq_ignore_ascii_case(&resolved.display_name)) + .map(|a| a.pid) + .ok_or_else(|| anyhow::anyhow!("Launched '{name}' but could not find its pid")); + } + let mut cmd = std::process::Command::new("open"); if new_instance { cmd.arg("-n"); } cmd.args(["-g", "-a", name]); @@ -245,7 +278,7 @@ fn launch_with_urls_by_name( for (k, v) in env { cmd.env(k, v); } let status = cmd.status()?; if !status.success() { - anyhow::bail!("Failed to launch '{name}'"); + anyhow::bail!("Could not locate app (name '{name}')"); } std::thread::sleep(std::time::Duration::from_millis(500)); let apps = crate::apps::list_running_apps(); diff --git a/libs/cua-driver-rs/tests/integration/test_api_parity.py b/libs/cua-driver-rs/tests/integration/test_api_parity.py index 3fbe1c2c50..6ceaad7fc0 100644 --- a/libs/cua-driver-rs/tests/integration/test_api_parity.py +++ b/libs/cua-driver-rs/tests/integration/test_api_parity.py @@ -1032,6 +1032,57 @@ def test_mcp_launch_app_unknown_bundle_id_raises_error(self) -> None: "launch_app", {"bundle_id": "com.example.no_such_app_xyzzy"} ) + # ── stdio MCP: launch_app name-resolution fallbacks ────────────────────── + # + # Locks the 3-pass `AppLauncher.locate` chain ported in PR #1492: + # 1) filesystem `.app` lookup, + # 2) LaunchServices bundle-ID lookup (so `name` accepts a bundle ID), + # 3) fuzzy scan (locale-aware running-app `localizedName`, then + # `CFBundleDisplayName`/`CFBundleName`/stem, case-insensitive). + # Both binaries must accept all three input shapes for the same app. + + def test_mcp_launch_app_by_name_accepts_bundle_id(self) -> None: + """`name` parameter must accept a bundle ID (`com.apple.calculator`). + + Pass 2 of the resolver: when `bundle_id` is omitted but `name` + looks like a bundle ID, LaunchServices is queried with it. + """ + subprocess.run(["pkill", "-x", "Calculator"], check=False) + time.sleep(0.3) + with self._mcp() as c: + result = c.call_tool("launch_app", {"name": "com.apple.calculator"}) + sc = result.get("structuredContent", result) + self.assertIn("pid", sc, f"launch_app result missing pid: {result}") + self.assertGreater(sc["pid"], 0) + self.assertEqual(sc.get("bundle_id"), "com.apple.calculator") + + def test_mcp_launch_app_by_name_case_insensitive(self) -> None: + """`name` matching must be case-insensitive (`CALCULATOR` works). + + Pass 3 of the resolver: filesystem lookup misses on the casing, + running-app `localizedName` and `CFBundleName` then match + case-insensitively. + """ + subprocess.run(["pkill", "-x", "Calculator"], check=False) + time.sleep(0.3) + with self._mcp() as c: + result = c.call_tool("launch_app", {"name": "CALCULATOR"}) + sc = result.get("structuredContent", result) + self.assertIn("pid", sc, f"launch_app result missing pid: {result}") + self.assertGreater(sc["pid"], 0) + self.assertEqual(sc.get("bundle_id"), "com.apple.calculator") + + def test_mcp_launch_app_unknown_name_raises_error(self) -> None: + """A `name` that matches no installed bundle must signal an error. + + Sentinel string is chosen so no real `.app` bundle, bundle ID, or + running app could plausibly match. All three resolver passes + should fail. + """ + self._assert_tool_raises_mcp_error( + "launch_app", {"name": "no_such_app_xyzzy_parity"} + ) + # ── stdio MCP: set_agent_cursor_motion ──────────────────────────────────── def test_mcp_set_agent_cursor_motion_spring(self) -> None: