diff --git a/src/server.rs b/src/server.rs index 659862a..e78d8f9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -66,6 +66,50 @@ pub struct ComputerUseLinux { desktop_size: Arc>>, } +fn sanitize_unsigned_integer_formats(value: &mut serde_json::Value) { + let serde_json::Value::Object(object) = value else { + return; + }; + + let has_unsigned_format = matches!( + object.get("format").and_then(serde_json::Value::as_str), + Some("uint" | "uint8" | "uint16" | "uint32" | "uint64" | "usize") + ); + if has_unsigned_format { + object.remove("format"); + } + + for nested in object.values_mut() { + match nested { + serde_json::Value::Object(_) => sanitize_unsigned_integer_formats(nested), + serde_json::Value::Array(items) => { + for item in items { + sanitize_unsigned_integer_formats(item); + } + } + _ => {} + } + } +} + +impl ComputerUseLinux { + fn mcp_tool_router(&self) -> rmcp::handler::server::router::tool::ToolRouter { + let mut router = Self::tool_router(); + for route in router.map.values_mut() { + let input_schema = Arc::make_mut(&mut route.attr.input_schema); + for value in input_schema.values_mut() { + sanitize_unsigned_integer_formats(value); + } + if let Some(output_schema) = route.attr.output_schema.as_mut() { + for value in Arc::make_mut(output_schema).values_mut() { + sanitize_unsigned_integer_formats(value); + } + } + } + router + } +} + #[tool_router] impl ComputerUseLinux { #[tool( @@ -1287,6 +1331,7 @@ impl ComputerUseLinux { } #[tool_handler( + router = self.mcp_tool_router(), name = "computer-use-linux", // NOTE: keep in lockstep with Cargo.toml + package.json on every release. // The rmcp tool_handler macro only accepts a string literal here, so this @@ -3743,6 +3788,49 @@ mod tests { use crate::atspi_tree::{AccessibilityAction, Bounds}; use crate::windows::{WindowBounds, GNOME_SHELL_EXTENSION_BACKEND}; + #[test] + fn exported_tool_schemas_omit_unsigned_integer_formats() { + let tools = ComputerUseLinux::default().mcp_tool_router().list_all(); + let value = serde_json::to_value(tools).unwrap(); + let mut unsupported = Vec::new(); + collect_unsigned_integer_formats(&value, "$", &mut unsupported); + + assert!( + unsupported.is_empty(), + "unsupported unsigned integer formats: {unsupported:?}" + ); + } + + fn collect_unsigned_integer_formats( + value: &serde_json::Value, + path: &str, + unsupported: &mut Vec, + ) { + match value { + serde_json::Value::Object(object) => { + if matches!( + object.get("format").and_then(serde_json::Value::as_str), + Some("uint" | "uint8" | "uint16" | "uint32" | "uint64" | "usize") + ) { + unsupported.push(path.to_string()); + } + for (key, nested) in object { + collect_unsigned_integer_formats(nested, &format!("{path}/{key}"), unsupported); + } + } + serde_json::Value::Array(items) => { + for (index, nested) in items.iter().enumerate() { + collect_unsigned_integer_formats( + nested, + &format!("{path}/{index}"), + unsupported, + ); + } + } + _ => {} + } + } + fn node(index: u32, bounds: Option) -> AccessibilityNode { node_with_actions(index, bounds, Vec::new()) } diff --git a/src/windowing/backends/hyprland.rs b/src/windowing/backends/hyprland.rs index a726684..2cf3be7 100644 --- a/src/windowing/backends/hyprland.rs +++ b/src/windowing/backends/hyprland.rs @@ -83,18 +83,30 @@ pub(crate) fn parse_hyprland_clients(json: &str) -> Result> { pub fn activate_window(window_id: u64) -> Result<()> { let address = format!("address:0x{window_id:x}"); - let output = hyprctl_output(&["dispatch", "focuswindow", &address]) + let lua_dispatch = lua_focus_dispatch(&address); + let lua_output = hyprctl_output(&["dispatch", &lua_dispatch]) + .with_context(|| format!("failed to run Hyprland Lua focus dispatcher for {address}"))?; + if lua_output.status.success() { + return Ok(()); + } + + let legacy_output = hyprctl_output(&["dispatch", "focuswindow", &address]) .with_context(|| format!("failed to run hyprctl dispatch focuswindow {address}"))?; - if output.status.success() { + if legacy_output.status.success() { Ok(()) } else { bail!( - "hyprctl dispatch focuswindow {address} failed: {}", - String::from_utf8_lossy(&output.stderr).trim() + "Hyprland window focus failed for {address}; Lua dispatcher: {}; legacy dispatcher: {}", + String::from_utf8_lossy(&lua_output.stderr).trim(), + String::from_utf8_lossy(&legacy_output.stderr).trim() ); } } +fn lua_focus_dispatch(address: &str) -> String { + format!("hl.dsp.focus({{ window = \"{address}\" }})") +} + fn hyprctl_output(args: &[&str]) -> std::io::Result { let mut command = Command::new("hyprctl"); let has_signature = std::env::var("HYPRLAND_INSTANCE_SIGNATURE") @@ -259,6 +271,14 @@ mod tests { use super::*; use std::time::Duration; + #[test] + fn builds_hyprland_055_lua_focus_dispatch() { + assert_eq!( + lua_focus_dispatch("address:0x1234abcd"), + "hl.dsp.focus({ window = \"address:0x1234abcd\" })" + ); + } + #[test] fn selects_wayland_matching_hyprland_instance_before_newer_nonmatch() { let older_match = HyprlandInstanceCandidate {