diff --git a/Cargo.lock b/Cargo.lock index 481bd46..36b459f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,61 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags", + "core-foundation", + "libc", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -34,6 +89,33 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -65,8 +147,13 @@ name = "hyperdeck-os" version = "0.1.0" dependencies = [ "base64", + "core-foundation", + "core-graphics", "hyperdeck-core", "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", "ureq", "windows", ] @@ -190,6 +277,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "litemap" version = "0.8.2" @@ -208,6 +301,105 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", + "objc2-metal", +] + [[package]] name = "once_cell" version = "1.21.4" diff --git a/crates/hyperdeck-os/Cargo.toml b/crates/hyperdeck-os/Cargo.toml index 33e166f..f8d6960 100644 --- a/crates/hyperdeck-os/Cargo.toml +++ b/crates/hyperdeck-os/Cargo.toml @@ -22,3 +22,11 @@ windows = { version = "0.58", features = [ "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", ] } + +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = { version = "0.24", features = ["elcapitan"] } # elcapitan: CGEvent::post_to_pid +core-foundation = "0.10" +objc2 = "0.5" +# libc: NSRunningApplication::runningApplicationWithProcessIdentifier +objc2-app-kit = { version = "0.2", features = ["NSRunningApplication", "libc"] } +objc2-foundation = "0.2" diff --git a/crates/hyperdeck-os/src/injector/keymap_darwin.rs b/crates/hyperdeck-os/src/injector/keymap_darwin.rs new file mode 100644 index 0000000..812e13d --- /dev/null +++ b/crates/hyperdeck-os/src/injector/keymap_darwin.rs @@ -0,0 +1,106 @@ +//! macOS virtual key codes + event flags (port of `keymap_darwin.go`). +//! +//! Pure — kept free of CoreGraphics calls so the chord→keycode/flags mapping is +//! unit-testable. + +use hyperdeck_core::domain::Modifier; + +// CGEventFlags modifier masks (stable values from CGEventTypes.h). +const FLAG_SHIFT: u64 = 1 << 17; +const FLAG_CONTROL: u64 = 1 << 18; +const FLAG_ALT: u64 = 1 << 19; +const FLAG_COMMAND: u64 = 1 << 20; + +/// Converts chord modifiers into a CGEventFlags bitmask. +pub fn event_flags(mods: &[Modifier]) -> u64 { + let mut f = 0; + for m in mods { + f |= match m { + Modifier::Shift => FLAG_SHIFT, + Modifier::Ctrl => FLAG_CONTROL, + Modifier::Alt => FLAG_ALT, + Modifier::Cmd => FLAG_COMMAND, + }; + } + f +} + +/// Returns the macOS ANSI virtual key code (kVK_* from Carbon HIToolbox) for a +/// base key name. Codes are physical positions on a US ANSI layout. +pub fn key_code(key: &str) -> Option { + let code = match key { + "space" => 49, + "return" | "enter" => 36, + "tab" => 48, + "esc" | "escape" => 53, + "delete" | "backspace" => 51, + "period" | "." => 47, + "comma" | "," => 43, + "left" => 123, + "right" => 124, + "down" => 125, + "up" => 126, + "a" => 0, + "s" => 1, + "d" => 2, + "f" => 3, + "h" => 4, + "g" => 5, + "z" => 6, + "x" => 7, + "c" => 8, + "v" => 9, + "b" => 11, + "q" => 12, + "w" => 13, + "e" => 14, + "r" => 15, + "y" => 16, + "t" => 17, + "o" => 31, + "u" => 32, + "i" => 34, + "p" => 35, + "l" => 37, + "j" => 38, + "k" => 40, + "n" => 45, + "m" => 46, + "1" => 18, + "2" => 19, + "3" => 20, + "4" => 21, + "5" => 23, + "6" => 22, + "7" => 26, + "8" => 28, + "9" => 25, + "0" => 29, + _ => return None, + }; + Some(code) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn key_codes() { + assert_eq!(key_code("space"), Some(49)); + assert_eq!(key_code("right"), Some(124)); + assert_eq!(key_code("a"), Some(0)); + assert_eq!(key_code("."), Some(47)); + assert_eq!(key_code("nope"), None); + } + + #[test] + fn flags() { + assert_eq!(event_flags(&[Modifier::Cmd]), 1 << 20); + assert_eq!( + event_flags(&[Modifier::Ctrl, Modifier::Shift]), + (1 << 18) | (1 << 17) + ); + assert_eq!(event_flags(&[]), 0); + } +} diff --git a/crates/hyperdeck-os/src/injector/macos_impl.rs b/crates/hyperdeck-os/src/injector/macos_impl.rs new file mode 100644 index 0000000..687571d --- /dev/null +++ b/crates/hyperdeck-os/src/injector/macos_impl.rs @@ -0,0 +1,145 @@ +//! macOS keystroke injection + window enumeration (port of `injector_darwin.go` +//! and `cinject_darwin.m`). + +use std::thread::sleep; +use std::time::Duration; + +use core_foundation::base::{CFType, TCFType}; +use core_foundation::boolean::CFBoolean; +use core_foundation::dictionary::{CFDictionary, CFDictionaryRef}; +use core_foundation::number::CFNumber; +use core_foundation::string::{CFString, CFStringRef}; +use core_graphics::display::CGDisplay; +use core_graphics::event::{CGEvent, CGEventFlags}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use core_graphics::window::{kCGWindowListExcludeDesktopElements, kCGWindowListOptionOnScreenOnly}; +use objc2_app_kit::{NSApplicationActivationOptions, NSRunningApplication}; + +use hyperdeck_core::domain::{Chord, Window}; +use hyperdeck_core::error::{DeckError, DeckResult}; +use hyperdeck_core::port::{KeyInjector, WindowEnumerator}; + +use super::keymap_darwin::{event_flags, key_code}; + +// Delays that make synthesized input reliable (mirrors cinject_darwin.m). +const FOCUS_SETTLE: Duration = Duration::from_millis(120); +const AFTER_KEY: Duration = Duration::from_millis(25); +const KEY_HOLD: Duration = Duration::from_millis(12); + +/// macOS injector + window enumerator. +pub struct MacInjector; + +impl KeyInjector for MacInjector { + // NSApplicationActivateIgnoringOtherApps is deprecated in macOS 14 but is the + // documented way to foreground another app for input; the Go code suppresses + // the same deprecation. activate()'s newer form does not raise reliably. + #[allow(deprecated)] + fn focus(&self, win: &Window) -> DeckResult<()> { + let pid = win.handle as i32; + // SAFETY: NSRunningApplication lookup + activation are standard AppKit + // calls; a missing pid yields None and an error. + let app = unsafe { NSRunningApplication::runningApplicationWithProcessIdentifier(pid) }; + let Some(app) = app else { + return Err(DeckError::Injector(format!( + "focus: could not activate pid {pid} ({})", + win.process + ))); + }; + unsafe { + app.activateWithOptions( + NSApplicationActivationOptions::NSApplicationActivateIgnoringOtherApps, + ); + } + sleep(FOCUS_SETTLE); // let the app become first responder before keys arrive + Ok(()) + } + + fn send_keys(&self, win: &Window, chords: &[Chord]) -> DeckResult<()> { + let pid = win.handle as i32; + for c in chords { + let Some(code) = key_code(&c.key) else { + return Err(DeckError::Injector(format!( + "sendkeys: no macOS key code for {:?}", + c.key + ))); + }; + let flags = CGEventFlags::from_bits_retain(event_flags(&c.mods)); + post_key_to_pid(pid, code, flags)?; + sleep(AFTER_KEY); + } + Ok(()) + } +} + +fn post_key_to_pid(pid: i32, keycode: u16, flags: CGEventFlags) -> DeckResult<()> { + let make_event = |down: bool| -> DeckResult { + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState) + .map_err(|_| DeckError::Injector("sendkeys: CGEventSource create failed".into()))?; + let event = CGEvent::new_keyboard_event(source, keycode, down) + .map_err(|_| DeckError::Injector("sendkeys: CGEvent create failed".into()))?; + if !flags.is_empty() { + event.set_flags(flags); + } + Ok(event) + }; + let down = make_event(true)?; + let up = make_event(false)?; + down.post_to_pid(pid); + sleep(KEY_HOLD); // key-hold so the target registers the press + up.post_to_pid(pid); + Ok(()) +} + +impl WindowEnumerator for MacInjector { + fn open_windows(&self) -> DeckResult> { + let option = kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements; + let Some(array) = CGDisplay::window_list_info(option, None) else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for i in 0..array.len() { + let Some(item) = array.get(i) else { continue }; + // Each element is a CGWindow info dictionary. + let dict: CFDictionary = + unsafe { CFDictionary::wrap_under_get_rule(*item as CFDictionaryRef) }; + let pid = dict_i64(&dict, "kCGWindowOwnerPID").unwrap_or(0); + let owner = dict_string(&dict, "kCGWindowOwnerName").unwrap_or_default(); + let title = dict_string(&dict, "kCGWindowName").unwrap_or_default(); + out.push(Window { + handle: pid as usize, + process: owner, + title, + }); + } + Ok(out) + } +} + +fn dict_value(dict: &CFDictionary, key: &str) -> Option { + dict.find(CFString::new(key)).map(|v| v.clone()) +} + +fn dict_string(dict: &CFDictionary, key: &str) -> Option { + dict_value(dict, key)? + .downcast::() + .map(|s| s.to_string()) +} + +fn dict_i64(dict: &CFDictionary, key: &str) -> Option { + dict_value(dict, key)?.downcast::()?.to_i64() +} + +/// Reports whether this process is trusted for Accessibility, prompting the user +/// to grant it (via System Settings) when it is not. +pub fn request_accessibility() -> bool { + let key = unsafe { CFString::wrap_under_get_rule(kAXTrustedCheckOptionPrompt) }; + let opts = + CFDictionary::from_CFType_pairs(&[(key.as_CFType(), CFBoolean::true_value().as_CFType())]); + unsafe { AXIsProcessTrustedWithOptions(opts.as_concrete_TypeRef()) != 0 } +} + +#[link(name = "ApplicationServices", kind = "framework")] +extern "C" { + fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> u8; + static kAXTrustedCheckOptionPrompt: CFStringRef; +} diff --git a/crates/hyperdeck-os/src/injector/mod.rs b/crates/hyperdeck-os/src/injector/mod.rs index 5d71714..b73f6ab 100644 --- a/crates/hyperdeck-os/src/injector/mod.rs +++ b/crates/hyperdeck-os/src/injector/mod.rs @@ -15,13 +15,22 @@ mod keymap_windows; #[cfg(windows)] mod windows_impl; +#[cfg(target_os = "macos")] +mod keymap_darwin; +#[cfg(target_os = "macos")] +mod macos_impl; + /// Returns the platform key injector. pub fn injector() -> Arc { #[cfg(windows)] { Arc::new(windows_impl::WinInput) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + { + Arc::new(macos_impl::MacInjector) + } + #[cfg(not(any(windows, target_os = "macos")))] { Arc::new(Noop) } @@ -33,7 +42,11 @@ pub fn enumerator() -> Arc { { Arc::new(windows_impl::WinInput) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] + { + Arc::new(macos_impl::MacInjector) + } + #[cfg(not(any(windows, target_os = "macos")))] { Arc::new(Noop) } @@ -43,14 +56,21 @@ pub fn enumerator() -> Arc { /// cannot. Only macOS gates this (Accessibility permission); elsewhere input is /// always permitted, so this returns `true`. pub fn request_accessibility() -> bool { - true + #[cfg(target_os = "macos")] + { + macos_impl::request_accessibility() + } + #[cfg(not(target_os = "macos"))] + { + true + } } /// No-op injector/enumerator for platforms without an implementation yet. -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "macos")))] struct Noop; -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "macos")))] impl KeyInjector for Noop { fn focus(&self, _w: &hyperdeck_core::domain::Window) -> hyperdeck_core::error::DeckResult<()> { Ok(()) @@ -64,7 +84,7 @@ impl KeyInjector for Noop { } } -#[cfg(not(windows))] +#[cfg(not(any(windows, target_os = "macos")))] impl WindowEnumerator for Noop { fn open_windows( &self,