diff --git a/.gitignore b/.gitignore index 2cdba3ea4..c1e6fae0d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ Temporary Items Packages/ Package.resolved *.xcodeproj/ +target/ +**/target/ # App artifacts *.app @@ -70,6 +72,7 @@ run-tests-automated.sh .build-ci/ .nano-banana-config.json .home/ +.worktrees/ .crush/ .tmp/ output/ diff --git a/.worktrees/gh-pages b/.worktrees/gh-pages deleted file mode 160000 index 4e1c0fae0..000000000 --- a/.worktrees/gh-pages +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4e1c0fae08942a419c570119b7daf8759d484435 diff --git a/External/kanata b/External/kanata index 4c569f1b3..c3dd87525 160000 --- a/External/kanata +++ b/External/kanata @@ -1 +1 @@ -Subproject commit 4c569f1b37dfb826b3402fdfec40b217f4859344 +Subproject commit c3dd875254aba572abb63d16961e6328bd34da81 diff --git a/Package.swift b/Package.swift index dc5542390..755cdd95f 100644 --- a/Package.swift +++ b/Package.swift @@ -37,6 +37,14 @@ let package = Package( name: "KeyPathHelper", targets: ["KeyPathHelper"] ), + .executable( + name: "KeyPathOutputBridge", + targets: ["KeyPathOutputBridge"] + ), + .executable( + name: "KeyPathKanataLauncher", + targets: ["KeyPathKanataLauncher"] + ), .executable( name: "smappservice-poc", targets: ["SMAppServicePOC"] @@ -46,7 +54,7 @@ let package = Package( targets: ["KeyPathPluginKit"] ), .executable( - name: "keypath", + name: "keypath-cli", targets: ["KeyPathCLI"] ), .library( @@ -153,7 +161,7 @@ let package = Package( // Privileged helper executable .executableTarget( name: "KeyPathHelper", - dependencies: [], + dependencies: ["KeyPathCore"], path: "Sources/KeyPathHelper", exclude: [ "Info.plist", @@ -164,6 +172,27 @@ let package = Package( .swiftLanguageMode(.v6) ] ), + .executableTarget( + name: "KeyPathOutputBridge", + dependencies: ["KeyPathCore"], + path: "Sources/KeyPathOutputBridge", + exclude: [ + "Info.plist", + "com.keypath.output-bridge.plist", + "KeyPathOutputBridge.entitlements" + ], + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), + .executableTarget( + name: "KeyPathKanataLauncher", + dependencies: ["KeyPathCore"], + path: "Sources/KeyPathKanataLauncher", + swiftSettings: [ + .swiftLanguageMode(.v6) + ] + ), // SMAppService POC test utility .executableTarget( name: "SMAppServicePOC", diff --git a/Rust/KeyPathKanataHostBridge/Cargo.lock b/Rust/KeyPathKanataHostBridge/Cargo.lock new file mode 100644 index 000000000..5ae3cbe38 --- /dev/null +++ b/Rust/KeyPathKanataHostBridge/Cargo.lock @@ -0,0 +1,2013 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[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 2.11.0", + "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 2.11.0", + "core-foundation", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.0", + "objc2", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "evdev" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25b686663ba7f08d92880ff6ba22170f1df4e83629341cba34cf82cd65ebea99" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix 0.29.0", +] + +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "kanata" +version = "1.11.0" +dependencies = [ + "anyhow", + "arboard", + "clap", + "core-graphics", + "dirs", + "encode_unicode", + "evdev", + "indoc", + "inotify", + "kanata-keyberon", + "kanata-parser", + "kanata-tcp-protocol", + "karabiner-driverkit", + "libc", + "log", + "miette", + "mio", + "native-windows-gui", + "nix 0.26.4", + "objc", + "once_cell", + "os_pipe", + "parking_lot", + "radix_trie", + "rustc-hash", + "sd-notify", + "serde_json", + "signal-hook", + "simplelog", + "time", + "web-time", + "winapi", +] + +[[package]] +name = "kanata-keyberon" +version = "0.1110.0" +dependencies = [ + "arraydeque", + "heapless", + "kanata-keyberon-macros", + "rustc-hash", +] + +[[package]] +name = "kanata-keyberon-macros" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f6f83f03390a5c13bbf68abea76a2b9527e197f5c00026805fd7af62a34752" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "kanata-parser" +version = "0.1110.0" +dependencies = [ + "anyhow", + "bitflags 2.11.0", + "bytemuck", + "itertools", + "kanata-keyberon", + "log", + "miette", + "once_cell", + "ordered-float", + "parking_lot", + "patricia_tree", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "kanata-tcp-protocol" +version = "0.1110.0" +dependencies = [ + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "karabiner-driverkit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a566e201dcf8689c106f7557e85c78f1f9668113238e847f9e84d2f4f5970a1a" +dependencies = [ + "cc", + "os_info", +] + +[[package]] +name = "keypath-kanata-host-bridge" +version = "0.1.0" +dependencies = [ + "kanata", + "kanata-parser", + "karabiner-driverkit", + "parking_lot", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" +dependencies = [ + "backtrace", + "backtrace-ext", + "is-terminal", + "miette-derive", + "once_cell", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "thiserror", + "unicode-width", +] + +[[package]] +name = "miette-derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native-windows-gui" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f7003a669f68deb6b7c57d74fff4f8e533c44a3f0b297492440ef4ff5a28454" +dependencies = [ + "bitflags 1.3.2", + "lazy_static", + "winapi", + "winapi-build", +] + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[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.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.0", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + +[[package]] +name = "os_info" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" +dependencies = [ + "android_system_properties", + "log", + "nix 0.30.1", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "owo-colors" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "patricia_tree" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f2f4539bffe53fc4b4da301df49d114b845b077bd5727b7fe2bd9d8df2ae68" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sd-notify" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4" +dependencies = [ + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "simplelog" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0" +dependencies = [ + "log", + "termcolor", + "time", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smawk" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84231692eb0d4d41e4cdd0cabfdd2e6cd9e255e65f80c9aa7c98dd502b4233d" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "supports-unicode" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f850c19edd184a205e883199a261ed44471c81e39bd95b1357f5febbef00e77a" +dependencies = [ + "is-terminal", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "textwrap" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7b3e525a49ec206798b40326a44121291b530c963cfb01018f63e135bac543d" +dependencies = [ + "smawk", + "unicode-linebreak", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Rust/KeyPathKanataHostBridge/Cargo.toml b/Rust/KeyPathKanataHostBridge/Cargo.toml new file mode 100644 index 000000000..0afcbb518 --- /dev/null +++ b/Rust/KeyPathKanataHostBridge/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "keypath-kanata-host-bridge" +version = "0.1.0" +edition = "2021" + +[lib] +name = "keypath_kanata_host_bridge" +crate-type = ["staticlib", "cdylib", "rlib"] + +[features] +default = [] +simulated-output-spike = ["kanata_state_machine/simulated_output"] +passthru-output-spike = [ + "kanata_state_machine/simulated_input", + "kanata_state_machine/simulated_output", +] + +[dependencies] +kanata_state_machine = { package = "kanata", path = "../../External/kanata", features = ["tcp_server"] } +kanata-parser = { path = "../../External/kanata/parser" } +karabiner-driverkit = "0.2.1" +parking_lot = "0.12" diff --git a/Rust/KeyPathKanataHostBridge/include/keypath_kanata_host_bridge.h b/Rust/KeyPathKanataHostBridge/include/keypath_kanata_host_bridge.h new file mode 100644 index 000000000..fcf196e25 --- /dev/null +++ b/Rust/KeyPathKanataHostBridge/include/keypath_kanata_host_bridge.h @@ -0,0 +1,33 @@ +#ifndef KEYPATH_KANATA_HOST_BRIDGE_H +#define KEYPATH_KANATA_HOST_BRIDGE_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +const char *keypath_kanata_bridge_version(void); +size_t keypath_kanata_bridge_default_cfg_count(void); +bool keypath_kanata_bridge_validate_config(const char *config_path, char *error_buffer, size_t error_buffer_len); +void *keypath_kanata_bridge_create_runtime(const char *config_path, char *error_buffer, size_t error_buffer_len); +bool keypath_kanata_bridge_run_runtime(const char *config_path, unsigned short tcp_port, char *error_buffer, size_t error_buffer_len); +bool keypath_kanata_bridge_initialize_output_sink(char *error_buffer, size_t error_buffer_len); +bool keypath_kanata_bridge_output_ready(void); +bool keypath_kanata_bridge_wait_until_output_ready(unsigned long long timeout_millis); +bool keypath_kanata_bridge_emit_key(unsigned int usage_page, unsigned int usage, bool is_key_down, char *error_buffer, size_t error_buffer_len); +size_t keypath_kanata_bridge_runtime_layer_count(const void *runtime); +void keypath_kanata_bridge_destroy_runtime(void *runtime); +void *keypath_kanata_bridge_create_passthru_runtime(const char *config_path, unsigned short tcp_port, char *error_buffer, size_t error_buffer_len); +size_t keypath_kanata_bridge_passthru_runtime_layer_count(const void *runtime); +bool keypath_kanata_bridge_start_passthru_runtime(void *runtime, char *error_buffer, size_t error_buffer_len); +bool keypath_kanata_bridge_passthru_send_input(void *runtime, unsigned long long value, unsigned int page, unsigned int code, char *error_buffer, size_t error_buffer_len); +int keypath_kanata_bridge_passthru_try_recv_output(void *runtime, unsigned long long *value_out, unsigned int *page_out, unsigned int *code_out, char *error_buffer, size_t error_buffer_len); +void keypath_kanata_bridge_destroy_passthru_runtime(void *runtime); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/Rust/KeyPathKanataHostBridge/src/lib.rs b/Rust/KeyPathKanataHostBridge/src/lib.rs new file mode 100644 index 000000000..f1e4de14f --- /dev/null +++ b/Rust/KeyPathKanataHostBridge/src/lib.rs @@ -0,0 +1,748 @@ +use std::ffi::{CStr, c_char, c_void}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +#[cfg(feature = "passthru-output-spike")] +use std::sync::mpsc::{self, Receiver}; +#[cfg(feature = "passthru-output-spike")] +use std::sync::mpsc::SyncSender; +#[cfg(feature = "passthru-output-spike")] +use std::sync::Arc; +#[cfg(feature = "passthru-output-spike")] +use std::sync::atomic::{AtomicBool, Ordering}; + +const BRIDGE_VERSION: &[u8] = concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes(); + +#[cfg(target_os = "macos")] +unsafe extern "C" { + #[link_name = "\u{1}__Z9init_sinkv"] + fn keypath_driverkit_init_sink() -> i32; +} + +#[cfg(feature = "passthru-output-spike")] +struct PassthruRuntime { + runtime: Arc>, + output_rx: Receiver, + processing_tx: parking_lot::Mutex>>, + tcp_server_address: Option, + started: AtomicBool, +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_version() -> *const c_char { + BRIDGE_VERSION.as_ptr().cast() +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_default_cfg_count() -> usize { + kanata_state_machine::default_cfg().len() +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_validate_config( + config_path: *const c_char, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + let path = match parse_config_path(config_path, error_buffer, error_buffer_len) { + Some(path) => path, + None => return false, + }; + + match kanata_parser::cfg::new_from_file(Path::new(&path)) { + Ok(_) => { + write_error(error_buffer, error_buffer_len, ""); + true + } + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + false + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_create_runtime( + config_path: *const c_char, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> *mut c_void { + let path = match parse_config_path(config_path, error_buffer, error_buffer_len) { + Some(path) => path, + None => return std::ptr::null_mut(), + }; + + let args = kanata_state_machine::ValidatedArgs { + paths: vec![PathBuf::from(path)], + tcp_server_address: None, + nodelay: true, + }; + + match kanata_state_machine::Kanata::new(&args) { + Ok(runtime) => { + write_error(error_buffer, error_buffer_len, ""); + Box::into_raw(Box::new(runtime)).cast() + } + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + std::ptr::null_mut() + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_runtime_layer_count(runtime: *const c_void) -> usize { + if runtime.is_null() { + return 0; + } + + let runtime = unsafe { &*(runtime.cast::()) }; + runtime.layer_info.len() +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_destroy_runtime(runtime: *mut c_void) { + if runtime.is_null() { + return; + } + + unsafe { + drop(Box::from_raw(runtime.cast::())); + } +} + +#[cfg(feature = "passthru-output-spike")] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_create_passthru_runtime( + config_path: *const c_char, + tcp_port: u16, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> *mut c_void { + let path = match parse_config_path(config_path, error_buffer, error_buffer_len) { + Some(path) => path, + None => return std::ptr::null_mut(), + }; + + let tcp_server_address = if tcp_port == 0 { + None + } else { + match kanata_state_machine::SocketAddrWrapper::from_str(&tcp_port.to_string()) { + Ok(address) => Some(address), + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + return std::ptr::null_mut(); + } + } + }; + + let args = kanata_state_machine::ValidatedArgs { + paths: vec![PathBuf::from(path)], + tcp_server_address: tcp_server_address.clone(), + nodelay: true, + }; + + let (tx_kout, rx_kout) = mpsc::channel(); + match kanata_state_machine::Kanata::new_with_output_channel(&args, Some(tx_kout)) { + Ok(runtime) => { + write_error(error_buffer, error_buffer_len, ""); + Box::into_raw(Box::new(PassthruRuntime { + runtime, + output_rx: rx_kout, + processing_tx: parking_lot::Mutex::new(None), + tcp_server_address, + started: AtomicBool::new(false), + })) + .cast() + } + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + std::ptr::null_mut() + } + } +} + +#[cfg(not(feature = "passthru-output-spike"))] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_create_passthru_runtime( + _config_path: *const c_char, + _tcp_port: u16, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> *mut c_void { + write_error( + error_buffer, + error_buffer_len, + "passthru output spike feature is not enabled in this bridge build", + ); + std::ptr::null_mut() +} + +#[cfg(feature = "passthru-output-spike")] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_destroy_passthru_runtime(runtime: *mut c_void) { + if runtime.is_null() { + return; + } + + unsafe { + drop(Box::from_raw(runtime.cast::())); + } +} + +#[cfg(not(feature = "passthru-output-spike"))] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_destroy_passthru_runtime(_runtime: *mut c_void) {} + +#[cfg(feature = "passthru-output-spike")] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_passthru_runtime_layer_count(runtime: *const c_void) -> usize { + if runtime.is_null() { + return 0; + } + + let runtime = unsafe { &*(runtime.cast::()) }; + runtime.runtime.lock().layer_info.len() +} + +#[cfg(not(feature = "passthru-output-spike"))] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_passthru_runtime_layer_count(_runtime: *const c_void) -> usize { + 0 +} + +#[cfg(feature = "passthru-output-spike")] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_passthru_try_recv_output( + runtime: *mut c_void, + value_out: *mut u64, + page_out: *mut u32, + code_out: *mut u32, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> i32 { + if runtime.is_null() { + write_error(error_buffer, error_buffer_len, "passthru runtime handle was null"); + return -1; + } + + let runtime = unsafe { &*(runtime.cast::()) }; + match runtime.output_rx.try_recv() { + Ok(event) => { + if !value_out.is_null() { + unsafe { *value_out = event.value; } + } + if !page_out.is_null() { + unsafe { *page_out = event.page; } + } + if !code_out.is_null() { + unsafe { *code_out = event.code; } + } + write_error(error_buffer, error_buffer_len, ""); + 1 + } + Err(mpsc::TryRecvError::Empty) => { + write_error(error_buffer, error_buffer_len, ""); + 0 + } + Err(mpsc::TryRecvError::Disconnected) => { + write_error( + error_buffer, + error_buffer_len, + "passthru output channel disconnected", + ); + -1 + } + } +} + +#[cfg(not(feature = "passthru-output-spike"))] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_passthru_try_recv_output( + _runtime: *mut c_void, + _value_out: *mut u64, + _page_out: *mut u32, + _code_out: *mut u32, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> i32 { + write_error( + error_buffer, + error_buffer_len, + "passthru output spike feature is not enabled in this bridge build", + ); + -1 +} + +#[cfg(feature = "passthru-output-spike")] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_start_passthru_runtime( + runtime: *mut c_void, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + if runtime.is_null() { + write_error(error_buffer, error_buffer_len, "passthru runtime handle was null"); + return false; + } + + let runtime = unsafe { &*(runtime.cast::()) }; + if runtime.started.load(Ordering::Acquire) { + write_error(error_buffer, error_buffer_len, ""); + return true; + } + + let (tx, rx) = std::sync::mpsc::sync_channel(100); + let (ntx, has_tcp_server) = if let Some(address) = runtime.tcp_server_address.clone() { + let socket_addr = *address.get_ref(); + + let mut server = kanata_state_machine::TcpServer::new(socket_addr, tx.clone()); + server.start(runtime.runtime.clone()); + let (ntx, nrx) = std::sync::mpsc::sync_channel(100); + kanata_state_machine::Kanata::start_notification_loop(nrx, server.connections); + (Some(ntx), true) + } else { + (None, false) + }; + + // Assign processing_tx BEFORE starting the processing loop so the channel + // is available to send_input as soon as the loop thread begins. + *runtime.processing_tx.lock() = Some(tx); + + // Intentionally avoid `Kanata::event_loop` in this passthrough spike path. + // On macOS that would construct `KbdIn`, which still uses DriverKit input APIs + // and can instantiate the pqrs client in the user-session host process. + kanata_state_machine::Kanata::start_processing_loop(runtime.runtime.clone(), rx, ntx, true); + runtime.started.store(true, Ordering::Release); + let _ = has_tcp_server; + write_error(error_buffer, error_buffer_len, ""); + true +} + +#[cfg(not(feature = "passthru-output-spike"))] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_start_passthru_runtime( + _runtime: *mut c_void, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + write_error( + error_buffer, + error_buffer_len, + "passthru output spike feature is not enabled in this bridge build", + ); + false +} + +#[cfg(feature = "passthru-output-spike")] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_passthru_send_input( + runtime: *mut c_void, + value: u64, + page: u32, + code: u32, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + if runtime.is_null() { + write_error(error_buffer, error_buffer_len, "passthru runtime handle was null"); + return false; + } + + let runtime = unsafe { &*(runtime.cast::()) }; + let tx_guard = runtime.processing_tx.lock(); + let Some(tx) = tx_guard.as_ref() else { + write_error( + error_buffer, + error_buffer_len, + "passthru runtime was not started", + ); + return false; + }; + + let input_event = kanata_state_machine::oskbd::InputEvent { value, page, code }; + let key_event = match kanata_state_machine::oskbd::KeyEvent::try_from(input_event) { + Ok(event) => event, + Err(()) => { + write_error( + error_buffer, + error_buffer_len, + &format!("unrecognized input event: value={value} page={page} code={code}"), + ); + return false; + } + }; + + match tx.send(key_event) { + Ok(()) => { + write_error(error_buffer, error_buffer_len, ""); + true + } + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + false + } + } +} + +#[cfg(not(feature = "passthru-output-spike"))] +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_passthru_send_input( + _runtime: *mut c_void, + _value: u64, + _page: u32, + _code: u32, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + write_error( + error_buffer, + error_buffer_len, + "passthru output spike feature is not enabled in this bridge build", + ); + false +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_run_runtime( + config_path: *const c_char, + tcp_port: u16, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + let path = match parse_config_path(config_path, error_buffer, error_buffer_len) { + Some(path) => path, + None => return false, + }; + + let tcp_server_address = if tcp_port == 0 { + None + } else { + match kanata_state_machine::SocketAddrWrapper::from_str(&tcp_port.to_string()) { + Ok(address) => Some(address), + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + return false; + } + } + }; + + let args = kanata_state_machine::ValidatedArgs { + paths: vec![PathBuf::from(path)], + tcp_server_address, + nodelay: true, + }; + + let kanata_arc = match kanata_state_machine::Kanata::new_arc(&args) { + Ok(kanata_arc) => kanata_arc, + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + return false; + } + }; + + let (tx, rx) = std::sync::mpsc::sync_channel(100); + + let (server, ntx, nrx) = if let Some(address) = args.tcp_server_address.clone() { + let socket_addr = *address.get_ref(); + match std::net::TcpListener::bind(socket_addr) { + Ok(listener) => drop(listener), + Err(error) => { + write_error( + error_buffer, + error_buffer_len, + &format!("tcp server port {tcp_port} unavailable: {error}"), + ); + return false; + } + } + + let mut server = kanata_state_machine::TcpServer::new(socket_addr, tx.clone()); + server.start(kanata_arc.clone()); + let (ntx, nrx) = std::sync::mpsc::sync_channel(100); + (Some(server), Some(ntx), Some(nrx)) + } else { + (None, None, None) + }; + + kanata_state_machine::Kanata::start_processing_loop(kanata_arc.clone(), rx, ntx, args.nodelay); + + if let (Some(server), Some(nrx)) = (server, nrx) { + kanata_state_machine::Kanata::start_notification_loop(nrx, server.connections); + } + + match kanata_state_machine::Kanata::event_loop(kanata_arc, tx) { + Ok(()) => { + write_error(error_buffer, error_buffer_len, ""); + true + } + Err(error) => { + write_error(error_buffer, error_buffer_len, &error.to_string()); + false + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_emit_key( + usage_page: u32, + usage: u32, + is_key_down: bool, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + let mut event = karabiner_driverkit::DKEvent { + value: if is_key_down { 1 } else { 0 }, + page: usage_page, + code: usage, + }; + + match karabiner_driverkit::send_key(&mut event) { + 0 => { + write_error(error_buffer, error_buffer_len, ""); + true + } + 1 => { + write_error( + error_buffer, + error_buffer_len, + &format!("unrecognized usage page/code: page={usage_page} usage={usage}"), + ); + false + } + 2 => { + write_error( + error_buffer, + error_buffer_len, + "DriverKit virtual keyboard not ready (sink disconnected)", + ); + false + } + code => { + write_error( + error_buffer, + error_buffer_len, + &format!("unexpected karabiner-driverkit send_key result: {code}"), + ); + false + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_initialize_output_sink( + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> bool { + #[cfg(target_os = "macos")] + unsafe { + match keypath_driverkit_init_sink() { + 0 => { + write_error(error_buffer, error_buffer_len, ""); + true + } + code => { + write_error( + error_buffer, + error_buffer_len, + &format!("DriverKit sink initialization failed with code {code}"), + ); + false + } + } + } + + #[cfg(not(target_os = "macos"))] + { + write_error( + error_buffer, + error_buffer_len, + "output sink initialization is only supported on macOS", + ); + false + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_output_ready() -> bool { + karabiner_driverkit::is_sink_ready() +} + +#[unsafe(no_mangle)] +pub extern "C" fn keypath_kanata_bridge_wait_until_output_ready(timeout_millis: u64) -> bool { + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_millis(timeout_millis); + + loop { + if keypath_kanata_bridge_output_ready() { + return true; + } + + if start.elapsed() >= timeout { + return false; + } + + std::thread::sleep(std::time::Duration::from_millis(100)); + } +} + +fn parse_config_path( + config_path: *const c_char, + error_buffer: *mut c_char, + error_buffer_len: usize, +) -> Option { + if config_path.is_null() { + write_error(error_buffer, error_buffer_len, "config path was null"); + return None; + } + + match unsafe { CStr::from_ptr(config_path) }.to_str() { + Ok(path) => Some(path.to_owned()), + Err(_) => { + write_error(error_buffer, error_buffer_len, "config path was not valid UTF-8"); + None + } + } +} + +fn write_error(buffer: *mut c_char, buffer_len: usize, message: &str) { + if buffer.is_null() || buffer_len == 0 { + return; + } + + let bytes = message.as_bytes(); + let copy_len = bytes.len().min(buffer_len.saturating_sub(1)); + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), buffer.cast::(), copy_len); + *buffer.add(copy_len) = 0; + } +} + +#[cfg(all(test, feature = "passthru-output-spike", target_os = "macos"))] +mod tests { + use super::*; + use std::ffi::CString; + use std::time::{Duration, Instant}; + + fn passthru_cfg_path() -> CString { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../External/kanata/cfg_samples/minimal.kbd"); + CString::new(path.to_str().expect("utf-8 path")).expect("cstring path") + } + + fn passthru_emit_cfg_path() -> CString { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../External/kanata/cfg_samples/simple.kbd"); + CString::new(path.to_str().expect("utf-8 path")).expect("cstring path") + } + + fn read_error_buffer(buffer: &[c_char]) -> String { + unsafe { CStr::from_ptr(buffer.as_ptr()) } + .to_string_lossy() + .into_owned() + } + + #[test] + fn create_passthru_runtime_returns_handle_and_empty_output_queue() { + let cfg_path = passthru_cfg_path(); + let mut error_buffer = vec![0 as c_char; 512]; + + let runtime = keypath_kanata_bridge_create_passthru_runtime( + cfg_path.as_ptr(), + 0, + error_buffer.as_mut_ptr(), + error_buffer.len(), + ); + + assert!( + !runtime.is_null(), + "expected passthru runtime, got error: {}", + read_error_buffer(&error_buffer) + ); + + let mut value = 99u64; + let mut page = 99u32; + let mut code = 99u32; + let recv_status = keypath_kanata_bridge_passthru_try_recv_output( + runtime, + &mut value, + &mut page, + &mut code, + error_buffer.as_mut_ptr(), + error_buffer.len(), + ); + + assert_eq!(recv_status, 0, "unexpected error: {}", read_error_buffer(&error_buffer)); + assert_eq!(read_error_buffer(&error_buffer), ""); + assert_eq!(value, 99); + assert_eq!(page, 99); + assert_eq!(code, 99); + + keypath_kanata_bridge_destroy_passthru_runtime(runtime); + } + + #[test] + fn passthru_runtime_processes_injected_input_without_event_loop() { + let cfg_path = passthru_emit_cfg_path(); + let mut error_buffer = vec![0 as c_char; 512]; + + let runtime = keypath_kanata_bridge_create_passthru_runtime( + cfg_path.as_ptr(), + 0, + error_buffer.as_mut_ptr(), + error_buffer.len(), + ); + assert!( + !runtime.is_null(), + "expected passthru runtime, got error: {}", + read_error_buffer(&error_buffer) + ); + + assert!(keypath_kanata_bridge_start_passthru_runtime( + runtime, + error_buffer.as_mut_ptr(), + error_buffer.len(), + )); + assert_eq!(read_error_buffer(&error_buffer), ""); + + let page_code = + kanata_state_machine::PageCode::try_from(kanata_state_machine::str_to_oscode("a").unwrap()) + .expect("page code"); + assert!(keypath_kanata_bridge_passthru_send_input( + runtime, + 1, + page_code.page, + page_code.code, + error_buffer.as_mut_ptr(), + error_buffer.len(), + )); + assert_eq!(read_error_buffer(&error_buffer), ""); + + let mut value = 0u64; + let mut page = 0u32; + let mut code = 0u32; + let deadline = Instant::now() + Duration::from_millis(250); + let mut recv_status = 0; + while Instant::now() < deadline { + recv_status = keypath_kanata_bridge_passthru_try_recv_output( + runtime, + &mut value, + &mut page, + &mut code, + error_buffer.as_mut_ptr(), + error_buffer.len(), + ); + if recv_status != 0 { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + + assert_eq!(recv_status, 1, "unexpected error: {}", read_error_buffer(&error_buffer)); + assert_eq!(value, 1); + assert_eq!(page, page_code.page); + assert_eq!(code, page_code.code); + + keypath_kanata_bridge_destroy_passthru_runtime(runtime); + } +} diff --git a/Scripts/README.md b/Scripts/README.md index e6959f7fb..068e485d0 100644 --- a/Scripts/README.md +++ b/Scripts/README.md @@ -7,7 +7,6 @@ - For local-only testing, set `ALLOW_UNSIGNED_SPARKLE=1` to continue without an EdDSA signature. - `./test.sh` — Run the full test suite (root) - `./Scripts/run-installer-reliability-matrix.sh` — Automated installer reliability matrix + diagnostic artifact bundle (`test-results/installer-reliability/latest`). -- `./Scripts/repro-duplicate-keys.sh` — CPU-load repro harness for duplicate keypress detection (filters navigation keys by default). Supports `--auto-type osascript` or `--auto-type peekaboo` for deterministic automated keystroke generation, and continuously samples Kanata process metrics (CPU%, memory, threads, priority). ## Scripts in this directory - `build-and-sign.sh` - The implementation of the build process diff --git a/Scripts/build-and-sign.sh b/Scripts/build-and-sign.sh index 4d762e22c..0edadeb3b 100755 --- a/Scripts/build-and-sign.sh +++ b/Scripts/build-and-sign.sh @@ -143,6 +143,9 @@ echo "🔬 Building kanata simulator..." # Build simulator for dry-run simulation ./Scripts/build-kanata-simulator.sh +echo "🧩 Building kanata host bridge..." +./Scripts/build-kanata-host-bridge.sh + echo "🔐 Building privileged helper..." # Build and sign the helper tool ./Scripts/build-helper.sh @@ -160,6 +163,8 @@ echo "🏗️ Building KeyPath and plugins..." # Build main app + insights plugin (KeyPathPluginKit is statically linked, no separate dylib needed) # Note: `swift build` accepts a single `--product`; passing it twice can skip the first one. swift build --configuration release --product KeyPath -Xswiftc -no-whole-module-optimization +swift build --configuration release --product KeyPathKanataLauncher -Xswiftc -no-whole-module-optimization +swift build --configuration release --product KeyPathOutputBridge -Xswiftc -no-whole-module-optimization swift build --configuration release --product KeyPathInsights -Xswiftc -no-whole-module-optimization echo "📦 Creating app bundle..." @@ -193,6 +198,9 @@ MACOS="${CONTENTS}/MacOS" # Copy bundled kanata simulator binary ditto "build/kanata-simulator" "$CONTENTS/Library/KeyPath/kanata-simulator" + # Copy bundled host bridge library used for in-process smoke checks and future runtime hosting + ditto "build/kanata-host-bridge/libkeypath_kanata_host_bridge.dylib" "$CONTENTS/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" + # Embed Sparkle.framework (required at runtime for updates; otherwise dyld aborts at launch) SPARKLE_FRAMEWORK_SRC="$BUILD_DIR/Sparkle.framework" if [ -d "$SPARKLE_FRAMEWORK_SRC" ]; then @@ -221,11 +229,11 @@ MACOS="${CONTENTS}/MacOS" exit 1 fi - # Copy kanata launcher script to enforce absolute config paths - KANATA_LAUNCHER_SRC="Scripts/kanata-launcher.sh" - KANATA_LAUNCHER_DST="$CONTENTS/Library/KeyPath/kanata-launcher" - ditto "$KANATA_LAUNCHER_SRC" "$KANATA_LAUNCHER_DST" - chmod 755 "$KANATA_LAUNCHER_DST" + # Copy the bundled runtime host executable used by SMAppService + KANATA_LAUNCHER_SRC="$BUILD_DIR/KeyPathKanataLauncher" + KANATA_LAUNCHER_DST="$CONTENTS/Library/KeyPath/kanata-launcher" + ditto "$KANATA_LAUNCHER_SRC" "$KANATA_LAUNCHER_DST" + chmod 755 "$KANATA_LAUNCHER_DST" # Embed privileged helper for SMJobBless echo "📦 Embedding privileged helper (SMAppService layout)..." @@ -235,9 +243,11 @@ mkdir -p "$HELPER_TOOLS" "$LAUNCH_DAEMONS" # Copy helper binary into Contents/Library/HelperTools/ ditto "$BUILD_DIR/KeyPathHelper" "$HELPER_TOOLS/KeyPathHelper" +ditto "$BUILD_DIR/KeyPathOutputBridge" "$HELPER_TOOLS/KeyPathOutputBridge" # Copy daemon plist into bundle-local LaunchDaemons with final name ditto "Sources/KeyPathHelper/com.keypath.helper.plist" "$LAUNCH_DAEMONS/com.keypath.helper.plist" +ditto "Sources/KeyPathOutputBridge/com.keypath.output-bridge.plist" "$LAUNCH_DAEMONS/com.keypath.output-bridge.plist" # Copy Kanata daemon plist for SMAppService ditto "Sources/KeyPathApp/com.keypath.kanata.plist" "$LAUNCH_DAEMONS/com.keypath.kanata.plist" @@ -246,12 +256,15 @@ ditto "Sources/KeyPathApp/com.keypath.kanata.plist" "$LAUNCH_DAEMONS/com.keypath local missing=0 for path in \ "$HELPER_TOOLS/KeyPathHelper" \ + "$HELPER_TOOLS/KeyPathOutputBridge" \ "$LAUNCH_DAEMONS/com.keypath.helper.plist" \ + "$LAUNCH_DAEMONS/com.keypath.output-bridge.plist" \ "$LAUNCH_DAEMONS/com.keypath.kanata.plist" \ "$FRAMEWORKS/Sparkle.framework" \ "$INSIGHTS_BUNDLE/Contents/MacOS/libKeyPathInsights" \ "$INSIGHTS_BUNDLE/Contents/Info.plist" \ "$KANATA_LAUNCHER_DST" \ + "$CONTENTS/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" \ "$CONTENTS/Library/KeyPath/kanata-simulator"; do if [ ! -e "$path" ]; then echo "❌ ERROR: Missing packaged artifact: $path" >&2 @@ -269,7 +282,9 @@ verify_embedded_artifacts ./Scripts/verify-kanata-plist.sh "$APP_BUNDLE" echo "✅ Helper embedded: $HELPER_TOOLS/KeyPathHelper" +echo "✅ Output bridge embedded: $HELPER_TOOLS/KeyPathOutputBridge" echo "✅ Helper plist embedded: $LAUNCH_DAEMONS/com.keypath.helper.plist" +echo "✅ Output bridge plist embedded: $LAUNCH_DAEMONS/com.keypath.output-bridge.plist" echo "✅ Kanata daemon plist embedded: $LAUNCH_DAEMONS/com.keypath.kanata.plist" # Copy main app Info.plist @@ -349,9 +364,20 @@ else --entitlements "$HELPER_ENTITLEMENTS" \ --sign "$SIGNING_IDENTITY" + OUTPUT_BRIDGE_ENTITLEMENTS="Sources/KeyPathOutputBridge/KeyPathOutputBridge.entitlements" + kp_sign "$HELPER_TOOLS/KeyPathOutputBridge" \ + --force --options=runtime \ + --identifier "com.keypath.output-bridge" \ + --entitlements "$OUTPUT_BRIDGE_ENTITLEMENTS" \ + --sign "$SIGNING_IDENTITY" + # Sign bundled kanata binary (already signed in build-kanata.sh, but ensure consistency) kp_sign "$CONTENTS/Library/KeyPath/kanata" --force --options=runtime --sign "$SIGNING_IDENTITY" + # Sign the bundled runtime host pieces explicitly before the outer app sign. + kp_sign "$CONTENTS/Library/KeyPath/kanata-launcher" --force --options=runtime --sign "$SIGNING_IDENTITY" + kp_sign "$CONTENTS/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" --force --options=runtime --sign "$SIGNING_IDENTITY" + # Sign bundled kanata simulator binary kp_sign "$CONTENTS/Library/KeyPath/kanata-simulator" --force --options=runtime --sign "$SIGNING_IDENTITY" diff --git a/Scripts/build-kanata-host-bridge.sh b/Scripts/build-kanata-host-bridge.sh new file mode 100755 index 000000000..3e7e04f6a --- /dev/null +++ b/Scripts/build-kanata-host-bridge.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# Build Script: build-kanata-host-bridge.sh +# Purpose: Build the Rust C-ABI bridge layer that a future bundled Swift host can link against. +# Output: +# - build/kanata-host-bridge/libkeypath_kanata_host_bridge.a +# - build/kanata-host-bridge/libkeypath_kanata_host_bridge.dylib +# - build/kanata-host-bridge/include/keypath_kanata_host_bridge.h + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +BRIDGE_ROOT="$PROJECT_ROOT/Rust/KeyPathKanataHostBridge" +BUILD_DIR="$PROJECT_ROOT/build/kanata-host-bridge" +BRIDGE_FEATURES="${KEYPATH_KANATA_HOST_BRIDGE_FEATURES:-passthru-output-spike}" +# KeyPath currently packages Apple Silicon-only local builds, so the bridge intentionally targets +# aarch64-apple-darwin here to match the shipped app/runtime artifacts. + +echo "🧩 Building KeyPath Kanata host bridge..." + +if ! command -v cargo >/dev/null 2>&1; then + echo "❌ Error: Rust toolchain (cargo) not found." >&2 + exit 1 +fi + +if [ ! -f "$BRIDGE_ROOT/Cargo.toml" ]; then + echo "❌ Error: Bridge crate not found at $BRIDGE_ROOT" >&2 + exit 1 +fi + +mkdir -p "$BUILD_DIR/include" + +if [ -n "$BRIDGE_FEATURES" ]; then + cargo build \ + --manifest-path "$BRIDGE_ROOT/Cargo.toml" \ + --release \ + --features "$BRIDGE_FEATURES" \ + --target aarch64-apple-darwin +else + cargo build \ + --manifest-path "$BRIDGE_ROOT/Cargo.toml" \ + --release \ + --target aarch64-apple-darwin +fi + +cp "$BRIDGE_ROOT/target/aarch64-apple-darwin/release/libkeypath_kanata_host_bridge.a" \ + "$BUILD_DIR/libkeypath_kanata_host_bridge.a" +cp "$BRIDGE_ROOT/target/aarch64-apple-darwin/release/libkeypath_kanata_host_bridge.dylib" \ + "$BUILD_DIR/libkeypath_kanata_host_bridge.dylib" +cp "$BRIDGE_ROOT/include/keypath_kanata_host_bridge.h" \ + "$BUILD_DIR/include/keypath_kanata_host_bridge.h" + +echo "✅ Host bridge built" +if [ -n "$BRIDGE_FEATURES" ]; then + echo "🧪 Bridge features: $BRIDGE_FEATURES" +fi +echo "📍 Static library: $BUILD_DIR/libkeypath_kanata_host_bridge.a" +echo "📍 Dynamic library: $BUILD_DIR/libkeypath_kanata_host_bridge.dylib" +echo "📍 Header: $BUILD_DIR/include/keypath_kanata_host_bridge.h" diff --git a/Scripts/build-kanata-runtime-library.sh b/Scripts/build-kanata-runtime-library.sh new file mode 100755 index 000000000..177e1213d --- /dev/null +++ b/Scripts/build-kanata-runtime-library.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# +# Build Script: build-kanata-runtime-library.sh +# Purpose: Produce a linkable static library artifact from the vendored Kanata source. +# Output: build/kanata-runtime/libkanata_state_machine.a +# +# This does NOT create a Swift-callable bridge by itself. The upstream crate exposes +# Rust symbols, not a stable C ABI. This script exists to validate the packaging/linking +# half of the long-term "bundled host owns HID capture" migration without changing the +# shipping runtime path yet. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +KANATA_SOURCE="$PROJECT_ROOT/External/kanata" +BUILD_DIR="$PROJECT_ROOT/build" +OUTPUT_DIR="$BUILD_DIR/kanata-runtime" +OUTPUT_LIB="$OUTPUT_DIR/libkanata_state_machine.a" +OUTPUT_INFO="$OUTPUT_DIR/README.txt" + +echo "🧱 Building Kanata runtime static library artifact..." + +if ! command -v cargo >/dev/null 2>&1; then + echo "❌ Error: Rust toolchain (cargo) not found." >&2 + exit 1 +fi + +if ! command -v rustup >/dev/null 2>&1; then + echo "❌ Error: rustup not found." >&2 + exit 1 +fi + +if [ ! -d "$KANATA_SOURCE" ]; then + echo "❌ Error: Kanata source not found at $KANATA_SOURCE" >&2 + echo " Run: git submodule update --init --recursive" >&2 + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +echo "📁 Kanata source: $KANATA_SOURCE" +echo "📁 Output directory: $OUTPUT_DIR" + +rustup target add aarch64-apple-darwin >/dev/null 2>&1 || true + +cd "$KANATA_SOURCE" +MACOSX_DEPLOYMENT_TARGET=11.0 \ +cargo rustc \ + --release \ + --lib \ + --target aarch64-apple-darwin \ + -- \ + --crate-type staticlib + +cd "$PROJECT_ROOT" + +SOURCE_LIB="$KANATA_SOURCE/target/aarch64-apple-darwin/release/libkanata_state_machine.a" +if [ ! -f "$SOURCE_LIB" ]; then + echo "❌ Error: Expected static library not found at $SOURCE_LIB" >&2 + exit 1 +fi + +cp "$SOURCE_LIB" "$OUTPUT_LIB" + +cat > "$OUTPUT_INFO" <<'EOF' +This artifact is a linkable static library build of Kanata's upstream Rust library target. + +Important: +- It is NOT a stable C ABI. +- Swift cannot call it directly without a Rust bridge layer that exports C-callable entry points. +- It exists to validate that KeyPath can package and link the upstream library boundary as part + of the long-term macOS runtime-host migration. + +Expected next step: +- add a small Rust bridge crate that depends on kanata_state_machine and exposes a stable C ABI + tailored to KeyPath's bundled macOS runtime host. +EOF + +echo "✅ Kanata runtime library built" +echo "📍 Static library: $OUTPUT_LIB" +echo "📍 Notes: $OUTPUT_INFO" +file "$OUTPUT_LIB" || true diff --git a/Scripts/manual-keystroke-test.sh b/Scripts/manual-keystroke-test.sh deleted file mode 100755 index ec9d51c7e..000000000 --- a/Scripts/manual-keystroke-test.sh +++ /dev/null @@ -1,364 +0,0 @@ -#!/usr/bin/env bash -# Manual keystroke fidelity test — exercises the REAL HID path through Kanata. -# -# Unlike the automated test (which uses osascript and bypasses Kanata), -# this requires you to physically type on your keyboard so Kanata actually -# processes every keystroke through its HID intercept → engine → virtual HID pipeline. -# -# The script: -# 1. Shows a reference passage in the terminal -# 2. Opens a blank Zed scratch file for you to type into -# 3. Starts CPU/memory/disk stress (configurable preset) -# 4. Monitors KeyPath log for duplicate detection diagnostics -# 5. When you're done, captures Zed content and diffs against reference -# 6. Analyzes KeyPath log for ⚠️ [DUPLICATE DETECTION] entries -# -# Usage: -# ./Scripts/manual-keystroke-test.sh [baseline|medium|high|vicious] - -set -euo pipefail - -PRESET="${1:-high}" -SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd) -PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." >/dev/null && pwd) -TIMESTAMP=$(date +%Y%m%d-%H%M%S) -TEST_DIR="${TMPDIR:-/tmp}/keypath-manual-test-$TIMESTAMP" -mkdir -p "$TEST_DIR" - -LOG_FILE="$HOME/Library/Logs/KeyPath/keypath-debug.log" -SCRATCH_FILE="$TEST_DIR/typed-output.txt" -REFERENCE_FILE="$TEST_DIR/reference.txt" -LOG_SNAPSHOT_START="$TEST_DIR/log-start-line.txt" -ANALYSIS_FILE="$TEST_DIR/analysis.txt" - -# --- Reference passage --- -# Deliberately includes: double letters (tt, ll, ss, ee, ff), punctuation, -# capitalization, numbers, symbols common in code, and varied word lengths. -# ~300 words, ~1500 chars — enough to surface timing issues without being exhausting. -cat > "$REFERENCE_FILE" << 'PASSAGE' -The little kitten sat still on the tall wooden stool. It blinked sleepily, then suddenly leapt off and dashed across the room. All the coffee cups rattled as it skidded past the bookshelf. - -Programming requires attention to small details. A missing semicolon, an off-by-one error, or a forgotten null check can cause hours of debugging. The best programmers are not the fastest typists but the most careful thinkers. - -func processEvent(_ event: KeyEvent) -> Bool { - guard event.type == .keyDown else { return false } - let mapped = keymap[event.keyCode] ?? event.keyCode - if modifiers.contains(.shift) { - return handleShifted(mapped, at: event.timestamp) - } - return handleNormal(mapped, at: event.timestamp) -} - -The 15 bees buzzed happily around 33 yellow flowers. She added 2 eggs, 1.5 cups of flour, and 0.75 teaspoons of baking soda. The recipe called for 350 degrees for 25 minutes. - -Success is not final, failure is not fatal: it is the courage to continue that counts. Every accomplishment starts with the decision to try. The difference between a successful person and others is not a lack of strength, not a lack of knowledge, but rather a lack of will. -PASSAGE - -REFERENCE_CHARS=$(wc -c < "$REFERENCE_FILE" | tr -d ' ') -REFERENCE_WORDS=$(wc -w < "$REFERENCE_FILE" | tr -d ' ') - -# --- Preflight --- -echo "╔══════════════════════════════════════════════════════════════╗" -echo "║ KeyPath Manual Keystroke Fidelity Test ║" -echo "║ (exercises REAL HID path through Kanata) ║" -echo "╠══════════════════════════════════════════════════════════════╣" -echo "║ Preset: $(printf '%-49s' "$PRESET")║" -echo "║ Reference: ${REFERENCE_WORDS} words, ${REFERENCE_CHARS} chars ║" -echo "║ Output: $TEST_DIR" -echo "╚══════════════════════════════════════════════════════════════╝" -echo - -errors=() -if ! pgrep -iq kanata 2>/dev/null; then - errors+=("Kanata is not running. Launch KeyPath first.") -fi -if [[ ! -f "$LOG_FILE" ]]; then - errors+=("KeyPath log not found: $LOG_FILE") -fi - -if [[ ${#errors[@]} -gt 0 ]]; then - echo "PREFLIGHT FAILED:" - for e in "${errors[@]}"; do echo " ✗ $e"; done - exit 1 -fi - -KANATA_PID=$(pgrep -i kanata | head -1) -echo " ✓ Kanata running (PID $KANATA_PID)" -echo " ✓ KeyPath log exists" -echo - -# --- Mark log position --- -LOG_LINE_COUNT=$(wc -l < "$LOG_FILE" | tr -d ' ') -echo "$LOG_LINE_COUNT" > "$LOG_SNAPSHOT_START" - -# --- Start CPU load --- -bg_pids=() -cleanup_load() { - for pid in "${bg_pids[@]:-}"; do - kill "$pid" 2>/dev/null || true - done - pkill -f "dd if=/dev/zero of=.*manual-test" 2>/dev/null || true -} -trap cleanup_load EXIT - -NUM_CORES=$(sysctl -n hw.ncpu 2>/dev/null || echo 8) - -case "$PRESET" in - baseline) - echo " No CPU load (baseline)" - ;; - medium) - (cd "$PROJECT_ROOT" && while true; do swift build -c debug >/dev/null 2>&1 || true; done) & - bg_pids+=("$!") - yes >/dev/null & bg_pids+=("$!") - yes >/dev/null & bg_pids+=("$!") - echo " CPU load: medium (compile loop + 2 hogs)" - ;; - high) - (cd "$PROJECT_ROOT" && while true; do swift build -c debug >/dev/null 2>&1 || true; done) & - bg_pids+=("$!") - for i in {1..6}; do yes >/dev/null & bg_pids+=("$!"); done - echo " CPU load: high (compile loop + 6 hogs)" - ;; - vicious) - (cd "$PROJECT_ROOT" && while true; do swift build -c debug >/dev/null 2>&1 || true; done) & - bg_pids+=("$!") - HOG_COUNT=$((NUM_CORES - 1)) - for (( i=0; i/dev/null & bg_pids+=("$!"); done - (while true; do dd if=/dev/zero of="${TEST_DIR}/io-stress-manual-test" bs=1m count=64 2>/dev/null; rm -f "${TEST_DIR}/io-stress-manual-test"; done) & - bg_pids+=("$!") - (python3 -c " -import time -blocks = [] -try: - for _ in range(20): - b = bytearray(50*1024*1024) - for i in range(0, len(b), 4096): b[i] = 0xFF - blocks.append(b) - time.sleep(0.5) - time.sleep(300) -except: time.sleep(300) -" 2>/dev/null) & - bg_pids+=("$!") - echo " CPU load: VICIOUS ($HOG_COUNT hogs + compile + disk I/O + 1GB memory pressure)" - ;; - *) - echo "Error: preset must be baseline, medium, high, or vicious" >&2 - exit 1 - ;; -esac - -sleep 2 - -# Show Kanata CPU to confirm it's alive and processing -kanata_cpu=$(ps -o %cpu= -p "$KANATA_PID" 2>/dev/null | tr -d ' ') -echo " Kanata CPU at start: ${kanata_cpu}%" -echo - -# --- Start live log monitor in background --- -# Watches for duplicate detection and key events in real time -echo " Starting log monitor (duplicate detection alerts will appear here)..." -echo " (ignoring: backspace, arrows, modifiers, space, enter, tab, esc)" -( - tail -n 0 -F "$LOG_FILE" 2>/dev/null | while IFS= read -r line; do - if echo "$line" | grep -q "DUPLICATE DETECTION"; then - echo " 🔴 $line" - elif echo "$line" | grep -q "Skipping duplicate"; then - # Filter out ignored keys from dedup skip messages too - if echo "$line" | grep -qiE "duplicate: (backspace|delete|left|right|up|down|home|end|pageup|pagedown|leftshift|rightshift|leftctrl|rightctrl|leftalt|rightalt|leftmeta|rightmeta|tab|escape|caps|numlock|space|enter|return) "; then - : # ignore - else - echo " 🟡 $line" - fi - fi - done -) & -MONITOR_PID=$! -bg_pids+=("$MONITOR_PID") - -# --- Open Zed and display reference --- -touch "$SCRATCH_FILE" -open -a "Zed" "$SCRATCH_FILE" & -sleep 2 - -# Clear screen so the passage is front and center -clear - -echo -echo "╔══════════════════════════════════════════════════════════════╗" -echo "║ ║" -echo "║ TYPE THIS TEXT INTO ZED (the blank file that just opened)║" -echo "║ Typos are fine — we only care about repeated characters. ║" -echo "║ CPU stress is running in the background. ║" -echo "║ ║" -echo "╚══════════════════════════════════════════════════════════════╝" -echo -echo "───────────────── START TYPING THIS ─────────────────" -echo -cat "$REFERENCE_FILE" -echo -echo "───────────────── STOP TYPING HERE ──────────────────" -echo -echo " Any 🔴 duplicate alerts from Kanata will appear below:" -echo -echo " ─── live alerts ───" -echo - -# Wait for user to finish typing -read -r -p ">>> Done typing? Press ENTER to analyze results... " - -TYPING_END=$(date '+%H:%M:%S') -echo -echo " Capturing results at $TYPING_END..." - -# --- Capture Zed content --- -osascript -e 'tell application "Zed" to activate' 2>/dev/null || true -sleep 0.5 -osascript -e 'tell application "System Events" to keystroke "a" using command down' 2>/dev/null || true -sleep 0.3 -osascript -e 'tell application "System Events" to keystroke "c" using command down' 2>/dev/null || true -sleep 1 -ACTUAL_OUTPUT=$(pbpaste 2>/dev/null || echo "") -echo "$ACTUAL_OUTPUT" > "$TEST_DIR/actual-output.txt" - -# --- Stop load & monitor --- -cleanup_load -trap - EXIT -kill "$MONITOR_PID" 2>/dev/null || true -pkill -f "tail.*keypath-debug.log" 2>/dev/null || true -sleep 1 - -# --- Check Kanata CPU was non-zero (confirms HID path was exercised) --- -echo -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "RESULTS" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -# --- Validity check: did Kanata actually process keystrokes? --- -LOG_START=$(cat "$LOG_SNAPSHOT_START") -NEW_LOG_LINES=$(tail -n +"$((LOG_START + 1))" "$LOG_FILE") - -# Note: pipe through tr to ensure single-line numeric output -KEY_EVENT_COUNT=$(echo "$NEW_LOG_LINES" | grep -c "KeyInput:" 2>/dev/null || echo "0"); KEY_EVENT_COUNT=$(echo "$KEY_EVENT_COUNT" | tr -d '[:space:]') -LAYER_EVENT_COUNT=$(echo "$NEW_LOG_LINES" | grep -c "CurrentLayerName" 2>/dev/null || echo "0"); LAYER_EVENT_COUNT=$(echo "$LAYER_EVENT_COUNT" | tr -d '[:space:]') - -echo -echo " HID Path Validation:" -if [[ "$KEY_EVENT_COUNT" -gt 0 ]]; then - echo " ✓ Kanata processed $KEY_EVENT_COUNT key events (HID path confirmed)" -else - echo " ✗ Kanata saw 0 key events — HID path NOT exercised!" - echo " This test is INVALID. Were you typing on the physical keyboard?" -fi -echo " Layer events: $LAYER_EVENT_COUNT" - -# --- Duplicate detection analysis --- -# Filter out navigation/modifier keys from duplicate counts -IGNORED_KEYS_PATTERN="(backspace|delete|left|right|up|down|home|end|pageup|pagedown|leftshift|rightshift|leftctrl|rightctrl|leftalt|rightalt|leftmeta|rightmeta|tab|escape|caps|numlock|space|enter|return)" -# Save filtered lines to files to avoid grep -c multi-line issues -echo "$NEW_LOG_LINES" | grep "DUPLICATE DETECTION" | grep -ivE "Key '$IGNORED_KEYS_PATTERN'" > "$TEST_DIR/dup-alerts-text.txt" 2>/dev/null || true -echo "$NEW_LOG_LINES" | grep "Skipping duplicate" | grep -ivE "duplicate: $IGNORED_KEYS_PATTERN " > "$TEST_DIR/dedup-skips-text.txt" 2>/dev/null || true -echo "$NEW_LOG_LINES" | grep "DUPLICATE DETECTION" | grep -iE "Key '$IGNORED_KEYS_PATTERN'" > "$TEST_DIR/dup-alerts-ignored.txt" 2>/dev/null || true -echo "$NEW_LOG_LINES" | grep "Skipping duplicate" | grep -iE "duplicate: $IGNORED_KEYS_PATTERN " > "$TEST_DIR/dedup-skips-ignored.txt" 2>/dev/null || true - -DUPLICATE_ALERTS=$(wc -l < "$TEST_DIR/dup-alerts-text.txt" | tr -d ' ') -DEDUP_SKIPS=$(wc -l < "$TEST_DIR/dedup-skips-text.txt" | tr -d ' ') -IGNORED_DUP_ALERTS=$(wc -l < "$TEST_DIR/dup-alerts-ignored.txt" | tr -d ' ') -IGNORED_DEDUP_SKIPS=$(wc -l < "$TEST_DIR/dedup-skips-ignored.txt" | tr -d ' ') - -echo -echo " Duplicate Detection (text keys only):" -echo " ⚠️ DUPLICATE DETECTION alerts: $DUPLICATE_ALERTS" -echo " 🚫 Dedup filter skips: $DEDUP_SKIPS" -echo " (ignored nav/modifier repeats: $IGNORED_DUP_ALERTS alerts, $IGNORED_DEDUP_SKIPS skips)" - -if [[ "$DUPLICATE_ALERTS" -gt 0 ]]; then - echo - echo " Duplicate details:" - echo "$NEW_LOG_LINES" | grep "DUPLICATE DETECTION" | sed 's/^/ /' -fi - -# --- Character comparison --- -expected_len=$REFERENCE_CHARS -actual_len=$(echo -n "$ACTUAL_OUTPUT" | wc -c | tr -d ' ') - -echo -echo " Character Comparison:" -echo " Reference: $expected_len chars" -echo " Typed: $actual_len chars" -echo " Difference: $((actual_len - expected_len)) chars" - -# --- Word-level diff --- -diff "$REFERENCE_FILE" "$TEST_DIR/actual-output.txt" > "$TEST_DIR/diff-report.txt" 2>&1 || true - -# Word diff for precision -diff --word-diff=porcelain "$REFERENCE_FILE" "$TEST_DIR/actual-output.txt" \ - > "$TEST_DIR/word-diff.txt" 2>&1 || true - -additions=$(grep -c '^+' "$TEST_DIR/word-diff.txt" 2>/dev/null || true); additions=${additions:-0}; additions=$(echo "$additions" | tr -d '[:space:]') -deletions=$(grep -c '^-' "$TEST_DIR/word-diff.txt" 2>/dev/null || true); deletions=${deletions:-0}; deletions=$(echo "$deletions" | tr -d '[:space:]') - -echo -echo " Word-Level Diff:" -echo " Additions (potential duplicates): $additions" -echo " Deletions (potential drops): $deletions" - -if [[ "$additions" -gt 0 ]]; then - echo - echo " Added words/chars (first 15):" - grep '^+' "$TEST_DIR/word-diff.txt" | head -15 | sed 's/^/ /' -fi - -# --- Save analysis --- -{ - echo "Manual Keystroke Test Analysis" - echo "Date: $(date)" - echo "Preset: $PRESET" - echo "Kanata PID: $KANATA_PID" - echo "Key events processed by Kanata: $KEY_EVENT_COUNT" - echo "Duplicate detection alerts: $DUPLICATE_ALERTS" - echo "Dedup filter skips: $DEDUP_SKIPS" - echo "Reference chars: $expected_len" - echo "Actual chars: $actual_len" - echo "Difference: $((actual_len - expected_len))" - echo "Word additions: $additions" - echo "Word deletions: $deletions" -} > "$ANALYSIS_FILE" - -# --- Overall verdict --- -echo -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "VERDICT" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -if [[ "$KEY_EVENT_COUNT" -eq 0 ]]; then - echo " ❌ INVALID TEST — Kanata did not process any key events." - echo " Ensure you're typing on the physical keyboard, not pasting." -elif [[ "$DUPLICATE_ALERTS" -gt 0 ]]; then - echo " 🔴 DUPLICATES DETECTED — $DUPLICATE_ALERTS consecutive-key alerts" - echo " under preset=$PRESET. The bug is NOT fully resolved." - echo " Review the alert details above for root-cause timing." -elif [[ "$actual_len" -gt "$((expected_len + 20))" ]]; then - extra=$((actual_len - expected_len)) - echo " 🔴 EXTRA CHARACTERS — $extra more chars than reference." - echo " Possible duplicate keystrokes that slipped past detection." -elif [[ "$DEDUP_SKIPS" -gt 0 ]]; then - echo " 🟡 DEDUP FILTER ACTIVE — $DEDUP_SKIPS duplicates caught and filtered." - echo " The fix is working but duplicates ARE being generated." - echo " This confirms the root cause is still present; the fix is a mitigation." -elif [[ "$actual_len" -eq "$expected_len" ]] || [[ "$additions" -le 2 ]]; then - echo " ✅ PASS — $KEY_EVENT_COUNT keystrokes through Kanata HID path," - echo " zero duplicate alerts, character counts match." - echo " Fix appears effective at the HID level under preset=$PRESET." -else - echo " ⚠️ INCONCLUSIVE — review diff for typos vs systematic duplicates." - echo " Small differences may be human typing errors." -fi - -echo -echo "Full results: $TEST_DIR/" -echo " reference.txt — what you should have typed" -echo " actual-output.txt — what appeared in Zed" -echo " diff-report.txt — line-level diff" -echo " word-diff.txt — word-level diff" -echo " analysis.txt — machine-readable summary" diff --git a/Scripts/quick-deploy.sh b/Scripts/quick-deploy.sh index 60d6d8b14..a8997b4a8 100755 --- a/Scripts/quick-deploy.sh +++ b/Scripts/quick-deploy.sh @@ -18,6 +18,7 @@ APP_BUNDLE="/Applications/${APP_NAME}.app" MACOS_DIR="$APP_BUNDLE/Contents/MacOS" RESOURCES_DIR="$APP_BUNDLE/Contents/Resources" ENTITLEMENTS="$PROJECT_DIR/KeyPath.entitlements" +WAS_RUNNING=0 # Local module cache to avoid invalidations and sandboxed cache paths. MODULE_CACHE="$PROJECT_DIR/.build/ModuleCache.noindex" @@ -115,12 +116,32 @@ if [[ -f "$SWIFTPM_LOCK" ]] && lsof "$SWIFTPM_LOCK" 2>/dev/null | grep -q swift; exit 0 fi +# Stop the currently running app before mutating the bundle on disk. +# +# Replacing binaries and re-signing a live .app can invalidate pages that the old +# process still has mapped, which shows up as a crash report with: +# SIGKILL (Code Signature Invalid) +# namespace CODESIGNING / Invalid Page +# +# We intentionally stop the app up front, then rebuild/copy/sign, then relaunch. +if pgrep -x "$APP_NAME" > /dev/null; then + WAS_RUNNING=1 + echo "🛑 Stopping running $APP_NAME before deploy..." + pkill -x "$APP_NAME" 2>/dev/null || true + for _ in {1..120}; do + if ! pgrep -x "$APP_NAME" >/dev/null; then + break + fi + sleep 0.05 + done +fi + # Build debug (fast - incremental) echo "🔨 Building..." BUILD_LOG=$(mktemp -t keypath-build.XXXXXX) # NOTE: `swift build --show-bin-path` does not reliably trigger a rebuild. # Always build first, then query the bin dir. -if ! swift build --product KeyPath --product KeyPathInsights "${MODULE_CACHE_FLAGS[@]}" 2> "$BUILD_LOG"; then +if ! swift build --product KeyPath --product KeyPathKanataLauncher --product KeyPathOutputBridge --product KeyPathInsights "${MODULE_CACHE_FLAGS[@]}" 2> "$BUILD_LOG"; then BUILD_END_MS=$(get_time_ms) DURATION=$((BUILD_END_MS - BUILD_START_MS)) echo "❌ Build failed" @@ -147,6 +168,61 @@ fi echo "📦 Deploying..." cp "$DEBUG_BIN" "$MACOS_DIR/$APP_NAME" +# Do not hot-swap the embedded privileged helper by default. +# +# The helper is registered via SMAppService and launchd keeps launch constraints +# tied to the previously blessed bundle contents. Replacing the helper binary +# inside /Applications during quick iteration can leave the registered helper in +# a spawn-failed state until it is explicitly re-registered. Opt in only when +# you intend to follow with a helper reinstall/repair flow. +if [[ "${KEYPATH_DEPLOY_HELPER:-0}" == "1" ]]; then + ./Scripts/build-helper.sh >/dev/null + + HELPER_BIN="$PROJECT_DIR/.build/arm64-apple-macosx/release/KeyPathHelper" + HELPER_DST="$APP_BUNDLE/Contents/Library/HelperTools/KeyPathHelper" + if [[ -f "$HELPER_BIN" ]]; then + mkdir -p "$(dirname "$HELPER_DST")" + cp "$HELPER_BIN" "$HELPER_DST" + chmod 755 "$HELPER_DST" + echo "⚠️ Deployed embedded helper. Re-register the privileged helper before testing XPC." + fi +fi + +# Sync the current bundled runtime host executable. +KANATA_LAUNCHER_BIN="$BIN_DIR/KeyPathKanataLauncher" +KANATA_LAUNCHER_DST="$APP_BUNDLE/Contents/Library/KeyPath/kanata-launcher" +if [[ -f "$KANATA_LAUNCHER_BIN" ]]; then + mkdir -p "$(dirname "$KANATA_LAUNCHER_DST")" + cp "$KANATA_LAUNCHER_BIN" "$KANATA_LAUNCHER_DST" + chmod 755 "$KANATA_LAUNCHER_DST" +fi + +OUTPUT_BRIDGE_BIN="$BIN_DIR/KeyPathOutputBridge" +OUTPUT_BRIDGE_DST="$APP_BUNDLE/Contents/Library/HelperTools/KeyPathOutputBridge" +if [[ -f "$OUTPUT_BRIDGE_BIN" ]]; then + mkdir -p "$(dirname "$OUTPUT_BRIDGE_DST")" + cp "$OUTPUT_BRIDGE_BIN" "$OUTPUT_BRIDGE_DST" + chmod 755 "$OUTPUT_BRIDGE_DST" +fi + +OUTPUT_BRIDGE_PLIST_SRC="$PROJECT_DIR/Sources/KeyPathOutputBridge/com.keypath.output-bridge.plist" +OUTPUT_BRIDGE_PLIST_DST="$APP_BUNDLE/Contents/Library/LaunchDaemons/com.keypath.output-bridge.plist" +if [[ -f "$OUTPUT_BRIDGE_PLIST_SRC" ]]; then + mkdir -p "$(dirname "$OUTPUT_BRIDGE_PLIST_DST")" + cp "$OUTPUT_BRIDGE_PLIST_SRC" "$OUTPUT_BRIDGE_PLIST_DST" +fi + +# Rebuild the Rust host bridge so the installed app does not silently reuse a stale +# dylib without the passthru runtime feature set required by the split-runtime host. +./Scripts/build-kanata-host-bridge.sh >/dev/null + +KANATA_HOST_BRIDGE_SRC="$PROJECT_DIR/build/kanata-host-bridge/libkeypath_kanata_host_bridge.dylib" +KANATA_HOST_BRIDGE_DST="$APP_BUNDLE/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" +if [[ -f "$KANATA_HOST_BRIDGE_SRC" ]]; then + mkdir -p "$(dirname "$KANATA_HOST_BRIDGE_DST")" + cp "$KANATA_HOST_BRIDGE_SRC" "$KANATA_HOST_BRIDGE_DST" +fi + # Sync app resources for fast iteration (quick-deploy doesn't rebuild the bundle). # This ensures new images/scripts added under Sources/KeyPathApp/Resources show up # immediately without requiring a full ./build.sh. @@ -182,28 +258,32 @@ fi echo "✍️ Signing..." SIGNING_IDENTITY="${CODESIGN_IDENTITY:-Developer ID Application: Micah Alpern (X2RKZ5TG99)}" if security find-identity -v -p codesigning | grep -Fq "$SIGNING_IDENTITY"; then + if [[ -f "$APP_BUNDLE/Contents/Library/HelperTools/KeyPathHelper" ]]; then + codesign --force --options=runtime --sign "$SIGNING_IDENTITY" "$APP_BUNDLE/Contents/Library/HelperTools/KeyPathHelper" 2>/dev/null || true + fi + if [[ -f "$APP_BUNDLE/Contents/Library/KeyPath/kanata-launcher" ]]; then + codesign --force --options=runtime --sign "$SIGNING_IDENTITY" "$APP_BUNDLE/Contents/Library/KeyPath/kanata-launcher" 2>/dev/null || true + fi + if [[ -f "$APP_BUNDLE/Contents/Library/HelperTools/KeyPathOutputBridge" ]]; then + codesign --force --options=runtime --sign "$SIGNING_IDENTITY" "$APP_BUNDLE/Contents/Library/HelperTools/KeyPathOutputBridge" 2>/dev/null || true + fi + if [[ -f "$APP_BUNDLE/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" ]]; then + codesign --force --options=runtime --sign "$SIGNING_IDENTITY" "$APP_BUNDLE/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" 2>/dev/null || true + fi codesign --force --options=runtime --sign "$SIGNING_IDENTITY" --entitlements "$ENTITLEMENTS" --deep "$APP_BUNDLE" 2>/dev/null else echo "⚠️ Developer ID identity not found; using ad-hoc signing (helper may reject this build)." codesign --force --sign - --entitlements "$ENTITLEMENTS" --deep "$APP_BUNDLE" 2>/dev/null fi -# Restart the app -echo "🔄 Restarting..." -if pgrep -x "$APP_NAME" > /dev/null; then - # Be strict about restarting; stale running processes are the #1 source of - # “my change didn’t apply” confusion during fast iteration. - pkill -x "$APP_NAME" 2>/dev/null || true - for _ in {1..60}; do - if ! pgrep -x "$APP_NAME" >/dev/null; then - break - fi - sleep 0.05 - done +# Restart the app only if it was running when deploy began. +if [[ "$WAS_RUNNING" == "1" ]]; then + echo "🔄 Restarting..." + open "$APP_BUNDLE" +else + echo "ℹ️ Deploy complete; app was not running, so it was not relaunched." fi -open "$APP_BUNDLE" - BUILD_END_MS=$(get_time_ms) DURATION=$((BUILD_END_MS - BUILD_START_MS)) diff --git a/Scripts/repro-duplicate-keys.sh b/Scripts/repro-duplicate-keys.sh deleted file mode 100755 index e939f997c..000000000 --- a/Scripts/repro-duplicate-keys.sh +++ /dev/null @@ -1,762 +0,0 @@ -#!/usr/bin/env bash -# Reproduce accidental duplicate key presses under controlled CPU load. -# Monitors KeyPath log in real time and alerts on N+ consecutive same-key presses, -# excluding navigation and other likely intentional keys by default. - -set -euo pipefail - -SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd) -PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." >/dev/null && pwd) - -LOG_FILE="${LOG_FILE:-$HOME/Library/Logs/KeyPath/keypath-debug.log}" -TRIALS=3 -DURATION=90 -THRESHOLD=3 -PRESET="medium" -COUNTDOWN=5 -AUTO_TYPE="" -AUTO_TYPE_WPM=60 - -# Default ignored keys (includes navigation and common non-text controls). -IGNORE_KEYS="backspace,left,right,up,down,home,end,pageup,pagedown,leftmeta,rightmeta,leftctrl,rightctrl,leftalt,rightalt,leftshift,rightshift,tab,escape,caps,numlock" - -# Corpus for automated typing — realistic mix of prose and code. -AUTO_TYPE_CORPUS=( - "The quick brown fox jumps over the lazy dog. " - "func handleKeyPress(_ event: KeyEvent) -> Bool { return true } " - "Programming is the art of telling another human what one wants the computer to do. " - "let result = try await manager.processEvent(key: .a, modifiers: [.shift]) " - "if context.permissions.inputMonitoring == .granted { startService() } " - "The five boxing wizards jump quickly at dawn. " - "guard let config = ConfigurationService.shared.load() else { return nil } " - "Pack my box with five dozen liquor jugs. " - "switch event.type { case .keyDown: handle(event) case .keyUp: release(event) } " - "How vexingly quick daft zebras jump over the lazy brown fox. " -) - -usage() { - cat < Load profile (default: medium) - --trials Number of trials (default: 3) - --duration Seconds per trial (default: 90) - --threshold Consecutive key threshold (default: 3) - --ignore-keys Comma-separated ignore key list - --log-file KeyPath log path (default: ~/Library/Logs/KeyPath/keypath-debug.log) - --countdown Countdown before each trial (default: 5) - --auto-type Generate keystrokes automatically (deterministic) - --auto-type-wpm Words per minute for auto-type (default: 60) - -h, --help Show help - -Examples: - ./Scripts/repro-duplicate-keys.sh - ./Scripts/repro-duplicate-keys.sh --preset high --trials 5 --duration 120 - ./Scripts/repro-duplicate-keys.sh --auto-type osascript --preset high - ./Scripts/repro-duplicate-keys.sh --ignore-keys "backspace,left,right,up,down" -USAGE -} - -while [[ $# -gt 0 ]]; do - case "$1" in - --preset) - PRESET="${2:-}" - shift 2 - ;; - --trials) - TRIALS="${2:-}" - shift 2 - ;; - --duration) - DURATION="${2:-}" - shift 2 - ;; - --threshold) - THRESHOLD="${2:-}" - shift 2 - ;; - --ignore-keys) - IGNORE_KEYS="${2:-}" - shift 2 - ;; - --log-file) - LOG_FILE="${2:-}" - shift 2 - ;; - --countdown) - COUNTDOWN="${2:-}" - shift 2 - ;; - --auto-type) - AUTO_TYPE="${2:-}" - shift 2 - ;; - --auto-type-wpm) - AUTO_TYPE_WPM="${2:-}" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - *) - echo "Unknown option: $1" >&2 - usage - exit 1 - ;; - esac -done - -# --- Preflight checks --- - -preflight_errors=() - -# 1. Kanata must be running. -if ! pgrep -iq kanata 2>/dev/null; then - preflight_errors+=("Kanata is not running. Start KeyPath and ensure the Kanata service is active.") -fi - -# 2. Log file must exist (Kanata writes key events here). -if [[ ! -f "$LOG_FILE" ]]; then - preflight_errors+=("Log file not found: $LOG_FILE - Kanata may not have started yet, or the log path is wrong (override with --log-file).") -fi - -# 3. Accessibility permission required for auto-type via osascript. -if [[ "${AUTO_TYPE:-}" == "osascript" ]]; then - # Probe by sending a no-op keystroke; System Events will fail if Accessibility is denied. - if ! osascript -e 'tell application "System Events" to keystroke ""' 2>/dev/null; then - preflight_errors+=("Accessibility permission denied for auto-type. - Grant access in System Settings > Privacy & Security > Accessibility for Terminal (or your terminal app).") - fi -fi - -if [[ ${#preflight_errors[@]} -gt 0 ]]; then - echo "Preflight failed — cannot start repro harness:" >&2 - echo >&2 - for i in "${!preflight_errors[@]}"; do - echo " $((i+1)). ${preflight_errors[$i]}" >&2 - echo >&2 - done - exit 1 -fi - -case "$PRESET" in - baseline|medium|high) - ;; - *) - echo "Error: --preset must be baseline, medium, or high" >&2 - exit 1 - ;; -esac - -if ! [[ "$TRIALS" =~ ^[0-9]+$ && "$TRIALS" -ge 1 ]]; then - echo "Error: --trials must be a positive integer" >&2 - exit 1 -fi - -if ! [[ "$DURATION" =~ ^[0-9]+$ && "$DURATION" -ge 1 ]]; then - echo "Error: --duration must be a positive integer" >&2 - exit 1 -fi - -if ! [[ "$THRESHOLD" =~ ^[0-9]+$ && "$THRESHOLD" -ge 2 ]]; then - echo "Error: --threshold must be an integer >= 2" >&2 - exit 1 -fi - -if ! [[ "$COUNTDOWN" =~ ^[0-9]+$ && "$COUNTDOWN" -ge 0 ]]; then - echo "Error: --countdown must be a non-negative integer" >&2 - exit 1 -fi - -if [[ -n "$AUTO_TYPE" ]]; then - case "$AUTO_TYPE" in - osascript) - ;; - peekaboo) - if ! command -v peekaboo &>/dev/null; then - echo "Error: peekaboo not found. Install with: brew install steipete/tap/peekaboo" >&2 - exit 1 - fi - ;; - *) - echo "Error: --auto-type must be osascript or peekaboo" >&2 - exit 1 - ;; - esac -fi - -if ! [[ "$AUTO_TYPE_WPM" =~ ^[0-9]+$ && "$AUTO_TYPE_WPM" -ge 1 ]]; then - echo "Error: --auto-type-wpm must be a positive integer" >&2 - exit 1 -fi - -timestamp=$(date +%Y%m%d-%H%M%S) -OUT_DIR="${TMPDIR:-/tmp}/keypath-duplicate-repro-$timestamp" -mkdir -p "$OUT_DIR" -ALERTS_FILE="$OUT_DIR/alerts.log" -EVENTS_FILE="$OUT_DIR/events.log" -SUMMARY_FILE="$OUT_DIR/summary.txt" -CPU_FILE="$OUT_DIR/cpu.log" -KANATA_FILE="$OUT_DIR/kanata-metrics.log" -ANALYSIS_FILE="$OUT_DIR/analysis-report.txt" - -bg_pids=() - -cleanup() { - for pid in "${bg_pids[@]:-}"; do - if kill -0 "$pid" >/dev/null 2>&1; then - kill "$pid" >/dev/null 2>&1 || true - fi - done -} -trap cleanup EXIT INT TERM - -start_compile_loop() { - ( - cd "$PROJECT_ROOT" - while true; do - swift build -c debug >/dev/null 2>&1 || true - done - ) & - bg_pids+=("$!") -} - -start_cpu_hogs() { - local hog_count=$1 - local i - for (( i=0; i/dev/null & - bg_pids+=("$!") - done -} - -start_stress() { - case "$PRESET" in - baseline) - ;; - medium) - start_compile_loop - start_cpu_hogs 2 - ;; - high) - start_compile_loop - start_cpu_hogs 6 - ;; - esac -} - -stop_stress() { - local pid - for pid in "${bg_pids[@]:-}"; do - if kill -0 "$pid" >/dev/null 2>&1; then - kill "$pid" >/dev/null 2>&1 || true - fi - done - bg_pids=() -} - -# --- Automated typing --- - -auto_type_osascript() { - local duration=$1 - local delay_per_char - # WPM -> delay: average word = 5 chars, so chars/sec = WPM * 5 / 60 - delay_per_char=$(awk "BEGIN { printf \"%.4f\", 60.0 / ($AUTO_TYPE_WPM * 5) }") - local end_time=$(( $(date +%s) + duration )) - local corpus_idx=0 - local corpus_len=${#AUTO_TYPE_CORPUS[@]} - - while [[ $(date +%s) -lt $end_time ]]; do - local text="${AUTO_TYPE_CORPUS[$corpus_idx]}" - osascript -e "tell application \"System Events\" to keystroke \"$text\"" 2>/dev/null || true - # Pace to approximate the target WPM - local char_count=${#text} - local pause - pause=$(awk "BEGIN { printf \"%.2f\", $char_count * $delay_per_char }") - sleep "$pause" - corpus_idx=$(( (corpus_idx + 1) % corpus_len )) - done -} - -auto_type_peekaboo() { - local duration=$1 - local end_time=$(( $(date +%s) + duration )) - local corpus_idx=0 - local corpus_len=${#AUTO_TYPE_CORPUS[@]} - - while [[ $(date +%s) -lt $end_time ]]; do - local text="${AUTO_TYPE_CORPUS[$corpus_idx]}" - peekaboo type "$text" --wpm "$AUTO_TYPE_WPM" 2>/dev/null || true - corpus_idx=$(( (corpus_idx + 1) % corpus_len )) - done -} - -start_auto_type() { - local duration=$1 - case "$AUTO_TYPE" in - osascript) - auto_type_osascript "$duration" & - bg_pids+=("$!") - ;; - peekaboo) - auto_type_peekaboo "$duration" & - bg_pids+=("$!") - ;; - esac -} - -# --- Kanata process metrics sampler --- - -start_kanata_metrics() { - ( - echo "timestamp|epoch_ms|pid|%cpu|%mem|rss_kb|vsz_kb|threads|state|pri" >> "$KANATA_FILE" - while true; do - ts=$(date '+%Y-%m-%d %H:%M:%S') - epoch_ms=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo "0") - # Find kanata process(es) — match the daemon binary. - # macOS ps doesn't support nlwp; get thread count from /proc or ps -M. - pids=$(pgrep -i kanata 2>/dev/null) || true - if [[ -n "$pids" ]]; then - while IFS= read -r kpid; do - metrics=$(ps -o pid=,%cpu=,%mem=,rss=,vsz=,state=,pri= -p "$kpid" 2>/dev/null) || true - # Thread count: count lines from ps -M minus the header - threads=$(ps -M -p "$kpid" 2>/dev/null | tail -n +2 | wc -l | tr -d ' ') || threads="?" - if [[ -n "$metrics" ]]; then - echo "$ts|$epoch_ms|$metrics|threads=$threads" >> "$KANATA_FILE" - fi - done <<< "$pids" - else - echo "$ts|$epoch_ms|NO_KANATA_PROCESS" >> "$KANATA_FILE" - fi - sleep 1 - done - ) & - kanata_metrics_pid=$! - bg_pids+=("$kanata_metrics_pid") -} - -echo "KeyPath duplicate-key repro harness" -echo " log file: $LOG_FILE" -echo " preset: $PRESET" -echo " trials: $TRIALS" -echo " duration: ${DURATION}s" -echo " threshold: $THRESHOLD" -echo " ignore keys: $IGNORE_KEYS" -echo " auto-type: ${AUTO_TYPE:-manual}" -if [[ -n "$AUTO_TYPE" ]]; then -echo " auto-type wpm: $AUTO_TYPE_WPM" -fi -echo " output dir: $OUT_DIR" -echo - -echo "Start time: $(date)" > "$SUMMARY_FILE" -echo "Preset: $PRESET" >> "$SUMMARY_FILE" -echo "Trials: $TRIALS" >> "$SUMMARY_FILE" -echo "Duration: ${DURATION}s" >> "$SUMMARY_FILE" -echo "Threshold: $THRESHOLD" >> "$SUMMARY_FILE" -echo "Ignore keys: $IGNORE_KEYS" >> "$SUMMARY_FILE" -echo "Auto-type: ${AUTO_TYPE:-manual}" >> "$SUMMARY_FILE" -if [[ -n "$AUTO_TYPE" ]]; then -echo "Auto-type WPM: $AUTO_TYPE_WPM" >> "$SUMMARY_FILE" -fi -echo >> "$SUMMARY_FILE" - -# Real-time event stream and duplicate detector. -# Each event line includes a high-resolution receive timestamp (ms precision) -# and the delta from the previous event on the same key, which is critical for -# distinguishing debounce issues (<5ms) from scheduling starvation (10-100ms+). -( - tail -n 0 -F "$LOG_FILE" | - awk -v threshold="$THRESHOLD" -v ignore_csv="$IGNORE_KEYS" -v events_out="$EVENTS_FILE" -v alerts_out="$ALERTS_FILE" ' - BEGIN { - split(ignore_csv, raw, ",") - for (i in raw) { - gsub(/^ +| +$/, "", raw[i]) - ignore[raw[i]] = 1 - } - # Use gettimeofday via strftime where available; fall back to log timestamp. - ms_cmd = "python3 -c \"import time; print(int(time.time()*1000))\" 2>/dev/null" - } - - /KeyInput: .* press/ { - log_ts = substr($0, 2, 19) - line = $0 - sub(/^.*KeyInput: /, "", line) - sub(/ press.*$/, "", line) - key = line - - # High-res receive timestamp (ms since epoch). - ms_cmd | getline now_ms - close(ms_cmd) - now_ms = now_ms + 0 - - # Delta from previous event on the SAME key. - if (key in last_ms) { - delta = now_ms - last_ms[key] - } else { - delta = -1 - } - last_ms[key] = now_ms - - printf "%s|%s|%d|%d\n", log_ts, key, now_ms, delta >> events_out - fflush(events_out) - - if (ignore[key]) next - - if (key == prev_key) { - run += 1 - run_deltas = run_deltas "," delta - } else { - prev_key = key - run = 1 - run_start = log_ts - run_start_ms = now_ms - run_deltas = "" - } - - if (run == threshold) { - span_ms = now_ms - run_start_ms - msg = sprintf("ALERT|%s|key=%s|run=%d|start=%s|span_ms=%d|deltas_ms=%s", log_ts, key, run, run_start, span_ms, run_deltas) - print msg - print msg >> alerts_out - fflush(alerts_out) - } - } - ' -) & -monitor_pid=$! -bg_pids+=("$monitor_pid") - -# CPU sampler for correlation. -( - while true; do - date '+%Y-%m-%d %H:%M:%S' >> "$CPU_FILE" - top -l 1 -n 0 -s 0 | head -n 12 >> "$CPU_FILE" 2>/dev/null || true - echo >> "$CPU_FILE" - sleep 1 - done -) & -cpu_pid=$! -bg_pids+=("$cpu_pid") - -# Kanata process metrics sampler. -start_kanata_metrics - -if [[ -n "$AUTO_TYPE" ]]; then - echo "Detector running. Auto-typing via $AUTO_TYPE at ${AUTO_TYPE_WPM} WPM." - echo "Ensure a text editor is focused to receive keystrokes." -else - echo "Detector running. Start typing when trials begin." - echo "Tip: use normal prose, then your typical coding flow." -fi -echo - -for (( trial=1; trial<=TRIALS; trial++ )); do - echo "=== Trial $trial/$TRIALS ===" - echo "Trial $trial start: $(date)" | tee -a "$SUMMARY_FILE" - - if [[ "$COUNTDOWN" -gt 0 ]]; then - echo "Starting in ${COUNTDOWN}s..." - sleep "$COUNTDOWN" - fi - - before_alerts=0 - if [[ -f "$ALERTS_FILE" ]]; then - before_alerts=$(wc -l < "$ALERTS_FILE") - fi - - start_stress - if [[ -n "$AUTO_TYPE" ]]; then - start_auto_type "$DURATION" - echo "Load active ($PRESET). Auto-typing for ${DURATION}s..." - else - echo "Load active ($PRESET). Type continuously for ${DURATION}s..." - fi - sleep "$DURATION" - stop_stress - - after_alerts=0 - if [[ -f "$ALERTS_FILE" ]]; then - after_alerts=$(wc -l < "$ALERTS_FILE") - fi - - trial_alerts=$((after_alerts - before_alerts)) - echo "Trial $trial alerts: $trial_alerts" | tee -a "$SUMMARY_FILE" - echo >> "$SUMMARY_FILE" - - if [[ "$trial" -lt "$TRIALS" ]]; then - echo "Cooldown 10s..." - sleep 10 - fi -done - -stop_stress -cleanup -trap - EXIT INT TERM - -total_alerts=0 -if [[ -f "$ALERTS_FILE" ]]; then - total_alerts=$(wc -l < "$ALERTS_FILE") -fi - -echo "End time: $(date)" >> "$SUMMARY_FILE" -echo "Total alerts: $total_alerts" >> "$SUMMARY_FILE" - -echo -echo "Generating analysis report..." - -# --- Analysis report --- -# Cross-correlates alerts, events, Kanata metrics, and CPU data to produce -# a structured report suitable for root-cause diagnosis. - -generate_analysis() { - local report="$ANALYSIS_FILE" - - cat > "$report" <
> "$report" - - # --- Section 2: Per-key breakdown --- - { - echo "2. PER-KEY BREAKDOWN" - echo " ─────────────────" - if [[ -f "$ALERTS_FILE" ]] && [[ -s "$ALERTS_FILE" ]]; then - echo " Key | Alerts | Interpretation" - echo " -------------|--------|---------------" - # Extract key= field, count occurrences, sort descending. - sed 's/.*key=\([^|]*\).*/\1/' "$ALERTS_FILE" \ - | sort | uniq -c | sort -rn \ - | while read -r count key; do - if [[ "$count" -ge 5 ]]; then - interp="FREQUENT — likely systemic" - elif [[ "$count" -ge 2 ]]; then - interp="moderate" - else - interp="rare — may be intentional" - fi - printf " %-13s| %-7s| %s\n" "$key" "$count" "$interp" - done - echo - # Check if all keys affected equally vs specific keys. - local unique_keys - unique_keys=$(sed 's/.*key=\([^|]*\).*/\1/' "$ALERTS_FILE" | sort -u | wc -l | tr -d ' ') - if [[ "$unique_keys" -le 2 ]]; then - echo " Observation: Only $unique_keys key(s) affected — suggests key-specific" - echo " tap-hold or config issue, not a systemic scheduling problem." - else - echo " Observation: $unique_keys different keys affected — suggests systemic" - echo " issue (scheduling starvation or event pipeline delay)." - fi - else - echo " No alerts to analyze." - fi - echo - } >> "$report" - - # --- Section 3: Inter-event timing analysis (the critical diagnostic) --- - { - echo "3. INTER-EVENT TIMING (duplicate gaps)" - echo " ────────────────────────────────────" - if [[ -f "$ALERTS_FILE" ]] && [[ -s "$ALERTS_FILE" ]]; then - # Extract deltas_ms from alerts and flatten. - local all_deltas - all_deltas=$(sed 's/.*deltas_ms=\([^|]*\).*/\1/' "$ALERTS_FILE" \ - | tr ',' '\n' | grep -v '^$' | sort -n) - - if [[ -n "$all_deltas" ]]; then - local delta_count min_d max_d median_d - delta_count=$(echo "$all_deltas" | wc -l | tr -d ' ') - min_d=$(echo "$all_deltas" | head -1) - max_d=$(echo "$all_deltas" | tail -1) - median_d=$(echo "$all_deltas" | awk -v n="$delta_count" 'NR==int(n/2)+1{print}') - - echo " Duplicate event gaps (ms between repeated same-key presses):" - echo " Count: $delta_count Min: ${min_d}ms Median: ${median_d}ms Max: ${max_d}ms" - echo - - # Bucket analysis for root-cause classification. - local sub5 sub20 sub100 over100 - sub5=$(echo "$all_deltas" | awk '$1 >= 0 && $1 < 5' | wc -l | tr -d ' ') - sub20=$(echo "$all_deltas" | awk '$1 >= 5 && $1 < 20' | wc -l | tr -d ' ') - sub100=$(echo "$all_deltas" | awk '$1 >= 20 && $1 < 100' | wc -l | tr -d ' ') - over100=$(echo "$all_deltas" | awk '$1 >= 100' | wc -l | tr -d ' ') - - echo " Gap distribution:" - echo " <5ms: $sub5 (hardware bounce / driver debounce failure)" - echo " 5-20ms: $sub20 (event pipeline stutter)" - echo " 20-100ms: $sub100 (Kanata scheduling starvation)" - echo " >100ms: $over100 (tap-hold timer drift or user double-tap)" - echo - - # Verdict. - echo " ROOT CAUSE INDICATORS:" - if [[ "$sub5" -gt "$sub20" && "$sub5" -gt "$sub100" ]]; then - echo " >>> Majority <5ms: Points to INPUT-LEVEL issue." - echo " Kanata may be receiving pre-duplicated events from the HID driver." - echo " Investigate: IOHIDDevice debounce settings, keyboard firmware." - elif [[ "$sub100" -gt "$sub5" && "$sub100" -gt "$sub20" ]]; then - echo " >>> Majority 20-100ms: Points to SCHEDULING STARVATION." - echo " Kanata's process is being deprioritized under CPU load, causing" - echo " its event loop to batch-process queued events as rapid repeats." - echo " Investigate: Kanata process priority (nice/renice), real-time" - echo " scheduling, or moving key processing to a higher-priority thread." - elif [[ "$over100" -gt "$sub5" && "$over100" -gt "$sub100" ]]; then - echo " >>> Majority >100ms: Points to TAP-HOLD TIMER DRIFT." - echo " Kanata's internal timers are misbehaving when the process is" - echo " delayed, causing held keys to be misinterpreted as taps." - echo " Investigate: tap-hold timeout values in keypath.kbd config," - echo " or use eager-tap / waiting-tap-timeout to reduce sensitivity." - elif [[ "$sub20" -gt "$sub5" && "$sub20" -gt "$sub100" ]]; then - echo " >>> Majority 5-20ms: Points to EVENT PIPELINE STUTTER." - echo " Brief stalls in the event processing chain (TCP relay," - echo " log flushing, or SwiftUI observer updates) are causing" - echo " event bunching. Investigate: async event forwarding, TCP" - echo " socket buffering, or log I/O blocking the event thread." - else - echo " >>> Mixed distribution: No single dominant cause." - echo " Multiple factors may be contributing. Review the raw deltas" - echo " and Kanata metrics for temporal correlation." - fi - else - echo " No inter-event delta data available." - fi - else - echo " No alerts — no timing data to analyze." - fi - echo - } >> "$report" - - # --- Section 4: Kanata process health during alerts --- - { - echo "4. KANATA PROCESS HEALTH" - echo " ─────────────────────" - if [[ -f "$KANATA_FILE" ]] && [[ -s "$KANATA_FILE" ]]; then - local kanata_missing - kanata_missing=$(grep -c "NO_KANATA_PROCESS" "$KANATA_FILE" 2>/dev/null || true) - local kanata_total - kanata_total=$(wc -l < "$KANATA_FILE" | tr -d ' ') - kanata_total=$((kanata_total - 1)) # subtract header - - if [[ "$kanata_missing" -gt 0 ]]; then - echo " WARNING: Kanata process was absent for $kanata_missing/${kanata_total} samples." - echo - fi - - # Extract CPU% values (skip header and NO_KANATA lines). - local cpu_vals - cpu_vals=$(grep -v "NO_KANATA\|timestamp\|^$" "$KANATA_FILE" 2>/dev/null \ - | awk -F'|' '{ - # The metrics field contains space-separated ps output. - # CPU% is the second field in the ps output. - split($3, parts, " +") - for (i in parts) { - if (parts[i] ~ /^[0-9]+\.?[0-9]*$/) { print parts[i]; break } - } - }' 2>/dev/null) || true - - if [[ -n "$cpu_vals" ]]; then - echo "$cpu_vals" | sort -n | awk ' - { sum += $1; count++; sorted[count] = $1; if ($1 > max) max = $1 } - END { - if (count == 0) { print " No CPU data available."; exit } - avg = sum / count - med = sorted[int(count/2)+1] - p95 = sorted[int(count*0.95)+1] - printf " Kanata CPU%%: avg=%.1f%% median=%.1f%% p95=%.1f%% max=%.1f%%\n", avg, med, p95, max - if (max > 80) { - print " >>> HIGH CPU: Kanata itself is CPU-bound. Event processing" - print " may be delayed by its own workload, not just OS scheduling." - } else if (avg < 5 && max < 20) { - print " >>> LOW CPU: Kanata is mostly idle. Duplicates are likely caused" - print " by scheduling delays (OS not waking Kanata fast enough)." - } - } - ' >> "$report" - else - echo " Could not extract Kanata CPU data." >> "$report" - fi - else - echo " No Kanata metrics collected." - fi - echo - } >> "$report" - - # --- Section 5: Alert timeline with Kanata state --- - { - echo "5. ALERT TIMELINE (first 15 alerts with context)" - echo " ──────────────────────────────────────────────" - if [[ -f "$ALERTS_FILE" ]] && [[ -s "$ALERTS_FILE" ]]; then - echo " Time | Key | Span | Gap pattern" - echo " --------------------|-------|--------|------------------" - head -n 15 "$ALERTS_FILE" | while IFS='|' read -r _ ts keyf runf startf spanf deltasf _rest; do - key=$(echo "$keyf" | sed 's/key=//') - span=$(echo "$spanf" | sed 's/span_ms=//') - deltas=$(echo "$deltasf" | sed 's/deltas_ms=//') - printf " %-20s| %-6s| %5sms| %s\n" "$ts" "$key" "$span" "${deltas}ms" - done - else - echo " No alerts to display." - fi - echo - } >> "$report" - - # --- Section 6: Recommendations --- - { - echo "6. NEXT STEPS" - echo " ──────────" - if [[ ! -f "$ALERTS_FILE" ]] || [[ ! -s "$ALERTS_FILE" ]]; then - echo " No duplicates detected at preset=$PRESET." - echo " Try: --preset high, or --duration 120 for longer trials." - else - echo " a) Compare presets: run with --preset baseline, then --preset high." - echo " If baseline=0 and high>0, CPU load is confirmed as the trigger." - echo " b) Review the gap distribution in Section 3 for root-cause category." - echo " c) Check Kanata process priority: ps -o pid,pri,nice -p \$(pgrep kanata)" - echo " d) Raw data for deeper analysis:" - echo " Events: $EVENTS_FILE (format: log_ts|key|epoch_ms|delta_ms)" - echo " Kanata: $KANATA_FILE (format: ts|epoch_ms|pid|cpu|mem|...)" - echo " CPU: $CPU_FILE" - fi - echo - echo "================================================================================" - } >> "$report" -} - -generate_analysis - -echo -echo "Run complete." -echo " Analysis: $ANALYSIS_FILE" -echo " Summary: $SUMMARY_FILE" -echo " Alerts: $ALERTS_FILE" -echo " Events: $EVENTS_FILE" -echo " CPU: $CPU_FILE" -echo " Kanata metrics: $KANATA_FILE" -echo - -# Print the report to stdout as well. -cat "$ANALYSIS_FILE" diff --git a/Scripts/run-duplicate-key-test.sh b/Scripts/run-duplicate-key-test.sh deleted file mode 100755 index e34eb5bf0..000000000 --- a/Scripts/run-duplicate-key-test.sh +++ /dev/null @@ -1,526 +0,0 @@ -#!/usr/bin/env bash -# Two-pronged duplicate keystroke test: -# 1. Runs the existing repro harness (monitors KeyPath notification pipeline) -# 2. Opens Zed with a scratch file, auto-types known corpus, then diffs input vs output -# -# Prerequisites: KeyPath must be running (Kanata active), Zed installed. -# Usage: -# ./Scripts/run-duplicate-key-test.sh [--preset baseline|medium|high|vicious] [--phase2-only] - -set -euo pipefail - -SKIP_PHASE1=false -PRESET_VAL="medium" - -while [[ $# -gt 0 ]]; do - case "$1" in - --phase2-only) SKIP_PHASE1=true; shift ;; - --preset) PRESET_VAL="${2:-medium}"; shift 2 ;; - baseline|medium|high|vicious) PRESET_VAL="$1"; shift ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -SCRIPT_DIR=$(cd "$(dirname "$0")" >/dev/null && pwd) -PROJECT_ROOT=$(cd "$SCRIPT_DIR/.." >/dev/null && pwd) -TIMESTAMP=$(date +%Y%m%d-%H%M%S) -TEST_DIR="${TMPDIR:-/tmp}/keypath-dup-test-$TIMESTAMP" -mkdir -p "$TEST_DIR" - -SCRATCH_FILE="$TEST_DIR/typed-output.txt" -EXPECTED_FILE="$TEST_DIR/expected-input.txt" -DIFF_FILE="$TEST_DIR/diff-report.txt" -DURATION=60 - -# --- Corpus --- -# Standard corpus for normal presets (~10 phrases) -CORPUS=( - "The quick brown fox jumps over the lazy dog. " - "func handleKeyPress(_ event: KeyEvent) -> Bool { return true } " - "Programming is the art of telling another human what one wants the computer to do. " - "let result = try await manager.processEvent(key: .a, modifiers: [.shift]) " - "if context.permissions.inputMonitoring == .granted { startService() } " - "The five boxing wizards jump quickly at dawn. " - "guard let config = ConfigurationService.shared.load() else { return nil } " - "Pack my box with five dozen liquor jugs. " - "switch event.type { case .keyDown: handle(event) case .keyUp: release(event) } " - "How vexingly quick daft zebras jump quickly over the lazy brown fox. " -) - -# Extended corpus for vicious mode — prose, code, special chars, punctuation-heavy -CORPUS_VICIOUS=( - "The quick brown fox jumps over the lazy dog. " - "func handleKeyPress(_ event: KeyEvent) -> Bool { return true } " - "Programming is the art of telling another human what one wants the computer to do. " - "let result = try await manager.processEvent(key: .a, modifiers: [.shift]) " - "if context.permissions.inputMonitoring == .granted { startService() } " - "The five boxing wizards jump quickly at dawn. " - "guard let config = ConfigurationService.shared.load() else { return nil } " - "Pack my box with five dozen liquor jugs. " - "switch event.type { case .keyDown: handle(event) case .keyUp: release(event) } " - "How vexingly quick daft zebras jump quickly over the lazy brown fox. " - "struct ContentView: View { var body: some View { Text(greeting).padding() } } " - "Every great developer you know got there by solving problems they were unqualified to solve until they actually did it. " - "for await event in stream { try await processor.handle(event) } " - "The difference between a good programmer and a great one is not how much they know, but how they think. " - "enum Action { case keyDown(KeyCode) case keyUp(KeyCode) case modifier(Set) } " - "async let alpha = fetchAlpha(); async let beta = fetchBeta(); let results = try await (alpha, beta) " - "Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it. " - "protocol KeyHandler { func handle(_ event: KeyEvent) async throws -> KeyAction } " - "extension Array where Element: Comparable { mutating func insertSorted(_ element: Element) { let idx = firstIndex(where: { element < $0 }) ?? endIndex; insert(element, at: idx) } } " - "There are only two hard things in Computer Science: cache invalidation and naming things. " - "@MainActor final class ViewModel: ObservableObject { @Published var state: ViewState = .idle } " - "The best error message is the one that never shows up. Design your systems to prevent errors, not just report them. " - "let pipeline = EventPipeline(); pipeline.add(stage: DeduplicationStage(window: .milliseconds(100))); pipeline.add(stage: ThrottleStage(rate: .perSecond(60))) " - "Task.detached(priority: .userInitiated) { await MainActor.run { self.updateUI(with: result) } } " - "Software is like entropy: it is difficult to grasp, weighs nothing, and obeys the Second Law of Thermodynamics; i.e., it always increases. " - "func debounce(delay: Duration, operation: @escaping (T) async -> Void) -> (T) async -> Void { var task: Task?; return { value in task?.cancel(); task = Task { try? await Task.sleep(for: delay); await operation(value) } } } " - "class KanataTCPClient { private let connection: NWConnection; private var requestCounter: UInt64 = 0; private let timeout: TimeInterval = 5.0 } " - "Simplicity is prerequisite for reliability. The unavoidable price of reliability is simplicity. It is a price which the very rich find most hard to pay. " - "NotificationCenter.default.publisher(for: .kanataKeyInput).compactMap { $0.userInfo }.sink { info in handleKey(info) }.store(in: &cancellables) " - "Any fool can write code that a computer can understand. Good programmers write code that humans can understand. " - "let encoder = JSONEncoder(); encoder.keyEncodingStrategy = .convertToSnakeCase; encoder.dateEncodingStrategy = .iso8601; let data = try encoder.encode(payload) " - "The most dangerous phrase in the English language is: We have always done it this way. " - "guard !Task.isCancelled else { throw CancellationError() } " - "Perfection is achieved, not when there is nothing more to add, but when there is nothing left to take away. " - "do { let response = try await client.send(command, timeout: .seconds(5)); return parse(response) } catch { logger.error(error); throw KeyPathError.communication(.timeout) } " - "import Foundation; import Network; import Observation; import OSLog " - "A language that does not affect the way you think about programming is not worth knowing. " - "withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { isExpanded.toggle() } " - "First, solve the problem. Then, write the code. " - "let semaphore = DispatchSemaphore(value: 0); defer { semaphore.signal() }; semaphore.wait(timeout: .now() + 5) " - "Measuring programming progress by lines of code is like measuring aircraft building progress by weight. " -) - -# --- Preset-specific configuration --- -TARGET_WPM=50 -TARGET_CHARS=500 - -case "$PRESET_VAL" in - vicious) - TARGET_WPM=200 - TARGET_CHARS=10000 # ~2000 words = ~8 pages - DURATION=90 - CORPUS=("${CORPUS_VICIOUS[@]}") - ;; -esac - -CHARS_PER_SEC=$(awk "BEGIN { printf \"%.1f\", ($TARGET_WPM * 5) / 60 }") - -echo "╔══════════════════════════════════════════════════════════════╗" -echo "║ KeyPath Duplicate Keystroke Test Suite ║" -echo "╠══════════════════════════════════════════════════════════════╣" -echo "║ Preset: $(printf '%-49s' "$PRESET_VAL")║" -if [[ "$PRESET_VAL" == "vicious" ]]; then -echo "║ Target: ${TARGET_WPM} WPM, ${TARGET_CHARS} chars (~$(( TARGET_CHARS / 5 )) words) ║" -echo "║ CPU stress: ALL cores + compile loop + disk I/O ║" -fi -echo "║ Output: $TEST_DIR" -echo "╚══════════════════════════════════════════════════════════════╝" -echo - -# --- Preflight --- -echo "Running preflight checks..." - -errors=() -if ! pgrep -iq kanata 2>/dev/null; then - errors+=("Kanata is not running. Launch KeyPath first.") -fi - -if ! mdfind "kMDItemCFBundleIdentifier == 'dev.zed.Zed'" 2>/dev/null | head -1 | grep -q .; then - errors+=("Zed not found. Install Zed or adjust this script for your editor.") -fi - -if [[ ${#errors[@]} -gt 0 ]]; then - echo - echo "PREFLIGHT FAILED:" - for e in "${errors[@]}"; do - echo " ✗ $e" - done - exit 1 -fi - -echo " ✓ Kanata running (PID $(pgrep -i kanata | head -1))" -echo " ✓ Zed installed" -echo " ✓ KeyPath log exists" -if [[ "$PRESET_VAL" == "vicious" ]]; then - NUM_CORES=$(sysctl -n hw.ncpu 2>/dev/null || echo 8) - echo " ✓ CPU cores: $NUM_CORES (will saturate all of them)" -fi -echo - -# --- Phase 1: Pipeline test (existing harness) --- -if [[ "$SKIP_PHASE1" == "false" ]] && [[ "$PRESET_VAL" != "vicious" ]]; then - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "PHASE 1: Notification Pipeline Test (repro harness)" - echo " Monitors KeyPath's TCP event log for duplicate notifications." - echo " This tests whether the 100ms dedup filter is working." - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo - - # Run in a subshell and kill any lingering children (tail -F) on exit - ( - "$SCRIPT_DIR/repro-duplicate-keys.sh" \ - --preset "$PRESET_VAL" \ - --trials 1 \ - --duration "$DURATION" \ - --countdown 3 \ - --auto-type osascript \ - --auto-type-wpm 50 2>&1 - # Kill any orphaned tail/awk processes from the repro harness - pkill -P $$ tail 2>/dev/null || true - ) | tee "$TEST_DIR/phase1-output.log" || true - - # Safety: kill any lingering tail -F from Phase 1 - pkill -f "tail.*keypath-debug.log" 2>/dev/null || true - sleep 1 - - echo - echo "Phase 1 complete. Results in $TEST_DIR/phase1-output.log" - echo -elif [[ "$PRESET_VAL" == "vicious" ]]; then - echo "Skipping Phase 1 for vicious preset (already validated at high — going straight to fidelity)" - echo -else - echo "Skipping Phase 1 (--phase2-only)" - echo -fi - -# --- Phase 2: Actual keystroke fidelity test --- -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -if [[ "$PRESET_VAL" == "vicious" ]]; then - echo "PHASE 2: VICIOUS Keystroke Fidelity Test" - echo " ${TARGET_WPM} WPM, ${TARGET_CHARS} chars, ALL cores saturated + disk I/O" - echo " This is the torture test. If this passes, the fix is bulletproof." -else - echo "PHASE 2: Keystroke Fidelity Test" - echo " Types known text into Zed, then compares input vs. output." - echo " This tests whether actual characters double in the editor." -fi -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo - -# Create the scratch file and open in Zed -touch "$SCRATCH_FILE" -echo "Opening scratch file in Zed: $SCRATCH_FILE" -open -a "Zed" "$SCRATCH_FILE" & -sleep 3 # Give Zed time to open and focus - -# Build expected output -> "$EXPECTED_FILE" -corpus_idx=0 -corpus_len=${#CORPUS[@]} -typed_chars=0 - -echo "Typing $TARGET_CHARS characters at ~${TARGET_WPM} WPM (~${CHARS_PER_SEC} chars/sec) into Zed..." -echo "(CPU load preset: $PRESET_VAL)" -echo - -# Start CPU load -bg_pids=() -cleanup_load() { - for pid in "${bg_pids[@]:-}"; do - kill "$pid" 2>/dev/null || true - done - # Kill any stray yes/dd/swift-build processes we spawned - if [[ "$PRESET_VAL" == "vicious" ]]; then - pkill -f "dd if=/dev/zero" 2>/dev/null || true - pkill -f "compressutil" 2>/dev/null || true - fi -} -trap cleanup_load EXIT - -NUM_CORES=$(sysctl -n hw.ncpu 2>/dev/null || echo 8) - -case "$PRESET_VAL" in - medium) - (cd "$PROJECT_ROOT" && while true; do swift build -c debug >/dev/null 2>&1 || true; done) & - bg_pids+=("$!") - yes >/dev/null & bg_pids+=("$!") - yes >/dev/null & bg_pids+=("$!") - echo " CPU load started (medium: compile loop + 2 hogs)" - ;; - high) - (cd "$PROJECT_ROOT" && while true; do swift build -c debug >/dev/null 2>&1 || true; done) & - bg_pids+=("$!") - for i in {1..6}; do yes >/dev/null & bg_pids+=("$!"); done - echo " CPU load started (high: compile loop + 6 hogs)" - ;; - vicious) - # --- MAXIMUM STRESS --- - echo " Starting VICIOUS load..." - - # 1. Swift compile loop (heavy, realistic) - (cd "$PROJECT_ROOT" && while true; do swift build -c debug >/dev/null 2>&1 || true; done) & - bg_pids+=("$!") - echo " ✓ Swift compile loop" - - # 2. Saturate ALL cores with yes processes - HOG_COUNT=$((NUM_CORES - 1)) # Leave 1 core for Kanata/system - for (( i=0; i/dev/null & bg_pids+=("$!"); done - echo " ✓ $HOG_COUNT CPU hog processes (saturating $NUM_CORES cores)" - - # 3. Disk I/O pressure — continuous writes to /tmp - (while true; do dd if=/dev/zero of="${TEST_DIR}/io-stress-$$" bs=1m count=64 2>/dev/null; rm -f "${TEST_DIR}/io-stress-$$"; done) & - bg_pids+=("$!") - echo " ✓ Disk I/O stress (64MB write loop)" - - # 4. Memory pressure — allocate and touch pages - (python3 -c " -import time -blocks = [] -try: - for _ in range(20): - b = bytearray(50 * 1024 * 1024) # 50MB block - for i in range(0, len(b), 4096): # Touch every page - b[i] = 0xFF - blocks.append(b) - time.sleep(0.5) - # Hold for duration of test - time.sleep(300) -except MemoryError: - time.sleep(300) -" 2>/dev/null) & - bg_pids+=("$!") - echo " ✓ Memory pressure (1GB allocation, touching pages)" - - # 5. Second compile loop on a temp package for extra scheduler contention - TEMP_PKG="${TEST_DIR}/stress-pkg" - mkdir -p "$TEMP_PKG/Sources/Stress" - cat > "$TEMP_PKG/Package.swift" << 'SWIFTPKG' -// swift-tools-version: 5.9 -import PackageDescription -let package = Package(name: "Stress", targets: [.executableTarget(name: "Stress", path: "Sources/Stress")]) -SWIFTPKG - cat > "$TEMP_PKG/Sources/Stress/main.swift" << 'SWIFTSRC' -import Foundation -let data = (0..<10000).map { String($0) }.joined(separator: ",") -let encoded = data.data(using: .utf8)! -let decoded = String(data: encoded, encoding: .utf8)! -print(decoded.count) -SWIFTSRC - (cd "$TEMP_PKG" && while true; do swift build 2>/dev/null; swift build -c release 2>/dev/null; done) & - bg_pids+=("$!") - echo " ✓ Second Swift compile loop (scheduler contention)" - - echo " All stress generators active. System should be near 100% CPU." - ;; - baseline) - echo " No CPU load (baseline)" - ;; -esac - -sleep 3 # Let load ramp up (extra time for vicious) -if [[ "$PRESET_VAL" == "vicious" ]]; then - sleep 3 # Extra ramp-up time - echo - echo " Verifying system load..." - # Show current load average - load=$(sysctl -n vm.loadavg 2>/dev/null || uptime | awk -F'load averages:' '{print $2}') - echo " Load average: $load" - kanata_cpu=$(ps -o %cpu= -p "$(pgrep -i kanata | head -1)" 2>/dev/null | tr -d ' ') - echo " Kanata CPU: ${kanata_cpu}%" - echo -fi - -# Activate Zed window -osascript -e 'tell application "Zed" to activate' 2>/dev/null || true -sleep 1 - -# --- Typing loop --- -# For vicious mode, we send longer chunks less frequently to maximize throughput. -# osascript `keystroke` sends the whole string as fast as HID can process it, -# so the effective WPM is controlled by chunk size and inter-chunk delay. - -echo " Typing started at $(date '+%H:%M:%S')..." - -if [[ "$PRESET_VAL" == "vicious" ]]; then - # Vicious: send 2-3 phrases at a time with minimal delay - # At 200 WPM = ~16.7 chars/sec, we need aggressive pacing - while [[ $typed_chars -lt $TARGET_CHARS ]]; do - # Build a chunk of 2-3 phrases - chunk="" - chunk_chars=0 - for (( j=0; j<3 && typed_chars+chunk_chars < TARGET_CHARS; j++ )); do - phrase="${CORPUS[$corpus_idx]}" - chunk+="$phrase" - chunk_chars=$((chunk_chars + ${#phrase})) - corpus_idx=$(( (corpus_idx + 1) % corpus_len )) - done - - printf "%s" "$chunk" >> "$EXPECTED_FILE" - osascript -e "tell application \"System Events\" to keystroke \"$chunk\"" 2>/dev/null || true - - typed_chars=$((typed_chars + chunk_chars)) - - # Pace: chars_in_chunk / target_chars_per_sec - pause=$(awk "BEGIN { p = $chunk_chars / $CHARS_PER_SEC; if (p < 0.05) p = 0.05; printf \"%.3f\", p }") - sleep "$pause" - - # Progress indicator every ~1000 chars - if (( typed_chars % 1000 < chunk_chars )); then - pct=$(( typed_chars * 100 / TARGET_CHARS )) - echo " ${typed_chars}/${TARGET_CHARS} chars (${pct}%)" - fi - done -else - # Normal presets: single phrase at a time - while [[ $typed_chars -lt $TARGET_CHARS ]]; do - phrase="${CORPUS[$corpus_idx]}" - printf "%s" "$phrase" >> "$EXPECTED_FILE" - osascript -e "tell application \"System Events\" to keystroke \"$phrase\"" 2>/dev/null || true - typed_chars=$((typed_chars + ${#phrase})) - corpus_idx=$(( (corpus_idx + 1) % corpus_len )) - char_count=${#phrase} - pause=$(awk "BEGIN { printf \"%.2f\", $char_count / $CHARS_PER_SEC }") - sleep "$pause" - done -fi - -echo -echo " Typing complete at $(date '+%H:%M:%S'). $typed_chars chars sent." -echo " Waiting 5s for Zed to flush..." -sleep 5 - -# Stop CPU load -cleanup_load -trap - EXIT - -# Give system a moment to settle after killing load -sleep 2 - -# Save Zed content via cmd+A, cmd+C, then pbpaste -osascript -e 'tell application "Zed" to activate' 2>/dev/null || true -sleep 0.5 -osascript -e 'tell application "System Events" to keystroke "a" using command down' 2>/dev/null || true -sleep 0.5 -osascript -e 'tell application "System Events" to keystroke "c" using command down' 2>/dev/null || true -sleep 1 -ACTUAL_OUTPUT=$(pbpaste 2>/dev/null || echo "") - -echo "$ACTUAL_OUTPUT" > "$TEST_DIR/actual-output.txt" - -echo -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "PHASE 2 RESULTS: Keystroke Fidelity" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -expected_len=$(wc -c < "$EXPECTED_FILE" | tr -d ' ') -actual_len=$(echo -n "$ACTUAL_OUTPUT" | wc -c | tr -d ' ') - -echo " Expected chars: $expected_len" -echo " Actual chars: $actual_len" -echo " Difference: $((actual_len - expected_len)) chars" -echo - -if [[ "$actual_len" -gt "$expected_len" ]]; then - extra=$((actual_len - expected_len)) - pct=$(awk "BEGIN { printf \"%.1f\", ($extra / $expected_len) * 100 }") - echo " ⚠️ EXTRA CHARACTERS DETECTED: $extra extra chars ($pct% inflation)" - echo " This suggests Kanata is emitting duplicate HID events under load." - echo -elif [[ "$actual_len" -lt "$expected_len" ]]; then - missing=$((expected_len - actual_len)) - pct=$(awk "BEGIN { printf \"%.1f\", ($missing / $expected_len) * 100 }") - echo " ⚠️ MISSING CHARACTERS: $missing chars dropped ($pct% loss)" - echo " This suggests keystroke events were lost under load." - echo -else - echo " ✓ Character counts match exactly." - echo -fi - -# Generate diff -diff <(cat "$EXPECTED_FILE") <(echo "$ACTUAL_OUTPUT") > "$DIFF_FILE" 2>&1 || true - -if [[ -s "$DIFF_FILE" ]]; then - # For vicious mode, show a more useful summary than raw diff - if [[ "$PRESET_VAL" == "vicious" ]]; then - echo " Diff summary:" - diff_lines=$(wc -l < "$DIFF_FILE" | tr -d ' ') - echo " Total diff lines: $diff_lines" - - # Find specific character-level differences using word diff - diff --word-diff=porcelain <(cat "$EXPECTED_FILE") <(echo "$ACTUAL_OUTPUT") \ - > "$TEST_DIR/word-diff.txt" 2>&1 || true - - additions=$(grep -c '^+' "$TEST_DIR/word-diff.txt" 2>/dev/null || echo "0") - deletions=$(grep -c '^-' "$TEST_DIR/word-diff.txt" 2>/dev/null || echo "0") - echo " Word-level additions: $additions" - echo " Word-level deletions: $deletions" - - if [[ "$additions" -gt 0 ]]; then - echo - echo " Added characters (first 10):" - grep '^+' "$TEST_DIR/word-diff.txt" | head -10 | sed 's/^/ /' - fi - if [[ "$deletions" -gt 0 ]]; then - echo - echo " Deleted characters (first 10):" - grep '^-' "$TEST_DIR/word-diff.txt" | head -10 | sed 's/^/ /' - fi - else - echo " Differences found (first 30 lines):" - head -30 "$DIFF_FILE" | sed 's/^/ /' - fi -else - echo " ✓ Output matches expected input exactly. No duplicates detected." -fi - -echo -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -echo "OVERALL ASSESSMENT" -echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - -phase1_alerts=0 -phase1_alert_file=$(find "${TMPDIR:-/tmp}" -name "alerts.log" -newer "$TEST_DIR" -maxdepth 2 2>/dev/null | head -1) -if [[ -n "$phase1_alert_file" ]] && [[ -s "$phase1_alert_file" ]]; then - phase1_alerts=$(wc -l < "$phase1_alert_file" | tr -d ' ') -fi - -char_diff=$((actual_len - expected_len)) -echo " Pipeline duplicates (Phase 1): $phase1_alerts" -echo " Character difference (Phase 2): $char_diff" -if [[ "$PRESET_VAL" == "vicious" ]]; then - echo " Stress level: ${TARGET_WPM} WPM, ${typed_chars} chars, all $NUM_CORES cores saturated" -fi -echo - -if [[ "$phase1_alerts" -eq 0 ]] && [[ "$actual_len" -eq "$expected_len" ]]; then - if [[ "$PRESET_VAL" == "vicious" ]]; then - echo " ✅ PASS: VICIOUS test passed — ${typed_chars} chars at ${TARGET_WPM} WPM" - echo " under maximum CPU/memory/disk stress with zero duplicates." - echo " The fix is bulletproof. MAL-57 is conclusively resolved." - else - echo " ✅ PASS: No duplicates detected at preset=$PRESET_VAL" - echo " The 100ms dedup filter appears effective and Kanata is not" - echo " emitting duplicate HID events at this load level." - fi -elif [[ "$phase1_alerts" -eq 0 ]] && [[ "$actual_len" -gt "$expected_len" ]]; then - echo " 🔴 FAIL: Pipeline clean but characters duplicated in editor!" - echo " The dedup fix is working at the UI layer, but Kanata is" - echo " emitting duplicate HID events to the OS. This is a deeper" - echo " issue — likely scheduling starvation or tap-hold timer drift." - echo " Root cause is in Kanata, not KeyPath." -elif [[ "$actual_len" -lt "$expected_len" ]]; then - echo " ⚠️ CHARS DROPPED: $((expected_len - actual_len)) characters lost under load." - echo " The system may be too overloaded for osascript to deliver" - echo " keystrokes reliably. This is a test infrastructure limit," - echo " not necessarily a Kanata issue. Review the diff for patterns." -elif [[ "$phase1_alerts" -gt 0 ]] && [[ "$actual_len" -gt "$expected_len" ]]; then - echo " 🔴 FAIL: Duplicates at both layers." - echo " Both the notification pipeline AND actual keystrokes show" - echo " duplicates. The problem is systemic." -else - echo " ⚠️ MIXED: Pipeline showed $phase1_alerts alerts but character" - echo " counts are close. Review the diff for details." -fi - -echo -echo "Full results: $TEST_DIR/" -echo " expected-input.txt — what was sent ($expected_len chars)" -echo " actual-output.txt — what appeared in Zed ($actual_len chars)" -echo " diff-report.txt — line-level diff" -if [[ "$PRESET_VAL" == "vicious" ]]; then -echo " word-diff.txt — word-level diff (character precision)" -fi diff --git a/Scripts/verify-kanata-host-bridge.py b/Scripts/verify-kanata-host-bridge.py new file mode 100644 index 000000000..657a5d3ca --- /dev/null +++ b/Scripts/verify-kanata-host-bridge.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +import ctypes +import os +import sys + + +def main() -> int: + if len(sys.argv) not in (2, 3, 4): + print( + "usage: verify-kanata-host-bridge.py [config-path] [--passthru]", + file=sys.stderr, + ) + return 2 + + dylib_path = sys.argv[1] + if not os.path.isfile(dylib_path): + print(f"missing bridge dylib: {dylib_path}", file=sys.stderr) + return 1 + + try: + bridge = ctypes.CDLL(dylib_path) + except OSError as exc: + print(f"failed to load bridge dylib: {exc}", file=sys.stderr) + return 1 + + bridge.keypath_kanata_bridge_version.restype = ctypes.c_char_p + bridge.keypath_kanata_bridge_default_cfg_count.restype = ctypes.c_size_t + bridge.keypath_kanata_bridge_validate_config.argtypes = [ + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_size_t, + ] + bridge.keypath_kanata_bridge_validate_config.restype = ctypes.c_bool + bridge.keypath_kanata_bridge_create_runtime.argtypes = [ + ctypes.c_char_p, + ctypes.c_char_p, + ctypes.c_size_t, + ] + bridge.keypath_kanata_bridge_create_runtime.restype = ctypes.c_void_p + bridge.keypath_kanata_bridge_runtime_layer_count.argtypes = [ctypes.c_void_p] + bridge.keypath_kanata_bridge_runtime_layer_count.restype = ctypes.c_size_t + bridge.keypath_kanata_bridge_destroy_runtime.argtypes = [ctypes.c_void_p] + bridge.keypath_kanata_bridge_destroy_runtime.restype = None + + version = bridge.keypath_kanata_bridge_version() + default_cfg_count = bridge.keypath_kanata_bridge_default_cfg_count() + + version_text = version.decode("utf-8") if version else "" + print(f"bridge version: {version_text}") + print(f"default cfg count: {default_cfg_count}") + + enable_passthru = "--passthru" in sys.argv[2:] + config_arg = next((arg for arg in sys.argv[2:] if arg != "--passthru"), None) + + if config_arg is not None: + error_buffer = ctypes.create_string_buffer(2048) + config_path = config_arg.encode("utf-8") + valid = bridge.keypath_kanata_bridge_validate_config( + config_path, + error_buffer, + len(error_buffer), + ) + print(f"config valid: {valid}") + if not valid: + print(f"config error: {error_buffer.value.decode('utf-8')}") + else: + runtime_error = ctypes.create_string_buffer(2048) + runtime = bridge.keypath_kanata_bridge_create_runtime( + config_path, + runtime_error, + len(runtime_error), + ) + print(f"runtime created: {bool(runtime)}") + if runtime: + print(f"runtime layer count: {bridge.keypath_kanata_bridge_runtime_layer_count(runtime)}") + bridge.keypath_kanata_bridge_destroy_runtime(runtime) + else: + print(f"runtime error: {runtime_error.value.decode('utf-8')}") + + if enable_passthru: + try: + bridge.keypath_kanata_bridge_create_passthru_runtime.argtypes = [ + ctypes.c_char_p, + ctypes.c_ushort, + ctypes.c_char_p, + ctypes.c_size_t, + ] + bridge.keypath_kanata_bridge_create_passthru_runtime.restype = ctypes.c_void_p + bridge.keypath_kanata_bridge_passthru_runtime_layer_count.argtypes = [ctypes.c_void_p] + bridge.keypath_kanata_bridge_passthru_runtime_layer_count.restype = ctypes.c_size_t + bridge.keypath_kanata_bridge_passthru_try_recv_output.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(ctypes.c_ulonglong), + ctypes.POINTER(ctypes.c_uint), + ctypes.POINTER(ctypes.c_uint), + ctypes.c_char_p, + ctypes.c_size_t, + ] + bridge.keypath_kanata_bridge_passthru_try_recv_output.restype = ctypes.c_int + bridge.keypath_kanata_bridge_destroy_passthru_runtime.argtypes = [ctypes.c_void_p] + bridge.keypath_kanata_bridge_destroy_passthru_runtime.restype = None + except AttributeError as exc: + print(f"passthru symbols unavailable: {exc}") + else: + passthru_error = ctypes.create_string_buffer(2048) + passthru_runtime = bridge.keypath_kanata_bridge_create_passthru_runtime( + config_path, + 37001, + passthru_error, + len(passthru_error), + ) + print(f"passthru runtime created: {bool(passthru_runtime)}") + if passthru_runtime: + print( + "passthru runtime layer count: " + f"{bridge.keypath_kanata_bridge_passthru_runtime_layer_count(passthru_runtime)}" + ) + value_out = ctypes.c_ulonglong() + page_out = ctypes.c_uint() + code_out = ctypes.c_uint() + recv_error = ctypes.create_string_buffer(2048) + recv_status = bridge.keypath_kanata_bridge_passthru_try_recv_output( + passthru_runtime, + ctypes.byref(value_out), + ctypes.byref(page_out), + ctypes.byref(code_out), + recv_error, + len(recv_error), + ) + print(f"passthru receive status: {recv_status}") + if recv_status == 1: + print( + "passthru output event: " + f"value={value_out.value} page={page_out.value} code={code_out.value}" + ) + elif recv_status < 0: + print(f"passthru receive error: {recv_error.value.decode('utf-8')}") + bridge.keypath_kanata_bridge_destroy_passthru_runtime(passthru_runtime) + else: + print(f"passthru runtime error: {passthru_error.value.decode('utf-8')}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Sources/KeyPathApp/Main.swift b/Sources/KeyPathApp/Main.swift index 831f6126a..7715111a7 100644 --- a/Sources/KeyPathApp/Main.swift +++ b/Sources/KeyPathApp/Main.swift @@ -2,7 +2,7 @@ import KeyPathAppKit import SwiftUI @main -struct KeyPathMain { +struct KeyPath { static func main() async { if let exitCode = await KeyPathCLIEntrypoint.runIfNeeded(arguments: CommandLine.arguments) { exit(exitCode) diff --git a/Sources/KeyPathAppKit/App.swift b/Sources/KeyPathAppKit/App.swift index eb90b5188..19d1f6783 100644 --- a/Sources/KeyPathAppKit/App.swift +++ b/Sources/KeyPathAppKit/App.swift @@ -1,5 +1,6 @@ import AppKit import KeyPathCore +import KeyPathDaemonLifecycle import KeyPathPermissions import KeyPathPluginKit import KeyPathWizardCore @@ -14,15 +15,18 @@ public struct KeyPathApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate private let isHeadlessMode: Bool + private let isOneShotProbeMode: Bool public init() { + let environment = ProcessInfo.processInfo.environment // Check if running in headless mode (started by LaunchAgent) let args = ProcessInfo.processInfo.arguments isHeadlessMode = - args.contains("--headless") || ProcessInfo.processInfo.environment["KEYPATH_HEADLESS"] == "1" + args.contains("--headless") || environment["KEYPATH_HEADLESS"] == "1" + isOneShotProbeMode = AppDelegate.isOneShotProbeEnvironment(environment) AppLogger.shared.info( - "🔍 [App] Initializing KeyPath - headless: \(isHeadlessMode), args: \(args)" + "🔍 [App] Initializing KeyPath - headless: \(isHeadlessMode), oneShotProbe: \(isOneShotProbeMode), args: \(args)" ) let info = BuildInfo.current() AppLogger.shared.info( @@ -53,7 +57,9 @@ public struct KeyPathApp: App { // Configure MainAppStateController early so it's ready when overlay starts observing. // Previously this was called in ContentView.onAppear which happens AFTER showForStartup(), // causing the health indicator to get stuck in "checking" state. - MainAppStateController.shared.configure(with: manager) + if !isOneShotProbeMode { + MainAppStateController.shared.configure(with: manager) + } // Ensure typing sounds manager is initialized so it can listen for key events // even before the overlay/settings UI is opened. @@ -76,31 +82,35 @@ public struct KeyPathApp: App { // Request user notification authorization after app has fully launched // Delayed to avoid UNUserNotificationCenter initialization issues during bundle setup - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) // 0.1s delay - UserNotificationService.shared.requestAuthorizationIfNeeded() + if !isOneShotProbeMode { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) // 0.1s delay + UserNotificationService.shared.requestAuthorizationIfNeeded() - // Start Kanata error monitoring - KanataErrorMonitor.shared.startMonitoring() - AppLogger.shared.info("🔍 [App] Started Kanata error monitoring") + // Start Kanata error monitoring + KanataErrorMonitor.shared.startMonitoring() + AppLogger.shared.info("🔍 [App] Started Kanata error monitoring") - // Initialize Sparkle update service - UpdateService.shared.initialize() - AppLogger.shared.info("🔄 [App] Sparkle update service initialized") + // Initialize Sparkle update service + UpdateService.shared.initialize() + AppLogger.shared.info("🔄 [App] Sparkle update service initialized") - // Discover and load plugin bundles - PluginManager.shared.discoverAndLoadPlugins() + // Discover and load plugin bundles + PluginManager.shared.discoverAndLoadPlugins() - // Fetch Kanata version for About panel - await BuildInfo.fetchKanataVersion() + // Fetch Kanata version for About panel + await BuildInfo.fetchKanataVersion() - // Start global hotkey monitoring (Option+Command+K to show/hide, Option+Command+L to reset/center) - GlobalHotkeyService.shared.startMonitoring() + // Start global hotkey monitoring (Option+Command+K to show/hide, Option+Command+L to reset/center) + GlobalHotkeyService.shared.startMonitoring() - // Initialize WindowManager with retry logic for CGS APIs - // initializeWithRetry() checks immediately, then uses exponential backoff if needed - await WindowManager.shared.initializeWithRetry() - AppLogger.shared.info("🪟 [App] WindowManager initialization complete") + // Initialize WindowManager with retry logic for CGS APIs + // initializeWithRetry() checks immediately, then uses exponential backoff if needed + await WindowManager.shared.initializeWithRetry() + AppLogger.shared.info("🪟 [App] WindowManager initialization complete") + } + } else { + AppLogger.shared.info("🧪 [App] One-shot probe mode active - skipping nonessential startup services") } } @@ -381,6 +391,17 @@ private func openPreferencesTab(_ notification: Notification.Name) { @MainActor class AppDelegate: NSObject, NSApplicationDelegate { + static let hostPassthruCaptureEnvKey = "KEYPATH_ENABLE_HOST_PASSTHRU_CAPTURE" + static func isOneShotProbeEnvironment(_ environment: [String: String] = ProcessInfo.processInfo.environment) + -> Bool + { + OneShotProbeEnvironment.isActive(environment) + } + private static let hostPassthruDiagnosticTriggerPath = "/var/tmp/keypath-host-passthru-diagnostic" + private static let hostPassthruBridgePrepTriggerPath = "/var/tmp/keypath-host-passthru-bridge-prep" + private static let hostPassthruBridgePrepOutputPath = "/var/tmp/keypath-host-passthru-bridge-env.txt" + private static let helperRepairTriggerPath = "/var/tmp/keypath-helper-repair" + private static let companionRestartProbeOutputPath = "/var/tmp/keypath-host-passthru-companion-restart.txt" var kanataManager: RuntimeCoordinator? var viewModel: KanataViewModel? var isHeadlessMode = false @@ -503,6 +524,227 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Set smart default keyboard layout on first launch setSmartKeyboardLayoutDefault() + let shouldRunHostPassthruDiagnostic = + ProcessInfo.processInfo.environment[OneShotProbeEnvironment.hostPassthruDiagnosticEnvKey] == "1" + || Foundation.FileManager().fileExists(atPath: Self.hostPassthruDiagnosticTriggerPath) + + if shouldRunHostPassthruDiagnostic { + try? Foundation.FileManager().removeItem(atPath: Self.hostPassthruDiagnosticTriggerPath) + AppLogger.shared.info("🧪 [AppDelegate] Running experimental host passthru diagnostics and exiting") + Task { @MainActor in + let diagnosticsService = DiagnosticsService( + processLifecycleManager: ProcessLifecycleManager() + ) + let diagnostic = await diagnosticsService.runHostPassthruDiagnostic() + AppLogger.shared.info( + "🧪 [AppDelegate] Host passthru diagnostic result: \(diagnostic.title) | severity=\(diagnostic.severity.rawValue) | details=\(diagnostic.technicalDetails)" + ) + FileHandle.standardError.write( + Data( + """ + [keypath-host-passthru-diagnostic] + title=\(diagnostic.title) + severity=\(diagnostic.severity.rawValue) + details=\(diagnostic.technicalDetails) + + """.utf8 + ) + ) + FileHandle.standardError.synchronizeFile() + Foundation.exit(0) + } + return + } + + let shouldPrepareHostPassthruBridge = + ProcessInfo.processInfo.environment[OneShotProbeEnvironment.hostPassthruBridgePrepEnvKey] == "1" + || Foundation.FileManager().fileExists(atPath: Self.hostPassthruBridgePrepTriggerPath) + + if shouldPrepareHostPassthruBridge { + try? Foundation.FileManager().removeItem(atPath: Self.hostPassthruBridgePrepTriggerPath) + AppLogger.shared.info("🧪 [AppDelegate] Preparing experimental host passthru bridge environment and exiting") + Task { @MainActor in + do { + let bridgeEnvironment = try await KanataRuntimePathCoordinator.prepareExperimentalOutputBridgeEnvironment( + hostPID: getpid() + ) + let sessionID = bridgeEnvironment[KanataRuntimePathCoordinator.experimentalOutputBridgeSessionEnvKey] ?? "missing" + let socketPath = bridgeEnvironment[KanataRuntimePathCoordinator.experimentalOutputBridgeSocketEnvKey] ?? "missing" + let payload = """ + session=\(sessionID) + socket=\(socketPath) + + """ + try payload.write( + toFile: Self.hostPassthruBridgePrepOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [AppDelegate] Prepared experimental host passthru bridge environment session=\(sessionID) socket=\(socketPath)" + ) + FileHandle.standardError.write( + Data( + """ + [keypath-host-passthru-bridge] + session=\(sessionID) + socket=\(socketPath) + output=\(Self.hostPassthruBridgePrepOutputPath) + + """.utf8 + ) + ) + FileHandle.standardError.synchronizeFile() + Foundation.exit(0) + } catch { + let message = error.localizedDescription + AppLogger.shared.error("🧪 [AppDelegate] Host passthru bridge preparation failed: \(message)") + FileHandle.standardError.write( + Data( + """ + [keypath-host-passthru-bridge] + error=\(message) + + """.utf8 + ) + ) + FileHandle.standardError.synchronizeFile() + Foundation.exit(1) + } + } + return + } + + let shouldRunHelperRepair = + ProcessInfo.processInfo.environment[OneShotProbeEnvironment.helperRepairEnvKey] == "1" + || Foundation.FileManager().fileExists(atPath: Self.helperRepairTriggerPath) + + if shouldRunHelperRepair { + try? Foundation.FileManager().removeItem(atPath: Self.helperRepairTriggerPath) + AppLogger.shared.info("🧪 [AppDelegate] Running helper cleanup/repair and exiting") + let useAppleScriptFallbackRaw = ProcessInfo.processInfo.environment["KEYPATH_HELPER_REPAIR_USE_APPLESCRIPT"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let useAppleScriptFallback = useAppleScriptFallbackRaw == nil + || useAppleScriptFallbackRaw == "1" + || useAppleScriptFallbackRaw == "true" + || useAppleScriptFallbackRaw == "yes" + Task { @MainActor in + let repaired = await HelperMaintenance.shared.runCleanupAndRepair( + useAppleScriptFallback: useAppleScriptFallback + ) + let details = HelperMaintenance.shared.logLines.joined(separator: " | ") + FileHandle.standardError.write( + Data( + """ + [keypath-helper-repair] + success=\(repaired) + use_apple_script_fallback=\(useAppleScriptFallback) + details=\(details) + + """.utf8 + ) + ) + NSApplication.shared.terminate(nil) + } + return + } + + let shouldRunCompanionRestartProbe = + ProcessInfo.processInfo.environment[OneShotProbeEnvironment.companionRestartProbeEnvKey] == "1" + + if shouldRunCompanionRestartProbe { + let captureRaw = ProcessInfo.processInfo.environment[Self.hostPassthruCaptureEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + + AppLogger.shared.info( + "🧪 [AppDelegate] Running output bridge companion restart probe and exiting" + ) + Task { @MainActor in + do { + var lines: [String] = [] + if let statusBefore = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_before=\(statusBefore.companionRunning)") + } else { + lines.append("companion_running_before=unknown") + } + lines.append("capture=\(includeCapture)") + + let pid = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + lines.append("host_pid=\(pid)") + try await Task.sleep(for: .milliseconds(300)) + + do { + try await KanataOutputBridgeCompanionManager.shared.restartCompanion() + lines.append("companion_restarted=1") + } catch { + lines.append("companion_restarted=0") + lines.append( + "companion_restart_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))" + ) + } + try await Task.sleep(for: .milliseconds(500)) + + if let statusAfter = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_after=\(statusAfter.companionRunning)") + } else { + lines.append("companion_running_after=unknown") + } + + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lines.append("host_stopped=1") + + let payload = lines.joined(separator: "\n") + "\n" + try payload.write( + toFile: Self.companionRestartProbeOutputPath, + atomically: true, + encoding: .utf8 + ) + FileHandle.standardError.write( + Data( + """ + [keypath-output-bridge-companion-restart] + \(payload) + """.utf8 + ) + ) + FileHandle.standardError.synchronizeFile() + Foundation.exit(0) + } catch { + let message = error.localizedDescription + AppLogger.shared.error( + "🧪 [AppDelegate] Output bridge companion restart probe failed: \(message)" + ) + FileHandle.standardError.write( + Data( + """ + [keypath-output-bridge-companion-restart] + error=\(message) + + """.utf8 + ) + ) + FileHandle.standardError.synchronizeFile() + Foundation.exit(1) + } + } + return + } + + if !isHeadlessMode, !ProcessInfo.processInfo.arguments.contains("--headless"), + let bundleIdentifier = Bundle.main.bundleIdentifier, + SingleInstanceCoordinator.activateExistingAndTerminateIfNeeded( + bundleIdentifier: bundleIdentifier + ) + { + AppLogger.shared.info("🪟 [AppDelegate] Duplicate normal app launch detected; exiting early") + return + } + // Phase 2/3: TCP-only mode (no authentication needed) AppLogger.shared.debug("📡 [AppDelegate] TCP communication mode - no auth token needed") @@ -551,7 +793,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { } if isHeadlessMode { - AppLogger.shared.info("🤖 [AppDelegate] Headless mode - starting kanata service automatically") + AppLogger.shared.info("🤖 [AppDelegate] Headless mode - starting KeyPath runtime automatically") // In headless mode, ensure kanata starts Task { @@ -562,7 +804,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if let manager = self.kanataManager { let started = await manager.startKanata(reason: "Headless auto-start") if !started { - AppLogger.shared.error("❌ [AppDelegate] Headless auto-start failed via KanataService") + AppLogger.shared.error("❌ [AppDelegate] Headless auto-start failed via runtime coordinator") } } else { AppLogger.shared.error("❌ [AppDelegate] Headless auto-start failed: RuntimeCoordinator unavailable") @@ -591,7 +833,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Startup + post-wizard validation trigger. NotificationCenter.default.addObserver( - forName: .kp_startupRevalidate, object: nil, queue: .main + forName: .kp_startupRevalidate, object: nil, queue: NotificationObserverManager.mainOperationQueue ) { _ in Task { @MainActor in await MainAppStateController.shared.performInitialValidation() @@ -601,7 +843,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Settings/permission flows sometimes post a “toast” message; show as a user notification now that // the main window is a splash. NotificationCenter.default.addObserver( - forName: NSNotification.Name("ShowUserFeedback"), object: nil, queue: .main + forName: NSNotification.Name("ShowUserFeedback"), object: nil, queue: NotificationObserverManager.mainOperationQueue ) { notification in if let message = notification.userInfo?["message"] as? String { Task { @MainActor in @@ -612,7 +854,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Reset-to-safe config action (used by notification buttons). NotificationCenter.default.addObserver( - forName: .resetToSafeConfig, object: nil, queue: .main + forName: .resetToSafeConfig, object: nil, queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor in _ = await self?.viewModel?.createDefaultUserConfigIfMissing() @@ -639,6 +881,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { AppLogger.shared.debug( "🪟 [AppDelegate] Main window controller created (deferring show until activation)" ) + mainWindowController?.primeForActivation() + AppLogger.shared.debug( + "🪟 [AppDelegate] Primed main window so Finder launches have a visible surface to activate" + ) // Overlay is shown on the first application activation (after the brief splash), // so launch reads as "splash -> overlay" instead of two windows at once. @@ -675,7 +921,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if started { AppLogger.shared.log("✅ [AppDelegate] Auto-launch sequence completed (simple)") } else { - AppLogger.shared.error("❌ [AppDelegate] Auto-launch failed via KanataService") + AppLogger.shared.error("❌ [AppDelegate] Auto-launch failed via runtime coordinator") } } else { AppLogger.shared.error( @@ -700,20 +946,6 @@ class AppDelegate: NSObject, NSApplicationDelegate { // Small delay to ensure overlay is visible first and orphan cleanup dialog can show first try? await Task.sleep(for: .seconds(1)) // 1s NotificationCenter.default.post(name: NSNotification.Name("ShowWizard"), object: nil) - - // On first install, also open the help browser to the installation guide - // so the user has context alongside the wizard - if !UserDefaults.standard.bool(forKey: "KeyPath.hasShownInstallationHelp") { - UserDefaults.standard.set(true, forKey: "KeyPath.hasShownInstallationHelp") - try? await Task.sleep(for: .milliseconds(2500)) // Let wizard render first - if let topic = HelpTopic.topic(forResource: "installation") { - HelpWindowController.shared.showBrowser( - selecting: topic, - keepOverlayVisible: true - ) - AppLogger.shared.info("📖 [AppDelegate] Opened installation help alongside wizard") - } - } } } } else { @@ -721,22 +953,22 @@ class AppDelegate: NSObject, NSApplicationDelegate { } // Observe notification action events - NotificationCenter.default.addObserver(forName: .retryStartService, object: nil, queue: .main) { [weak self] _ in + NotificationCenter.default.addObserver(forName: .retryStartService, object: nil, queue: NotificationObserverManager.mainOperationQueue) { [weak self] _ in Task { @MainActor in AppLogger.shared.log("🔄 [App] Retry start requested via notification") guard let manager = self?.kanataManager else { AppLogger.shared.error("❌ [App] Retry start requested but RuntimeCoordinator unavailable") return } - let success = await manager.restartServiceWithFallback(reason: "Notification retryStartService") + let success = await manager.startKanata(reason: "Notification retryStartService") if !success { - AppLogger.shared.error("❌ [App] Retry start failed via KanataService fallback") + AppLogger.shared.error("❌ [App] Retry start failed") } } } NotificationCenter.default.addObserver( - forName: .openInputMonitoringSettings, object: nil, queue: .main + forName: .openInputMonitoringSettings, object: nil, queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor in self?.kanataManager?.openInputMonitoringSettings() @@ -744,13 +976,153 @@ class AppDelegate: NSObject, NSApplicationDelegate { } NotificationCenter.default.addObserver( - forName: .openAccessibilitySettings, object: nil, queue: .main + forName: .openAccessibilitySettings, object: nil, queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor in self?.kanataManager?.openAccessibilitySettings() } } + NotificationCenter.default.addObserver( + forName: .exerciseCoordinatorSplitRuntimeRecovery, + object: nil, + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] note in + let outputPath = + note.userInfo?["outputPath"] as? String + ?? "/var/tmp/keypath-runtime-coordinator-companion-recovery.txt" + Task { @MainActor in + guard let self, let manager = self.kanataManager else { return } + var lines: [String] = [] + + lines.append("split_runtime_mode=always_on") + + do { + let started = await manager.startKanata(reason: "Coordinator split runtime recovery probe") + lines.append("coordinator_start_success=\(started)") + let startedState = manager.getCurrentUIState() + lines.append("runtime_path_after_start=\(startedState.activeRuntimePathTitle ?? "none")") + lines.append("runtime_detail_after_start=\(startedState.activeRuntimePathDetail ?? "none")") + + if let companionStatusBefore = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_before=\(companionStatusBefore.companionRunning)") + } else { + lines.append("companion_running_before=unknown") + } + + try await KanataOutputBridgeCompanionManager.shared.restartCompanion() + lines.append("companion_restarted=1") + + try await Task.sleep(for: .seconds(12)) + + let finalState = manager.getCurrentUIState() + lines.append("runtime_path_after_recovery=\(finalState.activeRuntimePathTitle ?? "none")") + lines.append("runtime_detail_after_recovery=\(finalState.activeRuntimePathDetail ?? "none")") + lines.append("last_error=\(finalState.lastError ?? "none")") + lines.append("last_warning=\(finalState.lastWarning ?? "none")") + lines.append("split_host_running_after_recovery=\(KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning)") + if let activePID = KanataSplitRuntimeHostService.shared.activePersistentHostPID { + lines.append("split_host_pid_after_recovery=\(activePID)") + } + if let companionStatusAfter = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_after=\(companionStatusAfter.companionRunning)") + } else { + lines.append("companion_running_after=unknown") + } + } catch { + lines.append("probe_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))") + } + + _ = await manager.stopKanata(reason: "Coordinator split runtime recovery probe cleanup") + lines.append("cleanup_complete=1") + + let payload = lines.joined(separator: "\n") + "\n" + do { + try payload.write(toFile: outputPath, atomically: true, encoding: .utf8) + AppLogger.shared.info( + "🧪 [AppDelegate] Coordinator split-runtime recovery probe completed output=\(outputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [AppDelegate] Failed to write coordinator split-runtime recovery probe output: \(error.localizedDescription)" + ) + } + } + } + + NotificationCenter.default.addObserver( + forName: .exerciseCoordinatorSplitRuntimeRestartSoak, + object: nil, + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] note in + let outputPath = + note.userInfo?["outputPath"] as? String + ?? "/var/tmp/keypath-runtime-coordinator-companion-restart-soak.txt" + let durationSeconds = note.userInfo?["durationSeconds"] as? Int ?? 20 + Task { @MainActor in + guard let self, let manager = self.kanataManager else { return } + var lines: [String] = [] + + lines.append("split_runtime_mode=always_on") + lines.append("duration_seconds=\(durationSeconds)") + + do { + let started = await manager.startKanata(reason: "Coordinator split runtime restart soak probe") + lines.append("coordinator_start_success=\(started)") + let startedState = manager.getCurrentUIState() + lines.append("runtime_path_after_start=\(startedState.activeRuntimePathTitle ?? "none")") + lines.append("runtime_detail_after_start=\(startedState.activeRuntimePathDetail ?? "none")") + + if let companionStatusBefore = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_before=\(companionStatusBefore.companionRunning)") + } else { + lines.append("companion_running_before=unknown") + } + + let preRestartDelaySeconds = max(1, durationSeconds / 2) + let postRestartDelaySeconds = max(1, durationSeconds - preRestartDelaySeconds) + try await Task.sleep(for: .seconds(preRestartDelaySeconds)) + + try await KanataOutputBridgeCompanionManager.shared.restartCompanion() + lines.append("companion_restarted=1") + + try await Task.sleep(for: .seconds(postRestartDelaySeconds)) + + let finalState = manager.getCurrentUIState() + lines.append("runtime_path_after_soak=\(finalState.activeRuntimePathTitle ?? "none")") + lines.append("runtime_detail_after_soak=\(finalState.activeRuntimePathDetail ?? "none")") + lines.append("last_error=\(finalState.lastError ?? "none")") + lines.append("last_warning=\(finalState.lastWarning ?? "none")") + lines.append("split_host_running_after_soak=\(KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning)") + if let activePID = KanataSplitRuntimeHostService.shared.activePersistentHostPID { + lines.append("split_host_pid_after_soak=\(activePID)") + } + if let companionStatusAfter = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_after=\(companionStatusAfter.companionRunning)") + } else { + lines.append("companion_running_after=unknown") + } + } catch { + lines.append("probe_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))") + } + + _ = await manager.stopKanata(reason: "Coordinator split runtime restart soak probe cleanup") + lines.append("cleanup_complete=1") + + let payload = lines.joined(separator: "\n") + "\n" + do { + try payload.write(toFile: outputPath, atomically: true, encoding: .utf8) + AppLogger.shared.info( + "🧪 [AppDelegate] Coordinator split-runtime restart soak probe completed output=\(outputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [AppDelegate] Failed to write coordinator split-runtime restart soak probe output: \(error.localizedDescription)" + ) + } + } + } + // Wire ActionDispatcher errors to user notifications (for deep link failures) ActionDispatcher.shared.onError = { message in UserNotificationService.shared.notifyActionError(message) @@ -939,6 +1311,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { if let vm = viewModel { mainWindowController = MainWindowController(viewModel: vm) AppLogger.shared.debug("🪟 [AppDelegate] Created main window controller on reopen") + mainWindowController?.primeForActivation() } else { AppLogger.shared.error( "❌ [AppDelegate] Cannot create window on reopen: ViewModel is nil" @@ -946,16 +1319,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { } } - // During early startup, defer showing until first activation completed to avoid layout reentrancy - if !initialMainWindowShown { - pendingReopenShow = true - AppLogger.shared.debug( - "🪟 [AppDelegate] Reopen received before first activation; deferring show" - ) - } else { - mainWindowController?.show(focus: true) - AppLogger.shared.debug("🪟 [AppDelegate] User-initiated reopen - showing main window") + // Finder/Dock reopen is an explicit user action. Show UI immediately rather than + // waiting for a separate activation callback, which may never arrive if the app is + // already running but has no visible windows. + suppressLaunchSplashAutoHide = true + pendingReopenShow = false + if NSApp.isHidden { + NSApp.unhide(nil) } + NSApp.activate(ignoringOtherApps: true) + mainWindowController?.show(focus: true) + initialMainWindowShown = true + AppLogger.shared.debug("🪟 [AppDelegate] User-initiated reopen - showing main window immediately") return true } diff --git a/Sources/KeyPathAppKit/CLI/CLIFacade.swift b/Sources/KeyPathAppKit/CLI/CLIFacade.swift index fa05bcf1e..17dc22da5 100644 --- a/Sources/KeyPathAppKit/CLI/CLIFacade.swift +++ b/Sources/KeyPathAppKit/CLI/CLIFacade.swift @@ -220,6 +220,8 @@ public struct CLIFacade: Sendable { kanataRunning: context.services.kanataRunning, karabinerDaemonRunning: context.services.karabinerDaemonRunning, vhidHealthy: context.services.vhidHealthy, + activeRuntimePathTitle: context.services.activeRuntimePathTitle, + activeRuntimePathDetail: context.services.activeRuntimePathDetail, hasConflicts: context.conflicts.hasConflicts, timestamp: context.timestamp ) @@ -362,6 +364,8 @@ public struct CLIStatusResult: Codable, Sendable { public let kanataRunning: Bool public let karabinerDaemonRunning: Bool public let vhidHealthy: Bool + public let activeRuntimePathTitle: String? + public let activeRuntimePathDetail: String? public let hasConflicts: Bool public let timestamp: Date } diff --git a/Sources/KeyPathAppKit/CLI/KeyPathCLI.swift b/Sources/KeyPathAppKit/CLI/KeyPathCLI.swift index ea01181b6..9c6193f60 100644 --- a/Sources/KeyPathAppKit/CLI/KeyPathCLI.swift +++ b/Sources/KeyPathAppKit/CLI/KeyPathCLI.swift @@ -138,6 +138,40 @@ public struct KeyPathCLI { print("Kanata Running: \(context.services.kanataRunning ? "✅" : "❌")") print("Karabiner Daemon: \(context.services.karabinerDaemonRunning ? "✅" : "❌")") print("VHID Healthy: \(context.services.vhidHealthy ? "✅" : "❌")") + if let runtimePathTitle = context.services.activeRuntimePathTitle { + print("Active Runtime Path: \(runtimePathTitle)") + if let runtimePathDetail = context.services.activeRuntimePathDetail { + print("Runtime Detail: \(runtimePathDetail)") + } + } + + if let runtimePathDecision = context.system.runtimePathDecision { + print("\n--- Runtime Path ---") + switch runtimePathDecision { + case let .useSplitRuntime(reason): + print("Mode: Split Runtime Ready") + print("Reason: \(reason)") + case let .useLegacySystemBinary(reason): + print("Mode: Legacy Fallback") + print("Reason: \(reason)") + case let .blocked(reason): + print("Mode: Blocked") + print("Reason: \(reason)") + } + } + + if let outputBridgeStatus = context.system.outputBridgeStatus { + print("\n--- Output Bridge Companion ---") + print("Available: \(outputBridgeStatus.available ? "✅" : "❌")") + print("Running: \(outputBridgeStatus.companionRunning ? "✅" : "❌")") + print("Requires Privileged Bridge: \(outputBridgeStatus.requiresPrivilegedBridge ? "✅" : "❌")") + if let socketDirectory = outputBridgeStatus.socketDirectory { + print("Socket Directory: \(socketDirectory)") + } + if let detail = outputBridgeStatus.detail, !detail.isEmpty { + print("Detail: \(detail)") + } + } // Conflicts if context.conflicts.hasConflicts { @@ -208,11 +242,6 @@ public struct KeyPathCLI { private func runRepair() async -> Int32 { print("Starting repair...") - if await attemptFastRepair() { - print("\n✅ Repair completed via KanataService restart") - return 0 - } - let broker = privilegeBrokerFactory() let report = await installerEngine.run(intent: .repair, using: broker) @@ -252,26 +281,6 @@ public struct KeyPathCLI { } } - private func attemptFastRepair() async -> Bool { - print("Attempting KanataService restart before full repair...") - let coordinator = ProcessCoordinator() - let restarted = await coordinator.restartService() - - guard restarted else { - print("Fast-path restart failed; continuing with InstallerEngine repair.") - return false - } - - let context = await installerEngine.inspectSystem() - if context.isOperational { - print("Kanata service healthy after restart; skipping InstallerEngine repair.") - return true - } else { - print("System still has issues after restart; running full repair.") - return false - } - } - /// Run uninstall command private func runUninstall(deleteConfig: Bool) async -> Int32 { print("Starting uninstall...") @@ -325,6 +334,32 @@ public struct KeyPathCLI { print("\n--- System Info ---") print("macOS Version: \(context.system.macOSVersion)") print("Driver Compatible: \(context.system.driverCompatible ? "✅" : "❌")") + if let runtimePathDecision = context.system.runtimePathDecision { + print("\n--- Runtime Path ---") + switch runtimePathDecision { + case let .useSplitRuntime(reason): + print("Mode: Split Runtime Ready") + print("Reason: \(reason)") + case let .useLegacySystemBinary(reason): + print("Mode: Legacy Fallback") + print("Reason: \(reason)") + case let .blocked(reason): + print("Mode: Blocked") + print("Reason: \(reason)") + } + } + if let outputBridgeStatus = context.system.outputBridgeStatus { + print("\n--- Output Bridge Companion ---") + print("Available: \(outputBridgeStatus.available ? "✅" : "❌")") + print("Running: \(outputBridgeStatus.companionRunning ? "✅" : "❌")") + print("Requires Privileged Bridge: \(outputBridgeStatus.requiresPrivilegedBridge ? "✅" : "❌")") + if let socketDirectory = outputBridgeStatus.socketDirectory { + print("Socket Directory: \(socketDirectory)") + } + if let detail = outputBridgeStatus.detail, !detail.isEmpty { + print("Detail: \(detail)") + } + } print("\n--- Plan Status ---") switch plan.status { diff --git a/Sources/KeyPathAppKit/Core/BlessDiagnostics.swift b/Sources/KeyPathAppKit/Core/BlessDiagnostics.swift index b598bae3a..8eb0b4eab 100644 --- a/Sources/KeyPathAppKit/Core/BlessDiagnostics.swift +++ b/Sources/KeyPathAppKit/Core/BlessDiagnostics.swift @@ -51,7 +51,7 @@ enum BlessDiagnostics { var helperReq = "" var notes: [String] = [] - let helperExists = FileManager.default.fileExists(atPath: helperPath) + let helperExists = Foundation.FileManager().fileExists(atPath: helperPath) if helperExists { let cs = runCmd("/usr/bin/codesign", ["-d", "-r-", helperPath]) helperReq = @@ -62,7 +62,7 @@ enum BlessDiagnostics { notes.append("Embedded helper not found at \(helperPath)") } - let plistExists = FileManager.default.fileExists(atPath: plistPath) + let plistExists = Foundation.FileManager().fileExists(atPath: plistPath) var statusText = "unknown" if #available(macOS 13, *) { diff --git a/Sources/KeyPathAppKit/Core/Contracts/ConfigurationProviding.swift b/Sources/KeyPathAppKit/Core/Contracts/ConfigurationProviding.swift index a2171d059..4ea2f1876 100644 --- a/Sources/KeyPathAppKit/Core/Contracts/ConfigurationProviding.swift +++ b/Sources/KeyPathAppKit/Core/Contracts/ConfigurationProviding.swift @@ -138,7 +138,7 @@ protocol FileConfigurationProviding: ConfigurationProviding { /// Default implementation for file-based configuration providers. extension FileConfigurationProviding { func reload() async throws -> Config { - guard FileManager.default.fileExists(atPath: configurationPath) else { + guard Foundation.FileManager().fileExists(atPath: configurationPath) else { throw KeyPathError.configuration(.fileNotFound(path: configurationPath)) } diff --git a/Sources/KeyPathAppKit/Core/Contracts/PrivilegedOperations.swift b/Sources/KeyPathAppKit/Core/Contracts/PrivilegedOperations.swift deleted file mode 100644 index 736ff2de2..000000000 --- a/Sources/KeyPathAppKit/Core/Contracts/PrivilegedOperations.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Foundation - -/// Abstraction for privileged operations needed by KeyPath. -/// -/// This is the seam we'll later swap from the legacy (osascript/launchctl) -/// implementation to an SMAppService/XPC-backed privileged helper. -public protocol PrivilegedOperations: Sendable { - /// Start the Kanata LaunchDaemon service. - func startKanataService() async -> Bool - - /// Restart the Kanata LaunchDaemon service. - func restartKanataService() async -> Bool - - /// Stop the Kanata LaunchDaemon service. - func stopKanataService() async -> Bool -} diff --git a/Sources/KeyPathAppKit/Core/HelperManager+ConnectionLifecycle.swift b/Sources/KeyPathAppKit/Core/HelperManager+ConnectionLifecycle.swift index a114da6d5..981c3b262 100644 --- a/Sources/KeyPathAppKit/Core/HelperManager+ConnectionLifecycle.swift +++ b/Sources/KeyPathAppKit/Core/HelperManager+ConnectionLifecycle.swift @@ -104,7 +104,7 @@ extension HelperManager { /// Verify the embedded helper's designated requirement roughly matches expectations. /// Logs warnings on mismatch; does not block connection (to avoid false positives during dev). private nonisolated func verifyEmbeddedHelperSignature() async { - let fm = FileManager.default + let fm = Foundation.FileManager() // Use the production app path (like SignatureHealthCheck does) // Bundle.main.bundlePath can be wrong when launched via Xcode tools let bundlePath = "/Applications/KeyPath.app" diff --git a/Sources/KeyPathAppKit/Core/HelperManager+Installation.swift b/Sources/KeyPathAppKit/Core/HelperManager+Installation.swift index 070c5540c..33ac5de31 100644 --- a/Sources/KeyPathAppKit/Core/HelperManager+Installation.swift +++ b/Sources/KeyPathAppKit/Core/HelperManager+Installation.swift @@ -14,9 +14,7 @@ extension HelperManager { } #endif AppLogger.shared.log("🔐 [SMAPPSERVICE-TRIGGER] *** Registering privileged helper via SMAppService") - // Log stack trace to identify caller - let callStack = Thread.callStackSymbols.prefix(10).joined(separator: "\n") - AppLogger.shared.log("🔐 [SMAPPSERVICE-TRIGGER] Helper install call stack:\n\(callStack)") + AppLogger.shared.log("🔐 [SMAPPSERVICE-TRIGGER] Helper install caller stack unavailable in this build") guard #available(macOS 13, *) else { throw HelperManagerError.installationFailed("Requires macOS 13+ for SMAppService") } @@ -150,7 +148,7 @@ extension HelperManager { private nonisolated func signingPreflightFailure() async -> String? { if TestEnvironment.isRunningTests { return nil } - let fm = FileManager.default + let fm = Foundation.FileManager() let bundlePath = Bundle.main.bundlePath let helperPath = bundlePath + "/Contents/Library/HelperTools/KeyPathHelper" diff --git a/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift b/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift index 4dd7dde60..aee0d08c4 100644 --- a/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift +++ b/Sources/KeyPathAppKit/Core/HelperManager+RequestHandlers.swift @@ -1,6 +1,19 @@ import Foundation import KeyPathCore +private final class HelperXPCCallCompletionState: @unchecked Sendable { + private var completed = false + private let lock = NSLock() + + func tryComplete() -> Bool { + lock.lock() + defer { lock.unlock() } + if completed { return false } + completed = true + return true + } +} + extension HelperManager { // MARK: - XPC Protocol Wrappers @@ -58,7 +71,6 @@ extension HelperManager { AppLogger.shared.log("⚠️ [HelperManager] CONCURRENT XPC CALL DETECTED: \(name)") AppLogger.shared.log(" → This may cause race conditions or hangs") AppLogger.shared.log(" → Active calls: \(activeXPCCalls.joined(separator: ", "))") - assertionFailure("Concurrent XPC call to \(name) - check caller logic") } activeXPCCalls.insert(name) @@ -70,21 +82,7 @@ extension HelperManager { // Execute with timeout to prevent infinite hangs when XPC connection is interrupted // Use a class with lock for thread-safe completion tracking - final class CompletionState: @unchecked Sendable { - private var _completed = false - private let lock = NSLock() - - /// Atomically try to mark as completed. Returns true if this call won the race. - func tryComplete() -> Bool { - lock.lock() - defer { lock.unlock() } - if _completed { return false } - _completed = true - return true - } - } - - let completionState = CompletionState() + let completionState = HelperXPCCallCompletionState() return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in // Set up timeout @@ -111,17 +109,67 @@ extension HelperManager { } } - // MARK: - LaunchDaemon Operations + /// Execute an XPC call that returns a value, with the same timeout and duplicate-call guard + /// used by the void-returning helper operations. + private func executeValueXPCCall( + _ name: String, + timeout: TimeInterval = 30.0, + _ call: @escaping @Sendable ( + HelperProtocol, + @escaping @Sendable (Result) -> Void + ) -> Void + ) async throws -> T { + if activeXPCCalls.contains(name) { + AppLogger.shared.log("⚠️ [HelperManager] CONCURRENT XPC CALL DETECTED: \(name)") + AppLogger.shared.log(" → This may cause race conditions or hangs") + AppLogger.shared.log(" → Active calls: \(activeXPCCalls.joined(separator: ", "))") + } + + activeXPCCalls.insert(name) + defer { activeXPCCalls.remove(name) } + + AppLogger.shared.log("📤 [HelperManager] Calling \(name)") + + let completionState = HelperXPCCallCompletionState() + + return try await withCheckedThrowingContinuation { continuation in + Task { + try? await Task.sleep(for: .seconds(timeout)) + guard completionState.tryComplete() else { return } + AppLogger.shared.log("⏱️ [HelperManager] \(name) timed out after \(Int(timeout))s") + continuation.resume(throwing: HelperManagerError.operationFailed("XPC call '\(name)' timed out after \(Int(timeout))s")) + } + + Task { + do { + let proxy = try await self.getRemoteProxy { error in + guard completionState.tryComplete() else { return } + continuation.resume(throwing: error) + } - func installLaunchDaemon(plistPath: String, serviceID: String) async throws { - try await executeXPCCall("installLaunchDaemon") { proxy, reply in - proxy.installLaunchDaemon(plistPath: plistPath, serviceID: serviceID, reply: reply) + call(proxy) { result in + guard completionState.tryComplete() else { return } + + switch result { + case let .success(value): + AppLogger.shared.info("✅ [HelperManager] \(name) succeeded") + continuation.resume(returning: value) + case let .failure(error): + AppLogger.shared.log("❌ [HelperManager] \(name) failed: \(error.localizedDescription)") + continuation.resume(throwing: error) + } + } + } catch { + guard completionState.tryComplete() else { return } + continuation.resume(throwing: error) + } + } } } - func restartUnhealthyServices() async throws { - try await executeXPCCall("restartUnhealthyServices") { proxy, reply in - proxy.restartUnhealthyServices(reply: reply) + func recoverRequiredRuntimeServices() async throws { + try await executeXPCCall("recoverRequiredRuntimeServices") { proxy, reply in + proxy.recoverRequiredRuntimeServices(reply: reply) } } @@ -143,9 +191,9 @@ extension HelperManager { } } - func installLaunchDaemonServicesWithoutLoading() async throws { - try await executeXPCCall("installLaunchDaemonServicesWithoutLoading") { proxy, reply in - proxy.installLaunchDaemonServicesWithoutLoading(reply: reply) + func installRequiredRuntimeServices() async throws { + try await executeXPCCall("installRequiredRuntimeServices") { proxy, reply in + proxy.installRequiredRuntimeServices(reply: reply) } } @@ -185,22 +233,115 @@ extension HelperManager { } } - // MARK: - Process Management + func getKanataOutputBridgeStatus() async throws -> KanataOutputBridgeStatus { + try await executeValueXPCCall("getKanataOutputBridgeStatus") { proxy, reply in + proxy.getKanataOutputBridgeStatus { payload, errorMessage in + if let errorMessage { + reply(.failure(HelperManagerError.operationFailed(errorMessage))) + return + } - func terminateProcess(_ pid: Int32) async throws { - AppLogger.shared.log("📤 [HelperManager] Calling terminateProcess(\(pid))") + guard let payload else { + reply(.failure(HelperManagerError.operationFailed("Missing output bridge status payload"))) + return + } - let proxy = try await getRemoteProxy { _ in } + do { + let status = try JSONDecoder().decode( + KanataOutputBridgeStatus.self, + from: Data(payload.utf8) + ) + reply(.success(status)) + } catch { + let trimmedPayload = payload.trimmingCharacters(in: .whitespacesAndNewlines) + AppLogger.shared.log( + "⚠️ [HelperManager] Failed to decode output bridge status payload: '\(trimmedPayload)'" + ) + if Self.shouldSoftenOutputBridgeStatusFailure { + reply( + .success( + KanataOutputBridgeStatus( + available: false, + companionRunning: false, + requiresPrivilegedBridge: true, + socketDirectory: KeyPathConstants.OutputBridge.socketDirectory, + detail: "output bridge status unavailable during one-shot probe" + ) + ) + ) + } else { + reply(.failure(HelperManagerError.operationFailed("Failed to decode output bridge status: \(error.localizedDescription)"))) + } + } + } + } + } - return try await withCheckedThrowingContinuation { continuation in + private static var shouldSoftenOutputBridgeStatusFailure: Bool { + OneShotProbeEnvironment.isActive() + } + + func prepareKanataOutputBridgeSession(hostPID: Int32) async throws -> KanataOutputBridgeSession { + try await executeValueXPCCall("prepareKanataOutputBridgeSession") { proxy, reply in + proxy.prepareKanataOutputBridgeSession(hostPID: hostPID) { payload, errorMessage in + if let errorMessage { + reply(.failure(HelperManagerError.operationFailed(errorMessage))) + return + } + + guard let payload else { + reply(.failure(HelperManagerError.operationFailed("Missing output bridge session payload"))) + return + } + + do { + let session = try JSONDecoder().decode( + KanataOutputBridgeSession.self, + from: Data(payload.utf8) + ) + reply(.success(session)) + } catch { + reply(.failure(HelperManagerError.operationFailed("Failed to decode output bridge session: \(error.localizedDescription)"))) + } + } + } + } + + func activateKanataOutputBridgeSession(sessionID: String) async throws { + // 45s instead of the default 30s: companion activation performs launchd kickstart + // followed by socket bootstrap, which can take significantly longer on slower CI + // machines and freshly repaired systems where the companion must cold-start. + try await executeXPCCall("activateKanataOutputBridgeSession", timeout: 45.0) { proxy, reply in + proxy.activateKanataOutputBridgeSession(sessionID: sessionID, reply: reply) + } + } + + func restartKanataOutputBridgeCompanion() async throws { + do { + try await executeXPCCall("restartKanataOutputBridgeCompanion") { proxy, reply in + proxy.restartKanataOutputBridgeCompanion(reply: reply) + } + } catch { + AppLogger.shared.log( + "⚠️ [HelperManager] restartKanataOutputBridgeCompanion failed, retrying with fresh XPC connection: \(error.localizedDescription)" + ) + clearConnection() + try await executeXPCCall("restartKanataOutputBridgeCompanion.retry") { proxy, reply in + proxy.restartKanataOutputBridgeCompanion(reply: reply) + } + } + } + + // MARK: - Process Management + + func terminateProcess(_ pid: Int32) async throws { + try await executeValueXPCCall("terminateProcess") { proxy, reply in proxy.terminateProcess(pid) { success, errorMessage in if success { - AppLogger.shared.info("✅ [HelperManager] terminateProcess succeeded") - continuation.resume() + reply(.success(())) } else { let error = errorMessage ?? "Unknown error" - AppLogger.shared.log("❌ [HelperManager] terminateProcess failed: \(error)") - continuation.resume(throwing: HelperManagerError.operationFailed(error)) + reply(.failure(HelperManagerError.operationFailed(error))) } } } diff --git a/Sources/KeyPathAppKit/Core/HelperManager+Status.swift b/Sources/KeyPathAppKit/Core/HelperManager+Status.swift index c473d29fc..edfb4dd4f 100644 --- a/Sources/KeyPathAppKit/Core/HelperManager+Status.swift +++ b/Sources/KeyPathAppKit/Core/HelperManager+Status.swift @@ -91,9 +91,8 @@ extension HelperManager { AppLogger.shared.log("📤 [HelperManager] Calling proxy.getVersion()") proxy.getVersion { version, error in - let threadName = Thread.current.isMainThread ? "main" : "background" AppLogger.shared.log( - "📥 [HelperManager] getVersion callback received on \(threadName) thread" + "📥 [HelperManager] getVersion callback received" ) // Cancel the timeout since the callback arrived. @@ -300,7 +299,7 @@ extension HelperManager { "/var/log/com.keypath.helper.stdout.log", "/var/log/com.keypath.helper.stderr.log" ] - for path in fileCandidates where FileManager.default.fileExists(atPath: path) { + for path in fileCandidates where Foundation.FileManager().fileExists(atPath: path) { do { let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) let data = try handle.readToEnd() diff --git a/Sources/KeyPathAppKit/Core/HelperManager.swift b/Sources/KeyPathAppKit/Core/HelperManager.swift index 973556f1a..77e13cfd8 100644 --- a/Sources/KeyPathAppKit/Core/HelperManager.swift +++ b/Sources/KeyPathAppKit/Core/HelperManager.swift @@ -20,7 +20,7 @@ actor HelperManager { // Allows unit tests to inject a fake SMAppService and simulate states like `.notFound`. // Default implementation wraps Apple's `SMAppService`. - #if DEBUG +#if DEBUG nonisolated(unsafe) static var smServiceFactory: (String) -> SMAppServiceProtocol = { plistName in NativeSMAppService(wrapped: ServiceManagement.SMAppService.daemon(plistName: plistName)) } @@ -30,7 +30,7 @@ actor HelperManager { nonisolated(unsafe) static var subprocessRunnerFactory: () -> SubprocessRunning = { SubprocessRunner.shared } - #else +#else nonisolated(unsafe) static let smServiceFactory: (String) -> SMAppServiceProtocol = { plistName in NativeSMAppService(wrapped: ServiceManagement.SMAppService.daemon(plistName: plistName)) } diff --git a/Sources/KeyPathAppKit/Core/HelperProtocol.swift b/Sources/KeyPathAppKit/Core/HelperProtocol.swift index a1ba9a3c0..f29b320d1 100644 --- a/Sources/KeyPathAppKit/Core/HelperProtocol.swift +++ b/Sources/KeyPathAppKit/Core/HelperProtocol.swift @@ -17,20 +17,9 @@ import Foundation /// - Parameter reply: Completion handler with (version string, errorMessage) func getVersion(reply: @escaping (String?, String?) -> Void) - // MARK: - LaunchDaemon Operations - - /// Install a single LaunchDaemon service - /// - Parameters: - /// - plistPath: Path to the plist file to install - /// - serviceID: Service identifier (e.g., "com.keypath.kanata") - /// - reply: Completion handler with (success, errorMessage) - func installLaunchDaemon( - plistPath: String, serviceID: String, reply: @escaping (Bool, String?) -> Void - ) - /// Restart services that are in an unhealthy state /// - Parameter reply: Completion handler with (success, errorMessage) - func restartUnhealthyServices(reply: @escaping (Bool, String?) -> Void) + func recoverRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) /// Regenerate and reload service configuration /// - Parameter reply: Completion handler with (success, errorMessage) @@ -44,9 +33,9 @@ import Foundation /// - Parameter reply: Completion handler with (success, errorMessage) func repairVHIDDaemonServices(reply: @escaping (Bool, String?) -> Void) - /// Install LaunchDaemon services without loading them + /// Install only the privileged services required by the split runtime path. /// - Parameter reply: Completion handler with (success, errorMessage) - func installLaunchDaemonServicesWithoutLoading(reply: @escaping (Bool, String?) -> Void) + func installRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) // MARK: - VirtualHID Operations @@ -78,6 +67,40 @@ import Foundation /// - reply: Completion handler with (success, errorMessage) func installBundledVHIDDriver(pkgPath: String, reply: @escaping (Bool, String?) -> Void) + /// Probe whether root-side pqrs VirtualHID output access is available for a future split runtime. + /// - Parameter reply: Completion handler with + /// - payload: JSON-encoded `KanataOutputBridgeStatus` + /// - errorMessage: failure details, if any + func getKanataOutputBridgeStatus( + reply: @escaping (String?, String?) -> Void + ) + + /// Prepare a privileged output-bridge session for a future split runtime. + /// - Parameters: + /// - hostPID: PID of the bundled user-session runtime that will connect + /// - reply: Completion handler with + /// - payload: JSON-encoded `KanataOutputBridgeSession` + /// - errorMessage: failure details, if any + func prepareKanataOutputBridgeSession( + hostPID: Int32, + reply: @escaping (String?, String?) -> Void + ) + + /// Activate a prepared privileged output-bridge session and ensure the dedicated companion binds its socket. + /// - Parameters: + /// - sessionID: session identifier returned by prepare + /// - reply: Completion handler with (success, errorMessage) + func activateKanataOutputBridgeSession( + sessionID: String, + reply: @escaping (Bool, String?) -> Void + ) + + /// Restart the dedicated output-bridge companion and ensure it is relaunched cleanly. + /// - Parameter reply: Completion handler with (success, errorMessage) + func restartKanataOutputBridgeCompanion( + reply: @escaping (Bool, String?) -> Void + ) + // MARK: - Process Management /// Terminate a specific process by PID diff --git a/Sources/KeyPathAppKit/Core/PrivilegedOperationsCoordinator.swift b/Sources/KeyPathAppKit/Core/PrivilegedOperationsCoordinator.swift index 88363370b..ed34e21e5 100644 --- a/Sources/KeyPathAppKit/Core/PrivilegedOperationsCoordinator.swift +++ b/Sources/KeyPathAppKit/Core/PrivilegedOperationsCoordinator.swift @@ -4,13 +4,10 @@ import KeyPathCore @MainActor protocol PrivilegedOperationsCoordinating: AnyObject { - func installLaunchDaemon(plistPath: String, serviceID: String) async throws func cleanupPrivilegedHelper() async throws - func installAllLaunchDaemonServices(kanataBinaryPath: String, kanataConfigPath: String, tcpPort: Int) async throws - func installAllLaunchDaemonServices() async throws - func restartUnhealthyServices() async throws + func installRequiredRuntimeServices() async throws + func recoverRequiredRuntimeServices() async throws func installServicesIfUninstalled(context: String) async throws -> Bool - func installLaunchDaemonServicesWithoutLoading() async throws func installNewsyslogConfig() async throws func regenerateServiceConfiguration() async throws func repairVHIDDaemonServices() async throws @@ -34,7 +31,7 @@ protocol PrivilegedOperationsCoordinating: AnyObject { /// **Usage:** /// ```swift /// let coordinator = PrivilegedOperationsCoordinator.shared -/// try await coordinator.installLaunchDaemon(plistPath: path, serviceID: id) +/// try await coordinator.installRequiredRuntimeServices() /// ``` @MainActor final class PrivilegedOperationsCoordinator { @@ -43,7 +40,9 @@ final class PrivilegedOperationsCoordinator { private static var lastServiceInstallAttempt: Date? private static var lastSMAppApprovalNotice: Date? private static let smAppApprovalNoticeThrottle: TimeInterval = 5 - private static let kanataReadinessTimeout: TimeInterval = 8 + // Clean macOS starts can legitimately take >10s because Kanata sleeps for 2s on startup + // and may wait up to 10s for the DriverKit virtual keyboard to become ready. + private static let kanataReadinessTimeout: TimeInterval = 20 private static let kanataReadinessPollIntervalSeconds: TimeInterval = 0.5 private static let kanataLaunchctlNotFoundExitCode: Int32 = 113 private static let persistentLaunchctlNotFoundThreshold = 3 @@ -55,6 +54,7 @@ final class PrivilegedOperationsCoordinator { case pendingApproval case staleRegistration case launchctlNotFoundPersistent + case tcpPortInUse case timedOut var isSuccess: Bool { @@ -71,6 +71,8 @@ final class PrivilegedOperationsCoordinator { "SMAppService registration is enabled but launchd cannot load the service" case .launchctlNotFoundPersistent: "launchctl repeatedly reported the Kanata service as not found" + case .tcpPortInUse: + "TCP port 37001 is already in use by an existing Kanata runtime" case .timedOut: "Kanata did not become running + TCP responsive within readiness timeout" } @@ -89,6 +91,8 @@ final class PrivilegedOperationsCoordinator { (() -> KanataDaemonManager.ServiceManagementState)? nonisolated(unsafe) static var installAllServicesOverride: (() async throws -> Void)? nonisolated(unsafe) static var installBundledKanataBinaryOverride: (() async throws -> Void)? + nonisolated(unsafe) static var recoverRequiredRuntimeServicesOverride: (() async throws -> Void)? + nonisolated(unsafe) static var killExistingKanataProcessesOverride: (() async throws -> Void)? nonisolated(unsafe) static var kanataReadinessOverride: ((String) async -> KanataReadinessResult)? @@ -96,10 +100,14 @@ final class PrivilegedOperationsCoordinator { serviceStateOverride = nil installAllServicesOverride = nil installBundledKanataBinaryOverride = nil + recoverRequiredRuntimeServicesOverride = nil + killExistingKanataProcessesOverride = nil kanataReadinessOverride = nil lastServiceInstallAttempt = nil lastSMAppApprovalNotice = nil staleRecoveryAttemptCount = 0 + KanataDaemonManager.registeredButNotLoadedOverride = nil + ServiceHealthChecker.runtimeSnapshotOverride = nil } #endif @@ -133,20 +141,6 @@ final class PrivilegedOperationsCoordinator { // MARK: - Unified Privileged Operations API - // MARK: LaunchDaemon Operations - - /// Install a LaunchDaemon plist file to /Library/LaunchDaemons/ - func installLaunchDaemon(plistPath: String, serviceID: String) async throws { - AppLogger.shared.log("🔐 [PrivCoordinator] Installing LaunchDaemon: \(serviceID)") - - switch Self.operationMode { - case .privilegedHelper: - try await helperInstallLaunchDaemon(plistPath: plistPath, serviceID: serviceID) - case .directSudo: - try await sudoInstallLaunchDaemon(plistPath: plistPath, serviceID: serviceID) - } - } - /// Remove any installed SMJobBless helper and its daemon plist/logs (developer convenience) func cleanupPrivilegedHelper() async throws { AppLogger.shared.log("🧹 [PrivCoordinator] Cleaning up privileged helper (dev)") @@ -164,47 +158,20 @@ final class PrivilegedOperationsCoordinator { } /// Install all LaunchDaemon services with explicit parameters - func installAllLaunchDaemonServices( - kanataBinaryPath: String, - kanataConfigPath: String, - tcpPort: Int - ) async throws { - AppLogger.shared.log( - "🔐 [PrivCoordinator] Installing all LaunchDaemon services via SMAppService" - ) - // Always use SMAppService path for Kanata - try await sudoInstallAllServices( - kanataBinaryPath: kanataBinaryPath, - kanataConfigPath: kanataConfigPath, - tcpPort: tcpPort - ) - try await enforceKanataRuntimePostcondition(after: "installAllLaunchDaemonServices(explicit)") - } - - /// Install all LaunchDaemon services (convenience overload - uses PreferencesService for config) - func installAllLaunchDaemonServices() async throws { - AppLogger.shared.log( - "🔐 [PrivCoordinator] Installing all LaunchDaemon services (using preferences)" - ) + func installRequiredRuntimeServices() async throws { + AppLogger.shared.log("🔐 [PrivCoordinator] Installing required split-runtime services") switch Self.operationMode { case .privilegedHelper: do { - // VirtualHID is managed via launchctl (root); Kanata is managed via SMAppService. - // Ensure VHID services are present/healthy, then register Kanata via SMAppService. - try await HelperManager.shared.restartUnhealthyServices() - try await KanataDaemonManager.shared.register() - AppLogger.shared.log( - "✅ [PrivCoordinator] Helper installed VHID services; Kanata registered via SMAppService" - ) + try await HelperManager.shared.installRequiredRuntimeServices() } catch { AppLogger.shared.log("⚠️ [PrivCoordinator] Helper failed (\(error)), falling back to sudo") - try await sudoInstallAllServicesWithPreferences() + try await sudoInstallRequiredRuntimeServices() } case .directSudo: - try await sudoInstallAllServicesWithPreferences() + try await sudoInstallRequiredRuntimeServices() } - try await enforceKanataRuntimePostcondition(after: "installAllLaunchDaemonServices") } private func currentServiceState() async -> KanataDaemonManager.ServiceManagementState { @@ -223,7 +190,7 @@ final class PrivilegedOperationsCoordinator { return } #endif - try await installAllLaunchDaemonServices() + try await installRequiredRuntimeServices() } private func enforceKanataRuntimePostcondition(after operation: String) async throws { @@ -297,23 +264,32 @@ final class PrivilegedOperationsCoordinator { return .run(reason: "state=\(state.description)", bypassedThrottle: false) } - /// Restart unhealthy LaunchDaemon services - func restartUnhealthyServices() async throws { - AppLogger.shared.log("🔐 [PrivCoordinator] Restarting unhealthy services") + /// Recover runtime services after a failed or stale service state. + func recoverRequiredRuntimeServices() async throws { + AppLogger.shared.log("🔐 [PrivCoordinator] Recovering runtime services") + +#if DEBUG + if let override = Self.recoverRequiredRuntimeServicesOverride { + try await override() + return + } +#endif // If the Kanata service is completely uninstalled, install everything first. if try await installServicesIfUninstalled(context: "pre-restart") { AppLogger.shared.log( "✅ [PrivCoordinator] Installed services before restart request – skipping restart call" ) - try await enforceKanataRuntimePostcondition(after: "restartUnhealthyServices(pre-restart)") + try await enforceKanataRuntimePostcondition(after: "recoverRequiredRuntimeServices(pre-restart)") return } + try await killExistingKanataProcessesForServiceRecovery() + switch Self.operationMode { case .privilegedHelper: do { - try await HelperManager.shared.restartUnhealthyServices() + try await HelperManager.shared.recoverRequiredRuntimeServices() AppLogger.shared.log("✅ [PrivCoordinator] Helper successfully restarted services") } catch { AppLogger.shared.log("⚠️ [PrivCoordinator] Helper failed (\(error)), falling back to sudo") @@ -328,7 +304,35 @@ final class PrivilegedOperationsCoordinator { AppLogger.shared.log("✅ [PrivCoordinator] Service installed after restart attempt") } - try await enforceKanataRuntimePostcondition(after: "restartUnhealthyServices") + try await enforceKanataRuntimePostcondition(after: "recoverRequiredRuntimeServices") + } + + private func killExistingKanataProcessesForServiceRecovery() async throws { +#if DEBUG + if let override = Self.killExistingKanataProcessesOverride { + try await override() + return + } +#endif + + AppLogger.shared.log( + "🧹 [PrivCoordinator] Clearing existing Kanata processes before service recovery to avoid TCP port collisions" + ) + + switch Self.operationMode { + case .privilegedHelper: + do { + try await HelperManager.shared.killAllKanataProcesses() + AppLogger.shared.log("✅ [PrivCoordinator] Helper cleared existing Kanata processes") + } catch { + AppLogger.shared.log( + "⚠️ [PrivCoordinator] Helper killAllKanataProcesses failed (\(error)); falling back to sudo" + ) + try await sudoKillAllKanata() + } + case .directSudo: + try await sudoKillAllKanata() + } } /// Installs all LaunchDaemon services via SMAppService when the Kanata daemon is missing. @@ -439,25 +443,6 @@ final class PrivilegedOperationsCoordinator { } } - /// Install LaunchDaemon services without loading them (for adopting orphaned processes) - func installLaunchDaemonServicesWithoutLoading() async throws { - AppLogger.shared.log( - "🔐 [PrivCoordinator] Installing LaunchDaemon services (install-only, no load)" - ) - - switch Self.operationMode { - case .privilegedHelper: - do { try await helperInstallServicesWithoutLoading() } catch { - AppLogger.shared.log( - "🚨 [PrivCoordinator] FALLBACK: helper installLaunchDaemonServicesWithoutLoading failed: \(error.localizedDescription). Using AppleScript/sudo path." - ) - try await sudoInstallServicesWithoutLoading() - } - case .directSudo: - try await sudoInstallServicesWithoutLoading() - } - } - // MARK: VirtualHID Operations /// Activate VirtualHID Manager @@ -596,7 +581,24 @@ final class PrivilegedOperationsCoordinator { // Ensure SMAppService launchd job exists after installing the binary // (common case: fresh reinstall leaves service missing even though binary is present) - try await installServicesIfUninstalled(context: "installBundledKanata") + let didInstallServices = try await installServicesIfUninstalled(context: "installBundledKanata") + + // Fresh installs can already report SMAppService as active while the runtime is still down. + // In that state, binary install + registration metadata is not enough; kick the service once + // before enforcing the strict runtime readiness postcondition. + if !didInstallServices { + let managementState = await currentServiceState() + if managementState == .smappserviceActive { + AppLogger.shared.log( + "🔐 [PrivCoordinator] Bundled Kanata installed while SMAppService was already active; restarting unhealthy services before readiness verification" + ) + try await recoverRequiredRuntimeServices() + AppLogger.shared.log( + "✅ [PrivCoordinator] Bundled Kanata install recovered runtime via recoverRequiredRuntimeServices" + ) + return + } + } let readiness = await verifyKanataReadinessAfterInstall(context: "installBundledKanata") guard readiness.isSuccess else { @@ -620,10 +622,6 @@ final class PrivilegedOperationsCoordinator { // MARK: - Privileged Helper Path (Phase 2 - Future Implementation) - private func helperInstallLaunchDaemon(plistPath: String, serviceID: String) async throws { - try await HelperManager.shared.installLaunchDaemon(plistPath: plistPath, serviceID: serviceID) - } - private func helperRegenerateConfig() async throws { AppLogger.shared.log("🔧 [PrivCoordinator] Bypassing helper - using SMAppService path directly") // Always use SMAppService path for Kanata (helper doesn't support SMAppService) @@ -654,7 +652,7 @@ final class PrivilegedOperationsCoordinator { private func removeLegacyKanataPlist(reason: String) async { let path = KanataDaemonManager.legacyPlistPath - guard FileManager.default.fileExists(atPath: path) else { return } + guard Foundation.FileManager().fileExists(atPath: path) else { return } // Validate path is a safe LaunchDaemons plist before interpolating into shell guard path.hasPrefix("/Library/LaunchDaemons/"), @@ -725,7 +723,9 @@ final class PrivilegedOperationsCoordinator { timeoutMs: timeoutMs ) - if runtimeSnapshot.isRunning && runtimeSnapshot.isResponding { + if runtimeSnapshot.isRunning && runtimeSnapshot.isResponding + && runtimeSnapshot.inputCaptureReady + { return .ready } @@ -775,11 +775,50 @@ final class PrivilegedOperationsCoordinator { AppLogger.shared.error( "❌ [PrivCoordinator] \(context): timed out waiting for Kanata readiness" ) + + if await detectKanataTCPPortConflict() { + AppLogger.shared.error( + "❌ [PrivCoordinator] \(context): detected TCP port 37001 conflict while Kanata remained down" + ) + return .tcpPortInUse + } + return .timedOut } - private func helperInstallServicesWithoutLoading() async throws { - try await HelperManager.shared.installLaunchDaemonServicesWithoutLoading() + private func detectKanataTCPPortConflict() async -> Bool { + let result: ProcessResult + do { + result = try await SubprocessRunner.shared.run( + "/usr/sbin/lsof", + args: ["-nP", "-iTCP:37001", "-sTCP:LISTEN"], + timeout: 2.0 + ) + } catch { + AppLogger.shared.log( + "⚠️ [PrivCoordinator] Failed to probe TCP port 37001 ownership: \(error.localizedDescription)" + ) + return false + } + + guard result.exitCode == 0 else { return false } + let lines = result.stdout + .split(separator: "\n", omittingEmptySubsequences: true) + .map(String.init) + guard lines.count >= 2 else { return false } + let output = result.stdout.lowercased() + + if output.contains("/library/keypath/bin/kanata") { + return true + } + if output.contains("/applications/keypath.app/contents/library/keypath/kanata") { + return true + } + if output.contains("kanata") { + return true + } + + return false } private func helperActivateVHID() async throws { @@ -865,13 +904,13 @@ final class PrivilegedOperationsCoordinator { } // 2) Ask helper to restart unhealthy services or install if missing - AppLogger.shared.log("🔎 [PrivCoordinator] Calling restartUnhealthyServices helper...") + AppLogger.shared.log("🔎 [PrivCoordinator] Calling recoverRequiredRuntimeServices helper...") do { - try await HelperManager.shared.restartUnhealthyServices() - AppLogger.shared.log("🔎 [PrivCoordinator] restartUnhealthyServices completed successfully") + try await HelperManager.shared.recoverRequiredRuntimeServices() + AppLogger.shared.log("🔎 [PrivCoordinator] recoverRequiredRuntimeServices completed successfully") } catch { AppLogger.shared.log( - "⚠️ [PrivCoordinator] Helper restartUnhealthyServices failed: \(error.localizedDescription)" + "⚠️ [PrivCoordinator] Helper recoverRequiredRuntimeServices failed: \(error.localizedDescription)" ) } @@ -937,53 +976,18 @@ final class PrivilegedOperationsCoordinator { // MARK: - Direct Sudo Path (Current Implementation) - /// Install LaunchDaemon plist using osascript with admin privileges - private func sudoInstallLaunchDaemon(plistPath: String, serviceID: String) async throws { - let launchDaemonsPath = "/Library/LaunchDaemons" - let finalPath = "\(launchDaemonsPath)/\(serviceID).plist" - - let command = """ - mkdir -p '\(launchDaemonsPath)' && \ - cp '\(plistPath)' '\(finalPath)' && \ - chown root:wheel '\(finalPath)' && \ - chmod 644 '\(finalPath)' - """ - - try await sudoExecuteCommand( - command, - description: "Install LaunchDaemon: \(serviceID)" - ) - } - - /// Install all LaunchDaemon services using consolidated single-prompt method - /// Uses extracted ServiceBootstrapper for full installation orchestration - private func sudoInstallAllServices( - kanataBinaryPath _: String, - kanataConfigPath _: String, - tcpPort _: Int - ) async throws { - // Uses extracted ServiceBootstrapper for full installation + private func sudoInstallRequiredRuntimeServices() async throws { let success = await ServiceBootstrapper.shared.installAllServices() if !success { - throw PrivilegedOperationError.installationFailed("Service installation failed") + throw PrivilegedOperationError.installationFailed("Required runtime service installation failed") } } - /// Install all LaunchDaemon services (convenience - uses PreferencesService) - /// Uses extracted ServiceBootstrapper - private func sudoInstallAllServicesWithPreferences() async throws { - let success = await ServiceBootstrapper.shared.installAllServices() - - if !success { - throw PrivilegedOperationError.installationFailed("Service installation failed") - } - } - - /// Restart unhealthy services + /// Recover runtime services /// Uses extracted ServiceBootstrapper private func sudoRestartServices() async throws { - let success = await ServiceBootstrapper.shared.restartUnhealthyServices() + let success = await ServiceBootstrapper.shared.recoverRequiredRuntimeServices() if !success { throw PrivilegedOperationError.operationFailed("Service restart failed") @@ -1021,17 +1025,6 @@ final class PrivilegedOperationsCoordinator { } } - /// Install LaunchDaemon services without loading them (for orphan adoption) - /// Uses extracted ServiceBootstrapper - private func sudoInstallServicesWithoutLoading() async throws { - let binaryPath = KanataBinaryInstaller.shared.getKanataBinaryPath() - let success = await ServiceBootstrapper.shared.installAllServicesWithoutLoading(binaryPath: binaryPath) - - if !success { - throw PrivilegedOperationError.operationFailed("Service installation (install-only) failed") - } - } - /// Activate VirtualHID Manager using VHIDDeviceManager private func sudoActivateVHID() async throws { let vhidManager = VHIDDeviceManager() @@ -1114,7 +1107,7 @@ final class PrivilegedOperationsCoordinator { let vhidPlist = "/Library/LaunchDaemons/\(vhidLabel).plist" // Determine if a LaunchDaemon is installed; prefer managed restart to prevent duplicates - let hasService = FileManager.default.fileExists(atPath: vhidPlist) + let hasService = Foundation.FileManager().fileExists(atPath: vhidPlist) AppLogger.shared.log("🔐 [PrivCoordinator] VHID LaunchDaemon installed: \(hasService)") // Log current PIDs before any action (for diagnostics) diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService+Validation.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService+Validation.swift index ab83f5759..ae16d629e 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService+Validation.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService+Validation.swift @@ -12,7 +12,7 @@ extension ConfigurationService { } let binaryPath = WizardSystemPaths.kanataActiveBinary - guard FileManager.default.isExecutableFile(atPath: binaryPath) else { + guard Foundation.FileManager().isExecutableFile(atPath: binaryPath) else { let message = "Kanata binary missing at \(binaryPath)" AppLogger.shared.log("❌ [ConfigService] File validation skipped: \(message)") return (false, [message]) @@ -136,7 +136,7 @@ extension ConfigurationService { do { let tempConfigURL = URL(fileURLWithPath: tempConfigPath) let configDir = URL(fileURLWithPath: configDirectory) - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: configDir, withIntermediateDirectories: true) try await writeFileURLAsync(string: config, to: tempConfigURL) AppLogger.shared.log( "📝 [Validation-CLI] Temp config written successfully (\(config.count) characters)" @@ -146,15 +146,15 @@ extension ConfigurationService { let kanataBinary = WizardSystemPaths.kanataActiveBinary AppLogger.shared.log("🔧 [Validation-CLI] Using kanata binary: \(kanataBinary)") - guard FileManager.default.isExecutableFile(atPath: kanataBinary) else { + guard Foundation.FileManager().isExecutableFile(atPath: kanataBinary) else { let message = "Kanata binary missing at \(kanataBinary)" AppLogger.shared.log("❌ [Validation-CLI] \(message)") if TestEnvironment.isTestMode { AppLogger.shared.log("🧪 [Validation-CLI] Skipping CLI validation in tests") - try? FileManager.default.removeItem(at: tempConfigURL) + try? Foundation.FileManager().removeItem(at: tempConfigURL) return (true, []) } - try? FileManager.default.removeItem(at: tempConfigURL) + try? Foundation.FileManager().removeItem(at: tempConfigURL) return (false, [message]) } @@ -183,7 +183,7 @@ extension ConfigurationService { if result.exitCode == 0 { AppLogger.shared.log("✅ [Validation-CLI] CLI validation PASSED") - try? FileManager.default.removeItem(at: tempConfigURL) + try? Foundation.FileManager().removeItem(at: tempConfigURL) return (true, []) } else { let errors = parseKanataErrors(output) @@ -193,7 +193,7 @@ extension ConfigurationService { "🧪 [Validation-CLI] Keeping temp config for debugging at \(tempConfigPath)" ) } else { - try? FileManager.default.removeItem(at: tempConfigURL) + try? Foundation.FileManager().removeItem(at: tempConfigURL) } AppLogger.shared.log( "❌ [Validation-CLI] CLI validation FAILED with \(errors.count) errors:" @@ -206,7 +206,7 @@ extension ConfigurationService { } catch is CancellationError { // Task was cancelled (e.g., by debounce replacing this task) — not a real failure. // Clean up and return success so callers don't show a spurious error dialog. - try? FileManager.default.removeItem(atPath: tempConfigPath) + try? Foundation.FileManager().removeItem(atPath: tempConfigPath) AppLogger.shared.log("⚠️ [Validation-CLI] Validation cancelled (task superseded)") return (true, []) } catch { @@ -216,7 +216,7 @@ extension ConfigurationService { "🧪 [Validation-CLI] Keeping temp config for debugging at \(tempConfigPath)" ) } else { - try? FileManager.default.removeItem(atPath: tempConfigPath) + try? Foundation.FileManager().removeItem(atPath: tempConfigPath) } AppLogger.shared.log("❌ [Validation-CLI] Validation process failed: \(error)") AppLogger.shared.log("❌ [Validation-CLI] Error type: \(type(of: error))") diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift index 9bd684074..766797012 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/ConfigurationService.swift @@ -132,8 +132,8 @@ public final class ConfigurationService: FileConfigurationProviding { } // Get file modification date - let attributes = try? FileManager.default.attributesOfItem(atPath: configurationPath) - let lastModified = (attributes?[.modificationDate] as? Date) ?? Date() + let attributes = try? Foundation.FileManager().attributesOfItem(atPath: configurationPath) + let lastModified = (attributes?[FileAttributeKey.modificationDate] as? Date) ?? Date() // Extract key mappings from content (simplified - could be enhanced) let keyMappings = extractKeyMappingsFromContent(content) @@ -347,7 +347,7 @@ public final class ConfigurationService: FileConfigurationProviding { // Create backup directory if it doesn't exist let backupDir = "\(configDirectory)/backups" let backupDirURL = URL(fileURLWithPath: backupDir) - try FileManager.default.createDirectory(at: backupDirURL, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: backupDirURL, withIntermediateDirectories: true) // Create timestamped backup filename let formatter = DateFormatter() @@ -447,7 +447,7 @@ public final class ConfigurationService: FileConfigurationProviding { // Create backup directory if it doesn't exist let backupDir = "\(configDirectory)/backups" let backupDirURL = URL(fileURLWithPath: backupDir) - try FileManager.default.createDirectory(at: backupDirURL, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: backupDirURL, withIntermediateDirectories: true) // Create timestamped backup filename let formatter = DateFormatter() @@ -525,7 +525,7 @@ extension ConfigurationService { return current.chordGroups } - guard FileManager.default.fileExists(atPath: configurationPath), + guard Foundation.FileManager().fileExists(atPath: configurationPath), let content = try? String(contentsOfFile: configurationPath, encoding: .utf8) else { return [] @@ -538,7 +538,7 @@ extension ConfigurationService { let sequences: [KanataDefseqParser.ParsedSequence] if let current = withLockedCurrentConfig(), !current.sequences.isEmpty { sequences = current.sequences - } else if FileManager.default.fileExists(atPath: configurationPath), + } else if Foundation.FileManager().fileExists(atPath: configurationPath), let content = try? String(contentsOfFile: configurationPath, encoding: .utf8) { sequences = KanataDefseqParser.parseSequences(from: content) @@ -562,7 +562,7 @@ extension ConfigurationService { } func readFileAsync(path: String) async throws -> String { - try await withCheckedThrowingContinuation { cont in + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in ioQueue.async { do { let content = try String(contentsOfFile: path, encoding: .utf8) @@ -591,7 +591,7 @@ extension ConfigurationService { } } - try await withCheckedThrowingContinuation { cont in + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in ioQueue.async { do { try string.write(toFile: path, atomically: true, encoding: .utf8) @@ -604,7 +604,7 @@ extension ConfigurationService { } func writeFileURLAsync(string: String, to url: URL) async throws { - try await withCheckedThrowingContinuation { cont in + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in ioQueue.async { do { try string.write(to: url, atomically: true, encoding: .utf8) @@ -617,13 +617,13 @@ extension ConfigurationService { } func createDirectoryAsync(path: String) async throws { - try await withCheckedThrowingContinuation { cont in + try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in ioQueue.async { do { - try FileManager.default.createDirectory( + try Foundation.FileManager().createDirectory( atPath: path, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o755] + attributes: [FileAttributeKey.posixPermissions: 0o755] ) cont.resume() } catch { @@ -636,7 +636,7 @@ extension ConfigurationService { func fileExistsAsync(path: String) async -> Bool { await withCheckedContinuation { cont in ioQueue.async { - cont.resume(returning: FileManager.default.fileExists(atPath: path)) + cont.resume(returning: Foundation.FileManager().fileExists(atPath: path)) } } } diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+AppSpecificKeys.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+AppSpecificKeys.swift index a86e800b6..f71b8d090 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+AppSpecificKeys.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfiguration+AppSpecificKeys.swift @@ -14,12 +14,12 @@ extension KanataConfiguration { AppLogger.shared.log("🔍 [ConfigGen] loadAppSpecificKeys: checking path \(path)") - guard FileManager.default.fileExists(atPath: path) else { + guard Foundation.FileManager().fileExists(atPath: path) else { AppLogger.shared.log("⚠️ [ConfigGen] loadAppSpecificKeys: file does not exist") return [] } - guard let data = FileManager.default.contents(atPath: path) else { + guard let data = Foundation.FileManager().contents(atPath: path) else { AppLogger.shared.log("⚠️ [ConfigGen] loadAppSpecificKeys: could not read file contents") return [] } diff --git a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift index 350deba3b..acb0b6f25 100644 --- a/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift +++ b/Sources/KeyPathAppKit/Infrastructure/Config/KanataConfigurationGenerator.swift @@ -251,7 +251,7 @@ public struct KanataConfiguration: Sendable { "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata" ] - let fm = FileManager.default + let fm = Foundation.FileManager() guard let kanataPath = candidates.first(where: { fm.isExecutableFile(atPath: $0) }) else { return nil } diff --git a/Sources/KeyPathAppKit/Infrastructure/Privileged/HelperBackedPrivilegedOperations.swift b/Sources/KeyPathAppKit/Infrastructure/Privileged/HelperBackedPrivilegedOperations.swift deleted file mode 100644 index 3cdbcd37f..000000000 --- a/Sources/KeyPathAppKit/Infrastructure/Privileged/HelperBackedPrivilegedOperations.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation -import KeyPathCore - -/// Helper-backed implementation of PrivilegedOperations. -/// Uses the SMAppService/XPC helper via PrivilegedOperationsCoordinator, with -/// clear, explicit logging on fallback to the legacy AppleScript path. -public struct HelperBackedPrivilegedOperations: PrivilegedOperations { - public init() {} - - public func startKanataService() async -> Bool { - do { - AppLogger.shared.log("🔐 [PrivOps] Helper-first startKanataService") - try await PrivilegedOperationsCoordinator.shared.restartUnhealthyServices() - return true - } catch { - if error.isSMAppServiceApprovalRequired { - AppLogger.shared.log( - "⚠️ [PrivOps] Helper start requires Background Items approval. Prompting user instead of falling back to legacy path." - ) - NotificationCenter.default.post(name: .smAppServiceApprovalRequired, object: nil) - return false - } - AppLogger.shared.log( - "❌ [PrivOps] Helper restartUnhealthyServices failed: \(error.localizedDescription)" - ) - return false - } - } - - public func restartKanataService() async -> Bool { - do { - AppLogger.shared.log("🔐 [PrivOps] Helper-first restartKanataService") - try await PrivilegedOperationsCoordinator.shared.restartUnhealthyServices() - return true - } catch { - if error.isSMAppServiceApprovalRequired { - AppLogger.shared.log( - "⚠️ [PrivOps] Helper restart requires Background Items approval. Prompting user instead of falling back to legacy path." - ) - NotificationCenter.default.post(name: .smAppServiceApprovalRequired, object: nil) - return false - } - AppLogger.shared.log( - "❌ [PrivOps] Helper restartUnhealthyServices failed: \(error.localizedDescription)" - ) - return false - } - } - - public func stopKanataService() async -> Bool { - do { - AppLogger.shared.log("🔐 [PrivOps] Helper-first stopKanataService (killAllKanataProcesses)") - try await PrivilegedOperationsCoordinator.shared.killAllKanataProcesses() - return true - } catch { - AppLogger.shared.log( - "❌ [PrivOps] helper killAllKanataProcesses failed: \(error.localizedDescription)" - ) - return false - } - } -} - -private extension Error { - var isSMAppServiceApprovalRequired: Bool { - if let privilegedError = self as? PrivilegedOperationError { - switch privilegedError { - case let .installationFailed(message), let .operationFailed(message): - return message.lowercased().contains("approval required in system settings") - case .commandFailed, .executionError: - return false - } - } - - let description = localizedDescription.lowercased() - return description.contains("approval required in system settings") - } -} diff --git a/Sources/KeyPathAppKit/Infrastructure/Privileged/PrivilegedOperationsProvider.swift b/Sources/KeyPathAppKit/Infrastructure/Privileged/PrivilegedOperationsProvider.swift deleted file mode 100644 index 6d3aaa0f2..000000000 --- a/Sources/KeyPathAppKit/Infrastructure/Privileged/PrivilegedOperationsProvider.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation -import KeyPathCore - -/// Central access point for acquiring the app-wide PrivilegedOperations implementation. -/// -/// Initially backed by the legacy implementation; later will be swapped to the -/// SMAppService/XPC-backed implementation without touching call sites. -public enum PrivilegedOperationsProvider: Sendable { - public static let shared: PrivilegedOperations = { - if TestEnvironment.isTestMode { - return MockPrivilegedOperations() - } - return HelperBackedPrivilegedOperations() - }() -} diff --git a/Sources/KeyPathAppKit/Infrastructure/Testing/MockPrivilegedOperations.swift b/Sources/KeyPathAppKit/Infrastructure/Testing/MockPrivilegedOperations.swift deleted file mode 100644 index b8b7eb206..000000000 --- a/Sources/KeyPathAppKit/Infrastructure/Testing/MockPrivilegedOperations.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Foundation - -/// Test double for PrivilegedOperations used during tests to avoid admin prompts -public struct MockPrivilegedOperations: PrivilegedOperations { - public init() {} - - public func startKanataService() async -> Bool { - true - } - - public func restartKanataService() async -> Bool { - true - } - - public func stopKanataService() async -> Bool { - true - } -} diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/ActionDeterminer.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/ActionDeterminer.swift index 277c47a36..34d2a60a6 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/ActionDeterminer.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/ActionDeterminer.swift @@ -61,9 +61,8 @@ enum ActionDeterminer { } } - // If the SMAppService/launchd jobs are missing or Kanata isn't running, reinstall services - if !context.components.launchDaemonServicesHealthy || !context.services.kanataRunning { - actions.append(.installLaunchDaemonServices) + if !context.components.vhidServicesHealthy { + actions.append(.installRequiredRuntimeServices) } // Check if daemon needs starting @@ -82,11 +81,6 @@ enum ActionDeterminer { ) } - // Restart unhealthy services - if !context.services.backgroundServicesHealthy { - actions.append(.restartUnhealthyServices) - } - return actions } @@ -137,9 +131,9 @@ enum ActionDeterminer { actions.append(.installPrivilegedHelper) } - // Always install services for fresh install - // NOTE: Manager activation must happen first (added above if needed) - actions.append(.installLaunchDaemonServices) + if !context.components.vhidServicesHealthy { + actions.append(.installRequiredRuntimeServices) + } return actions } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/AutoFixActionDescriptions.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/AutoFixActionDescriptions.swift index 94e0194ce..5d631cd0c 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/AutoFixActionDescriptions.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/AutoFixActionDescriptions.swift @@ -24,20 +24,14 @@ enum AutoFixActionDescriptions { "Create configuration directories" case .activateVHIDDeviceManager: "Activate VirtualHID Device Manager" - case .installLaunchDaemonServices: - "Install LaunchDaemon services" - case .adoptOrphanedProcess: - "Connect existing Kanata to KeyPath management" - case .replaceOrphanedProcess: - "Replace orphaned process with managed service" + case .installRequiredRuntimeServices: + "Install required runtime services" case .installBundledKanata: "Install Kanata binary" case .repairVHIDDaemonServices: "Repair VHID LaunchDaemon services" case .synchronizeConfigPaths: "Fix config path mismatch between KeyPath and Kanata" - case .restartUnhealthyServices: - "Restart failing system services" case .installLogRotation: "Install newsyslog config to keep logs under 10MB" case .replaceKanataWithBundled: @@ -62,8 +56,8 @@ enum AutoFixActionDescriptions { /// Get detailed error message for specific auto-fix failures static func errorMessage(for action: AutoFixAction) -> String { switch action { - case .installLaunchDaemonServices: - "Failed to install system services. Check that you provided admin password and try again." + case .installRequiredRuntimeServices: + "Failed to install required runtime services. Check that you provided admin password and try again." case .activateVHIDDeviceManager: "Failed to activate driver extensions. Please manually approve in System Settings > General > Login Items & Extensions." case .installBundledKanata: @@ -74,10 +68,6 @@ enum AutoFixActionDescriptions { "Failed to create configuration directories. Check file system permissions." case .restartVirtualHIDDaemon: "Failed to restart Virtual HID daemon." - case .restartUnhealthyServices: - "Failed to restart system services. This usually means:\n\n• Admin password was not provided when prompted\n" - + "• Missing services could not be installed\n• System permission denied for service restart\n\n" - + "Try the Fix button again and provide admin password when prompted." default: "Failed to \(describe(action).lowercased()). Check logs for details and try again." } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine+Recipes.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine+Recipes.swift index 48c14ffe9..b3ca90e4e 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine+Recipes.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine+Recipes.swift @@ -37,17 +37,11 @@ extension InstallerEngine { /// Convert an AutoFixAction to a ServiceRecipe func recipeForAction(_ action: AutoFixAction, context _: SystemContext) -> ServiceRecipe? { switch action { - case .installLaunchDaemonServices: + case .installRequiredRuntimeServices: ServiceRecipe( - id: InstallerRecipeID.installLaunchDaemonServices, - type: .installService, - serviceID: nil, - launchctlActions: [ - .bootstrap(serviceID: KeyPathConstants.Bundle.daemonID), - .bootstrap(serviceID: KeyPathConstants.Bundle.vhidDaemonID), - .bootstrap(serviceID: KeyPathConstants.Bundle.vhidManagerID) - ], - healthCheck: HealthCheckCriteria(serviceID: KeyPathConstants.Bundle.daemonID, shouldBeRunning: true) + id: InstallerRecipeID.installRequiredRuntimeServices, + type: .installComponent, + serviceID: KeyPathConstants.Bundle.outputBridgeID ) case .installBundledKanata: @@ -101,18 +95,10 @@ extension InstallerEngine { ) ) - case .restartUnhealthyServices: - ServiceRecipe( - id: InstallerRecipeID.restartUnhealthyServices, - type: .restartService, - serviceID: nil - ) - case .restartVirtualHIDDaemon: - // Same recipe as restartUnhealthyServices (verified restart path) ServiceRecipe( - id: InstallerRecipeID.restartUnhealthyServices, - type: .restartService, + id: InstallerRecipeID.repairVHIDDaemonServices, + type: .installComponent, serviceID: nil ) @@ -193,28 +179,6 @@ extension InstallerEngine { serviceID: nil ) - case .adoptOrphanedProcess: - ServiceRecipe( - id: InstallerRecipeID.adoptOrphanedProcess, - type: .installComponent, - serviceID: KeyPathConstants.Bundle.daemonID, - healthCheck: HealthCheckCriteria( - serviceID: KeyPathConstants.Bundle.daemonID, - shouldBeRunning: true - ) - ) - - case .replaceOrphanedProcess: - ServiceRecipe( - id: InstallerRecipeID.replaceOrphanedProcess, - type: .installComponent, - serviceID: KeyPathConstants.Bundle.daemonID, - healthCheck: HealthCheckCriteria( - serviceID: KeyPathConstants.Bundle.daemonID, - shouldBeRunning: true - ) - ) - case .replaceKanataWithBundled: ServiceRecipe( id: InstallerRecipeID.replaceKanataWithBundled, @@ -271,8 +235,8 @@ extension InstallerEngine { /// Map AutoFixAction to recipe ID func recipeIDForAction(_ action: AutoFixAction) -> String { switch action { - case .installLaunchDaemonServices: - InstallerRecipeID.installLaunchDaemonServices + case .installRequiredRuntimeServices: + InstallerRecipeID.installRequiredRuntimeServices case .installBundledKanata: InstallerRecipeID.installBundledKanata case .installCorrectVHIDDriver: @@ -285,8 +249,6 @@ extension InstallerEngine { InstallerRecipeID.reinstallPrivilegedHelper case .startKarabinerDaemon: InstallerRecipeID.startKarabinerDaemon - case .restartUnhealthyServices: - InstallerRecipeID.restartUnhealthyServices case .terminateConflictingProcesses: InstallerRecipeID.terminateConflictingProcesses case .fixDriverVersionMismatch: @@ -294,8 +256,7 @@ extension InstallerEngine { case .installMissingComponents: InstallerRecipeID.installMissingComponents case .restartVirtualHIDDaemon: - // restartVirtualHIDDaemon maps to restartUnhealthyServices recipe - InstallerRecipeID.restartUnhealthyServices + InstallerRecipeID.repairVHIDDaemonServices case .createConfigDirectories: InstallerRecipeID.createConfigDirectories case .activateVHIDDeviceManager: @@ -312,10 +273,6 @@ extension InstallerEngine { InstallerRecipeID.regenerateServiceConfig case .restartCommServer: InstallerRecipeID.restartCommServer - case .adoptOrphanedProcess: - InstallerRecipeID.adoptOrphanedProcess - case .replaceOrphanedProcess: - InstallerRecipeID.replaceOrphanedProcess case .replaceKanataWithBundled: InstallerRecipeID.replaceKanataWithBundled case .synchronizeConfigPaths: diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine.swift index 222c7a882..990816268 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine.swift @@ -64,17 +64,53 @@ public final class InstallerEngine { // Get system compatibility info from SystemRequirements let systemInfo = systemRequirements.getSystemInfo() + let runtimePathDecision: KanataRuntimePathDecision? = + if TestEnvironment.isRunningTests { + nil + } else { + await KanataRuntimePathCoordinator.evaluateCurrentPath() + } + let outputBridgeStatus: KanataOutputBridgeStatus? = + if TestEnvironment.isRunningTests { + nil + } else { + try? await HelperManager.shared.getKanataOutputBridgeStatus() + } // Convert SystemInfo to EngineSystemInfo let engineSystemInfo = EngineSystemInfo( macOSVersion: systemInfo.macosVersion.versionString, - driverCompatible: systemInfo.compatibilityResult.isCompatible + driverCompatible: systemInfo.compatibilityResult.isCompatible, + runtimePathDecision: runtimePathDecision, + outputBridgeStatus: outputBridgeStatus + ) + + let activeRuntimePathStatus: (title: String, detail: String)? = { + if KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning { + let pid = KanataSplitRuntimeHostService.shared.activePersistentHostPID ?? 0 + return ( + title: SplitRuntimeIdentity.hostTitle, + detail: "\(SplitRuntimeIdentity.hostDetailPrefix) (PID \(pid)) with privileged output companion" + ) + } + + return nil + }() + + let services = HealthStatus( + kanataRunning: snapshot.health.kanataRunning, + karabinerDaemonRunning: snapshot.health.karabinerDaemonRunning, + vhidHealthy: snapshot.health.vhidHealthy, + kanataInputCaptureReady: snapshot.health.kanataInputCaptureReady, + kanataInputCaptureIssue: snapshot.health.kanataInputCaptureIssue, + activeRuntimePathTitle: activeRuntimePathStatus?.title, + activeRuntimePathDetail: activeRuntimePathStatus?.detail ) // Convert SystemSnapshot to SystemContext let context = SystemContext( permissions: snapshot.permissions, - services: snapshot.health, + services: services, conflicts: snapshot.conflicts, components: snapshot.components, helper: snapshot.helper, @@ -153,7 +189,7 @@ public final class InstallerEngine { // Check that system directories exist (not writable - installation uses admin privileges) let launchDaemonsDir = KeyPathConstants.System.launchDaemonsDir - if !FileManager.default.fileExists(atPath: launchDaemonsDir) { + if !Foundation.FileManager().fileExists(atPath: launchDaemonsDir) { return Requirement( name: "LaunchDaemons directory missing", status: .blocked @@ -401,7 +437,7 @@ public final class InstallerEngine { // Ensure canonical Kanata binary exists at /Library/KeyPath/bin/kanata before installing services. // This prevents "service installed" while the daemon runs with a different path (bundle fallback), // which would cause permission identity drift (AX/IM entries keyed by executable path). - if recipe.id == InstallerRecipeID.installLaunchDaemonServices, + if recipe.id == InstallerRecipeID.installRequiredRuntimeServices, KanataBinaryDetector.shared.needsInstallation() { AppLogger.shared.log( @@ -410,8 +446,7 @@ public final class InstallerEngine { try await broker.installBundledKanata() } - // Install all LaunchDaemon services - try await broker.installAllLaunchDaemonServices() + try await broker.installRequiredRuntimeServices() } /// Execute restartService recipe @@ -436,8 +471,7 @@ public final class InstallerEngine { throw InstallerError.healthCheckFailed("Karabiner daemon restart verification failed") } } else { - // Restart all unhealthy services - try await broker.restartUnhealthyServices() + throw InstallerError.healthCheckFailed("Unsupported restart recipe: \(recipe.id)") } } @@ -471,6 +505,9 @@ public final class InstallerEngine { case InstallerRecipeID.activateVHIDManager: try await broker.activateVirtualHIDManager() + case InstallerRecipeID.installRequiredRuntimeServices: + try await broker.installRequiredRuntimeServices() + case InstallerRecipeID.repairVHIDDaemonServices: try await broker.repairVHIDDaemonServices() @@ -481,14 +518,7 @@ public final class InstallerEngine { try await broker.regenerateServiceConfiguration() case InstallerRecipeID.restartCommServer: - try await broker.restartUnhealthyServices() - - case InstallerRecipeID.adoptOrphanedProcess: - try await broker.installAllLaunchDaemonServices() - - case InstallerRecipeID.replaceOrphanedProcess: - try await broker.killAllKanataProcesses() - try await broker.installAllLaunchDaemonServices() + try await broker.regenerateServiceConfiguration() case InstallerRecipeID.replaceKanataWithBundled: try await broker.installBundledKanata() @@ -533,10 +563,10 @@ public final class InstallerEngine { return true } - let health = await checkKanataServiceHealth() - let ready = health.isRunning && health.isResponding + let runtimeSnapshot = await ServiceHealthChecker.shared.checkKanataServiceRuntimeSnapshot() + let ready = ServiceHealthChecker.decideKanataHealth(for: runtimeSnapshot).isHealthy AppLogger.shared.log( - "🔍 [InstallerEngine] Kanata strict health check: state=\(managementState.description), running=\(health.isRunning), responding=\(health.isResponding), ready=\(ready)" + "🔍 [InstallerEngine] Kanata strict health check: state=\(managementState.description), running=\(runtimeSnapshot.isRunning), responding=\(runtimeSnapshot.isResponding), inputCaptureReady=\(runtimeSnapshot.inputCaptureReady), ready=\(ready)" ) return ready } @@ -564,8 +594,14 @@ public final class InstallerEngine { /// Check Kanata service health (running + TCP responsive) public func checkKanataServiceHealth(tcpPort: Int = 37001) async -> KanataHealthSnapshot { - let health = await ServiceHealthChecker.shared.checkKanataServiceHealth(tcpPort: tcpPort) - return KanataHealthSnapshot(isRunning: health.isRunning, isResponding: health.isResponding) + let runtimeSnapshot = await ServiceHealthChecker.shared.checkKanataServiceRuntimeSnapshot( + tcpPort: tcpPort + ) + return KanataHealthSnapshot( + isRunning: runtimeSnapshot.isRunning, + isResponding: runtimeSnapshot.isResponding, + inputCaptureReady: runtimeSnapshot.inputCaptureReady + ) } /// Convenience wrapper that chains inspectSystem() → makePlan() → execute() internally. @@ -621,16 +657,15 @@ public final class InstallerEngine { /// Execute a single AutoFixAction by generating a plan that includes that specific action /// This is useful for GUI single-action fixes where the user clicks a specific "Fix" button - /// Note: Some actions (like installLaunchDaemonServices) are only in install plans, not repair plans + /// Note: Some actions (like installRequiredRuntimeServices) are only in install plans, not repair plans. public func runSingleAction(_ action: AutoFixAction, using broker: PrivilegeBroker) async -> InstallerReport { AppLogger.shared.log("🔧 [InstallerEngine] runSingleAction(\(action), using:) starting") let context = await inspectSystem() - // Determine which intent would include this action - // installLaunchDaemonServices is install-specific, others are typically repair - let intent: InstallIntent = action == .installLaunchDaemonServices ? .install : .repair + // Determine which intent would include this action. + let intent: InstallIntent = action == .installRequiredRuntimeServices ? .install : .repair let basePlan = await makePlan(for: intent, context: context) diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngineTypes.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngineTypes.swift index 64ffe12b9..419827136 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngineTypes.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngineTypes.swift @@ -87,10 +87,21 @@ public struct EngineSystemInfo: Sendable, Equatable { public let macOSVersion: String /// Driver compatibility status public let driverCompatible: Bool + /// Current planned Kanata runtime path, if evaluated + public let runtimePathDecision: KanataRuntimePathDecision? + /// Privileged output companion status, if evaluated + public let outputBridgeStatus: KanataOutputBridgeStatus? - public init(macOSVersion: String, driverCompatible: Bool) { + public init( + macOSVersion: String, + driverCompatible: Bool, + runtimePathDecision: KanataRuntimePathDecision? = nil, + outputBridgeStatus: KanataOutputBridgeStatus? = nil + ) { self.macOSVersion = macOSVersion self.driverCompatible = driverCompatible + self.runtimePathDecision = runtimePathDecision + self.outputBridgeStatus = outputBridgeStatus } } @@ -296,10 +307,17 @@ public struct KanataHealthSnapshot: Sendable { public let isRunning: Bool /// Whether the service is responding to TCP health checks public let isResponding: Bool + /// Whether Kanata can actually capture input from the active keyboard devices. + public let inputCaptureReady: Bool - public init(isRunning: Bool, isResponding: Bool) { + public init(isRunning: Bool, isResponding: Bool, inputCaptureReady: Bool = true) { self.isRunning = isRunning self.isResponding = isResponding + self.inputCaptureReady = inputCaptureReady + } + + public var isReady: Bool { + isRunning && isResponding && inputCaptureReady } } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerRecipeID.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerRecipeID.swift index 1b95d200a..9feb3d1c2 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerRecipeID.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/InstallerRecipeID.swift @@ -4,14 +4,13 @@ import Foundation /// /// Keep this minimal: only IDs that are referenced from multiple files should live here. enum InstallerRecipeID { - static let installLaunchDaemonServices = "install-launch-daemon-services" + static let installRequiredRuntimeServices = "install-required-runtime-services" static let installBundledKanata = "install-bundled-kanata" static let installCorrectVHIDDriver = "install-correct-vhid-driver" static let installLogRotation = "install-log-rotation" static let installPrivilegedHelper = "install-privileged-helper" static let reinstallPrivilegedHelper = "reinstall-privileged-helper" static let startKarabinerDaemon = "start-karabiner-daemon" - static let restartUnhealthyServices = "restart-unhealthy-services" static let terminateConflictingProcesses = "terminate-conflicting-processes" static let fixDriverVersionMismatch = "fix-driver-version-mismatch" static let installMissingComponents = "install-missing-components" @@ -23,8 +22,6 @@ enum InstallerRecipeID { static let regenerateCommServiceConfig = "regenerate-comm-service-config" static let regenerateServiceConfig = "regenerate-service-config" static let restartCommServer = "restart-comm-server" - static let adoptOrphanedProcess = "adopt-orphaned-process" - static let replaceOrphanedProcess = "replace-orphaned-process" static let replaceKanataWithBundled = "replace-kanata-with-bundled" static let synchronizeConfigPaths = "synchronize-config-paths" } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/IssueGenerator.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/IssueGenerator.swift index 3dcdced8a..bdfbad84f 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/IssueGenerator.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/IssueGenerator.swift @@ -253,7 +253,7 @@ class IssueGenerator { for mismatch in result.mismatches { let issue = WizardIssue( - identifier: .component(.kanataService), // Use existing identifier + identifier: .component(.keyPathRuntime), severity: .error, category: .installation, title: "Config Path Mismatch", @@ -290,9 +290,9 @@ class IssueGenerator { private func permissionDescription(for permission: PermissionRequirement) -> String { switch permission { case .kanataInputMonitoring: - "Kanata needs Input Monitoring permission to process keys. Add /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Input Monitoring." + "The KeyPath runtime needs Input Monitoring permission to process keys. Add the runtime binary at /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Input Monitoring." case .kanataAccessibility: - "Kanata needs Accessibility permission for system access. Add /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Accessibility." + "The KeyPath runtime needs Accessibility permission for system access. Add the runtime binary at /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Accessibility." case .driverExtensionEnabled: "Karabiner driver extension must be enabled in System Settings." case .backgroundServicesEnabled: @@ -307,9 +307,9 @@ class IssueGenerator { private func userActionForPermission(_ permission: PermissionRequirement) -> String { switch permission { case .kanataInputMonitoring: - "Grant permission to /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Input Monitoring" + "Grant permission to the Kanata engine binary used by KeyPath at /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Input Monitoring" case .kanataAccessibility: - "Grant permission to /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Accessibility" + "Grant permission to the Kanata engine binary used by KeyPath at /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Accessibility" case .driverExtensionEnabled: "Enable in System Settings > Privacy & Security > Driver Extensions" case .backgroundServicesEnabled: @@ -327,7 +327,7 @@ class IssueGenerator { case .privilegedHelperUnhealthy: "Privileged Helper Not Working" case .kanataBinaryMissing: WizardConstants.Titles.kanataBinaryMissing case .bundledKanataMissing: "⚠️ CRITICAL: App Bundle Corrupted" - case .kanataService: "Kanata Service Missing" + case .keyPathRuntime: "KeyPath Runtime Missing" case .karabinerDriver: WizardConstants.Titles.karabinerDriverMissing case .karabinerDaemon: WizardConstants.Titles.daemonNotRunning case .vhidDeviceManager: "VirtualHIDDevice Manager Missing" @@ -336,10 +336,8 @@ class IssueGenerator { case .vhidDaemonMisconfigured: "VirtualHIDDevice Daemon Misconfigured" case .vhidDriverVersionMismatch: "Karabiner Driver Version Incompatible" case .kanataBinaryVersionMismatch: "Kanata Binary Trust Mismatch" - case .launchDaemonServices: "LaunchDaemon Services Not Installed" - case .launchDaemonServicesUnhealthy: "LaunchDaemon Services Failing" case .kanataTCPServer: "TCP Server Not Responding" - case .orphanedKanataProcess: "Orphaned Kanata Process" + case .orphanedKanataProcess: "External Kanata Conflict" case .communicationServerConfiguration: "Communication Server Configuration Outdated" case .communicationServerNotResponding: "Communication Server Not Responding" case .tcpServerConfiguration: "TCP Server Configuration Outdated" @@ -358,8 +356,8 @@ class IssueGenerator { "The kanata binary needs to be installed to system location from KeyPath's bundled Developer ID signed version. This ensures proper code signing for Input Monitoring permission." case .bundledKanataMissing: "CRITICAL: The kanata binary is missing from the KeyPath app bundle. This indicates the app was not packaged correctly. Please download and reinstall KeyPath from the official release page." - case .kanataService: - "Kanata service configuration is missing." + case .keyPathRuntime: + "KeyPath runtime is not running." case .karabinerDriver: "Karabiner-Elements driver is required for virtual HID functionality." case .karabinerDaemon: @@ -378,21 +376,13 @@ class IssueGenerator { "The installed Karabiner-DriverKit-VirtualHIDDevice version is incompatible with the current version of Kanata. Kanata v1.10.0 requires driver v\(VHIDDeviceManager.requiredDriverVersionString), but a different version is installed. KeyPath includes the correct driver version and can install it for you." case .kanataBinaryVersionMismatch: "The installed kanata binary at /Library/KeyPath/bin/kanata does not match KeyPath's trusted signing identity. This can happen if the binary was replaced by another distribution or signer. Click Fix to reinstall the trusted bundled binary." - case .launchDaemonServices: - "LaunchDaemon services are not installed or loaded. These provide reliable system-level service management for KeyPath components." - case .launchDaemonServicesUnhealthy: - "LaunchDaemon services are loaded but crashing or failing. This usually indicates a configuration problem or permission issue that can be fixed by restarting the services." case .kanataTCPServer: "Kanata TCP server is not responding on the configured port. This is used for config validation and external integration. Service may need restart with TCP enabled." case .orphanedKanataProcess: """ - Kanata is running outside of LaunchDaemon management. This prevents reliable lifecycle control and hot-reload functionality. + Another Kanata runtime is already running outside KeyPath's split runtime architecture. - KeyPath can fix this by either: - • **Adopt** (Recommended): Install management without interrupting your current session - • **Replace**: Stop current process and start a managed one (cleaner but interrupts current mappings) - - The wizard will automatically choose the best option based on your configuration. + KeyPath Runtime cannot safely take control while that external process is active. Stop the external Kanata process, then start KeyPath Runtime again. """ case .communicationServerConfiguration: """ @@ -425,7 +415,7 @@ class IssueGenerator { private func getAutoFixAction(for component: ComponentRequirement) -> AutoFixAction? { switch component { - case .karabinerDriver, .vhidDeviceManager, .bundledKanataMissing: + case .karabinerDriver, .bundledKanataMissing: nil // These require manual intervention (bundledKanataMissing = reinstall app) case .vhidDeviceActivation: .activateVHIDDeviceManager @@ -433,22 +423,20 @@ class IssueGenerator { .restartVirtualHIDDaemon case .vhidDaemonMisconfigured: .repairVHIDDaemonServices + case .vhidDeviceManager: + .installRequiredRuntimeServices case .vhidDriverVersionMismatch: .fixDriverVersionMismatch case .kanataBinaryVersionMismatch: .replaceKanataWithBundled - case .launchDaemonServices: - .installLaunchDaemonServices - case .launchDaemonServicesUnhealthy: - .restartUnhealthyServices case .kanataBinaryMissing: .installBundledKanata // Install bundled kanata binary to system location - case .kanataService: - .installLaunchDaemonServices // Service configuration files + case .keyPathRuntime: + nil case .kanataTCPServer: - .restartUnhealthyServices // TCP server requires service restart with updated config + nil case .orphanedKanataProcess: - .adoptOrphanedProcess // Default to adopting the orphaned process + .terminateConflictingProcesses case .communicationServerConfiguration: .regenerateCommServiceConfiguration // Update LaunchDaemon plist with communication settings case .communicationServerNotResponding: @@ -468,6 +456,8 @@ class IssueGenerator { switch component { case .karabinerDriver: "Install Karabiner-Elements from website" + case .keyPathRuntime: + "Start KeyPath Runtime from the wizard or app status controls" case .vhidDeviceManager: "Install Karabiner-VirtualHIDDevice from website" case .kanataBinaryMissing: diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryDetector.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryDetector.swift index 90cd7f645..71aeb6959 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryDetector.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryDetector.swift @@ -84,7 +84,7 @@ final class KanataBinaryDetector: Sendable { // Priority 3: Check if bundled binary is specifically missing (packaging issue) let bundledPath = WizardSystemPaths.bundledKanataPath - if !FileManager.default.fileExists(atPath: bundledPath) { + if !Foundation.FileManager().fileExists(atPath: bundledPath) { AppLogger.shared.log("❌ [KanataBinaryDetector] CRITICAL: Bundled kanata binary missing from app bundle at: \(bundledPath)") AppLogger.shared.log("❌ [KanataBinaryDetector] This indicates a packaging issue - the app was not built correctly") return DetectionResult( @@ -147,7 +147,7 @@ final class KanataBinaryDetector: Sendable { func hasVersionMismatch() -> Bool { let systemPath = WizardSystemPaths.kanataSystemInstallPath let bundledPath = WizardSystemPaths.bundledKanataPath - let fm = FileManager.default + let fm = Foundation.FileManager() let sysExists = fm.fileExists(atPath: systemPath) let bunExists = fm.fileExists(atPath: bundledPath) @@ -177,7 +177,7 @@ final class KanataBinaryDetector: Sendable { private func checkSystemInstallation() -> DetectionResult? { let systemPath = WizardSystemPaths.kanataSystemInstallPath - guard FileManager.default.fileExists(atPath: systemPath) else { + guard Foundation.FileManager().fileExists(atPath: systemPath) else { return nil } @@ -198,7 +198,7 @@ final class KanataBinaryDetector: Sendable { private func checkBundledBinary() -> DetectionResult? { let bundledPath = WizardSystemPaths.bundledKanataPath - guard FileManager.default.fileExists(atPath: bundledPath) else { + guard Foundation.FileManager().fileExists(atPath: bundledPath) else { return nil } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryInstaller.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryInstaller.swift index a691c0fa7..ffdb7eca9 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryInstaller.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/KanataBinaryInstaller.swift @@ -27,7 +27,7 @@ final class KanataBinaryInstaller { // Ensure bundled binary exists // NOTE: This case is now surfaced as a .critical wizard issue via KanataBinaryDetector // detecting .bundledMissing status and IssueGenerator creating a .bundledKanataMissing component issue - guard FileManager.default.fileExists(atPath: bundledPath) else { + guard Foundation.FileManager().fileExists(atPath: bundledPath) else { AppLogger.shared.log( "❌ [KanataBinaryInstaller] CRITICAL: Bundled kanata binary not found at: \(bundledPath)" ) @@ -38,7 +38,7 @@ final class KanataBinaryInstaller { } // Verify the bundled binary is executable - guard FileManager.default.isExecutableFile(atPath: bundledPath) else { + guard Foundation.FileManager().isExecutableFile(atPath: bundledPath) else { AppLogger.shared.log( "❌ [KanataBinaryInstaller] Bundled kanata binary exists but is not executable: \(bundledPath)" ) @@ -62,7 +62,7 @@ final class KanataBinaryInstaller { if TestEnvironment.shouldSkipAdminOperations { AppLogger.shared.log("⚠️ [KanataBinaryInstaller] TEST MODE: Skipping actual binary installation") // In test mode, just verify the source exists and return success - success = FileManager.default.fileExists(atPath: bundledPath) + success = Foundation.FileManager().fileExists(atPath: bundledPath) } else { // Mark warm-up before replacement so health checks can treat launchctl "not found" // transitions as transient while the daemon is being swapped. @@ -184,13 +184,13 @@ final class KanataBinaryInstaller { let bundledPath = WizardSystemPaths.bundledKanataPath // If system version doesn't exist, we need to install it - guard FileManager.default.fileExists(atPath: systemPath) else { + guard Foundation.FileManager().fileExists(atPath: systemPath) else { AppLogger.shared.log("🔄 [KanataBinaryInstaller] System kanata not found - initial installation needed") return true } // If bundled version doesn't exist, no upgrade possible - guard FileManager.default.fileExists(atPath: bundledPath) else { + guard Foundation.FileManager().fileExists(atPath: bundledPath) else { AppLogger.shared.log("⚠️ [KanataBinaryInstaller] Bundled kanata not found - cannot upgrade") return false } @@ -244,14 +244,14 @@ final class KanataBinaryInstaller { let systemPath = WizardSystemPaths.kanataSystemInstallPath // Verify the system path exists, otherwise fall back to bundled - if FileManager.default.fileExists(atPath: systemPath) { + if Foundation.FileManager().fileExists(atPath: systemPath) { AppLogger.shared.log( "✅ [KanataBinaryInstaller] Using system Kanata path (has TCC permissions): \(systemPath)" ) return systemPath } else { let bundledPath = WizardSystemPaths.bundledKanataPath - if FileManager.default.fileExists(atPath: bundledPath) { + if Foundation.FileManager().fileExists(atPath: bundledPath) { AppLogger.shared.log( "⚠️ [KanataBinaryInstaller] System kanata not found, using bundled path: \(bundledPath)" ) @@ -268,7 +268,7 @@ final class KanataBinaryInstaller { /// Check if bundled Kanata binary exists in app bundle func isBundledKanataAvailable() -> Bool { let bundledPath = WizardSystemPaths.bundledKanataPath - let exists = FileManager.default.fileExists(atPath: bundledPath) + let exists = Foundation.FileManager().fileExists(atPath: bundledPath) if exists { AppLogger.shared.log("✅ [KanataBinaryInstaller] Bundled kanata available at: \(bundledPath)") } else { diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/KarabinerComponentsStatusEvaluator.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/KarabinerComponentsStatusEvaluator.swift index 344621da9..a4a8ceac1 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/KarabinerComponentsStatusEvaluator.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/KarabinerComponentsStatusEvaluator.swift @@ -7,7 +7,6 @@ import KeyPathWizardCore /// Individual Karabiner components that can be checked independently enum KarabinerComponent { case driver // Karabiner VirtualHID driver and related components - case backgroundServices // LaunchDaemon services for automatic startup } // MARK: - Karabiner Components Status Evaluator @@ -31,7 +30,8 @@ enum KarabinerComponentsStatusEvaluator { // Use WizardStateInterpreter to check for comprehensive Karabiner-related component issues let hasKarabinerIssues = issues.contains { issue in - // Check for installation issues related to Karabiner/VHID/background services + // Check for installation issues related to Karabiner/VHID only. + // Legacy recovery services are no longer part of normal Karabiner readiness. if issue.category == .installation { switch issue.identifier { case .component(.karabinerDriver), @@ -40,16 +40,13 @@ enum KarabinerComponentsStatusEvaluator { .component(.vhidDeviceActivation), .component(.vhidDeviceRunning), .component(.vhidDaemonMisconfigured), - .component(.vhidDriverVersionMismatch), - .component(.launchDaemonServices), - .component(.launchDaemonServicesUnhealthy): + .component(.vhidDriverVersionMismatch): return true default: return false } } - // Background services category maps here; Kanata service (.daemon) is handled separately. - return issue.category == .backgroundServices + return false } return hasKarabinerIssues ? .failed : .completed @@ -84,26 +81,6 @@ enum KarabinerComponentsStatusEvaluator { return false } return hasDriverIssues ? .failed : .completed - - case .backgroundServices: - // Check for background services issues - let hasBackgroundServiceIssues = issues.contains { issue in - if issue.category == .installation { - switch issue.identifier { - case .component(.launchDaemonServices), - .component(.launchDaemonServicesUnhealthy): - return true - default: - return false - } - } - // Daemon issues also bubble into this section for user clarity - if case .component(.karabinerDaemon) = issue.identifier, issue.category == .daemon { - return true - } - return issue.category == .backgroundServices - } - return hasBackgroundServiceIssues ? .failed : .completed } } @@ -112,7 +89,7 @@ enum KarabinerComponentsStatusEvaluator { /// - Returns: Array of issues related to Karabiner components static func getKarabinerRelatedIssues(from issues: [WizardIssue]) -> [WizardIssue] { issues.filter { issue in - // Include installation issues related to Karabiner + // Include installation issues related to Karabiner/VHID only. if issue.category == .installation { switch issue.identifier { case .component(.karabinerDriver), @@ -120,8 +97,6 @@ enum KarabinerComponentsStatusEvaluator { .component(.vhidDeviceManager), .component(.vhidDeviceActivation), .component(.vhidDeviceRunning), - .component(.launchDaemonServices), - .component(.launchDaemonServicesUnhealthy), .component(.vhidDaemonMisconfigured), .component(.vhidDriverVersionMismatch): return true @@ -129,8 +104,7 @@ enum KarabinerComponentsStatusEvaluator { return false } } - // Background services issues only (Kanata service is separate) - return issue.category == .backgroundServices + return false } } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/PackageManager.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/PackageManager.swift index bab7eeb00..d01d751ca 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/PackageManager.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/PackageManager.swift @@ -84,7 +84,7 @@ class PackageManager { "/usr/local/bin/brew" // Intel Homebrew ] - for path in homebrewPaths where FileManager.default.fileExists(atPath: path) { + for path in homebrewPaths where Foundation.FileManager().fileExists(atPath: path) { AppLogger.shared.log("✅ [PackageManager] Homebrew found at: \(path)") return true } @@ -105,7 +105,7 @@ class PackageManager { "/usr/local/bin/brew" // Intel Homebrew ] - for path in homebrewPaths where FileManager.default.fileExists(atPath: path) { + for path in homebrewPaths where Foundation.FileManager().fileExists(atPath: path) { return path } @@ -114,9 +114,9 @@ class PackageManager { /// Gets the Homebrew bin directory for installed packages func getHomebrewBinPath() -> String? { - if FileManager.default.fileExists(atPath: "/opt/homebrew/bin/brew") { + if Foundation.FileManager().fileExists(atPath: "/opt/homebrew/bin/brew") { return "/opt/homebrew/bin" // ARM Homebrew - } else if FileManager.default.fileExists(atPath: "/usr/local/bin/brew") { + } else if Foundation.FileManager().fileExists(atPath: "/usr/local/bin/brew") { return "/usr/local/bin" // Intel Homebrew } @@ -135,7 +135,7 @@ class PackageManager { "\(NSHomeDirectory())/.cargo/bin/kanata" // Rust cargo installation ] - for path in possiblePaths where FileManager.default.fileExists(atPath: path) { + for path in possiblePaths where Foundation.FileManager().fileExists(atPath: path) { let installationType = determineInstallationType(path: path) let codeSigningStatus = detectCodeSigningStatus(at: path) AppLogger.shared.log( @@ -180,9 +180,9 @@ class PackageManager { /// Caches results based on file modification date and size for performance func getCodeSigningStatus(at path: String) -> CodeSigningStatus { // Read file attributes once (used for both cache validation and caching) - guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), - let modDate = attributes[.modificationDate] as? Date, - let fileSize = attributes[.size] as? Int64 + guard let attributes = try? Foundation.FileManager().attributesOfItem(atPath: path), + let modDate = attributes[FileAttributeKey.modificationDate] as? Date, + let fileSize = attributes[FileAttributeKey.size] as? Int64 else { // Can't cache without metadata - perform check and return return detectCodeSigningStatus(at: path) @@ -491,8 +491,8 @@ class PackageManager { // Check for partial Homebrew installation let homebrewDirs = ["/opt/homebrew", "/usr/local/Homebrew"] for dir in homebrewDirs { - if FileManager.default.fileExists(atPath: dir), - !FileManager.default.fileExists(atPath: "\(dir)/bin/brew") + if Foundation.FileManager().fileExists(atPath: dir), + !Foundation.FileManager().fileExists(atPath: "\(dir)/bin/brew") { AppLogger.shared.log( "⚠️ [PackageManager] Found \(dir) but no brew executable - possible incomplete installation" @@ -502,8 +502,8 @@ class PackageManager { // Check for Cargo installation without Kanata let cargoPath = "\(NSHomeDirectory())/.cargo/bin" - if FileManager.default.fileExists(atPath: cargoPath), - !FileManager.default.fileExists(atPath: "\(cargoPath)/kanata") + if Foundation.FileManager().fileExists(atPath: cargoPath), + !Foundation.FileManager().fileExists(atPath: "\(cargoPath)/kanata") { AppLogger.shared.log( "ℹ️ [PackageManager] Cargo detected but no Kanata binary - user may need to install via 'cargo install kanata'" diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/PermissionGrantCoordinator.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/PermissionGrantCoordinator.swift index 31a586264..ef672920a 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/PermissionGrantCoordinator.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/PermissionGrantCoordinator.swift @@ -220,7 +220,7 @@ class PermissionGrantCoordinator { } private func attemptKanataRestart(kanataManager: RuntimeCoordinator) async -> Bool { - let success = await kanataManager.restartServiceWithFallback( + let success = await kanataManager.restartKanata( reason: "Permission grant restart" ) if success { @@ -366,19 +366,16 @@ class PermissionGrantCoordinator { logger.log("🔄 [ServiceBounce] Bounce flag cleared") } - /// Perform the service bounce via the InstallerEngine façade + /// Perform the service bounce via the privileged coordinator seam. func performServiceBounce() async -> Bool { - logger.log("🔄 [ServiceBounce] Bounce via InstallerEngine: restartUnhealthyServices") - let report = await InstallerEngine() - .runSingleAction(.restartUnhealthyServices, using: PrivilegeBroker()) - if report.success { + logger.log("🔄 [ServiceBounce] Bounce via PrivilegeBroker.recoverRequiredRuntimeServices") + do { + try await PrivilegeBroker().recoverRequiredRuntimeServices() logger.log("✅ [ServiceBounce] Bounce completed successfully") return true + } catch { + logger.log("❌ [ServiceBounce] Bounce failed: \(error.localizedDescription)") + return false } - - logger.log( - "❌ [ServiceBounce] Bounce failed: \(report.failureReason ?? "Unknown error")" - ) - return false } } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/PlistGenerator.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/PlistGenerator.swift index d9ba3a489..83df38c12 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/PlistGenerator.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/PlistGenerator.swift @@ -45,19 +45,11 @@ enum PlistGenerator { tcpPort: Int = 37001, verboseLogging: Bool = false ) -> [String] { - var arguments = [binaryPath, "--cfg", configPath] - - // Add TCP port for communication server - arguments.append(contentsOf: ["--port", "\(tcpPort)"]) - - // Keep production logging quiet by default. - // Trace logging is opt-in for advanced diagnostics. - if verboseLogging { - arguments.append("--trace") - } - arguments.append("--log-layer-changes") - - return arguments + KanataRuntimeLaunchRequest( + configPath: configPath, + inheritedArguments: ["--port", "\(tcpPort)", "--log-layer-changes"], + addTraceLogging: verboseLogging + ).commandLine(binaryPath: binaryPath) } /// Generate the Kanata service launchd plist XML content. diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegeBroker.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegeBroker.swift index 932c88574..4c85ef50c 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegeBroker.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegeBroker.swift @@ -18,23 +18,16 @@ public struct PrivilegeBroker { self.coordinator = coordinator } - // MARK: - LaunchDaemon Operations - - /// Install a LaunchDaemon plist file to /Library/LaunchDaemons/ - public func installLaunchDaemon(plistPath: String, serviceID: String) async throws { - try await coordinator.installLaunchDaemon(plistPath: plistPath, serviceID: serviceID) - } - // MARK: - Service Management - /// Install all LaunchDaemon services - public func installAllLaunchDaemonServices() async throws { - try await coordinator.installAllLaunchDaemonServices() + /// Install only the privileged services required by the split runtime path. + public func installRequiredRuntimeServices() async throws { + try await coordinator.installRequiredRuntimeServices() } /// Restart unhealthy services - public func restartUnhealthyServices() async throws { - try await coordinator.restartUnhealthyServices() + public func recoverRequiredRuntimeServices() async throws { + try await coordinator.recoverRequiredRuntimeServices() } /// Install newsyslog config for log rotation @@ -42,11 +35,6 @@ public struct PrivilegeBroker { try await coordinator.installNewsyslogConfig() } - /// Install LaunchDaemon services without loading (adopt/replace paths) - public func installLaunchDaemonServicesWithoutLoading() async throws { - try await coordinator.installLaunchDaemonServicesWithoutLoading() - } - /// Regenerate service configuration (TCP/plist refresh) public func regenerateServiceConfiguration() async throws { try await coordinator.regenerateServiceConfiguration() @@ -85,7 +73,7 @@ public struct PrivilegeBroker { } /// Stop Kanata LaunchDaemon and kill any remaining processes - public func stopKanataService() async throws { + public func stopRecoveryDaemonService() async throws { let cmd = "/bin/launchctl bootout system/\(KanataDaemonManager.kanataServiceID) 2>/dev/null || true" try await coordinator.sudoExecuteCommand(cmd, description: "Stop Kanata service") try await coordinator.killAllKanataProcesses() diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegedExecutor.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegedExecutor.swift index 7af8484ea..d9d4dc970 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegedExecutor.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegedExecutor.swift @@ -184,7 +184,7 @@ final class PrivilegedExecutor: @unchecked Sendable { func testAdminDialog() -> Bool { AppLogger.shared.log("🔧 [PrivilegedExecutor] Testing admin dialog capability...") AppLogger.shared.log( - "🔧 [PrivilegedExecutor] Current thread: \(Thread.isMainThread ? "main" : "background")" + "🔧 [PrivilegedExecutor] Current thread: \(pthread_main_np() != 0 ? "main" : "background")" ) // Skip test if called during startup to prevent freezes diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceBootstrapper.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceBootstrapper.swift index 0e8295d89..6f81bf107 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceBootstrapper.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceBootstrapper.swift @@ -104,7 +104,7 @@ final class ServiceBootstrapper { // Test mode: just check if plist exists if TestEnvironment.shouldSkipAdminOperations { let plistPath = getPlistPath(for: serviceID) - let exists = FileManager.default.fileExists(atPath: plistPath) + let exists = Foundation.FileManager().fileExists(atPath: plistPath) AppLogger.shared.log( "🧪 [ServiceBootstrapper] Test mode - service \(serviceID) loaded: \(exists)" ) @@ -329,8 +329,8 @@ final class ServiceBootstrapper { let daemonPlistPath = getPlistPath(for: Self.vhidDaemonServiceID) let managerPlistPath = getPlistPath(for: Self.vhidManagerServiceID) let snapshot = VHIDInstallSnapshot( - daemonPlistExisted: FileManager.default.fileExists(atPath: daemonPlistPath), - managerPlistExisted: FileManager.default.fileExists(atPath: managerPlistPath), + daemonPlistExisted: Foundation.FileManager().fileExists(atPath: daemonPlistPath), + managerPlistExisted: Foundation.FileManager().fileExists(atPath: managerPlistPath), daemonLoaded: await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidDaemonServiceID), managerLoaded: await ServiceHealthChecker.shared.isServiceLoaded(serviceID: Self.vhidManagerServiceID) ) @@ -441,7 +441,7 @@ final class ServiceBootstrapper { prompt: "KeyPath needs to install the log rotation config." ) - try? FileManager.default.removeItem(atPath: tempPath) + try? Foundation.FileManager().removeItem(atPath: tempPath) if result.success { AppLogger.shared.log("✅ [ServiceBootstrapper] Newsyslog config installed successfully") @@ -471,7 +471,7 @@ final class ServiceBootstrapper { /// Check if newsyslog config is installed func isNewsyslogConfigInstalled() -> Bool { - FileManager.default.fileExists(atPath: "/etc/newsyslog.d/com.keypath.conf") + Foundation.FileManager().fileExists(atPath: "/etc/newsyslog.d/com.keypath.conf") } // MARK: - Restart Unhealthy Services @@ -486,7 +486,7 @@ final class ServiceBootstrapper { /// /// - Returns: `true` if all services are healthy after the operation @MainActor - func restartUnhealthyServices() async -> Bool { + func recoverRequiredRuntimeServices() async -> Bool { AppLogger.shared.log("🔧 [ServiceBootstrapper] Starting comprehensive service health fix") // Skip in test mode @@ -764,7 +764,7 @@ final class ServiceBootstrapper { defer { for tempFile in prepared.tempFiles { - try? FileManager.default.removeItem(atPath: tempFile) + try? Foundation.FileManager().removeItem(atPath: tempFile) } } @@ -985,65 +985,4 @@ final class ServiceBootstrapper { } } - /// Install all LaunchDaemon service plists without loading them - /// - /// Used for adopting orphan processes where we want the plist in place - /// but don't want to load services yet. - /// - /// - Parameter binaryPath: Unused (retained for call-site compatibility) - /// - Returns: `true` if all plists were installed successfully - func installAllServicesWithoutLoading(binaryPath _: String) async -> Bool { - AppLogger.shared.log("🔧 [ServiceBootstrapper] Installing service plists (no loading)") - - // Skip admin operations in test environment - if TestEnvironment.shouldSkipAdminOperations { - AppLogger.shared.log("🧪 [TestEnvironment] Skipping service installation - returning mock success") - return true - } - - // Generate plists - let vhidDaemonPlist = PlistGenerator.generateVHIDDaemonPlist() - let vhidManagerPlist = PlistGenerator.generateVHIDManagerPlist() - - let launchDaemonsDir = getLaunchDaemonsPath() - let vhidDaemonPlistPath = "\(launchDaemonsDir)/\(Self.vhidDaemonServiceID).plist" - let vhidManagerPlistPath = "\(launchDaemonsDir)/\(Self.vhidManagerServiceID).plist" - - let specs = [ - PlistInstallSpec( - content: vhidDaemonPlist, - path: vhidDaemonPlistPath, - serviceID: Self.vhidDaemonServiceID - ), - PlistInstallSpec( - content: vhidManagerPlist, - path: vhidManagerPlistPath, - serviceID: Self.vhidManagerServiceID - ) - ] - - let prepared: (tempFiles: [String], commands: [String]) - do { - prepared = try preparePlistInstall(specs: specs) - } catch { - AppLogger.shared.log("❌ [ServiceBootstrapper] Failed to prepare service plists: \(error)") - return false - } - - defer { - for tempFile in prepared.tempFiles { - try? FileManager.default.removeItem(atPath: tempFile) - } - } - - let result = await executePrivilegedBatch( - label: "install VirtualHID service plists", - commands: prepared.commands, - prompt: "KeyPath needs to install the VirtualHID service plists." - ) - AppLogger.shared.log( - "🔧 [ServiceBootstrapper] Install-only result: success=\(result.success)" - ) - return result.success - } } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceHealthChecker.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceHealthChecker.swift index 64f028f0d..b9955801a 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceHealthChecker.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/ServiceHealthChecker.swift @@ -31,11 +31,20 @@ final class ServiceHealthChecker: @unchecked Sendable { let managementState: KanataDaemonManager.ServiceManagementState let isRunning: Bool let isResponding: Bool + let inputCaptureReady: Bool + let inputCaptureIssue: String? let launchctlExitCode: Int32? let staleEnabledRegistration: Bool let recentlyRestarted: Bool } + struct KanataInputCaptureStatus: Sendable, Equatable { + let isReady: Bool + let issue: String? + + static let ready = KanataInputCaptureStatus(isReady: true, issue: nil) + } + enum KanataHealthDecision: Equatable { case healthy case transient(reason: String) @@ -56,6 +65,8 @@ final class ServiceHealthChecker: @unchecked Sendable { (() async -> KanataServiceRuntimeSnapshot)? nonisolated(unsafe) static var recentlyRestartedOverride: ((String, TimeInterval?) -> Bool)? + nonisolated(unsafe) static var inputCaptureStatusOverride: + (() async -> KanataInputCaptureStatus)? #endif // MARK: - Service Identifiers @@ -83,7 +94,7 @@ final class ServiceHealthChecker: @unchecked Sendable { if serviceID == Self.kanataServiceID { if TestEnvironment.shouldSkipAdminOperations { let plistPath = getPlistPath(for: serviceID) - let exists = FileManager.default.fileExists(atPath: plistPath) + let exists = Foundation.FileManager().fileExists(atPath: plistPath) AppLogger.shared.log( "🔍 [ServiceHealthChecker] (test) Kanata service loaded via file existence: \(exists)" ) @@ -139,7 +150,7 @@ final class ServiceHealthChecker: @unchecked Sendable { // For non-Kanata services or Kanata in legacy mode, use launchctl print if TestEnvironment.shouldSkipAdminOperations { let plistPath = getPlistPath(for: serviceID) - let exists = FileManager.default.fileExists(atPath: plistPath) + let exists = Foundation.FileManager().fileExists(atPath: plistPath) AppLogger.shared.log( "🔍 [ServiceHealthChecker] (test) Service \(serviceID) considered loaded: \(exists)" ) @@ -192,7 +203,7 @@ final class ServiceHealthChecker: @unchecked Sendable { if TestEnvironment.shouldSkipAdminOperations { let plistPath = getPlistPath(for: serviceID) - let exists = FileManager.default.fileExists(atPath: plistPath) + let exists = Foundation.FileManager().fileExists(atPath: plistPath) AppLogger.shared.log( "🔍 [ServiceHealthChecker] (test) Service \(serviceID) considered healthy: \(exists)" ) @@ -346,8 +357,10 @@ final class ServiceHealthChecker: @unchecked Sendable { return KanataHealthSnapshot(isRunning: false, isResponding: false) } + let managementState = await KanataDaemonManager.shared.refreshManagementState() + // 1) launchctl check for PID using SubprocessRunner - let runningState = await evaluateKanataLaunchctlRunningState() + let runningState = await evaluateKanataLaunchctlRunningState(managementState: managementState) let isRunning = runningState.isRunning // 2) TCP probe (Hello/Status) - runs off MainActor via Task.detached for blocking socket ops @@ -406,7 +419,8 @@ final class ServiceHealthChecker: @unchecked Sendable { } #endif - let runningState = await evaluateKanataLaunchctlRunningState() + let runningState = await evaluateKanataLaunchctlRunningState(managementState: managementState) + let inputCaptureStatus = await checkKanataInputCaptureStatus() let tcpOK = await Task.detached { [self] in if let portEnv = ProcessInfo.processInfo.environment["KEYPATH_TCP_PORT"], let overridePort = Int(portEnv) @@ -420,6 +434,8 @@ final class ServiceHealthChecker: @unchecked Sendable { managementState: managementState, isRunning: runningState.isRunning, isResponding: tcpOK, + inputCaptureReady: inputCaptureStatus.isReady, + inputCaptureIssue: inputCaptureStatus.issue, launchctlExitCode: runningState.exitCode, staleEnabledRegistration: staleEnabledRegistration, recentlyRestarted: Self.wasRecentlyRestarted( @@ -429,35 +445,42 @@ final class ServiceHealthChecker: @unchecked Sendable { ) } - private nonisolated func evaluateKanataLaunchctlRunningState() async + private nonisolated func evaluateKanataLaunchctlRunningState( + managementState: KanataDaemonManager.ServiceManagementState + ) async -> (isRunning: Bool, exitCode: Int32?) { - do { - let result = try await SubprocessRunner.shared.launchctl( - "print", ["system/\(Self.kanataServiceID)"] - ) - if result.exitCode != 0 { - return (false, result.exitCode) - } + var lastExitCode: Int32? + for target in KanataDaemonManager.preferredLaunchctlTargets(for: managementState) { + do { + let result = try await SubprocessRunner.shared.launchctl("print", [target]) + lastExitCode = result.exitCode + if result.exitCode != 0 { + continue + } - for line in result.stdout.components(separatedBy: "\n") where line.contains("pid =") { - let components = line.components(separatedBy: "=") - if components.count == 2, - Int(components[1].trimmingCharacters(in: .whitespaces)) != nil - { - return (true, result.exitCode) + for line in result.stdout.components(separatedBy: "\n") where line.contains("pid =") { + let components = line.components(separatedBy: "=") + if components.count == 2, + Int(components[1].trimmingCharacters(in: .whitespaces)) != nil + { + return (true, result.exitCode) + } } + } catch { + AppLogger.shared.warn("⚠️ [ServiceHealthChecker] launchctl check failed for \(target): \(error)") } - return (false, result.exitCode) - } catch { - AppLogger.shared.warn("⚠️ [ServiceHealthChecker] launchctl check failed: \(error)") - return (false, nil) } + return (false, lastExitCode) } nonisolated static func decideKanataHealth( for runtimeSnapshot: KanataServiceRuntimeSnapshot ) -> KanataHealthDecision { + if !runtimeSnapshot.inputCaptureReady { + return .unhealthy(reason: runtimeSnapshot.inputCaptureIssue ?? "input-capture-not-ready") + } + if runtimeSnapshot.isRunning, runtimeSnapshot.isResponding { return .healthy } @@ -495,6 +518,40 @@ final class ServiceHealthChecker: @unchecked Sendable { return ServiceBootstrapper.wasRecentlyRestarted(serviceID, within: window) } + nonisolated func checkKanataInputCaptureStatus() async -> KanataInputCaptureStatus { +#if DEBUG + if let override = Self.inputCaptureStatusOverride { + return await override() + } +#endif + // There is no stable Apple API here for "the live runtime can capture the built-in + // keyboard right now," so we use Kanata's known stderr denial line as a runtime fallback + // signal and fail closed when macOS is actively denying capture. + + guard let logChunk = readRecentKanataStderrLog(), !logChunk.isEmpty else { + return .ready + } + + for rawLine in logChunk.components(separatedBy: .newlines).reversed() { + let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } + let lower = line.lowercased() + guard lower.contains("iohiddeviceopen error"), + lower.contains("not permitted"), + lower.contains("apple internal keyboard / trackpad") + else { + continue + } + + return KanataInputCaptureStatus( + isReady: false, + issue: "kanata-cannot-open-built-in-keyboard" + ) + } + + return .ready + } + // MARK: - Configuration Checks /// Check if Kanata service plist file exists (but may not be loaded). @@ -502,7 +559,7 @@ final class ServiceHealthChecker: @unchecked Sendable { /// - Returns: `true` if the plist file exists func isKanataPlistInstalled() -> Bool { let plistPath = getKanataPlistPath() - return FileManager.default.fileExists(atPath: plistPath) + return Foundation.FileManager().fileExists(atPath: plistPath) } /// Verifies that the installed VHID LaunchDaemon plist points to the DriverKit daemon path. @@ -546,6 +603,23 @@ final class ServiceHealthChecker: @unchecked Sendable { getPlistPath(for: Self.kanataServiceID) } + private nonisolated func readRecentKanataStderrLog(maxBytes: Int = 64 * 1024) -> String? { + let stderrPath = ProcessInfo.processInfo.environment["KEYPATH_KANATA_STDERR_PATH"] + ?? KeyPathConstants.Logs.kanataStderr + guard let fileHandle = FileHandle(forReadingAtPath: stderrPath) else { + return nil + } + defer { try? fileHandle.close() } + + let fileSize: UInt64 = (try? fileHandle.seekToEnd()) ?? 0 + let offset = fileSize > UInt64(maxBytes) ? fileSize - UInt64(maxBytes) : 0 + try? fileHandle.seek(toOffset: offset) + guard let data = try? fileHandle.readToEnd(), !data.isEmpty else { + return nil + } + return String(decoding: data, as: UTF8.self) + } + /// Get the launchd daemons directory path private func getLaunchDaemonsPath() -> String { let env = ProcessInfo.processInfo.environment diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/SystemContextAdapter.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/SystemContextAdapter.swift index 482df0361..e936e5bf9 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/SystemContextAdapter.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/SystemContextAdapter.swift @@ -53,6 +53,13 @@ struct SystemContextAdapter { return .missingPermissions(missing: missingPerms) } + if !context.services.kanataInputCaptureReady { + AppLogger.shared.log( + "📊 [SystemContextAdapter] Decision: MISSING PERMISSIONS (Kanata cannot open built-in keyboard)" + ) + return .missingPermissions(missing: [.kanataInputMonitoring]) + } + // 4. Check if Kanata is running - components exist and permissions granted if context.services.kanataRunning { AppLogger.shared.log("📊 [SystemContextAdapter] Decision: ACTIVE (kanata running)") @@ -122,12 +129,6 @@ struct SystemContextAdapter { if !context.components.vhidDeviceHealthy { missing.append(.vhidDeviceRunning) } - // Use vhidServicesHealthy for Karabiner-related missing components - // (Kanata service health is checked separately on the Kanata Components page) - if !context.components.vhidServicesHealthy { - missing.append(.launchDaemonServices) - } - return missing } @@ -165,9 +166,9 @@ struct SystemContextAdapter { // This commonly occurs when KeyPath lacks Full Disk Access and cannot read TCC.db for Kanata. return switch identifier { case .permission(.kanataInputMonitoring): - "Not verified (grant Full Disk Access to verify). If remapping doesn’t work, add /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Input Monitoring." + "Not verified (grant Full Disk Access to verify). If remapping doesn’t work, add the Kanata engine binary used by KeyPath at /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Input Monitoring." case .permission(.kanataAccessibility): - "Not verified (grant Full Disk Access to verify). If remapping doesn’t work, add /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Accessibility." + "Not verified (grant Full Disk Access to verify). If remapping doesn’t work, add the Kanata engine binary used by KeyPath at /Library/KeyPath/bin/kanata in System Settings > Privacy & Security > Accessibility." default: "Not verified (grant Full Disk Access to verify)." } @@ -209,19 +210,36 @@ struct SystemContextAdapter { appendPermissionIssue( context.permissions.kanata.inputMonitoring, identifier: .permission(.kanataInputMonitoring), - title: "Kanata Input Monitoring Permission", - deniedDescription: "Kanata needs Input Monitoring permission", - userAction: "Grant Input Monitoring permission to kanata in System Settings", + title: "Kanata Engine Input Monitoring Permission", + deniedDescription: "The Kanata engine used by KeyPath needs Input Monitoring permission", + userAction: "Grant Input Monitoring permission to the Kanata engine binary in System Settings", includeUnknown: true ) appendPermissionIssue( context.permissions.kanata.accessibility, identifier: .permission(.kanataAccessibility), - title: "Kanata Accessibility Permission", - deniedDescription: "Kanata needs Accessibility permission", - userAction: "Grant Accessibility permission to kanata in System Settings", + title: "Kanata Engine Accessibility Permission", + deniedDescription: "The Kanata engine used by KeyPath needs Accessibility permission", + userAction: "Grant Accessibility permission to the Kanata engine binary in System Settings", includeUnknown: true ) + if !context.services.kanataInputCaptureReady, + !issues.contains(where: { $0.identifier == .permission(.kanataInputMonitoring) }) + { + issues.append( + WizardIssue( + identifier: .permission(.kanataInputMonitoring), + severity: .error, + category: .permissions, + title: "KeyPath Runtime Cannot Open Built-In Keyboard", + description: + "KeyPath Runtime is running but cannot open the built-in keyboard device, so remapping will not work on this laptop.", + autoFixAction: nil, + userAction: + "Regrant Input Monitoring for the Kanata engine binary at /Library/KeyPath/bin/kanata and restart KeyPath" + ) + ) + } // Component issues if !context.components.kanataBinaryInstalled { @@ -230,8 +248,8 @@ struct SystemContextAdapter { identifier: .component(.kanataBinaryMissing), severity: .error, category: .installation, - title: "Kanata Binary Missing", - description: "Kanata binary is not installed", + title: "Kanata Engine Missing", + description: "The Kanata engine binary used by KeyPath is not installed", autoFixAction: .installBundledKanata, userAction: nil ) @@ -290,17 +308,17 @@ struct SystemContextAdapter { ) ) } - // Use vhidServicesHealthy for the issue shown on Karabiner Components page - // (Kanata service health is handled separately - see kanataService issue below) + // Use a VHID-specific issue for the Karabiner Components page. + // Legacy recovery services are reported separately and should not be the primary signal here. if !context.components.vhidServicesHealthy { issues.append( WizardIssue( - identifier: .component(.launchDaemonServices), + identifier: .component(.vhidDeviceManager), severity: .error, category: .installation, title: "VHID Services Unhealthy", description: "Karabiner VirtualHID services (daemon and manager) are not healthy", - autoFixAction: .installLaunchDaemonServices, + autoFixAction: .installRequiredRuntimeServices, userAction: nil ) ) @@ -337,34 +355,21 @@ struct SystemContextAdapter { ) ) } - // Background services should only depend on Karabiner daemon + VHID, not Kanata runtime - if !context.services.backgroundServicesHealthy { - issues.append( - WizardIssue( - identifier: .component(.launchDaemonServices), - severity: .warning, - category: .backgroundServices, - title: "Services Unhealthy", - description: "Some services are not healthy", - autoFixAction: .restartUnhealthyServices, - userAction: nil - ) - ) - } - // Kanata service health issue - separate from VHID services (shown on Kanata Components page) - if !context.services.kanataRunning, context.components.vhidServicesHealthy { + if !context.services.kanataRunning, context.components.vhidServicesHealthy, + context.services.kanataInputCaptureReady + { // Only show if VHID is healthy but Kanata isn't running // (if VHID is unhealthy, that's the primary issue to fix first) issues.append( WizardIssue( - identifier: .component(.kanataService), + identifier: .component(.keyPathRuntime), severity: .error, category: .daemon, - title: "Kanata Service Not Running", - description: "Kanata keyboard remapping service is not running", - autoFixAction: .installLaunchDaemonServices, - userAction: nil + title: "KeyPath Runtime Not Running", + description: "KeyPath keyboard remapping runtime is not running", + autoFixAction: nil, + userAction: "Start KeyPath Runtime from the wizard or app status controls" ) ) } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/VHIDDeviceManager.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/VHIDDeviceManager.swift index 77ffdc0d7..e8fcd2fc9 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/VHIDDeviceManager.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/VHIDDeviceManager.swift @@ -78,7 +78,7 @@ final class VHIDDeviceManager: @unchecked Sendable { /// Checks if the VirtualHIDDevice Manager application is installed func detectInstallation() -> Bool { - let fileManager = FileManager.default + let fileManager = Foundation.FileManager() let appExists = fileManager.fileExists(atPath: Self.vhidManagerPath) AppLogger.shared.log( @@ -90,7 +90,7 @@ final class VHIDDeviceManager: @unchecked Sendable { /// Checks if the VirtualHIDDevice Manager has been activated /// This involves checking if the daemon binaries are in place func detectActivation() -> Bool { - let fileManager = FileManager.default + let fileManager = Foundation.FileManager() let daemonExists = fileManager.fileExists(atPath: Self.vhidDeviceDaemonPath) AppLogger.shared.log( @@ -109,7 +109,7 @@ final class VHIDDeviceManager: @unchecked Sendable { var issues: [String] = [] for target in targets { - guard FileManager.default.fileExists(atPath: target.path) else { continue } + guard Foundation.FileManager().fileExists(atPath: target.path) else { continue } if await isQuarantined(at: target.path) { issues.append("\(target.label) appears quarantined: \(target.path)") @@ -434,22 +434,21 @@ final class VHIDDeviceManager: @unchecked Sendable { } #endif - guard FileManager.default.fileExists(atPath: Self.vhidDeviceDaemonInfoPlistPath) else { + guard Foundation.FileManager().fileExists(atPath: Self.vhidDeviceDaemonInfoPlistPath) else { AppLogger.shared.log( "🔍 [VHIDManager] Info.plist not found at \(Self.vhidDeviceDaemonInfoPlistPath)" ) return nil } - guard let plistData = FileManager.default.contents(atPath: Self.vhidDeviceDaemonInfoPlistPath) + guard let plistData = Foundation.FileManager().contents(atPath: Self.vhidDeviceDaemonInfoPlistPath) else { AppLogger.shared.log("❌ [VHIDManager] Failed to read Info.plist") return nil } do { - let plist = - try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] let version = plist?["CFBundleShortVersionString"] as? String AppLogger.shared.log("🔍 [VHIDManager] Installed daemon version: \(version ?? "unknown")") return version diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAsyncOperationManager.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAsyncOperationManager.swift index bd9e7312c..1029ebcea 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAsyncOperationManager.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAsyncOperationManager.swift @@ -333,11 +333,11 @@ enum WizardOperations { static func startService(kanataManager: RuntimeCoordinator) -> AsyncOperation { AsyncOperation( id: "start_service", - name: "Start Kanata Service" + name: "Start KeyPath Runtime" ) { progressCallback in progressCallback(0.1) - let restarted = await kanataManager.restartServiceWithFallback( + let restarted = await kanataManager.startKanata( reason: "Wizard async start operation" ) @@ -513,7 +513,7 @@ struct WizardError: LocalizedError { ] ) - case let op where op.contains("Auto Fix: Start Kanata Service"): + case let op where op.contains("Auto Fix: Start KeyPath Runtime"): return ( "The keyboard remapping service won't start", [ @@ -602,7 +602,7 @@ struct WizardError: LocalizedError { ] ) - case let op where op.contains("Start Kanata Service"): + case let op where op.contains("Start KeyPath Runtime"): return ( "Couldn't start the keyboard service", [ @@ -681,20 +681,14 @@ extension AutoFixAction { "Create Config Directories" case .activateVHIDDeviceManager: "Activate VirtualHIDDevice Manager" - case .installLaunchDaemonServices: - "Install LaunchDaemon Services" - case .adoptOrphanedProcess: - "Adopt Orphaned Process" - case .replaceOrphanedProcess: - "Replace Orphaned Process" + case .installRequiredRuntimeServices: + "Install Required Runtime Services" case .installBundledKanata: "Install Kanata Binary" case .repairVHIDDaemonServices: "Repair VHID Daemon Services" case .synchronizeConfigPaths: "Synchronize Config Paths" - case .restartUnhealthyServices: - "Restart Unhealthy Services" case .installLogRotation: "Install Log Rotation" case .replaceKanataWithBundled: diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift index badede6cf..c79cb82ba 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardAutoFixer.swift @@ -111,7 +111,7 @@ class WizardAutoFixer { /// Backward-compat reset hook used by Settings; delegates to façade repair action. @MainActor func resetEverything() async -> Bool { - let report = await installerEngine.runSingleAction(.restartUnhealthyServices, using: PrivilegeBroker()) + let report = await installerEngine.runSingleAction(.terminateConflictingProcesses, using: PrivilegeBroker()) return report.success } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardNavigationEngine.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardNavigationEngine.swift index 6113dfc1a..ac0a89603 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardNavigationEngine.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardNavigationEngine.swift @@ -272,7 +272,7 @@ final class WizardNavigationEngine: WizardNavigating { switch comp { case .karabinerDriver, .karabinerDaemon, .vhidDeviceManager, .vhidDeviceActivation, - .vhidDeviceRunning, .launchDaemonServices, + .vhidDeviceRunning, .vhidDaemonMisconfigured, .vhidDriverVersionMismatch: return true default: @@ -285,7 +285,7 @@ final class WizardNavigationEngine: WizardNavigating { issues.contains { issue in if case let .component(comp) = issue.identifier { switch comp { - case .kanataBinaryMissing, .kanataBinaryVersionMismatch, .kanataService: + case .kanataBinaryMissing, .kanataBinaryVersionMismatch: return true default: return false @@ -389,7 +389,7 @@ final class WizardNavigationEngine: WizardNavigating { case .active: "Close Setup" case .serviceNotRunning, .ready: - "Start Kanata Service" + "Start KeyPath Runtime" default: "Continue Setup" } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardOperationsUIExtension.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardOperationsUIExtension.swift index e69241681..8b85faa9d 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardOperationsUIExtension.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardOperationsUIExtension.swift @@ -75,13 +75,13 @@ extension WizardOperations { category: .daemon, title: "System check timed out", description: "KeyPath couldn't finish checking system status. This can happen if the helper or services are unresponsive.", - autoFixAction: .restartUnhealthyServices, - userAction: "Try restarting KeyPath or click Restart Services." + autoFixAction: nil, + userAction: "Try restarting KeyPath." ) return SystemStateResult( state: .serviceNotRunning, issues: [issue], - autoFixActions: [.restartUnhealthyServices], + autoFixActions: [], detectionTimestamp: Date() ) } diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardRouter.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardRouter.swift index 8af15bfc7..7f5bdca4f 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardRouter.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardRouter.swift @@ -74,7 +74,6 @@ enum WizardRouter { .component(.vhidDeviceManager), .component(.vhidDeviceActivation), .component(.vhidDeviceRunning), - .component(.launchDaemonServices), .component(.vhidDaemonMisconfigured), .component(.vhidDriverVersionMismatch): return true @@ -92,7 +91,7 @@ enum WizardRouter { switch $0.identifier { case .component(.kanataBinaryMissing), .component(.kanataBinaryVersionMismatch), - .component(.kanataService): + .component(.keyPathRuntime): return true default: return false diff --git a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardStateInterpreter.swift b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardStateInterpreter.swift index 2cc588904..8a85460fe 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/Core/WizardStateInterpreter.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/Core/WizardStateInterpreter.swift @@ -158,7 +158,6 @@ struct WizardStateInterpreter { .component(.vhidDeviceManager), .component(.vhidDeviceActivation), .component(.vhidDeviceRunning), - .component(.launchDaemonServices), .component(.vhidDaemonMisconfigured), .component(.vhidDriverVersionMismatch): return true @@ -166,8 +165,8 @@ struct WizardStateInterpreter { return false } } - // Include daemon and background services issues - return issue.category == .daemon || issue.category == .backgroundServices + // Include daemon issues only. Legacy recovery services are reported elsewhere. + return issue.category == .daemon } case .kanataComponents: // Kanata-related components @@ -175,7 +174,7 @@ struct WizardStateInterpreter { if issue.category == .installation { switch issue.identifier { case .component(.kanataBinaryMissing), - .component(.kanataService): + .component(.keyPathRuntime): return true default: return false diff --git a/Sources/KeyPathAppKit/InstallationWizard/README.md b/Sources/KeyPathAppKit/InstallationWizard/README.md index f8d143524..561d25826 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/README.md +++ b/Sources/KeyPathAppKit/InstallationWizard/README.md @@ -297,7 +297,7 @@ Each page is 400-600 lines and follows a consistent pattern: - **Wizard won't advance**: Check `WizardNavigationEngine.determineCurrentPage()` logs - **Auto-fix fails**: Look at `WizardAutoFixer.performAutoFix()` return value - **Permissions incorrect**: Trust `PermissionOracle` (see `Sources/KeyPathPermissions/PermissionOracle.swift`) -- **Service won't start**: Check `/var/log/com.keypath.kanata.stdout.log` and `launchctl print system/com.keypath.kanata` +- **Service won't start**: Check `/var/log/com.keypath.kanata.stdout.log` and `launchctl print gui/$(id -u)/com.keypath.kanata` ## Related Documentation diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardActionSection.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardActionSection.swift index 4de5b1b36..78159017a 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardActionSection.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardActionSection.swift @@ -112,7 +112,7 @@ struct WizardActionSection: View { // Handle other cases based on systemState switch systemState { case .serviceNotRunning, .ready: - WizardButton("Start Kanata Service", style: .primary, isDefaultAction: true) { + WizardButton("Start KeyPath Runtime", style: .primary, isDefaultAction: true) { onStartService() } diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardSystemStatusOverview.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardSystemStatusOverview.swift index f6f831450..4a78820d6 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardSystemStatusOverview.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Components/WizardSystemStatusOverview.swift @@ -266,7 +266,7 @@ struct WizardSystemStatusOverview: View { ) ) - // 7. Kanata Service (depends on helper + driver) + // 7. KeyPath Runtime (depends on helper + driver) let serviceStatus = getServiceStatus() let serviceNavigation = getServiceNavigationTarget() let serviceIssues = issues.filter { issue in @@ -276,7 +276,7 @@ struct WizardSystemStatusOverview: View { StatusItemModel( id: "kanata-service", icon: "app.badge.checkmark", - title: "Kanata Service", + title: "KeyPath Runtime", subtitle: kanataIsRunning ? "Running" : nil, status: serviceStatus, isNavigable: true, @@ -612,8 +612,7 @@ struct WizardSystemStatusOverview: View { if issue.category == .installation { switch issue.identifier { case .component(.kanataBinaryMissing), - .component(.kanataService), - .component(.orphanedKanataProcess): + .component(.keyPathRuntime): return true default: return false diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+Actions.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+Actions.swift index 1a9638d72..3e34c852d 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+Actions.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+Actions.swift @@ -100,7 +100,7 @@ extension InstallationWizardView { AppLogger.shared.log("🔧 [Wizard] Auto-fix for specific action: \(action)") // Short-circuit service installs when Login Items approval is pending - if action == .installLaunchDaemonServices || action == .restartUnhealthyServices, + if action == .installRequiredRuntimeServices, await KanataDaemonManager.shared.refreshManagementState() == .smappservicePending { if !suppressToast { @@ -116,7 +116,7 @@ extension InstallationWizardView { // Give VHID/launch-service operations more time let timeoutSeconds = switch action { case .restartVirtualHIDDaemon, .installCorrectVHIDDriver, .repairVHIDDaemonServices, - .installLaunchDaemonServices: + .installRequiredRuntimeServices: 30.0 default: 12.0 @@ -127,7 +127,7 @@ extension InstallationWizardView { let deferToastActions: Set = [ .restartVirtualHIDDaemon, .installCorrectVHIDDriver, .repairVHIDDaemonServices, - .installLaunchDaemonServices + .installRequiredRuntimeServices ] let deferSuccessToast = deferToastActions.contains(action) var successToastPending = false diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+OperationProgress.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+OperationProgress.swift index 3c56d7439..e700b8f69 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+OperationProgress.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+OperationProgress.swift @@ -24,18 +24,16 @@ extension InstallationWizardView { return "Starting System Daemon" } else if operationId.contains("auto_fix_restartVirtualHIDDaemon") { return "Restarting Virtual HID Daemon" - } else if operationId.contains("auto_fix_installLaunchDaemonServices") { - return "Installing Launch Services" + } else if operationId.contains("auto_fix_installRequiredRuntimeServices") { + return "Installing Required Runtime Services" } else if operationId.contains("auto_fix_createConfigDirectories") { return "Creating Configuration Directories" } else if operationId.contains("state_detection") { return "Detecting System State" } else if operationId.contains("start_service") { - return "Starting Kanata Service" + return "Starting KeyPath Runtime" } else if operationId.contains("grant_permission") { return "Waiting for Permission Grant" - } else if operationId.contains("auto_fix_restartUnhealthyServices") { - return "Restarting Failing Services" } else { return "Processing Operation" } diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UIComponents.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UIComponents.swift index d8c89620a..34e6530ba 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UIComponents.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UIComponents.swift @@ -16,7 +16,7 @@ extension InstallationWizardView { systemState: stateMachine.wizardState, issues: stateMachine.wizardIssues, stateInterpreter: stateInterpreter, - onStartService: startKanataService, + onStartService: startKeyPathRuntime, onDismiss: { dismissAndRefreshMainScreen() }, onNavigateToPage: { page in stateMachine.navigateToPage(page) diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UnifiedRefresh.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UnifiedRefresh.swift index 7b9ed80c9..4394867c5 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UnifiedRefresh.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/InstallationWizardView+UnifiedRefresh.swift @@ -216,7 +216,7 @@ extension InstallationWizardView { } } - func startKanataService() { + func startKeyPathRuntime() { Task { // Show safety confirmation before starting let shouldStart = await showStartConfirmation() @@ -227,19 +227,19 @@ extension InstallationWizardView { asyncOperationManager.execute(operation: operation) { (success: Bool) in if success { - AppLogger.shared.log("✅ [Wizard] Kanata service started successfully") - toastManager.showSuccess("Kanata service started") + AppLogger.shared.log("✅ [Wizard] KeyPath Runtime started successfully") + toastManager.showSuccess("KeyPath Runtime started") dismissAndRefreshMainScreen() } else { - AppLogger.shared.log("❌ [Wizard] Failed to start Kanata service") + AppLogger.shared.log("❌ [Wizard] Failed to start KeyPath Runtime") let failureMessage = kanataManager.lastError - ?? "Kanata service failed to stay running. Review /var/log/com.keypath.kanata.stderr.log for details." + ?? "KeyPath Runtime failed to stay running. Review /var/log/com.keypath.kanata.stderr.log for details." toastManager.showError(failureMessage) } } onFailure: { error in AppLogger.shared.log( - "❌ [Wizard] Error starting Kanata service: \(error.localizedDescription)" + "❌ [Wizard] Error starting KeyPath Runtime: \(error.localizedDescription)" ) toastManager.showError("Start failed: \(error.localizedDescription)") } diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardAccessibilityPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardAccessibilityPage.swift index 0299953db..2d97db961 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardAccessibilityPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardAccessibilityPage.swift @@ -192,7 +192,7 @@ struct WizardAccessibilityPage: View { "🔘 [WizardAccessibilityPage] Fix clicked for kanata - opening System Settings and revealing kanata" ) let path = WizardSystemPaths.kanataSystemInstallPath - if !FileManager.default.fileExists(atPath: path) { + if !Foundation.FileManager().fileExists(atPath: path) { AppLogger.shared.warn( "⚠️ [WizardAccessibilityPage] Kanata system binary missing at \(path) - routing to Kanata Components" ) diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardFullDiskAccessPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardFullDiskAccessPage.swift index 80e8b3f74..3a85f79ba 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardFullDiskAccessPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardFullDiskAccessPage.swift @@ -220,7 +220,7 @@ struct WizardFullDiskAccessPage: View { AppLogger.shared.log("🔐 [Wizard] FDA check: Testing system TCC database access") // Try to read the system TCC database - if FileManager.default.isReadableFile(atPath: systemTCCPath) { + if Foundation.FileManager().isReadableFile(atPath: systemTCCPath) { // Try a very light read operation if let data = try? Data( contentsOf: URL(fileURLWithPath: systemTCCPath), options: .mappedIfSafe diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardHelperPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardHelperPage.swift index d258aae52..f283fa622 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardHelperPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardHelperPage.swift @@ -383,7 +383,7 @@ struct WizardHelperPage: View { hasLoggedDiagnostics = true let mainURL = Bundle.main.url(forResource: "permissions-login-items", withExtension: "png") - let moduleURL = Bundle.module.url(forResource: "permissions-login-items", withExtension: "png") + let moduleURL = KeyPathAppKitResources.url(forResource: "permissions-login-items", withExtension: "png") let mainImageLoaded = mainURL.flatMap { NSImage(contentsOf: $0) } != nil let moduleImageLoaded = moduleURL.flatMap { NSImage(contentsOf: $0) } != nil let windowHeight = NSApp.keyWindow?.frame.height ?? 0 @@ -402,7 +402,7 @@ struct WizardHelperPage: View { private var loginItemsScreenshot: NSImage? { let resourceName = "permissions-login-items" - if let moduleURL = Bundle.module.url(forResource: resourceName, withExtension: "png"), + if let moduleURL = KeyPathAppKitResources.url(forResource: resourceName, withExtension: "png"), let image = NSImage(contentsOf: moduleURL) { return image @@ -572,7 +572,7 @@ struct WizardHelperPage: View { // Try to read version from the helper's Info.plist sibling or embedded // For simplicity, we'll use a hardcoded version that matches HelperService.swift // In production, this should read from the helper's Info.plist - guard FileManager.default.fileExists(atPath: helperInfoPath) else { return nil } + guard Foundation.FileManager().fileExists(atPath: helperInfoPath) else { return nil } // Read version from helper's Info.plist in Sources // For now, return the known bundled version diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardInputMonitoringPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardInputMonitoringPage.swift index 324457c52..47d5f9dbb 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardInputMonitoringPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardInputMonitoringPage.swift @@ -173,7 +173,7 @@ struct WizardInputMonitoringPage: View { "🔧 [WizardInputMonitoringPage] Kanata Fix clicked - opening System Settings and revealing kanata" ) let path = WizardSystemPaths.kanataSystemInstallPath - if !FileManager.default.fileExists(atPath: path) { + if !Foundation.FileManager().fileExists(atPath: path) { AppLogger.shared.warn( "⚠️ [WizardInputMonitoringPage] Kanata system binary missing at \(path) - routing to Kanata Components" ) diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataComponentsPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataComponentsPage.swift index d694f8c56..b6933b9d0 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataComponentsPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataComponentsPage.swift @@ -123,9 +123,7 @@ struct WizardKanataComponentsPage: View { switch issue.identifier { case .component(.kanataBinaryMissing), .component(.kanataBinaryVersionMismatch), - .component(.kanataService), - .component(.launchDaemonServices), - .component(.launchDaemonServicesUnhealthy): + .component(.keyPathRuntime): return true default: return false @@ -149,12 +147,10 @@ struct WizardKanataComponentsPage: View { } return hasIssue ? .failed : .completed - case "Kanata Service": + case "KeyPath Runtime": let hasIssue = issues.contains { issue in if case let .component(component) = issue.identifier { - return component == .kanataService - || component == .launchDaemonServices - || component == .launchDaemonServicesUnhealthy + return component == .keyPathRuntime } return false } @@ -174,11 +170,11 @@ struct WizardKanataComponentsPage: View { if case let .component(component) = issue.identifier { switch component { case .kanataBinaryMissing: - return "Kanata Binary" + return "Kanata Engine" case .kanataBinaryVersionMismatch: return "Kanata Binary Update" - case .kanataService: - return "Kanata Service Configuration" + case .keyPathRuntime: + return "KeyPath Runtime Configuration" default: return issue.title } @@ -191,12 +187,12 @@ struct WizardKanataComponentsPage: View { if case let .component(component) = issue.identifier { switch component { case .kanataBinaryMissing: - return "Kanata is required for remapping. Click Fix to install it." + return "Kanata powers KeyPath remapping. Click Fix to install the engine." case .kanataBinaryVersionMismatch: return "A newer version of Kanata is bundled with KeyPath. Click Fix to update." - case .kanataService: - return "Background service configuration required for Kanata." + case .keyPathRuntime: + return "KeyPath Runtime is not running." default: return issue.description } @@ -219,9 +215,7 @@ struct WizardKanataComponentsPage: View { } if let serviceIssue = kanataIssues.first(where: { switch $0.identifier { - case .component(.kanataService), - .component(.launchDaemonServices), - .component(.launchDaemonServicesUnhealthy): + case .component(.keyPathRuntime): true default: false @@ -374,7 +368,7 @@ struct WizardKanataComponentsPage: View { await MainActor.run { actionStatus = .inProgress(message: "Restarting Kanata service…") } - _ = await kanataManager.restartServiceWithFallback( + _ = await kanataManager.restartKanata( reason: "Kanata binary updated" ) await kanataManager.updateStatus() diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataServicePage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataServicePage.swift index 463991a28..a6d6bff5a 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataServicePage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKanataServicePage.swift @@ -52,12 +52,12 @@ struct WizardKanataServicePage: View { var description: String { switch self { - case .running: "Service is running" - case .stopped: "Service is not running" + case .running: "Runtime is running" + case .stopped: "Runtime is not running" case .failed: "Service error" - case .starting: "Service is starting..." - case .stopping: "Service is stopping..." - case .unknown: "Checking service status..." + case .starting: "Runtime is starting..." + case .stopping: "Runtime is stopping..." + case .unknown: "Checking runtime status..." } } } @@ -70,7 +70,7 @@ struct WizardKanataServicePage: View { overlayIcon: serviceStatus.icon, overlayColor: serviceStatus.color, overlaySize: .large, - title: "Kanata Service", + title: "KeyPath Runtime", subtitle: statusMessage, iconTapAction: { refreshStatus() } ) @@ -183,17 +183,17 @@ struct WizardKanataServicePage: View { private var statusMessage: String { switch serviceStatus { case .running: - "Kanata is running and ready to process keyboard events." + "KeyPath Runtime, powered by Kanata, is running and ready to process keyboard events." case .stopped: - "Kanata service is not running. Click Fix to start it." + "KeyPath runtime is not running. Click Fix to start it." case .failed: - "Kanata failed to start. Click Fix to retry." + "KeyPath Runtime, powered by Kanata, failed to start. Click Fix to retry." case .starting: - "Starting Kanata service…" + "Starting KeyPath runtime…" case .stopping: - "Stopping Kanata service…" + "Stopping KeyPath runtime…" case .unknown: - "Checking Kanata service status… If this takes too long, click Fix." + "Checking KeyPath runtime status… If this takes too long, click Fix." } } @@ -210,7 +210,7 @@ struct WizardKanataServicePage: View { private func startService() { isPerformingAction = true serviceStatus = .starting - actionStatus = .inProgress(message: "Starting Kanata service…") + actionStatus = .inProgress(message: "Starting KeyPath runtime…") Task { @MainActor in _ = await kanataManager.startKanata(reason: "Wizard service start button") @@ -223,10 +223,10 @@ struct WizardKanataServicePage: View { private func restartService() { isPerformingAction = true serviceStatus = .stopping - actionStatus = .inProgress(message: "Restarting Kanata service…") + actionStatus = .inProgress(message: "Restarting KeyPath runtime…") Task { @MainActor in - _ = await kanataManager.restartServiceWithFallback(reason: "Wizard service restart button") + _ = await kanataManager.restartKanata(reason: "Wizard service restart button") isPerformingAction = false await refreshStatusAsync() evaluateServiceCompletion(target: .running, actionName: "Kanata restart") @@ -236,7 +236,7 @@ struct WizardKanataServicePage: View { private func stopService() { isPerformingAction = true serviceStatus = .stopping - actionStatus = .inProgress(message: "Stopping Kanata service…") + actionStatus = .inProgress(message: "Stopping KeyPath runtime…") Task { @MainActor in _ = await kanataManager.stopKanata(reason: "Wizard service stop button") @@ -254,10 +254,10 @@ struct WizardKanataServicePage: View { } private func refreshStatusAsync() async { - let serviceState = await kanataManager.currentServiceState() + let runtimeStatus = await kanataManager.currentRuntimeStatus() let processStatus = ServiceStatusEvaluator.evaluate( - kanataIsRunning: serviceState.isRunning, + kanataIsRunning: runtimeStatus.isRunning, systemState: systemState, issues: issues ) @@ -266,19 +266,19 @@ struct WizardKanataServicePage: View { await MainActor.run { withAnimation(.easeInOut(duration: 0.3)) { - applyStatusUpdate(serviceState: serviceState, processStatus: processStatus) + applyStatusUpdate(runtimeStatus: runtimeStatus, processStatus: processStatus) } } } @MainActor private func applyStatusUpdate( - serviceState: KanataService.ServiceState, + runtimeStatus: RuntimeCoordinator.RuntimeStatus, processStatus: ServiceProcessStatus ) { var derivedStatus: ServiceStatus - switch serviceState { + switch runtimeStatus { case .running: derivedStatus = .running case .stopped: @@ -291,11 +291,8 @@ struct WizardKanataServicePage: View { } else { derivedStatus = .failed(error: reason) } - case .maintenance: + case .starting: derivedStatus = .starting - case .requiresApproval: - let message = "Approval required in System Settings ▸ Privacy & Security" - derivedStatus = .failed(error: message) case .unknown: derivedStatus = .unknown } @@ -406,8 +403,8 @@ struct WizardKanataServicePage: View { private nonisolated static func extractConfigError(from stderrPath: String) -> String? { // Ignore stale stderr logs so old config errors don't surface after reinstalls. let maxLogAge: TimeInterval = 10 * 60 - if let attributes = try? FileManager.default.attributesOfItem(atPath: stderrPath), - let modifiedAt = attributes[.modificationDate] as? Date, + if let attributes = try? Foundation.FileManager().attributesOfItem(atPath: stderrPath), + let modifiedAt = attributes[Foundation.FileAttributeKey.modificationDate] as? Date, Date().timeIntervalSince(modifiedAt) > maxLogAge { return nil @@ -571,21 +568,21 @@ struct WizardKanataServicePage_Previews: PreviewProvider { issues: [], onRefresh: {} ) - .previewDisplayName("Kanata Service - Stopped") + .previewDisplayName("KeyPath Runtime - Stopped") WizardKanataServicePage( - systemState: .missingComponents(missing: [.kanataService]), + systemState: .missingComponents(missing: [.keyPathRuntime]), issues: [], onRefresh: {} ) - .previewDisplayName("Kanata Service - Missing Component") + .previewDisplayName("KeyPath Runtime - Missing Component") WizardKanataServicePage( systemState: .ready, issues: [], onRefresh: {} ) - .previewDisplayName("Kanata Service - Ready") + .previewDisplayName("KeyPath Runtime - Ready") } .environment(viewModel) .environment(stateMachine) diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerComponentsPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerComponentsPage.swift index 18bf9e274..0561c2aef 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerComponentsPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerComponentsPage.swift @@ -70,26 +70,6 @@ struct WizardKarabinerComponentsPage: View { } } - // Show Background Services only if showAllItems OR if it has issues - if showAllItems || componentStatus(for: .backgroundServices) != .completed { - HStack(spacing: 12) { - Image( - systemName: componentStatus(for: .backgroundServices) == .completed - ? "checkmark.circle.fill" : "xmark.circle.fill" - ) - .foregroundColor( - componentStatus(for: .backgroundServices) == .completed ? .green : .red - ) - HStack(spacing: 0) { - Text("Background Services") - .font(.headline) - .fontWeight(.semibold) - Text(" - Karabiner services in Login Items for startup") - .font(.headline) - .fontWeight(.regular) - } - } - } } .frame(maxWidth: .infinity) .padding(WizardDesign.Spacing.cardPadding) @@ -214,9 +194,7 @@ struct WizardKarabinerComponentsPage: View { ) } - private var needsManualAction: Bool { - componentStatus(for: .backgroundServices) == .failed - } + private var needsManualAction: Bool { false } private func navigateToNextStep() { if issues.isEmpty { @@ -471,14 +449,10 @@ struct WizardKarabinerComponentsPage: View { } let needsDaemonRepair = vhidIssues.contains(where: { $0.identifier == .component(.vhidDeviceRunning) }) || - issues.contains(where: { $0.identifier == .component(.karabinerDaemon) }) || - issues.contains(where: { $0.identifier == .component(.launchDaemonServicesUnhealthy) }) + issues.contains(where: { $0.identifier == .component(.karabinerDaemon) }) if needsDaemonRepair { AppLogger.shared.log("🧭 [FIX-VHID \(session)] Action: repairVHIDDaemonServices (daemon not running)") success = await performAutoFix(.repairVHIDDaemonServices) || success - } else if vhidIssues.contains(where: { $0.identifier == .component(.launchDaemonServices) }) { - AppLogger.shared.log("🧭 [FIX-VHID \(session)] Action: installLaunchDaemonServices") - success = await performAutoFix(.installLaunchDaemonServices) || success } // Always run a verified restart last to ensure single-owner state @@ -609,8 +583,8 @@ struct WizardKarabinerComponentsPage: View { /// Attempts automatic repair of background services private func performAutomaticServiceRepair() async -> Bool { - AppLogger.shared.log("🔧 [Service Repair] Installing/repairing LaunchDaemon services") - let success = await performAutoFix(.installLaunchDaemonServices) + AppLogger.shared.log("🔧 [Service Repair] Installing required runtime services") + let success = await performAutoFix(.installRequiredRuntimeServices) if success { AppLogger.shared.log("✅ [Service Repair] Service repair succeeded") @@ -658,10 +632,10 @@ struct WizardKarabinerComponentsPage: View { AppLogger.shared.log("🔄 [Karabiner Fix] State refresh \(refreshCompleted ? "completed" : "timed out") after \(refreshElapsed)s") // Check if service is already running - if so, we're done. - let serviceState = await kanataManager.currentServiceState() - if serviceState.isRunning { + let runtimeStatus = await kanataManager.currentRuntimeStatus() + if runtimeStatus.isRunning { let totalElapsed = String(format: "%.2f", Date().timeIntervalSince(t0)) - AppLogger.shared.log("🔄 [Karabiner Fix] refreshAndWait() completed - service already running (elapsed=\(totalElapsed)s)") + AppLogger.shared.log("🔄 [Karabiner Fix] refreshAndWait() completed - runtime already running (elapsed=\(totalElapsed)s)") return } diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerImportPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerImportPage.swift index 3e5be4f2f..c761cc095 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerImportPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardKarabinerImportPage.swift @@ -243,7 +243,7 @@ struct WizardKarabinerImportPage: View { isConverting = true let path = WizardSystemPaths.karabinerConfigPath - guard let data = FileManager.default.contents(atPath: path) else { + guard let data = Foundation.FileManager().contents(atPath: path) else { errorMessage = "Could not read Karabiner config file" isConverting = false return @@ -252,8 +252,8 @@ struct WizardKarabinerImportPage: View { do { let result = try converterService.convert(data: data, profileIndex: nil) conversionResult = result - selectedCollectionIds = Set(result.collections.map(\.id)) - selectedAppKeymapIds = Set(result.appKeymaps.map(\.id)) + selectedCollectionIds = Set(result.collections.map { $0.id }) + selectedAppKeymapIds = Set(result.appKeymaps.map { $0.id }) } catch { errorMessage = error.localizedDescription } diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardSummaryPage.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardSummaryPage.swift index 79f48ff5f..4cf730d03 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardSummaryPage.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/Pages/WizardSummaryPage.swift @@ -380,13 +380,12 @@ struct WizardSummaryPage: View { ) if karabinerStatus == .failed { count += 1 } - // 6. Kanata Engine Setup (failed => red) + // 6. Keyboard Engine Setup (failed => red) let hasKanataIssues = issues.contains { issue in if issue.category == .installation { switch issue.identifier { case .component(.kanataBinaryMissing), - .component(.kanataService), - .component(.orphanedKanataProcess): + .component(.keyPathRuntime): return true default: return false diff --git a/Sources/KeyPathAppKit/InstallationWizard/UI/WizardWindowController.swift b/Sources/KeyPathAppKit/InstallationWizard/UI/WizardWindowController.swift index 7c6428d5c..840b6fbaa 100644 --- a/Sources/KeyPathAppKit/InstallationWizard/UI/WizardWindowController.swift +++ b/Sources/KeyPathAppKit/InstallationWizard/UI/WizardWindowController.swift @@ -105,7 +105,7 @@ final class WizardWindowController { sizeObserver = NotificationCenter.default.addObserver( forName: .wizardContentSizeChanged, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor [weak self] in self?.scheduleResizeDebouncedUpdate() diff --git a/Sources/KeyPathAppKit/Managers/Configuration/ConfigurationManager.swift b/Sources/KeyPathAppKit/Managers/Configuration/ConfigurationManager.swift index 7b2d1074c..3e2895398 100644 --- a/Sources/KeyPathAppKit/Managers/Configuration/ConfigurationManager.swift +++ b/Sources/KeyPathAppKit/Managers/Configuration/ConfigurationManager.swift @@ -103,7 +103,7 @@ final class ConfigurationManager: @preconcurrency ConfigurationManaging { // @pr } func validateConfigFile() async -> (isValid: Bool, errors: [String]) { - guard FileManager.default.fileExists(atPath: configPath) else { + guard Foundation.FileManager().fileExists(atPath: configPath) else { return (false, ["Config file does not exist at: \(configPath)"]) } @@ -140,7 +140,7 @@ final class ConfigurationManager: @preconcurrency ConfigurationManaging { // @pr // Ensure config directory exists let configDirectoryURL = URL(fileURLWithPath: configDirectory) - try FileManager.default.createDirectory( + try Foundation.FileManager().createDirectory( at: configDirectoryURL, withIntermediateDirectories: true ) @@ -158,13 +158,13 @@ final class ConfigurationManager: @preconcurrency ConfigurationManaging { // @pr AppLogger.shared.log("📡 [ConfigManager] Saving validated config (TCP-only mode)") let configDir = URL(fileURLWithPath: configDirectory) - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: configDir, withIntermediateDirectories: true) AppLogger.shared.log("🔍 [ConfigManager] Config directory created/verified: \(configDirectory)") let configURL = URL(fileURLWithPath: configPath) // Check if file exists before writing - let fileExists = FileManager.default.fileExists(atPath: configPath) + let fileExists = Foundation.FileManager().fileExists(atPath: configPath) AppLogger.shared.log("🔍 [ConfigManager] Config file exists before write: \(fileExists)") // Write the config @@ -172,9 +172,9 @@ final class ConfigurationManager: @preconcurrency ConfigurationManaging { // @pr AppLogger.shared.log("✅ [ConfigManager] Config written to file successfully") // Get modification time after write - let afterAttributes = try FileManager.default.attributesOfItem(atPath: configPath) - let afterModTime = afterAttributes[.modificationDate] as? Date - let fileSize = afterAttributes[.size] as? Int ?? 0 + let afterAttributes = try Foundation.FileManager().attributesOfItem(atPath: configPath) + let afterModTime = afterAttributes[Foundation.FileAttributeKey.modificationDate] as? Date + let fileSize = afterAttributes[Foundation.FileAttributeKey.size] as? Int ?? 0 AppLogger.shared.log( "🔍 [ConfigManager] Modification time after write: \(afterModTime?.description ?? "unknown")" ) @@ -235,7 +235,7 @@ final class ConfigurationManager: @preconcurrency ConfigurationManaging { // @pr func ensureValidStartupConfig() async -> (mappings: [KeyMapping], validationError: ConfigValidationError?) { AppLogger.shared.log("📂 [ConfigManager] Ensuring valid startup configuration") - guard FileManager.default.fileExists(atPath: configPath) else { + guard Foundation.FileManager().fileExists(atPath: configPath) else { AppLogger.shared.log("ℹ️ [ConfigManager] No existing config file found at: \(configPath)") AppLogger.shared.log("ℹ️ [ConfigManager] Starting with empty mappings") return ([], nil) @@ -352,7 +352,7 @@ final class ConfigurationManager: @preconcurrency ConfigurationManaging { // @pr return false } - let exists = FileManager.default.fileExists(atPath: configPath) + let exists = Foundation.FileManager().fileExists(atPath: configPath) if exists { AppLogger.shared.log("✅ [ConfigManager] Verified user config exists at \(configPath)") } else { diff --git a/Sources/KeyPathAppKit/Managers/Diagnostics/DiagnosticsManager.swift b/Sources/KeyPathAppKit/Managers/Diagnostics/DiagnosticsManager.swift index a7ba61172..1e8dd3281 100644 --- a/Sources/KeyPathAppKit/Managers/Diagnostics/DiagnosticsManager.swift +++ b/Sources/KeyPathAppKit/Managers/Diagnostics/DiagnosticsManager.swift @@ -24,6 +24,12 @@ protocol DiagnosticsManaging: Sendable { /// Check service health func checkHealth(tcpPort: Int) async -> ServiceHealthStatus + /// Record a VirtualHID connection failure and report whether recovery should trigger. + func recordConnectionFailure() async -> Bool + + /// Record a VirtualHID connection success. + func recordConnectionSuccess() async + /// Diagnose Kanata failure func diagnoseFailure(exitCode: Int32, output: String) -> [KanataDiagnostic] @@ -36,16 +42,19 @@ protocol DiagnosticsManaging: Sendable { final class DiagnosticsManager: @preconcurrency DiagnosticsManaging { // @preconcurrency: @MainActor satisfies Sendable via isolation private var diagnostics: [KanataDiagnostic] = [] private let diagnosticsService: DiagnosticsServiceProtocol - private let kanataService: KanataService + private let healthMonitor: ServiceHealthMonitorProtocol + private let processStatusProvider: @MainActor @Sendable () async -> ProcessHealthStatus private var logMonitorTask: Task? init( diagnosticsService: DiagnosticsServiceProtocol, - kanataService: KanataService + healthMonitor: ServiceHealthMonitorProtocol, + processStatusProvider: @escaping @MainActor @Sendable () async -> ProcessHealthStatus ) { self.diagnosticsService = diagnosticsService - self.kanataService = kanataService + self.healthMonitor = healthMonitor + self.processStatusProvider = processStatusProvider } func addDiagnostic(_ diagnostic: KanataDiagnostic) { @@ -76,7 +85,7 @@ final class DiagnosticsManager: @preconcurrency DiagnosticsManaging { // @precon guard let self else { return } let logPath = WizardSystemPaths.kanataLogFile - guard FileManager.default.fileExists(atPath: logPath) else { + guard Foundation.FileManager().fileExists(atPath: logPath) else { AppLogger.shared.log("⚠️ [DiagnosticsManager] Kanata log file not found at \(logPath)") return } @@ -151,12 +160,24 @@ final class DiagnosticsManager: @preconcurrency DiagnosticsManaging { // @precon logMonitorTask?.cancel() logMonitorTask = nil Task { @MainActor [weak self] in - await self?.kanataService.recordConnectionSuccess() // Reset on stop + await self?.recordConnectionSuccess() // Reset on stop } } func checkHealth(tcpPort: Int) async -> ServiceHealthStatus { - await kanataService.checkHealth(tcpPort: tcpPort) + let processStatus = await processStatusProvider() + return await healthMonitor.checkServiceHealth( + processStatus: processStatus, + tcpPort: tcpPort + ) + } + + func recordConnectionFailure() async -> Bool { + await healthMonitor.recordConnectionFailure() + } + + func recordConnectionSuccess() async { + await healthMonitor.recordConnectionSuccess() } func diagnoseFailure(exitCode: Int32, output: String) -> [KanataDiagnostic] { @@ -183,7 +204,7 @@ final class DiagnosticsManager: @preconcurrency DiagnosticsManaging { // @precon } // Record connection success - await kanataService.recordConnectionSuccess() + await recordConnectionSuccess() return } @@ -215,7 +236,7 @@ final class DiagnosticsManager: @preconcurrency DiagnosticsManaging { // @precon } // Record connection failure for health monitoring - let shouldRecover = await kanataService.recordConnectionFailure() + let shouldRecover = await recordConnectionFailure() if shouldRecover { AppLogger.shared.log( "🚨 [DiagnosticsManager] Max connection failures reached - recovery recommended" diff --git a/Sources/KeyPathAppKit/Managers/HelperMaintenance.swift b/Sources/KeyPathAppKit/Managers/HelperMaintenance.swift index 2593df605..2ca2ef71b 100644 --- a/Sources/KeyPathAppKit/Managers/HelperMaintenance.swift +++ b/Sources/KeyPathAppKit/Managers/HelperMaintenance.swift @@ -59,14 +59,18 @@ final class HelperMaintenance { log("✅ App copy check: OK (\(copies.first ?? "unknown"))") } - // Step 1: Best-effort unregister via SMAppService - await unregisterHelperIfPresent() - - // Step 2: Try to install/register helper first (preferred, no AppleScript) + // Step 1: Try to refresh/register the helper in-place first. + // + // If the helper is already healthy, tearing it down first can introduce its own + // SMAppService failure modes and makes fast iteration harder. Prefer an idempotent + // register/refresh attempt, then fall back to full unregister/cleanup only if needed. if await registerHelper() { log("✅ Helper registered via SMAppService on first attempt") } else { - log("⚠️ Primary registration failed; attempting cleanup then retry") + log("⚠️ Direct helper refresh failed; attempting cleanup then retry") + + // Step 2: Best-effort unregister via SMAppService + await unregisterHelperIfPresent() // Step 3: Stop/bootout any launchd job remnants await bootoutHelperJob() @@ -224,7 +228,7 @@ final class HelperMaintenance { return await override(useAppleScriptFallback) } let (removedDirectly, _) = await Task.detached { () -> (Bool, Bool) in - let fm = FileManager.default + let fm = Foundation.FileManager() let legacyBin = "/Library/PrivilegedHelperTools/com.keypath.helper" let legacyPlist = "/Library/LaunchDaemons/com.keypath.helper.plist" @@ -303,7 +307,7 @@ final class HelperMaintenance { NSHomeDirectory() + "/Applications/KeyPath.app", NSHomeDirectory() + "/Downloads/KeyPath.app" ] - for p in defaults where FileManager.default.fileExists(atPath: p) { + for p in defaults where Foundation.FileManager().fileExists(atPath: p) { candidates.append(p) } return candidates.isEmpty ? defaults : candidates diff --git a/Sources/KeyPathAppKit/Managers/InstallationCoordinator.swift b/Sources/KeyPathAppKit/Managers/InstallationCoordinator.swift index d169ef1f5..fe41094b6 100644 --- a/Sources/KeyPathAppKit/Managers/InstallationCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/InstallationCoordinator.swift @@ -21,7 +21,7 @@ final class InstallationCoordinator { } // Check for user config file - let hasUserConfig = FileManager.default.fileExists(atPath: configPath) + let hasUserConfig = Foundation.FileManager().fileExists(atPath: configPath) if !hasUserConfig { AppLogger.shared.log("🆕 [InstallCoordinator] No user config found at \(configPath) - fresh install detected") @@ -66,7 +66,7 @@ final class InstallationCoordinator { AppLogger.shared.log("🔧 [Installation] Step \(stepNumber)/\(totalSteps): Checking Karabiner driver...") let driverPath = KeyPathConstants.VirtualHID.driverPath - if !FileManager.default.fileExists(atPath: driverPath) { + if !Foundation.FileManager().fileExists(atPath: driverPath) { AppLogger.shared.log("⚠️ [Installation] Step \(stepNumber) WARNING: Karabiner driver not found at \(driverPath)") AppLogger.shared.log("ℹ️ [Installation] User should install Karabiner-Elements first") return StepResult(stepNumber: stepNumber, totalSteps: totalSteps, success: true, warning: true) @@ -89,7 +89,7 @@ final class InstallationCoordinator { func checkConfigFile(configPath: String, stepNumber: Int = 4, totalSteps: Int = 5) -> StepResult { AppLogger.shared.log("🔧 [Installation] Step \(stepNumber)/\(totalSteps): Creating user configuration...") - if FileManager.default.fileExists(atPath: configPath) { + if Foundation.FileManager().fileExists(atPath: configPath) { AppLogger.shared.log("✅ [Installation] Step \(stepNumber) SUCCESS: User config available at \(configPath)") return StepResult(stepNumber: stepNumber, totalSteps: totalSteps, success: true, warning: false) } else { diff --git a/Sources/KeyPathAppKit/Managers/KanataDaemonManager.swift b/Sources/KeyPathAppKit/Managers/KanataDaemonManager.swift index a50eab4a3..5414a1943 100644 --- a/Sources/KeyPathAppKit/Managers/KanataDaemonManager.swift +++ b/Sources/KeyPathAppKit/Managers/KanataDaemonManager.swift @@ -1,6 +1,7 @@ import Foundation import KeyPathCore import ServiceManagement +import Darwin /// Manager for Kanata LaunchDaemon registration via SMAppService /// @@ -114,7 +115,7 @@ class KanataDaemonManager { /// - Returns: The current ServiceManagementState @discardableResult nonisolated func refreshManagementState() async -> ServiceManagementState { - let hasLegacy = FileManager.default.fileExists(atPath: Self.legacyPlistPath) + let hasLegacy = Foundation.FileManager().fileExists(atPath: Self.legacyPlistPath) let svc = Self.smServiceFactory(Self.kanataPlistName) let smStatus = svc.status @@ -243,7 +244,7 @@ class KanataDaemonManager { /// Check if legacy launchctl installation exists /// - Returns: true if plist exists at /Library/LaunchDaemons/com.keypath.kanata.plist nonisolated func hasLegacyInstallation() -> Bool { - FileManager.default.fileExists(atPath: Self.legacyPlistPath) + Foundation.FileManager().fileExists(atPath: Self.legacyPlistPath) } /// Check if SMAppService is currently being used for Kanata daemon management @@ -284,22 +285,16 @@ class KanataDaemonManager { // Run expensive checks async return await Task.detached { - // 2. Check launchd state - let launchctlOutput: String - do { - let result = try await SubprocessRunner.shared.launchctl("print", ["system/\(Self.kanataServiceID)"]) - launchctlOutput = result.exitCode == 0 ? result.stdout : "" - } catch { - launchctlOutput = "" - } + let launchctlOutputs = await Self.readLaunchctlOutputs(for: .smappserviceActive) // 3. Check if process is running let processIsRunning = await Self.pgrepKanataProcessAsync() // 4. Analyze the state - let launchctlCanFindService = !launchctlOutput.isEmpty - let isSpawnFailed = launchctlOutput.contains("spawn failed") || - launchctlOutput.contains("last exit code = 78") + let launchctlCanFindService = launchctlOutputs.contains { !$0.output.isEmpty } + let isSpawnFailed = launchctlOutputs.contains { entry in + entry.output.contains("spawn failed") || entry.output.contains("last exit code = 78") + } // Issue detected if: // - Service registered but launchd can't find it, OR @@ -327,6 +322,40 @@ class KanataDaemonManager { }.value } + nonisolated static func preferredLaunchctlTargets( + for managementState: ServiceManagementState, + userID: uid_t = getuid() + ) -> [String] { + let guiTarget = "gui/\(userID)/\(kanataServiceID)" + let systemTarget = "system/\(kanataServiceID)" + + switch managementState { + case .legacyActive: + return [systemTarget] + case .smappserviceActive, .smappservicePending, .conflicted, .unknown, .uninstalled: + return [guiTarget, systemTarget] + } + } + + nonisolated private static func readLaunchctlOutputs(for managementState: ServiceManagementState) + async -> [(target: String, output: String, exitCode: Int32?)] + { + var outputs: [(target: String, output: String, exitCode: Int32?)] = [] + for target in preferredLaunchctlTargets(for: managementState) { + do { + let result = try await SubprocessRunner.shared.launchctl("print", [target]) + outputs.append(( + target: target, + output: result.exitCode == 0 ? result.stdout : "", + exitCode: result.exitCode + )) + } catch { + outputs.append((target: target, output: "", exitCode: nil)) + } + } + return outputs + } + // MARK: - Registration /// Register Kanata daemon via SMAppService @@ -335,9 +364,7 @@ class KanataDaemonManager { AppLogger.shared.log( "🔐 [SMAPPSERVICE-TRIGGER] *** ENTRY POINT *** Registering Kanata daemon via SMAppService" ) - // Log stack trace to identify caller - let callStack = Thread.callStackSymbols.prefix(10).joined(separator: "\n") - AppLogger.shared.log("🔐 [SMAPPSERVICE-TRIGGER] Call stack:\n\(callStack)") + AppLogger.shared.log("🔐 [SMAPPSERVICE-TRIGGER] Caller stack unavailable in this build") AppLogger.shared.log( "🔍 [KanataDaemonManager] macOS version check: \(ProcessInfo.processInfo.operatingSystemVersionString)" ) @@ -361,7 +388,7 @@ class KanataDaemonManager { AppLogger.shared.log("🔍 [KanataDaemonManager] Checking for plist at: \(expectedPlistPath)") // First check the expected location (build scripts place it here) - if FileManager.default.fileExists(atPath: expectedPlistPath) { + if Foundation.FileManager().fileExists(atPath: expectedPlistPath) { AppLogger.shared.log( "✅ [KanataDaemonManager] Found plist at expected location: \(expectedPlistPath)" ) @@ -405,10 +432,21 @@ class KanataDaemonManager { ) } - // Validate kanata binary exists in app bundle - let kanataPath = "\(bundlePath)/Contents/Library/KeyPath/kanata" + // Validate runtime host exists in app bundle + let launcherPath = WizardSystemPaths.bundledKanataLauncherPath + AppLogger.shared.log("🔍 [KanataDaemonManager] Checking for Kanata launcher at: \(launcherPath)") + guard Foundation.FileManager().fileExists(atPath: launcherPath) else { + AppLogger.shared.log("❌ [KanataDaemonManager] Kanata launcher not found at: \(launcherPath)") + throw KanataDaemonError.registrationFailed( + "Kanata launcher not found in app bundle: \(launcherPath)" + ) + } + AppLogger.shared.log("✅ [KanataDaemonManager] Kanata launcher found") + + // Validate kanata core binary exists in app bundle + let kanataPath = WizardSystemPaths.bundledKanataPath AppLogger.shared.log("🔍 [KanataDaemonManager] Checking for Kanata binary at: \(kanataPath)") - guard FileManager.default.fileExists(atPath: kanataPath) else { + guard Foundation.FileManager().fileExists(atPath: kanataPath) else { AppLogger.shared.log("❌ [KanataDaemonManager] Kanata binary not found at: \(kanataPath)") throw KanataDaemonError.registrationFailed( "Kanata binary not found in app bundle: \(kanataPath)" diff --git a/Sources/KeyPathAppKit/Managers/Process/ProcessCoordinator.swift b/Sources/KeyPathAppKit/Managers/Process/ProcessCoordinator.swift deleted file mode 100644 index cd510c19e..000000000 --- a/Sources/KeyPathAppKit/Managers/Process/ProcessCoordinator.swift +++ /dev/null @@ -1,124 +0,0 @@ -import Foundation -import KeyPathCore -import KeyPathDaemonLifecycle - -/// Protocol for coordinating process lifecycle operations -/// Provides a unified interface for starting, stopping, and restarting Kanata service -@MainActor -protocol ProcessCoordinating: AnyObject { - /// Start the Kanata service - /// - Returns: True if service started successfully - func startService() async -> Bool - - /// Stop the Kanata service - /// - Returns: True if service stopped successfully - func stopService() async -> Bool - - /// Restart the Kanata service - /// - Returns: True if service restarted successfully - func restartService() async -> Bool - - /// Check if the service is currently running - /// - Returns: True if service is running - func isServiceRunning() async -> Bool -} - -/// Coordinates process lifecycle operations by delegating to InstallerEngine -/// This provides a clean interface for RuntimeCoordinator (formerly RuntimeCoordinator) -@MainActor -final class ProcessCoordinator: ProcessCoordinating { - private let kanataService: KanataService - private let installerEngine: InstallerEngine - private let privilegeBroker: PrivilegeBroker - - init( - kanataService: KanataService = .shared, - installerEngine: InstallerEngine = InstallerEngine(), - privilegeBroker: PrivilegeBroker = PrivilegeBroker() - ) { - self.kanataService = kanataService - self.installerEngine = installerEngine - self.privilegeBroker = privilegeBroker - } - - func startService() async -> Bool { - AppLogger.shared.log("🚀 [ProcessCoordinator] Starting Kanata service via KanataService…") - do { - try await kanataService.start() - await kanataService.refreshStatus() - AppLogger.shared.log("✅ [ProcessCoordinator] KanataService start succeeded") - return true - } catch { - let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - AppLogger.shared.warn( - "⚠️ [ProcessCoordinator] KanataService start failed (\(message)) - falling back to InstallerEngine repair" - ) - let report = await installerEngine.run(intent: .repair, using: privilegeBroker) - if report.success { - await kanataService.refreshStatus() - AppLogger.shared.info("✅ [ProcessCoordinator] InstallerEngine fallback start succeeded") - return true - } else { - AppLogger.shared.error( - "❌ [ProcessCoordinator] InstallerEngine fallback start failed: \(report.failureReason ?? "Unknown error")" - ) - return false - } - } - } - - func stopService() async -> Bool { - AppLogger.shared.log("🛑 [ProcessCoordinator] Stopping Kanata service via KanataService…") - do { - try await kanataService.stop() - await kanataService.refreshStatus() - AppLogger.shared.log("✅ [ProcessCoordinator] KanataService stop succeeded") - return true - } catch { - let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - AppLogger.shared.warn( - "⚠️ [ProcessCoordinator] KanataService stop failed (\(message)) - falling back to privilege broker" - ) - do { - try await privilegeBroker.stopKanataService() - await kanataService.refreshStatus() - AppLogger.shared.info("✅ [ProcessCoordinator] Privilege broker stop fallback succeeded") - return true - } catch { - AppLogger.shared.error("❌ [ProcessCoordinator] Privilege broker stop failed: \(error)") - return false - } - } - } - - func restartService() async -> Bool { - AppLogger.shared.log("🔄 [ProcessCoordinator] Restarting Kanata service via KanataService…") - do { - try await kanataService.restart() - await kanataService.refreshStatus() - AppLogger.shared.log("✅ [ProcessCoordinator] KanataService restart succeeded") - return true - } catch { - let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - AppLogger.shared.warn( - "⚠️ [ProcessCoordinator] KanataService restart failed (\(message)) - falling back to InstallerEngine repair" - ) - let report = await installerEngine.run(intent: .repair, using: privilegeBroker) - if report.success { - await kanataService.refreshStatus() - AppLogger.shared.info("✅ [ProcessCoordinator] InstallerEngine fallback restart succeeded") - return true - } else { - AppLogger.shared.error( - "❌ [ProcessCoordinator] InstallerEngine fallback restart failed: \(report.failureReason ?? "Unknown error")" - ) - return false - } - } - } - - func isServiceRunning() async -> Bool { - let state = await kanataService.refreshStatus() - return state.isRunning - } -} diff --git a/Sources/KeyPathAppKit/Managers/Process/ProcessManager.swift b/Sources/KeyPathAppKit/Managers/Process/ProcessManager.swift deleted file mode 100644 index e053e3346..000000000 --- a/Sources/KeyPathAppKit/Managers/Process/ProcessManager.swift +++ /dev/null @@ -1,257 +0,0 @@ -import Foundation -import KeyPathCore -import KeyPathDaemonLifecycle - -/// Protocol for managing Kanata process lifecycle -protocol ProcessManaging: Sendable { - /// Start the Kanata service with the given configuration - func startService(configPath: String, arguments: [String]) async -> Bool - - /// Stop the Kanata service - func stopService() async -> Bool - - /// Restart the Kanata service - func restartService(configPath: String, arguments: [String]) async -> Bool - - /// Check the current service status - func status() async -> (isRunning: Bool, pid: Int?) - - /// Resolve any process conflicts before starting - func resolveConflicts() async - - /// Verify no process conflicts exist after starting - func verifyNoConflicts() async - - /// Cleanup when app terminates - func cleanup() async -} - -/// Manages Kanata process lifecycle using LaunchDaemon -// SAFETY: @unchecked Sendable — all stored state (processLifecycleManager, -// karabinerConflictService) is immutable after init and itself Sendable. -final class ProcessManager: ProcessManaging, @unchecked Sendable { - private let processLifecycleManager: ProcessLifecycleManager - private let karabinerConflictService: KarabinerConflictManaging - - init( - processLifecycleManager: ProcessLifecycleManager, - karabinerConflictService: KarabinerConflictManaging - ) { - self.processLifecycleManager = processLifecycleManager - self.karabinerConflictService = karabinerConflictService - } - - func startService(configPath _: String, arguments: [String]) async -> Bool { - AppLogger.shared.log("🚀 [ProcessManager] Starting Kanata LaunchDaemon service...") - - // Resolve any conflicts first - await resolveConflicts() - - // Start the LaunchDaemon service - let success = await startLaunchDaemonService() - - if success { - // Wait a moment for service to initialize - try? await Task.sleep(for: .seconds(1)) // 1 second - - // Verify service started successfully - let serviceStatus = await checkLaunchDaemonStatus() - if let pid = serviceStatus.pid { - AppLogger.shared.log("📝 [ProcessManager] LaunchDaemon service started with PID: \(pid)") - - // Register with lifecycle manager - let command = arguments.joined(separator: " ") - await processLifecycleManager.registerStartedProcess( - pid: Int32(pid), command: "launchd: \(command)" - ) - - // Check for process conflicts after starting - await verifyNoConflicts() - - AppLogger.shared.log( - "✅ [ProcessManager] Successfully started Kanata LaunchDaemon service (PID: \(pid))" - ) - return true - } else { - AppLogger.shared.log( - "⚠️ [ProcessManager] Service started but no PID found - may still be initializing" - ) - return false - } - } else { - AppLogger.shared.log("❌ [ProcessManager] Failed to start LaunchDaemon service") - return false - } - } - - func stopService() async -> Bool { - AppLogger.shared.log("🛑 [ProcessManager] Stopping Kanata LaunchDaemon service...") - - // Stop the LaunchDaemon service - let success = await stopLaunchDaemonService() - - if success { - AppLogger.shared.log("✅ [ProcessManager] Successfully stopped Kanata LaunchDaemon service") - - // Unregister from lifecycle manager - await processLifecycleManager.unregisterProcess() - return true - } else { - AppLogger.shared.log("⚠️ [ProcessManager] Failed to stop Kanata LaunchDaemon service") - return false - } - } - - func restartService(configPath: String, arguments: [String]) async -> Bool { - AppLogger.shared.log("🔄 [ProcessManager] Restarting Kanata...") - let stopped = await stopService() - guard stopped else { - AppLogger.shared.log("⚠️ [ProcessManager] Failed to stop service during restart") - return false - } - return await startService(configPath: configPath, arguments: arguments) - } - - func status() async -> (isRunning: Bool, pid: Int?) { - await checkLaunchDaemonStatus() - } - - func resolveConflicts() async { - AppLogger.shared.log("🔍 [ProcessManager] Checking for conflicting Kanata processes...") - - let conflicts = await processLifecycleManager.detectConflicts() - let allProcesses = conflicts.managedProcesses + conflicts.externalProcesses - - if !allProcesses.isEmpty { - AppLogger.shared.log( - "⚠️ [ProcessManager] Found \(allProcesses.count) existing Kanata processes" - ) - - for processInfo in allProcesses { - AppLogger.shared.log( - "⚠️ [ProcessManager] Process PID \(processInfo.pid): \(processInfo.command)" - ) - } - - // Terminate only external processes via lifecycle manager - do { - try await processLifecycleManager.terminateExternalProcesses() - } catch { - AppLogger.shared.log("⚠️ [ProcessManager] Failed to terminate external processes: \(error)") - } - } else { - AppLogger.shared.log("✅ [ProcessManager] No conflicting processes found") - } - } - - func verifyNoConflicts() async { - // Wait a moment for any conflicts to surface - try? await Task.sleep(for: .milliseconds(500)) // 0.5 seconds - - let conflicts = await processLifecycleManager.detectConflicts() - let managedProcesses = conflicts.managedProcesses - let conflictProcesses = conflicts.externalProcesses - - AppLogger.shared.log( - "🔍 [ProcessManager] Process status: \(managedProcesses.count) managed, \(conflictProcesses.count) conflicts" - ) - - // Show managed processes (should be our LaunchDaemon) - for processInfo in managedProcesses { - AppLogger.shared.log( - "✅ [ProcessManager] Managed LaunchDaemon process: PID \(processInfo.pid)" - ) - } - - // Show any conflicting processes (these are the problem) - for processInfo in conflictProcesses { - AppLogger.shared.log( - "⚠️ [ProcessManager] Conflicting process: PID \(processInfo.pid) - \(processInfo.command)" - ) - } - - if conflictProcesses.isEmpty { - AppLogger.shared.log( - "✅ [ProcessManager] Clean single-process architecture confirmed - no conflicts" - ) - } else { - AppLogger.shared.log( - "⚠️ [ProcessManager] WARNING: \(conflictProcesses.count) conflicting processes detected!" - ) - } - } - - func cleanup() async { - _ = await stopService() - } - - // MARK: - Private Helper Methods - - /// Start the LaunchDaemon service via privileged operations facade - private func startLaunchDaemonService() async -> Bool { - AppLogger.shared.log("🚀 [ProcessManager] Starting Kanata service via PrivilegedOperations...") - return await PrivilegedOperationsProvider.shared.startKanataService() - } - - /// Stop the Kanata LaunchDaemon service via privileged operations facade - private func stopLaunchDaemonService() async -> Bool { - AppLogger.shared.log("🛑 [ProcessManager] Stopping Kanata service via PrivilegedOperations...") - let ok = await PrivilegedOperationsProvider.shared.stopKanataService() - if ok { - // Wait a moment for graceful shutdown - try? await Task.sleep(for: .seconds(1)) - } - return ok - } - - /// Check LaunchDaemon service status - private func checkLaunchDaemonStatus() async -> (isRunning: Bool, pid: Int?) { - AppLogger.shared.log("🔍 [ProcessManager] Checking LaunchDaemon service status...") - - // Skip actual system calls in test environment - if TestEnvironment.shouldSkipAdminOperations { - AppLogger.shared.log("🧪 [ProcessManager] Skipping launchctl check - returning mock data") - return (true, nil) // Mock: service loaded but not running - } - - let task = Process() - task.executableURL = URL(fileURLWithPath: "/bin/launchctl") - task.arguments = ["print", "system/com.keypath.kanata"] - - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - - do { - try task.run() - task.waitUntilExit() - - let data = pipe.fileHandleForReading.readDataToEndOfFile() - let output = String(data: data, encoding: .utf8) ?? "" - - // Parse PID from launchctl output - // Format: "pid = 12345" - var pid: Int? - for line in output.components(separatedBy: "\n") where line.contains("pid =") { - let components = line.components(separatedBy: "=") - if components.count == 2 { - let pidString = components[1].trimmingCharacters(in: .whitespacesAndNewlines) - pid = Int(pidString) - break - } - } - - // Service is running if we got a PID - let isRunning = pid != nil - if isRunning { - AppLogger.shared.log("✅ [ProcessManager] Service is running with PID: \(pid!)") - } else { - AppLogger.shared.log("⚠️ [ProcessManager] Service status check returned no PID") - } - return (isRunning, pid) - } catch { - AppLogger.shared.log("❌ [ProcessManager] Failed to check service status: \(error)") - return (false, nil) - } - } -} diff --git a/Sources/KeyPathAppKit/Managers/RecoveryCoordinator.swift b/Sources/KeyPathAppKit/Managers/RecoveryCoordinator.swift index 7c1f6c4da..60016cf9e 100644 --- a/Sources/KeyPathAppKit/Managers/RecoveryCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RecoveryCoordinator.swift @@ -103,7 +103,7 @@ final class RecoveryCoordinator { return } - // Try starting Kanata normally via KanataService + // Try starting the runtime normally via the runtime coordinator let started = await startKanata() if !started { AppLogger.shared.error("❌ [Recovery] Failed to start Kanata during validation") diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Configuration.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Configuration.swift index 21471846c..1098f8d0a 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Configuration.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Configuration.swift @@ -44,8 +44,15 @@ extension RuntimeCoordinator { /// Main reload method using TCP protocol func triggerConfigReload() async -> ReloadResult { - // Skip reloads if SMAppService is awaiting approval; avoid long TCP timeouts - let smState = await KanataDaemonManager.shared.refreshManagementState() + // Use cached state to avoid synchronous IPC to SMAppService in hot path (see CLAUDE.md) + // Only refresh if we have no cached state yet + let smState: KanataDaemonManager.ServiceManagementState + let cached = await MainActor.run { KanataDaemonManager.shared.currentManagementState } + if cached == .unknown { + smState = await KanataDaemonManager.shared.refreshManagementState() + } else { + smState = cached + } if smState == .smappservicePending { AppLogger.shared.warn( "⚠️ [Reload] Skipping TCP reload because SMAppService requires approval" @@ -59,7 +66,7 @@ extension RuntimeCoordinator { } // Skip reloads if Kanata service isn't healthy yet; avoid connection-refused storm - let healthStatus = await kanataService.checkHealth( + let healthStatus = await diagnosticsManager.checkHealth( tcpPort: PreferencesService.shared.tcpServerPort ) if !healthStatus.isHealthy { diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Diagnostics.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Diagnostics.swift index feec35de8..e0dd23047 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Diagnostics.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Diagnostics.swift @@ -49,7 +49,7 @@ extension RuntimeCoordinator { } case .restartService: - success = await restartServiceWithFallback( + success = await restartKanata( reason: "AutoFix diagnostic: \(diagnostic.title)" ) } diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Lifecycle.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Lifecycle.swift index 8cde98cfc..33cd52685 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Lifecycle.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Lifecycle.swift @@ -12,6 +12,72 @@ import Network extension RuntimeCoordinator { // MARK: - Process Synchronization and Initialization + func startSplitRuntimeCompanionMonitor() { + splitRuntimeCompanionMonitorTask?.cancel() + splitRuntimeCompanionMonitorTask = Task { @MainActor [weak self] in + guard let self else { return } + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } + await self.checkSplitRuntimeCompanionHealth() + } + } + } + + func checkSplitRuntimeCompanionHealth() async { + guard KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning else { + return + } + + guard !isRecoveringSplitRuntimeCompanion else { + return + } + + guard let status = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() else { + return + } + + guard !status.companionRunning else { + return + } + + isRecoveringSplitRuntimeCompanion = true + defer { isRecoveringSplitRuntimeCompanion = false } + + AppLogger.shared.warn( + "⚠️ [SplitRuntime] Output bridge companion not running while split host is active; attempting recovery" + ) + + do { + let recovery = try await KanataSplitRuntimeHostService.shared.restartCompanionAndRecoverPersistentHost() + guard recovery.companionRunningAfterRestart, recovery.recoveredHostPID != nil else { + throw NSError( + domain: "KeyPath.SplitRuntime", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Split runtime companion recovery did not restore a healthy host"] + ) + } + + lastWarning = "Split runtime host recovered after output bridge companion interruption." + lastError = nil + notifyStateChanged() + return + } catch { + AppLogger.shared.error( + "❌ [SplitRuntime] Failed to recover split runtime host after output bridge companion interruption: \(error.localizedDescription)" + ) + let failedPID = KanataSplitRuntimeHostService.shared.activePersistentHostPID ?? 0 + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + await handleSplitRuntimeHostExit( + pid: failedPID, + exitCode: -1, + terminationReason: "output-bridge-companion-unavailable", + expected: false, + stderrLogPath: nil + ) + } + } + func performInitialization() async { // Prevent concurrent initialization if isInitializing { @@ -44,9 +110,42 @@ extension RuntimeCoordinator { // Try to start Kanata automatically on launch if environment allows let context = await engine.inspectSystem() + let splitRuntimeDecision = await currentSplitRuntimeDecision() + let splitRuntimePreferred: Bool + switch splitRuntimeDecision { + case .useSplitRuntime: + splitRuntimePreferred = true + case .useLegacySystemBinary, .blocked: + splitRuntimePreferred = false + } - // Check if Kanata is already running + // Check if Kanata is already running. If split runtime is the preferred healthy path but + // the active runtime is still the legacy daemon, use normal startup to cut over instead + // of treating the legacy path as "good enough". if context.services.kanataRunning { + let activeRuntimeTitle = context.services.activeRuntimePathTitle? + .trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) + if activeRuntimeTitle == SplitRuntimeIdentity.hostTitle { + AppLogger.shared.info("✅ [Init] Split runtime host is already running - skipping initialization") + return + } + + if splitRuntimePreferred { + AppLogger.shared.log( + "🔀 [Init] Kanata is already running via \(activeRuntimeTitle ?? "an unknown runtime path"); cutting over to split runtime host" + ) + let started = await startKanata(reason: "Initialization split runtime cutover") + if started { + AppLogger.shared.log("✅ [Init] Initialization cutover to split runtime host completed") + return + } + + AppLogger.shared.warn( + "⚠️ [Init] Initialization cutover to split runtime host failed; leaving existing runtime in place" + ) + return + } + AppLogger.shared.info("✅ [Init] Kanata is already running - skipping initialization") return } @@ -76,4 +175,38 @@ extension RuntimeCoordinator { AppLogger.shared.warn("⚠️ [Recovery] Failed to kill Kanata processes: \(failureReason)") } } + + func handleSplitRuntimeHostExit( + pid: pid_t, + exitCode: Int32, + terminationReason: String, + expected: Bool, + stderrLogPath: String? + ) async { + guard pid > 0 + else { + return + } + + AppLogger.shared.log( + "🧪 [SplitRuntime] Persistent host exited pid=\(pid) code=\(exitCode) reason=\(terminationReason) expected=\(expected)" + ) + + await AppContextService.shared.stop() + + if expected { + notifyStateChanged() + return + } + + var message = "Split runtime host exited unexpectedly" + if let stderrLogPath, !stderrLogPath.isEmpty { + message += " (see \(stderrLogPath))" + } + message += ". KeyPath no longer auto-falls back to the legacy daemon. Toggle the service again to restart the split runtime host." + + lastError = message + lastWarning = nil + notifyStateChanged() + } } diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+ServiceManagement.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+ServiceManagement.swift index a780c7e00..8d6217f20 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+ServiceManagement.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+ServiceManagement.swift @@ -3,6 +3,38 @@ import KeyPathCore import KeyPathPermissions extension RuntimeCoordinator { + enum RuntimeStatus: Equatable, Sendable { + case running(pid: Int) + case stopped + case failed(reason: String) + case starting + case unknown + + var isRunning: Bool { + if case .running = self { return true } + return false + } + } + + func currentSplitRuntimeDecision() async -> KanataRuntimePathDecision { + return await KanataRuntimePathCoordinator.evaluateCurrentPath() + } + + func shouldUseSplitRuntimeHost() async -> Bool { + let decision = await currentSplitRuntimeDecision() + switch decision { + case let .useSplitRuntime(reason): + AppLogger.shared.info("🧪 [Service] Split runtime host selected: \(reason)") + return true + case let .useLegacySystemBinary(reason): + AppLogger.shared.info("🧪 [Service] Split runtime host disabled by evaluator, using legacy path: \(reason)") + return false + case let .blocked(reason): + AppLogger.shared.warn("⚠️ [Service] Split runtime host blocked, using legacy path: \(reason)") + return false + } + } + /// Starts Kanata with VirtualHID connection validation func startKanataWithValidation() async { await recoveryCoordinator.startKanataWithValidation( @@ -18,8 +50,9 @@ extension RuntimeCoordinator { // MARK: - Service Management Helpers @discardableResult - func startKanata(reason: String = "Manual start") async -> Bool { + func startKanata(reason: String = "Manual start", precomputedDecision: KanataRuntimePathDecision? = nil) async -> Bool { AppLogger.shared.log("🚀 [Service] Starting Kanata (\(reason))") + lastWarning = nil // CRITICAL: Check VHID daemon health before starting Kanata // If Kanata starts without a healthy VHID daemon, it will grab keyboard input @@ -31,21 +64,61 @@ extension RuntimeCoordinator { return false } - do { - try await kanataService.start() - await kanataService.refreshStatus() + let decision: KanataRuntimePathDecision + if let precomputedDecision { + decision = precomputedDecision + } else { + decision = await currentSplitRuntimeDecision() + } + switch decision { + case .useSplitRuntime: + break + case let .useLegacySystemBinary(evalReason), let .blocked(evalReason): + let message = + "Split runtime host is enabled, but KeyPath could not start it: \(evalReason). " + + "The legacy recovery daemon is no longer used for ordinary startup." + AppLogger.shared.error("❌ [Service] \(message)") + lastError = message + notifyStateChanged() + return false + } - // Start the app context service for per-app keymaps - // This monitors frontmost app and activates virtual keys via TCP - await AppContextService.shared.start() + let legacyWasRunning = await recoveryDaemonService.isRecoveryDaemonRunning() + if legacyWasRunning { + AppLogger.shared.log( + "🔀 [Service] Split runtime selected while legacy recovery daemon is active - stopping legacy recovery daemon before cutover" + ) + do { + _ = try await recoveryDaemonService.stopIfRunning() + await AppContextService.shared.stop() + AppLogger.shared.log("✅ [Service] Legacy recovery daemon stopped for split-runtime cutover") + } catch { + let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + AppLogger.shared.error( + "❌ [Service] Could not stop legacy recovery daemon for split-runtime cutover: \(message)" + ) + lastError = + "Split runtime host is ready, but KeyPath could not stop the legacy recovery daemon for cutover: \(message)" + notifyStateChanged() + return false + } + } + do { + let pid = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost(includeCapture: true) + AppLogger.shared.log("✅ [Service] Started split-runtime host (PID \(pid))") + await AppContextService.shared.start() lastError = nil + lastWarning = nil notifyStateChanged() return true } catch { let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription - AppLogger.shared.error("❌ [Service] Start failed: \(message)") - lastError = "Start failed: \(message)" + AppLogger.shared.error( + "❌ [Service] Split-runtime host start failed during normal startup: \(message)" + ) + lastError = + "Split runtime host failed to start: \(message). Legacy fallback is reserved for recovery paths." notifyStateChanged() return false } @@ -58,9 +131,19 @@ extension RuntimeCoordinator { // Stop the app context service first await AppContextService.shared.stop() + if KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning { + let pid = KanataSplitRuntimeHostService.shared.activePersistentHostPID ?? 0 + AppLogger.shared.log("🛑 [Service] Stopping split-runtime host (PID \(pid))") + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lastError = nil + lastWarning = nil + notifyStateChanged() + return true + } + do { - try await kanataService.stop() - await kanataService.refreshStatus() + _ = try await recoveryDaemonService.stopIfRunning() + lastWarning = nil notifyStateChanged() return true } catch { @@ -74,39 +157,55 @@ extension RuntimeCoordinator { @discardableResult func restartKanata(reason: String = "Manual restart") async -> Bool { - await restartServiceWithFallback(reason: reason) - } + let splitDecision = await currentSplitRuntimeDecision() + if KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning { + let stopped = await stopKanata(reason: "\(reason) (stop split runtime)") + guard stopped else { return false } + return await startKanata(reason: "\(reason) (start split runtime)", precomputedDecision: splitDecision) + } - func currentServiceState() async -> KanataService.ServiceState { - await kanataService.refreshStatus() + switch splitDecision { + case .useSplitRuntime: + if await recoveryDaemonService.isRecoveryDaemonRunning() { + let stopped = await stopKanata(reason: "\(reason) (stop legacy recovery daemon)") + guard stopped else { return false } + } + return await startKanata(reason: "\(reason) (start split runtime)", precomputedDecision: splitDecision) + case let .useLegacySystemBinary(evalReason), let .blocked(evalReason): + let message = + "Split runtime host is enabled, but KeyPath could not restart it: \(evalReason). " + + "The legacy recovery daemon is no longer used for ordinary restart." + AppLogger.shared.error("❌ [Service] \(message)") + lastError = message + notifyStateChanged() + return false + } } - @discardableResult - func restartServiceWithFallback(reason: String) async -> Bool { - AppLogger.shared.log("🔄 [ServiceRestart] \(reason) - delegating to ProcessCoordinator") - let restarted = await processCoordinator.restartService() - - let state = await kanataService.refreshStatus() - let isRunning = state.isRunning + func currentRuntimeStatus() async -> RuntimeStatus { + if isStartingKanata { + return .starting + } - if restarted, isRunning { - AppLogger.shared.log("✅ [ServiceRestart] Kanata is running (state=\(state.description))") - notifyStateChanged() - return true + if KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning { + return .running(pid: Int(KanataSplitRuntimeHostService.shared.activePersistentHostPID ?? 0)) } - if !restarted { - AppLogger.shared.warn("⚠️ [ServiceRestart] ProcessCoordinator restart failed") - } else { - AppLogger.shared.warn("⚠️ [ServiceRestart] Restart finished but state=\(state.description)") + // Secondary check: the legacy recovery daemon may still be active during + // migration. Report it as running so callers (e.g. resetToDefaultConfig) + // don't skip TCP reload. + if await recoveryDaemonService.isRecoveryDaemonRunning() { + AppLogger.shared.warn( + "⚠️ [Service] Split runtime host is not running but legacy recovery daemon is active — half-migrated state" + ) + return .running(pid: 0) } - notifyStateChanged() - return false - } + return .stopped + } /// Check if permission issues should trigger the wizard func shouldShowWizardForPermissions() async -> Bool { - let snapshot = await PermissionOracle.shared.currentSnapshot() + let snapshot = await PermissionOracle.shared.forceRefresh() return snapshot.blockingIssue != nil } diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+State.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+State.swift index 5aa3beac8..b7325904b 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+State.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator+State.swift @@ -2,6 +2,17 @@ import Foundation import KeyPathCore extension RuntimeCoordinator { + private func currentRuntimePathStatus() -> (title: String, detail: String)? { + if KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning { + let pid = KanataSplitRuntimeHostService.shared.activePersistentHostPID ?? 0 + return ( + title: SplitRuntimeIdentity.hostTitle, + detail: "\(SplitRuntimeIdentity.hostDetailPrefix) (PID \(pid)) with privileged output companion" + ) + } + return nil + } + // MARK: - SaveCoordinatorDelegate func saveStatusDidChange(_ status: SaveStatus) { @@ -43,9 +54,9 @@ extension RuntimeCoordinator { func buildUIState() -> KanataUIState { // Sync diagnostics from DiagnosticsManager diagnostics = diagnosticsManager.getDiagnostics() + let runtimePathStatus = currentRuntimePathStatus() - // Debug: Log custom rules count when building state - AppLogger.shared.log("📊 [RuntimeCoordinator] buildUIState: customRules.count = \(customRules.count)") + AppLogger.shared.debug("📊 [RuntimeCoordinator] buildUIState: customRules.count = \(customRules.count)") if let error = lastError { AppLogger.shared.debug("🚨 [RuntimeCoordinator] buildUIState: lastError = \(error)") } @@ -61,6 +72,8 @@ extension RuntimeCoordinator { diagnostics: diagnostics, lastProcessExitCode: lastProcessExitCode, lastConfigUpdate: lastConfigUpdate, + activeRuntimePathTitle: runtimePathStatus?.title, + activeRuntimePathDetail: runtimePathStatus?.detail, // Validation & Save Status validationError: validationError, diff --git a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift index 9428d1cbb..211443a31 100644 --- a/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift @@ -11,51 +11,51 @@ import Network // Manages the Kanata process lifecycle and configuration directly. // -// # Architecture: Main Coordinator + Extension Files (~1,800 lines total) +// # Architecture: Main Coordinator + Extension Files // // RuntimeCoordinator is the main orchestrator for Kanata process management and configuration. // It's split across multiple extension files for maintainability: // // ## Extension Files (organized by concern): // -// **RuntimeCoordinator.swift** (main file, ~960 lines) +// **RuntimeCoordinator.swift** (main file) // - Core initialization and state management // - UI state snapshots and ViewModel interface // - Health monitoring and auto-start logic // - Diagnostics and error handling // -// **RuntimeCoordinator+Configuration.swift** (~184 lines) +// **RuntimeCoordinator+Configuration.swift** // - Config reload triggering and TCP communication // - Key mapping save operations // -// **RuntimeCoordinator+RuleCollections.swift** (~112 lines) +// **RuntimeCoordinator+RuleCollections.swift** // - Rule collection CRUD and persistence // -// **RuntimeCoordinator+ServiceManagement.swift** (~119 lines) -// - LaunchDaemon service start/stop/restart +// **RuntimeCoordinator+ServiceManagement.swift** +// - Runtime host start/stop/restart // -// **RuntimeCoordinator+ConfigMaintenance.swift** (~89 lines) +// **RuntimeCoordinator+ConfigMaintenance.swift** // - Config backup, repair, and safe-config fallback // -// **RuntimeCoordinator+Lifecycle.swift** (~77 lines) +// **RuntimeCoordinator+Lifecycle.swift** // - Process lifecycle state transitions // -// **RuntimeCoordinator+State.swift** (~73 lines) +// **RuntimeCoordinator+State.swift** // - UI state snapshot building // -// **RuntimeCoordinator+ConfigHotReload.swift** (~68 lines) +// **RuntimeCoordinator+ConfigHotReload.swift** // - File-change-driven hot reload // -// **RuntimeCoordinator+Diagnostics.swift** (~64 lines) +// **RuntimeCoordinator+Diagnostics.swift** // - System analysis and failure diagnosis // -// **RuntimeCoordinator+ConflictResolution.swift** (~29 lines) +// **RuntimeCoordinator+ConflictResolution.swift** // - Karabiner conflict detection helpers // -// **RuntimeCoordinator+Engine.swift** (~13 lines) +// **RuntimeCoordinator+Engine.swift** // - Kanata engine communication (stub) // -// **RuntimeCoordinator+Output.swift** (~13 lines) +// **RuntimeCoordinator+Output.swift** // - Log parsing and monitoring (stub) // // ## Key Dependencies (used by extensions): @@ -105,6 +105,28 @@ import Network @MainActor class RuntimeCoordinator: SaveCoordinatorDelegate { + final class NotificationTokenStore: @unchecked Sendable { + private var tokens: [NSObjectProtocol] = [] + private let lock = NSLock() + + func append(_ token: NSObjectProtocol) { + lock.lock() + defer { lock.unlock() } + tokens.append(token) + } + + func removeAll() { + lock.lock() + let tokens = self.tokens + self.tokens.removeAll() + lock.unlock() + + for token in tokens { + NotificationCenter.default.removeObserver(token) + } + } + } + // MARK: - Internal State Properties // Note: These are internal (not private) to allow extensions to access them @@ -155,11 +177,11 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { } let configDirectory = KeyPathConstants.Config.directory + let configFileName = "keypath.kbd" // MARK: - Manager Dependencies (Refactored Architecture) - let processManager: ProcessManaging let configurationManager: ConfigurationManaging let diagnosticsManager: DiagnosticsManaging let configRepairService: ConfigRepairService @@ -172,10 +194,9 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { let processLifecycleManager: ProcessLifecycleManager // Additional dependencies needed by extensions - let processCoordinator: ProcessCoordinating let installerEngine: InstallerEngine let privilegeBroker: PrivilegeBroker - let kanataService: KanataService + let recoveryDaemonService: RecoveryDaemonService nonisolated let diagnosticsService: DiagnosticsServiceProtocol let reloadSafetyMonitor = ReloadSafetyMonitor() // internal for use by extensions let karabinerConflictService: KarabinerConflictManaging @@ -194,9 +215,13 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { let recoveryCoordinator: RecoveryCoordinator // internal for extension access let installationCoordinator: InstallationCoordinator let ruleCollectionsCoordinator: RuleCollectionsCoordinator + let serviceHealthMonitor: ServiceHealthMonitor var isStartingKanata = false var isInitializing = false + var isRecoveringSplitRuntimeCompanion = false + var splitRuntimeCompanionMonitorTask: Task? + let notificationObserverTokens = NotificationTokenStore() let isHeadlessMode: Bool // MARK: - Process Synchronization (Phase 1) @@ -214,6 +239,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { init(engineClient: EngineClient? = nil, injectedConfigurationService: ConfigurationService? = nil, configRepairService: ConfigRepairService? = nil) { AppLogger.shared.log("🏗️ [RuntimeCoordinator] init() called") + let isOneShotProbeMode = AppDelegate.isOneShotProbeEnvironment() // Check if running in headless mode isHeadlessMode = @@ -233,8 +259,8 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { ) } - // Phase 3: Use shared KanataService for dependencies - let kanataService = KanataService.shared + // Phase 3: Use shared RecoveryDaemonService for dependencies + let recoveryDaemonService = RecoveryDaemonService.shared let lifecycleManager = ProcessLifecycleManager() processLifecycleManager = lifecycleManager @@ -251,15 +277,8 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { let diagnosticsService = DiagnosticsService(processLifecycleManager: lifecycleManager) privilegeBroker = PrivilegeBroker() installerEngine = InstallerEngine() - let processCoordinator = ProcessCoordinator( - kanataService: kanataService, - installerEngine: installerEngine, - privilegeBroker: privilegeBroker - ) - // Store for extensions - self.processCoordinator = processCoordinator - self.kanataService = kanataService + self.recoveryDaemonService = recoveryDaemonService self.diagnosticsService = diagnosticsService self.karabinerConflictService = karabinerConflictService self.configBackupManager = configBackupManager @@ -281,12 +300,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { configFileWatcher: configFileWatcher ) installationCoordinator = InstallationCoordinator() - - // Initialize ProcessManager - processManager = ProcessManager( - processLifecycleManager: lifecycleManager, - karabinerConflictService: karabinerConflictService - ) + serviceHealthMonitor = ServiceHealthMonitor(processLifecycle: lifecycleManager) // Initialize ConfigurationManager configurationManager = ConfigurationManager( @@ -298,13 +312,19 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { // Initialize DiagnosticsManager diagnosticsManager = DiagnosticsManager( diagnosticsService: diagnosticsService, - kanataService: kanataService + healthMonitor: serviceHealthMonitor, + processStatusProvider: { + if let splitHostPID = KanataSplitRuntimeHostService.shared.activePersistentHostPID { + return ProcessHealthStatus(isRunning: true, pid: Int(splitHostPID)) + } + return ProcessHealthStatus(isRunning: false, pid: nil) + } ) // Initialize ConfigRepairService self.configRepairService = configRepairService ?? AnthropicConfigRepairService() - // Initialize EngineClien + // Initialize EngineClient self.engineClient = engineClient ?? TCPEngineClient() // Initialize RecoveryCoordinator (will be configured after all initialization) @@ -317,7 +337,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { // Dispatch heavy initialization work to background thread (skip during unit tests) // Prefer structured concurrency; a plain Task{} runs off the main actor by default - if !TestEnvironment.isRunningTests { + if !TestEnvironment.isRunningTests && !isOneShotProbeMode { Task { [weak self] in // Clean up any orphaned processes first await self?.processLifecycleManager.cleanupOrphanedProcesses() @@ -325,7 +345,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { } } else { AppLogger.shared.debug( - "🧪 [RuntimeCoordinator] Skipping background initialization in test environment" + "🧪 [RuntimeCoordinator] Skipping background initialization in \(isOneShotProbeMode ? "one-shot probe" : "test") mode" ) } @@ -367,7 +387,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { await self?.restartKarabinerDaemon() ?? false }, restartService: { [weak self] reason in - await self?.restartServiceWithFallback(reason: reason) ?? false + await self?.restartKanata(reason: reason) ?? false } ) @@ -375,7 +395,9 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { ruleCollectionsManager.onRulesChanged = { [weak self] in guard let self else { return } _ = await triggerConfigReload() - notifyStateChanged() + await MainActor.run { + self.notifyStateChanged() + } // Notify overlay to rebuild layer mapping AppLogger.shared.debug("🔔 [RuntimeCoordinator] Posting kanataConfigChanged notification") @@ -418,29 +440,66 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { self?.configFileWatcher?.suppressEvents(for: 1.0, reason: "Internal rule change") } - AppLogger.shared.log( - "🏗️ [RuntimeCoordinator] About to call bootstrapRuleCollections and startEventMonitoring" - ) - Task { await ruleCollectionsManager.bootstrap() } - ruleCollectionsManager.startEventMonitoring(port: PreferencesService.shared.tcpServerPort) - HrmObservabilityService.shared.startMonitoring(port: PreferencesService.shared.tcpServerPort) + if !isOneShotProbeMode { + AppLogger.shared.log( + "🏗️ [RuntimeCoordinator] About to call bootstrapRuleCollections and startEventMonitoring" + ) + Task { + await ruleCollectionsManager.bootstrap() + ruleCollectionsManager.startEventMonitoring(port: PreferencesService.shared.tcpServerPort) + } + HrmObservabilityService.shared.startMonitoring(port: PreferencesService.shared.tcpServerPort) + startSplitRuntimeCompanionMonitor() + } else { + AppLogger.shared.log("🧪 [RuntimeCoordinator] One-shot probe mode - skipping bootstrap and event monitoring") + } // Observe config-affecting preference changes (e.g., nav trigger mode) to regenerate config - NotificationCenter.default.addObserver( - forName: .configAffectingPreferenceChanged, - object: nil, - queue: .main - ) { @Sendable [weak self] _ in - guard let self else { return } - Task { @MainActor in - AppLogger.shared.log("🔄 [RuntimeCoordinator] Config-affecting preference changed, regenerating config...") - await self.ruleCollectionsManager.regenerateConfigFromCollections() - } + if !isOneShotProbeMode { + notificationObserverTokens.append(NotificationCenter.default.addObserver( + forName: .configAffectingPreferenceChanged, + object: nil, + queue: NotificationObserverManager.mainOperationQueue + ) { @Sendable [weak self] _ in + guard let self else { return } + Task { @MainActor in + AppLogger.shared.log("🔄 [RuntimeCoordinator] Config-affecting preference changed, regenerating config...") + await self.ruleCollectionsManager.regenerateConfigFromCollections() + } + }) + + notificationObserverTokens.append(NotificationCenter.default.addObserver( + forName: .splitRuntimeHostExited, + object: nil, + queue: NotificationObserverManager.mainOperationQueue + ) { @Sendable [weak self] note in + guard let self else { return } + let pid = note.userInfo?[KanataSplitRuntimeHostExitInfo.pidUserInfoKey] as? pid_t ?? 0 + let exitCode = note.userInfo?[KanataSplitRuntimeHostExitInfo.exitCodeUserInfoKey] as? Int32 ?? 0 + let terminationReason = + note.userInfo?[KanataSplitRuntimeHostExitInfo.terminationReasonUserInfoKey] as? String ?? "unknown" + let expected = note.userInfo?[KanataSplitRuntimeHostExitInfo.expectedUserInfoKey] as? Bool ?? false + let stderrLogPath = note.userInfo?[KanataSplitRuntimeHostExitInfo.stderrLogPathUserInfoKey] as? String + Task { @MainActor in + await self.handleSplitRuntimeHostExit( + pid: pid, + exitCode: exitCode, + terminationReason: terminationReason, + expected: expected, + stderrLogPath: stderrLogPath + ) + } + }) } AppLogger.shared.log("🏗️ [RuntimeCoordinator] init() completed") } + deinit { + splitRuntimeCompanionMonitorTask?.cancel() + notificationObserverTokens.removeAll() + } + // Note: RuleCollectionsManager handles its own cleanup in deinit // MARK: - Public Interface @@ -517,7 +576,6 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { } /// Run full installation via InstallerEngine façade. - /// This replaces direct calls to PrivilegedOperationsCoordinator.installAllLaunchDaemonServices(). func runFullInstall(reason: String = "RuntimeCoordinator install request") async -> InstallerReport { AppLogger.shared.log("🔧 [RuntimeCoordinator] runFullInstall invoked (\(reason))") return await installerEngine.run(intent: .install, using: privilegeBroker) @@ -555,10 +613,9 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { /// Stop Kanata when the app is terminating (async version). func cleanup() async { - do { - try await kanataService.stop() - } catch { - AppLogger.shared.warn("⚠️ [RuntimeCoordinator] Failed to stop Kanata during cleanup: \(error.localizedDescription)") + let stopped = await stopKanata(reason: "App termination cleanup") + if !stopped { + AppLogger.shared.warn("⚠️ [RuntimeCoordinator] Failed to stop runtime during cleanup") } } @@ -812,7 +869,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { // Ensure config directory exists let configDir = URL(fileURLWithPath: configDirectory) - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: configDir, withIntermediateDirectories: true) // Write the default config (unconditionally) try defaultConfig.write(to: configURL, atomically: true, encoding: .utf8) @@ -837,9 +894,9 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { return } - // Apply changes immediately via TCP reload if service is running - let serviceState = await kanataService.refreshStatus() - if serviceState.isRunning { + // Apply changes immediately via TCP reload if the real runtime is running + let runtimeStatus = await currentRuntimeStatus() + if runtimeStatus.isRunning { AppLogger.shared.info("🔄 [Reset] Triggering immediate config reload via TCP...") let reloadResult = await triggerConfigReload() @@ -860,7 +917,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { saveStatus = .failed("Reset reload failed: \(error)") } // If TCP reload fails, fall back to service restart - _ = await restartServiceWithFallback(reason: "Default config reload fallback") + _ = await restartKanata(reason: "Default config reload fallback") } // Reset to idle after a delay @@ -868,6 +925,11 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { try? await Task.sleep(for: .seconds(2)) self?.saveStatus = .idle } + } else if await recoveryDaemonService.isRecoveryDaemonRunning() { + AppLogger.shared.warn( + "⚠️ [Reset] Skipping TCP reload because only the recovery daemon is active. " + + "The split runtime host is not running." + ) } } @@ -901,7 +963,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { for event in events { switch event { case .virtualHIDConnectionFailed: - let shouldTriggerRecovery = await kanataService.recordConnectionFailure() + let shouldTriggerRecovery = await diagnosticsManager.recordConnectionFailure() if shouldTriggerRecovery { AppLogger.shared.log( "🚨 [LogMonitor] Maximum connection failures reached - triggering recovery" @@ -909,7 +971,7 @@ class RuntimeCoordinator: SaveCoordinatorDelegate { await triggerVirtualHIDRecovery() } case .virtualHIDConnected: - await kanataService.recordConnectionSuccess() + await diagnosticsManager.recordConnectionSuccess() } } } diff --git a/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift b/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift index f1df56000..5ab636bd1 100644 --- a/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/SaveCoordinator.swift @@ -205,7 +205,7 @@ final class SaveCoordinator { let configDir = configurationService.configDirectory let configDirURL = URL(fileURLWithPath: configDir) - try FileManager.default.createDirectory( + try Foundation.FileManager().createDirectory( at: configDirURL, withIntermediateDirectories: true ) @@ -308,7 +308,7 @@ final class SaveCoordinator { let configPath = configurationService.configurationPath let configURL = URL(fileURLWithPath: configPath) let configDir = URL(fileURLWithPath: configurationService.configDirectory) - try FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: configDir, withIntermediateDirectories: true) try safeConfig.write(to: configURL, atomically: true, encoding: .utf8) AppLogger.shared.warn("🛡️ [SaveCoordinator] Wrote minimal safe config to \(configPath)") } diff --git a/Sources/KeyPathAppKit/Managers/UninstallCoordinator.swift b/Sources/KeyPathAppKit/Managers/UninstallCoordinator.swift index 4ed1c2c2f..b0836402b 100644 --- a/Sources/KeyPathAppKit/Managers/UninstallCoordinator.swift +++ b/Sources/KeyPathAppKit/Managers/UninstallCoordinator.swift @@ -242,9 +242,10 @@ final class UninstallCoordinator { return bundled } - let repoPath = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let workingDirectory = Foundation.ProcessInfo.processInfo.environment["PWD"] ?? "." + let repoPath = URL(fileURLWithPath: workingDirectory) .appendingPathComponent("Sources/KeyPath/Resources/uninstall.sh") - if FileManager.default.isExecutableFile(atPath: repoPath.path) { + if Foundation.FileManager().isExecutableFile(atPath: repoPath.path) { return repoPath } diff --git a/Sources/KeyPathAppKit/Models/KanataUIState.swift b/Sources/KeyPathAppKit/Models/KanataUIState.swift index fde2f0ef5..f2d24b262 100644 --- a/Sources/KeyPathAppKit/Models/KanataUIState.swift +++ b/Sources/KeyPathAppKit/Models/KanataUIState.swift @@ -41,6 +41,8 @@ struct KanataUIState: Sendable { let diagnostics: [KanataDiagnostic] let lastProcessExitCode: Int32? let lastConfigUpdate: Date + let activeRuntimePathTitle: String? + let activeRuntimePathDetail: String? // UI State (Legacy status removed) // Removed: errorReason, showWizard, launchFailureStatus @@ -63,6 +65,8 @@ struct KanataUIState: Sendable { diagnostics: [], lastProcessExitCode: nil, lastConfigUpdate: Date(), + activeRuntimePathTitle: nil, + activeRuntimePathDetail: nil, validationError: nil, saveStatus: .idle, pendingRuleConflict: nil diff --git a/Sources/KeyPathAppKit/Models/QMKLayoutParser.swift b/Sources/KeyPathAppKit/Models/QMKLayoutParser.swift index b80bc8c9a..354377489 100644 --- a/Sources/KeyPathAppKit/Models/QMKLayoutParser.swift +++ b/Sources/KeyPathAppKit/Models/QMKLayoutParser.swift @@ -194,7 +194,7 @@ enum QMKLayoutParser { idOverride: String? = nil, nameOverride: String? = nil ) -> PhysicalLayout? { - guard let url = Bundle.module.url(forResource: filename, withExtension: "json") else { + guard let url = KeyPathAppKitResources.url(forResource: filename, withExtension: "json") else { print("QMKLayoutParser: Could not find \(filename).json in bundle") return nil } diff --git a/Sources/KeyPathAppKit/Resources/action-uri-launcher-drawer.png b/Sources/KeyPathAppKit/Resources/action-uri-launcher-drawer.png index 290b4d741..d44be50bf 100644 Binary files a/Sources/KeyPathAppKit/Resources/action-uri-launcher-drawer.png and b/Sources/KeyPathAppKit/Resources/action-uri-launcher-drawer.png differ diff --git a/Sources/KeyPathAppKit/Resources/action-uri-overlay-header.png b/Sources/KeyPathAppKit/Resources/action-uri-overlay-header.png index 5d863683e..8def6917e 100644 Binary files a/Sources/KeyPathAppKit/Resources/action-uri-overlay-header.png and b/Sources/KeyPathAppKit/Resources/action-uri-overlay-header.png differ diff --git a/Sources/KeyPathAppKit/Resources/alt-layouts-keymap-picker.png b/Sources/KeyPathAppKit/Resources/alt-layouts-keymap-picker.png index 8ec497cf1..77eace9dc 100644 Binary files a/Sources/KeyPathAppKit/Resources/alt-layouts-keymap-picker.png and b/Sources/KeyPathAppKit/Resources/alt-layouts-keymap-picker.png differ diff --git a/Sources/KeyPathAppKit/Resources/hrm-per-finger-sliders.png b/Sources/KeyPathAppKit/Resources/hrm-per-finger-sliders.png index 0c80bd02e..58b22e542 100644 Binary files a/Sources/KeyPathAppKit/Resources/hrm-per-finger-sliders.png and b/Sources/KeyPathAppKit/Resources/hrm-per-finger-sliders.png differ diff --git a/Sources/KeyPathAppKit/Resources/hrm-typing-feel-slider.png b/Sources/KeyPathAppKit/Resources/hrm-typing-feel-slider.png index 0c98fe16e..01d600546 100644 Binary files a/Sources/KeyPathAppKit/Resources/hrm-typing-feel-slider.png and b/Sources/KeyPathAppKit/Resources/hrm-typing-feel-slider.png differ diff --git a/Sources/KeyPathAppKit/Resources/install-overlay-base.png b/Sources/KeyPathAppKit/Resources/install-overlay-base.png index c0b85df80..06653d521 100644 Binary files a/Sources/KeyPathAppKit/Resources/install-overlay-base.png and b/Sources/KeyPathAppKit/Resources/install-overlay-base.png differ diff --git a/Sources/KeyPathAppKit/Resources/install-overlay-health-green.png b/Sources/KeyPathAppKit/Resources/install-overlay-health-green.png index 1d6daeb43..103fbf985 100644 Binary files a/Sources/KeyPathAppKit/Resources/install-overlay-health-green.png and b/Sources/KeyPathAppKit/Resources/install-overlay-health-green.png differ diff --git a/Sources/KeyPathAppKit/Resources/installation.md b/Sources/KeyPathAppKit/Resources/installation.md index c60f643e8..8b89c2f70 100644 --- a/Sources/KeyPathAppKit/Resources/installation.md +++ b/Sources/KeyPathAppKit/Resources/installation.md @@ -225,9 +225,9 @@ If something went wrong (file missing or corrupted), click **Fix** and the wizar --- -## Step 6: Start Keyboard Service +## Step 6: Start KeyPath Runtime -The final step. The wizard starts the kanata service that runs in the background. Once it's running, your key remappings are active system-wide. +The final step. The wizard starts the KeyPath Runtime, powered by the Kanata engine in the background. Once it is running, your key remappings are active system-wide. The wizard shows a live status indicator: diff --git a/Sources/KeyPathAppKit/Resources/kb-layouts-layout-picker.png b/Sources/KeyPathAppKit/Resources/kb-layouts-layout-picker.png index a6e0a5d52..ac8c32dbd 100644 Binary files a/Sources/KeyPathAppKit/Resources/kb-layouts-layout-picker.png and b/Sources/KeyPathAppKit/Resources/kb-layouts-layout-picker.png differ diff --git a/Sources/KeyPathAppKit/Resources/keymap-card-selected.png b/Sources/KeyPathAppKit/Resources/keymap-card-selected.png index 4edd147fd..a54439e90 100644 Binary files a/Sources/KeyPathAppKit/Resources/keymap-card-selected.png and b/Sources/KeyPathAppKit/Resources/keymap-card-selected.png differ diff --git a/Sources/KeyPathAppKit/Resources/keymap-card-unselected.png b/Sources/KeyPathAppKit/Resources/keymap-card-unselected.png index 37898a83a..3bba13709 100644 Binary files a/Sources/KeyPathAppKit/Resources/keymap-card-unselected.png and b/Sources/KeyPathAppKit/Resources/keymap-card-unselected.png differ diff --git a/Sources/KeyPathAppKit/Resources/overlay-header-unhealthy.png b/Sources/KeyPathAppKit/Resources/overlay-header-unhealthy.png index c5a644764..7c8d3fdb8 100644 Binary files a/Sources/KeyPathAppKit/Resources/overlay-header-unhealthy.png and b/Sources/KeyPathAppKit/Resources/overlay-header-unhealthy.png differ diff --git a/Sources/KeyPathAppKit/Services/ActionDispatcher.swift b/Sources/KeyPathAppKit/Services/ActionDispatcher.swift index 4028041af..400f32cbd 100644 --- a/Sources/KeyPathAppKit/Services/ActionDispatcher.swift +++ b/Sources/KeyPathAppKit/Services/ActionDispatcher.swift @@ -1,6 +1,7 @@ import AppKit import Foundation import KeyPathCore +import KeyPathDaemonLifecycle import KeyPathPluginKit // MARK: - Action Dispatch Result @@ -30,6 +31,42 @@ public enum ActionDispatchResult: Sendable { /// - `keypath://script/{path}` - Execute a script (requires security approval) @MainActor public final class ActionDispatcher { + private static let hostPassthruDiagnosticOutputPath = "/var/tmp/keypath-host-passthru-diagnostic.txt" + private static let hostPassthruLiveOutputPath = "/var/tmp/keypath-host-passthru-live.txt" + private static let hostPassthruCycleOutputPath = "/var/tmp/keypath-host-passthru-cycle.txt" + private static let hostPassthruCompanionRestartOutputPath = "/var/tmp/keypath-host-passthru-companion-restart.txt" + private static let hostPassthruSoakOutputPath = "/var/tmp/keypath-host-passthru-soak.txt" + private static let hostPassthruCompanionRestartSoakOutputPath = "/var/tmp/keypath-host-passthru-companion-restart-soak.txt" + private static let coordinatorSplitRuntimeRecoveryOutputPath = "/var/tmp/keypath-runtime-coordinator-companion-recovery.txt" + private static let coordinatorSplitRuntimeRestartSoakOutputPath = "/var/tmp/keypath-runtime-coordinator-companion-restart-soak.txt" + private static let helperRepairOutputPath = "/var/tmp/keypath-helper-repair.txt" + private static let diagnosticSystemActions: Set = [ + "prepare-host-passthru-bridge", + "prepare-host-bridge", + "prep-host-passthru-bridge", + "run-host-passthru-diagnostic", + "run-host-diagnostic", + "host-passthru-diagnostic", + "start-host-passthru", + "stop-host-passthru", + "exercise-host-passthru-cycle", + "exercise-output-bridge-companion-restart", + "exercise-host-passthru-soak", + "exercise-output-bridge-companion-restart-soak", + "exercise-coordinator-split-runtime-recovery", + "exercise-coordinator-split-runtime-restart-soak", + "repair-helper" + ] + + private static var diagnosticActionsEnabled: Bool { +#if DEBUG + if TestEnvironment.isRunningTests { + return true + } +#endif + return ProcessInfo.processInfo.environment["KEYPATH_ENABLE_DIAGNOSTIC_ACTIONS"] == "1" + } + // MARK: - Singleton public static let shared = ActionDispatcher() @@ -147,7 +184,7 @@ public final class ActionDispatcher { for path in commonPaths { let url = URL(fileURLWithPath: path) - if FileManager.default.fileExists(atPath: path), !urlsToTry.contains(url) { + if Foundation.FileManager().fileExists(atPath: path), !urlsToTry.contains(url) { urlsToTry.append(url) } } @@ -355,7 +392,7 @@ public final class ActionDispatcher { /// Trigger a macOS system action /// Format: keypath://system/{action} - /// Actions: mission-control, spotlight, dictation, dnd, launchpad, notification-center, siri + /// Actions: mission-control, spotlight, dictation, dnd, launchpad, notification-center, siri, prepare-host-passthru-bridge, run-host-passthru-diagnostic, start-host-passthru, stop-host-passthru, exercise-host-passthru-cycle, exercise-output-bridge-companion-restart, exercise-coordinator-split-runtime-recovery, exercise-coordinator-split-runtime-restart-soak, repair-helper private func handleSystem(_ uri: KeyPathActionURI) -> ActionDispatchResult { guard let action = uri.target else { let message = "system action requires action name: keypath://system/{action}" @@ -366,7 +403,432 @@ public final class ActionDispatcher { AppLogger.shared.log("⚙️ [ActionDispatcher] System action: \(action)") + if Self.diagnosticSystemActions.contains(action.lowercased()), !Self.diagnosticActionsEnabled { + let message = "Diagnostic system action '\(action)' is disabled in this build context." + AppLogger.shared.log("⚠️ [ActionDispatcher] \(message)") + onError?(message) + return .failed("system", NSError(domain: "ActionDispatcher", code: 5, userInfo: [ + NSLocalizedDescriptionKey: message + ])) + } + + if TestEnvironment.isRunningTests, Self.diagnosticSystemActions.contains(action.lowercased()) { + return .success + } + switch action.lowercased() { + case "prepare-host-passthru-bridge", "prepare-host-bridge", "prep-host-passthru-bridge": + Task { @MainActor in + do { + let session = try await KanataOutputBridgeCompanionManager.shared + .prepareEnvironmentAndPersist( + hostPID: getpid(), + outputPath: KanataOutputBridgeCompanionManager.bridgeEnvironmentOutputPath + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Prepared host passthru bridge session=\(session.sessionID) socket=\(session.socketPath) output=\(KanataOutputBridgeCompanionManager.bridgeEnvironmentOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to prepare host passthru bridge: \(error.localizedDescription)" + ) + self.onError?("Failed to prepare host passthru bridge: \(error.localizedDescription)") + } + } + return .success + + case "run-host-passthru-diagnostic", "run-host-diagnostic", "host-passthru-diagnostic": + let captureRaw = uri.queryItems["capture"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + Task { @MainActor in + let diagnosticsService = DiagnosticsService( + processLifecycleManager: ProcessLifecycleManager() + ) + let diagnostic = await diagnosticsService.runHostPassthruDiagnostic(includeCapture: includeCapture) + let payload = """ + title=\(diagnostic.title) + severity=\(diagnostic.severity.rawValue) + details=\(diagnostic.technicalDetails) + """ + do { + try payload.write( + toFile: Self.hostPassthruDiagnosticOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Host passthru diagnostic completed capture=\(includeCapture) output=\(Self.hostPassthruDiagnosticOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to write host passthru diagnostic output: \(error.localizedDescription)" + ) + self.onError?("Failed to write host passthru diagnostic output: \(error.localizedDescription)") + } + } + return .success + + case "start-host-passthru", "start-host-runtime": + let captureRaw = uri.queryItems["capture"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == nil || captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + Task { @MainActor in + do { + let pid = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + let payload = """ + pid=\(pid) + capture=\(includeCapture) + """ + try payload.write( + toFile: Self.hostPassthruLiveOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Started persistent host passthru pid=\(pid) capture=\(includeCapture) output=\(Self.hostPassthruLiveOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to start persistent host passthru: \(error.localizedDescription)" + ) + self.onError?("Failed to start persistent host passthru: \(error.localizedDescription)") + } + } + return .success + + case "stop-host-passthru", "stop-host-runtime": + Task { @MainActor in + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + let payload = "stopped=1\n" + try? payload.write( + toFile: Self.hostPassthruLiveOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info("🧪 [ActionDispatcher] Stopped persistent host passthru") + } + return .success + + case "exercise-host-passthru-cycle", "exercise-host-runtime-cycle": + let captureRaw = uri.queryItems["capture"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == nil || captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + Task { @MainActor in + do { + var lines: [String] = [] + let firstPID = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + lines.append("first_pid=\(firstPID)") + lines.append("capture=\(includeCapture)") + try await Task.sleep(for: .milliseconds(250)) + + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lines.append("stopped_first=1") + try await Task.sleep(for: .milliseconds(250)) + + let secondPID = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + lines.append("second_pid=\(secondPID)") + try await Task.sleep(for: .milliseconds(250)) + + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lines.append("stopped_second=1") + + let payload = lines.joined(separator: "\n") + "\n" + try payload.write( + toFile: Self.hostPassthruCycleOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Exercised persistent host passthru cycle capture=\(includeCapture) output=\(Self.hostPassthruCycleOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to exercise persistent host passthru cycle: \(error.localizedDescription)" + ) + self.onError?("Failed to exercise persistent host passthru cycle: \(error.localizedDescription)") + } + } + return .success + + case "exercise-host-passthru-soak", "exercise-host-runtime-soak": + let captureRaw = uri.queryItems["capture"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == nil || captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + let durationSeconds = max(1, Int(uri.queryItems["seconds"] ?? "") ?? 30) + Task { @MainActor in + do { + var lines: [String] = [] + let pid = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + lines.append("host_pid=\(pid)") + lines.append("capture=\(includeCapture)") + lines.append("duration_seconds=\(durationSeconds)") + + if let statusBefore = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_before=\(statusBefore.companionRunning)") + } else { + lines.append("companion_running_before=unknown") + } + + try await Task.sleep(for: .seconds(durationSeconds)) + + let hostAliveAtEnd = KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning + lines.append("host_alive_at_end=\(hostAliveAtEnd)") + if let activePID = KanataSplitRuntimeHostService.shared.activePersistentHostPID { + lines.append("host_pid_at_end=\(activePID)") + } + + if let statusAfter = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_after=\(statusAfter.companionRunning)") + } else { + lines.append("companion_running_after=unknown") + } + + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lines.append("host_stopped=1") + + let payload = lines.joined(separator: "\n") + "\n" + try payload.write( + toFile: Self.hostPassthruSoakOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Exercised persistent host passthru soak capture=\(includeCapture) seconds=\(durationSeconds) output=\(Self.hostPassthruSoakOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to exercise persistent host passthru soak: \(error.localizedDescription)" + ) + self.onError?("Failed to exercise persistent host passthru soak: \(error.localizedDescription)") + } + } + return .success + + case "exercise-output-bridge-companion-restart", "exercise-host-passthru-companion-restart": + let captureRaw = uri.queryItems["capture"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == nil || captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + Task { @MainActor in + do { + var lines: [String] = [] + if let statusBefore = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_before=\(statusBefore.companionRunning)") + } else { + lines.append("companion_running_before=unknown") + } + lines.append("capture=\(includeCapture)") + + let pid = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + lines.append("host_pid=\(pid)") + try await Task.sleep(for: .milliseconds(300)) + + do { + let recovery = try await KanataSplitRuntimeHostService.shared + .restartCompanionAndRecoverPersistentHost() + lines.append("companion_restarted=1") + lines.append("companion_running_after=\(recovery.companionRunningAfterRestart)") + if let recoveredHostPID = recovery.recoveredHostPID { + lines.append("host_recovered=1") + lines.append("host_pid_after_recovery=\(recoveredHostPID)") + } else { + lines.append("host_recovered=0") + lines.append("host_recovery_reason=no-active-host") + } + } catch { + lines.append("companion_restarted=0") + lines.append( + "companion_restart_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))" + ) + lines.append("host_recovered=0") + lines.append( + "host_recovery_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))" + ) + } + try await Task.sleep(for: .milliseconds(300)) + + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lines.append("host_stopped=1") + + let payload = lines.joined(separator: "\n") + "\n" + try payload.write( + toFile: Self.hostPassthruCompanionRestartOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Exercised output bridge companion restart capture=\(includeCapture) output=\(Self.hostPassthruCompanionRestartOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to exercise output bridge companion restart: \(error.localizedDescription)" + ) + self.onError?("Failed to exercise output bridge companion restart: \(error.localizedDescription)") + } + } + return .success + + case "exercise-output-bridge-companion-restart-soak", "exercise-host-passthru-companion-restart-soak": + let captureRaw = uri.queryItems["capture"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = captureRaw == nil || captureRaw == "1" || captureRaw == "true" || captureRaw == "yes" + let durationSeconds = max(2, Int(uri.queryItems["seconds"] ?? "") ?? 30) + Task { @MainActor in + do { + var lines: [String] = [] + if let statusBefore = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_before=\(statusBefore.companionRunning)") + } else { + lines.append("companion_running_before=unknown") + } + lines.append("capture=\(includeCapture)") + lines.append("duration_seconds=\(durationSeconds)") + + let pid = try await KanataSplitRuntimeHostService.shared.startPersistentPassthruHost( + includeCapture: includeCapture + ) + lines.append("host_pid=\(pid)") + + let preRestartDelayMilliseconds = max(250, (durationSeconds * 1000) / 2) + let postRestartDelayMilliseconds = max(250, durationSeconds * 1000 - preRestartDelayMilliseconds) + + try await Task.sleep(for: .milliseconds(preRestartDelayMilliseconds)) + + do { + let recovery = try await KanataSplitRuntimeHostService.shared + .restartCompanionAndRecoverPersistentHost() + lines.append("companion_restarted=1") + lines.append("companion_running_after_restart=\(recovery.companionRunningAfterRestart)") + if let recoveredHostPID = recovery.recoveredHostPID { + lines.append("host_recovered=1") + lines.append("host_pid_after_recovery=\(recoveredHostPID)") + } else { + lines.append("host_recovered=0") + lines.append("host_recovery_reason=no-active-host") + } + } catch { + lines.append("companion_restarted=0") + lines.append( + "companion_restart_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))" + ) + lines.append("host_recovered=0") + lines.append( + "host_recovery_error=\(error.localizedDescription.replacingOccurrences(of: "\n", with: " "))" + ) + } + + try await Task.sleep(for: .milliseconds(postRestartDelayMilliseconds)) + + let hostAliveAtEnd = KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning + lines.append("host_alive_at_end=\(hostAliveAtEnd)") + if let activePID = KanataSplitRuntimeHostService.shared.activePersistentHostPID { + lines.append("host_pid_at_end=\(activePID)") + } + + if let statusAfter = try? await KanataOutputBridgeCompanionManager.shared.outputBridgeStatus() { + lines.append("companion_running_after=\(statusAfter.companionRunning)") + } else { + lines.append("companion_running_after=unknown") + } + + KanataSplitRuntimeHostService.shared.stopPersistentPassthruHost() + lines.append("host_stopped=1") + + let payload = lines.joined(separator: "\n") + "\n" + try payload.write( + toFile: Self.hostPassthruCompanionRestartSoakOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Exercised output bridge companion restart soak capture=\(includeCapture) seconds=\(durationSeconds) output=\(Self.hostPassthruCompanionRestartSoakOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to exercise output bridge companion restart soak: \(error.localizedDescription)" + ) + self.onError?("Failed to exercise output bridge companion restart soak: \(error.localizedDescription)") + } + } + return .success + + case "exercise-coordinator-split-runtime-recovery", "exercise-runtime-coordinator-companion-recovery": + NotificationCenter.default.post( + name: .exerciseCoordinatorSplitRuntimeRecovery, + object: nil, + userInfo: ["outputPath": Self.coordinatorSplitRuntimeRecoveryOutputPath] + ) + return .success + + case "exercise-coordinator-split-runtime-restart-soak", "exercise-runtime-coordinator-companion-restart-soak": + let durationSeconds = max(2, Int(uri.queryItems["seconds"] ?? "") ?? 20) + NotificationCenter.default.post( + name: .exerciseCoordinatorSplitRuntimeRestartSoak, + object: nil, + userInfo: [ + "outputPath": Self.coordinatorSplitRuntimeRestartSoakOutputPath, + "durationSeconds": durationSeconds + ] + ) + return .success + + case "repair-helper": + if TestEnvironment.isRunningTests { + return .success + } + + let useAppleScriptFallbackRaw = uri.queryItems["applescript"]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let useAppleScriptFallback = useAppleScriptFallbackRaw == nil + || useAppleScriptFallbackRaw == "1" + || useAppleScriptFallbackRaw == "true" + || useAppleScriptFallbackRaw == "yes" + + Task { @MainActor in + let repaired = await HelperMaintenance.shared.runCleanupAndRepair( + useAppleScriptFallback: useAppleScriptFallback + ) + let details = HelperMaintenance.shared.logLines.joined(separator: " | ") + let payload = """ + success=\(repaired) + use_apple_script_fallback=\(useAppleScriptFallback) + details=\(details) + """ + do { + try payload.write( + toFile: Self.helperRepairOutputPath, + atomically: true, + encoding: .utf8 + ) + AppLogger.shared.info( + "🧪 [ActionDispatcher] Helper repair completed success=\(repaired) output=\(Self.helperRepairOutputPath)" + ) + } catch { + AppLogger.shared.error( + "❌ [ActionDispatcher] Failed to write helper repair output: \(error.localizedDescription)" + ) + self.onError?("Failed to write helper repair output: \(error.localizedDescription)") + } + } + return .success + case "mission-control", "missioncontrol", "expose": // Mission Control - open the expose launcher app let workspace = NSWorkspace.shared @@ -385,7 +847,7 @@ public final class ActionDispatcher { "/Applications/Mission Control.app" ] for path in missionControlPaths { - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { workspace.openApplication(at: URL(fileURLWithPath: path), configuration: NSWorkspace.OpenConfiguration()) { _, _ in } return .success } @@ -484,7 +946,7 @@ public final class ActionDispatcher { return .success default: - let message = "Unknown system action: \(action). Supported: mission-control, spotlight, dictation, dnd, launchpad, notification-center, siri" + let message = "Unknown system action: \(action). Supported: mission-control, spotlight, dictation, dnd, launchpad, notification-center, siri, prepare-host-passthru-bridge, run-host-passthru-diagnostic, start-host-passthru, stop-host-passthru, exercise-host-passthru-cycle, exercise-output-bridge-companion-restart, exercise-coordinator-split-runtime-recovery, exercise-coordinator-split-runtime-restart-soak, repair-helper" AppLogger.shared.log("⚠️ [ActionDispatcher] \(message)") onError?(message) return .unknownAction(action) @@ -555,7 +1017,7 @@ public final class ActionDispatcher { // Check if path exists var isDirectory: ObjCBool = false - guard FileManager.default.fileExists(atPath: expandedPath, isDirectory: &isDirectory) else { + guard Foundation.FileManager().fileExists(atPath: expandedPath, isDirectory: &isDirectory) else { let message = "Folder not found: \(expandedPath)" AppLogger.shared.log("❌ [ActionDispatcher] \(message)") onError?(message) @@ -780,9 +1242,9 @@ public final class ActionDispatcher { switch ext { case "py": // Try python3 first, fall back to python - if FileManager.default.fileExists(atPath: "/usr/bin/python3") { + if Foundation.FileManager().fileExists(atPath: "/usr/bin/python3") { process.executableURL = URL(fileURLWithPath: "/usr/bin/python3") - } else if FileManager.default.fileExists(atPath: "/usr/local/bin/python3") { + } else if Foundation.FileManager().fileExists(atPath: "/usr/local/bin/python3") { process.executableURL = URL(fileURLWithPath: "/usr/local/bin/python3") } else { process.executableURL = URL(fileURLWithPath: "/usr/bin/python") @@ -799,9 +1261,9 @@ public final class ActionDispatcher { case "lua": // Lua is typically installed via Homebrew - if FileManager.default.fileExists(atPath: "/usr/local/bin/lua") { + if Foundation.FileManager().fileExists(atPath: "/usr/local/bin/lua") { process.executableURL = URL(fileURLWithPath: "/usr/local/bin/lua") - } else if FileManager.default.fileExists(atPath: "/opt/homebrew/bin/lua") { + } else if Foundation.FileManager().fileExists(atPath: "/opt/homebrew/bin/lua") { process.executableURL = URL(fileURLWithPath: "/opt/homebrew/bin/lua") } else { throw NSError(domain: "InterpretedScript", code: 1, userInfo: [ diff --git a/Sources/KeyPathAppKit/Services/AppConfigGenerator.swift b/Sources/KeyPathAppKit/Services/AppConfigGenerator.swift index 06673661d..9a189ed32 100644 --- a/Sources/KeyPathAppKit/Services/AppConfigGenerator.swift +++ b/Sources/KeyPathAppKit/Services/AppConfigGenerator.swift @@ -134,10 +134,10 @@ public enum AppConfigGenerator { // Ensure directory exists let directory = (path as NSString).deletingLastPathComponent - try FileManager.default.createDirectory( + try Foundation.FileManager().createDirectory( atPath: directory, withIntermediateDirectories: true, - attributes: nil + attributes: nil as [FileAttributeKey: Any]? ) // VALIDATE BEFORE WRITING - prevent broken configs from being saved @@ -403,7 +403,7 @@ public enum AppConfigGenerator { public extension AppConfigGenerator { /// Check if the app config file exists static var configFileExists: Bool { - FileManager.default.fileExists(atPath: appConfigPath) + Foundation.FileManager().fileExists(atPath: appConfigPath) } /// Read the current app config file content @@ -414,8 +414,8 @@ public extension AppConfigGenerator { /// Delete the app config file static func deleteConfig() throws { let path = appConfigPath - if FileManager.default.fileExists(atPath: path) { - try FileManager.default.removeItem(atPath: path) + if Foundation.FileManager().fileExists(atPath: path) { + try Foundation.FileManager().removeItem(atPath: path) AppLogger.shared.log("🗑️ [AppConfigGenerator] Deleted app config at \(path)") } } diff --git a/Sources/KeyPathAppKit/Services/AppIconResolver.swift b/Sources/KeyPathAppKit/Services/AppIconResolver.swift index 3260ac8ab..22b939d3b 100644 --- a/Sources/KeyPathAppKit/Services/AppIconResolver.swift +++ b/Sources/KeyPathAppKit/Services/AppIconResolver.swift @@ -40,7 +40,7 @@ enum AppIconResolver { ] for path in searchPaths { - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { return NSWorkspace.shared.icon(forFile: path) } } @@ -53,7 +53,7 @@ enum AppIconResolver { "/System/Applications/\(variation).app" ] for path in paths { - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { return NSWorkspace.shared.icon(forFile: path) } } @@ -94,7 +94,7 @@ enum AppIconResolver { let expandedPath = (path as NSString).expandingTildeInPath // If folder exists, get its actual icon (may have custom icon) - if FileManager.default.fileExists(atPath: expandedPath) { + if Foundation.FileManager().fileExists(atPath: expandedPath) { return NSWorkspace.shared.icon(forFile: expandedPath) } @@ -116,7 +116,7 @@ enum AppIconResolver { let ext = URL(fileURLWithPath: expandedPath).pathExtension.lowercased() // If file exists, get its actual icon - if FileManager.default.fileExists(atPath: expandedPath) { + if Foundation.FileManager().fileExists(atPath: expandedPath) { return NSWorkspace.shared.icon(forFile: expandedPath) } diff --git a/Sources/KeyPathAppKit/Services/AppKeymapStore.swift b/Sources/KeyPathAppKit/Services/AppKeymapStore.swift index a18f79295..823d2afea 100644 --- a/Sources/KeyPathAppKit/Services/AppKeymapStore.swift +++ b/Sources/KeyPathAppKit/Services/AppKeymapStore.swift @@ -38,7 +38,7 @@ actor AppKeymapStore { /// In-memory cache of keymaps private var cachedKeymaps: [AppKeymap]? - init(fileURL: URL? = nil, fileManager: FileManager = .default) { + init(fileURL: URL? = nil, fileManager: FileManager = Foundation.FileManager()) { self.fileManager = fileManager let defaultDirectory = URL( fileURLWithPath: WizardSystemPaths.userConfigDirectory, isDirectory: true @@ -160,7 +160,10 @@ actor AppKeymapStore { /// Post notification on main thread that keymaps have changed private func postChangeNotification() { Task { @MainActor in - NotificationCenter.default.post(name: .appKeymapsDidChange, object: nil) + NotificationObserverManager.defaultCenter.post( + name: Notification.Name.appKeymapsDidChange, + object: nil as AnyObject? + ) } } diff --git a/Sources/KeyPathAppKit/Services/BrowserHistoryScanner.swift b/Sources/KeyPathAppKit/Services/BrowserHistoryScanner.swift index 4eeaec270..ecfdcdae2 100644 --- a/Sources/KeyPathAppKit/Services/BrowserHistoryScanner.swift +++ b/Sources/KeyPathAppKit/Services/BrowserHistoryScanner.swift @@ -71,12 +71,12 @@ actor BrowserHistoryScanner { var isInstalled: Bool { if self == .firefox { // Firefox profile directory check - return FileManager.default.fileExists(atPath: historyPath) + return Foundation.FileManager().fileExists(atPath: historyPath) } if isChromiumBased, let basePath = chromiumBasePath { - return FileManager.default.fileExists(atPath: basePath) + return Foundation.FileManager().fileExists(atPath: basePath) } - return FileManager.default.fileExists(atPath: historyPath) + return Foundation.FileManager().fileExists(atPath: historyPath) } var isChromiumBased: Bool { @@ -111,7 +111,7 @@ actor BrowserHistoryScanner { "\(NSHomeDirectory())/Library/Messages", "\(NSHomeDirectory())/Library/Cookies" ] - return testPaths.contains { FileManager.default.isReadableFile(atPath: $0) } + return testPaths.contains { Foundation.FileManager().isReadableFile(atPath: $0) } } /// Get installed browsers that can be scanned @@ -189,8 +189,9 @@ actor BrowserHistoryScanner { // Safari keeps database locked, so we need to copy it first let tempPath = NSTemporaryDirectory() + "safari_history_\(UUID().uuidString).db" - try FileManager.default.copyItem(atPath: dbPath, toPath: tempPath) - defer { try? FileManager.default.removeItem(atPath: tempPath) } + let fileManager = Foundation.FileManager() + try fileManager.copyItem(atPath: dbPath, toPath: tempPath) + defer { try? fileManager.removeItem(atPath: tempPath) } var db: OpaquePointer? guard sqlite3_open_v2(tempPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { @@ -270,8 +271,9 @@ actor BrowserHistoryScanner { private func scanChromium(path: String) throws -> [VisitedSite] { // Chromium also keeps database locked let tempPath = NSTemporaryDirectory() + "chromium_history_\(UUID().uuidString).db" - try FileManager.default.copyItem(atPath: path, toPath: tempPath) - defer { try? FileManager.default.removeItem(atPath: tempPath) } + let fileManager = Foundation.FileManager() + try fileManager.copyItem(atPath: path, toPath: tempPath) + defer { try? fileManager.removeItem(atPath: tempPath) } var db: OpaquePointer? guard sqlite3_open_v2(tempPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { @@ -352,8 +354,9 @@ actor BrowserHistoryScanner { private func scanFirefoxProfile(at profileDir: String) throws -> [VisitedSite] { let dbPath = "\(profileDir)/places.sqlite" let tempPath = NSTemporaryDirectory() + "firefox_history_\(UUID().uuidString).db" - try FileManager.default.copyItem(atPath: dbPath, toPath: tempPath) - defer { try? FileManager.default.removeItem(atPath: tempPath) } + let fileManager = Foundation.FileManager() + try fileManager.copyItem(atPath: dbPath, toPath: tempPath) + defer { try? fileManager.removeItem(atPath: tempPath) } var db: OpaquePointer? guard sqlite3_open_v2(tempPath, &db, SQLITE_OPEN_READONLY, nil) == SQLITE_OK else { @@ -409,7 +412,7 @@ actor BrowserHistoryScanner { func commitProfile() { guard let currentPath else { return } let fullPath = isRelative ? "\(profilesPath)/\(currentPath)" : currentPath - if FileManager.default.fileExists(atPath: "\(fullPath)/places.sqlite") { + if Foundation.FileManager().fileExists(atPath: "\(fullPath)/places.sqlite") { profiles.append(fullPath) } } @@ -445,7 +448,7 @@ actor BrowserHistoryScanner { /// Fallback: scan for .default and .default-release profiles. private func fallbackFirefoxProfilePaths(at profilesPath: String) -> [String] { - let fm = FileManager.default + let fm = Foundation.FileManager() guard let contents = try? fm.contentsOfDirectory(atPath: profilesPath) else { return [] } @@ -475,25 +478,25 @@ actor BrowserHistoryScanner { { for profileDir in infoCache.keys.sorted() { let path = "\(basePath)/\(profileDir)/History" - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { historyPaths.append(path) } } } if historyPaths.isEmpty { - if let contents = try? FileManager.default.contentsOfDirectory(atPath: basePath) { + if let contents = try? Foundation.FileManager().contentsOfDirectory(atPath: basePath) { let candidateDirs = contents.filter { $0 == "Default" || $0.hasPrefix("Profile ") } for dir in candidateDirs { let path = "\(basePath)/\(dir)/History" - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { historyPaths.append(path) } } } } - if historyPaths.isEmpty, FileManager.default.fileExists(atPath: fallbackHistoryPath) { + if historyPaths.isEmpty, Foundation.FileManager().fileExists(atPath: fallbackHistoryPath) { historyPaths.append(fallbackHistoryPath) } diff --git a/Sources/KeyPathAppKit/Services/ConfigBackupManager.swift b/Sources/KeyPathAppKit/Services/ConfigBackupManager.swift index a89d316fe..db6eb0959 100644 --- a/Sources/KeyPathAppKit/Services/ConfigBackupManager.swift +++ b/Sources/KeyPathAppKit/Services/ConfigBackupManager.swift @@ -31,7 +31,7 @@ public final class ConfigBackupManager { /// Create a backup of the current config file before user editing /// Returns true if backup was created, false if current config is invalid public func createPreEditBackup() -> Bool { - guard FileManager.default.fileExists(atPath: configPath) else { + guard Foundation.FileManager().fileExists(atPath: configPath) else { AppLogger.shared.log("⚠️ [BackupManager] No config file to backup at: \(configPath)") return false } @@ -73,12 +73,12 @@ public final class ConfigBackupManager { /// Get list of available backup files, sorted by date (newest first) public func getAvailableBackups() -> [BackupInfo] { - guard FileManager.default.fileExists(atPath: backupDirectory) else { + guard Foundation.FileManager().fileExists(atPath: backupDirectory) else { return [] } do { - let files = try FileManager.default.contentsOfDirectory(atPath: backupDirectory) + let files = try Foundation.FileManager().contentsOfDirectory(atPath: backupDirectory) let backupFiles = files.filter { $0.hasSuffix(".kbd") } var backups: [BackupInfo] = [] @@ -86,9 +86,9 @@ public final class ConfigBackupManager { for file in backupFiles { let fullPath = "\(backupDirectory)/\(file)" - if let attributes = try? FileManager.default.attributesOfItem(atPath: fullPath), - let modDate = attributes[.modificationDate] as? Date, - let size = attributes[.size] as? Int64 + if let attributes = try? Foundation.FileManager().attributesOfItem(atPath: fullPath), + let modDate = attributes[Foundation.FileAttributeKey.modificationDate] as? Date, + let size = attributes[Foundation.FileAttributeKey.size] as? Int64 { backups.append( BackupInfo( @@ -112,7 +112,7 @@ public final class ConfigBackupManager { /// Restore config from a specific backup public func restoreFromBackup(_ backup: BackupInfo) throws { - guard FileManager.default.fileExists(atPath: backup.fullPath) else { + guard Foundation.FileManager().fileExists(atPath: backup.fullPath) else { throw KeyPathError.configuration(.backupNotFound) } @@ -127,10 +127,10 @@ public final class ConfigBackupManager { } // Create a backup of current config (if it exists) before restoring - if FileManager.default.fileExists(atPath: configPath) { + if Foundation.FileManager().fileExists(atPath: configPath) { let currentBackupPath = "\(backupDirectory)/before_restore_\(Date().timeIntervalSince1970).kbd" - try? FileManager.default.copyItem(atPath: configPath, toPath: currentBackupPath) + try? Foundation.FileManager().copyItem(atPath: configPath, toPath: currentBackupPath) } // Restore the backup @@ -168,13 +168,13 @@ public final class ConfigBackupManager { // MARK: - Private Methods private func createBackupDirectoryIfNeeded() { - guard !FileManager.default.fileExists(atPath: backupDirectory) else { return } + guard !Foundation.FileManager().fileExists(atPath: backupDirectory) else { return } do { - try FileManager.default.createDirectory( + try Foundation.FileManager().createDirectory( atPath: backupDirectory, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o755] + attributes: [Foundation.FileAttributeKey.posixPermissions: 0o755] ) AppLogger.shared.log("✅ [BackupManager] Created backup directory: \(backupDirectory)") } catch { @@ -191,7 +191,7 @@ public final class ConfigBackupManager { for backup in backupsToDelete { do { - try FileManager.default.removeItem(atPath: backup.fullPath) + try Foundation.FileManager().removeItem(atPath: backup.fullPath) AppLogger.shared.log("🗑️ [BackupManager] Deleted old backup: \(backup.filename)") } catch { AppLogger.shared.log( diff --git a/Sources/KeyPathAppKit/Services/ConfigFileWatcher.swift b/Sources/KeyPathAppKit/Services/ConfigFileWatcher.swift index bf44c6211..37f760feb 100644 --- a/Sources/KeyPathAppKit/Services/ConfigFileWatcher.swift +++ b/Sources/KeyPathAppKit/Services/ConfigFileWatcher.swift @@ -97,7 +97,7 @@ class ConfigFileWatcher: @unchecked Sendable { AppLogger.shared.log("📁 [FileWatcher] Parent directory: \(watchedDirectoryPath ?? "unknown")") // Check if file exists and start appropriate monitoring - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { AppLogger.shared.log("📁 [FileWatcher] Target file exists - setting up direct file monitoring") setupFileMonitoring() } else { @@ -175,7 +175,7 @@ class ConfigFileWatcher: @unchecked Sendable { } // Ensure directory exists - guard FileManager.default.fileExists(atPath: directoryPath) else { + guard Foundation.FileManager().fileExists(atPath: directoryPath) else { AppLogger.shared.log("❌ [FileWatcher] Directory doesn't exist: \(directoryPath)") handleDirectoryMonitoringFailure() return @@ -342,7 +342,7 @@ class ConfigFileWatcher: @unchecked Sendable { AppLogger.shared.log("📁 [FileWatcher] Processing directory change event") // Check if our target file was created - if FileManager.default.fileExists(atPath: filePath) { + if Foundation.FileManager().fileExists(atPath: filePath) { AppLogger.shared.log("🎉 [FileWatcher] Target file was created! Switching to file monitoring") // Stop directory monitoring @@ -371,7 +371,7 @@ class ConfigFileWatcher: @unchecked Sendable { AppLogger.shared.log("📁 [FileWatcher] Processing file change event for: \(path)") // Check if file still exists (handles atomic writes where file is deleted/recreated) - if !FileManager.default.fileExists(atPath: path) { + if !Foundation.FileManager().fileExists(atPath: path) { AppLogger.shared.log( "⚠️ [FileWatcher] File no longer exists - may be atomic write in progress" ) @@ -380,7 +380,7 @@ class ConfigFileWatcher: @unchecked Sendable { // Wait a brief moment and check again try? await Task.sleep(for: .milliseconds(100)) // 100ms - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { AppLogger.shared.log("📁 [FileWatcher] File reappeared - atomic write completed") } else { AppLogger.shared.log( @@ -477,7 +477,7 @@ class ConfigFileWatcher: @unchecked Sendable { } do { - let attributes = try FileManager.default.attributesOfItem(atPath: path) + let attributes = try Foundation.FileManager().attributesOfItem(atPath: path) let modDate = attributes[.modificationDate] as? Date lastModificationDate = modDate AppLogger.shared.log( @@ -496,7 +496,7 @@ class ConfigFileWatcher: @unchecked Sendable { } do { - let attributes = try FileManager.default.attributesOfItem(atPath: path) + let attributes = try Foundation.FileManager().attributesOfItem(atPath: path) let currentModDate = attributes[.modificationDate] as? Date AppLogger.shared.log( @@ -540,7 +540,7 @@ class ConfigFileWatcher: @unchecked Sendable { AppLogger.shared.log("📁 [FileWatcher] Processing debounced file change for: \(path)") // Check if file still exists (important for atomic writes) - guard FileManager.default.fileExists(atPath: path) else { + guard Foundation.FileManager().fileExists(atPath: path) else { AppLogger.shared.log("⚠️ [FileWatcher] File no longer exists during processing: \(path)") // Switch to directory monitoring to watch for recreation @@ -564,7 +564,7 @@ class ConfigFileWatcher: @unchecked Sendable { // Get file size for logging do { - let attributes = try FileManager.default.attributesOfItem(atPath: path) + let attributes = try Foundation.FileManager().attributesOfItem(atPath: path) let fileSize = attributes[.size] as? Int64 ?? 0 AppLogger.shared.log( "📁 [FileWatcher] Triggering file change callback for file (\(fileSize) bytes)" diff --git a/Sources/KeyPathAppKit/Services/ConfigHotReloadService.swift b/Sources/KeyPathAppKit/Services/ConfigHotReloadService.swift index 6c9009e6d..ad095b4fa 100644 --- a/Sources/KeyPathAppKit/Services/ConfigHotReloadService.swift +++ b/Sources/KeyPathAppKit/Services/ConfigHotReloadService.swift @@ -101,13 +101,12 @@ final class ConfigHotReloadService { /// - Returns: Result with success status and optional new content/mappings func handleExternalChange(configPath: String) async -> ReloadResult { AppLogger.shared.log("📝 [ConfigHotReload] External config file change detected") - // Notify detection callbacks.onDetected?() callbacks.onValidating?() // Check file exists - guard FileManager.default.fileExists(atPath: configPath) else { + guard Foundation.FileManager().fileExists(atPath: configPath) else { AppLogger.shared.error("❌ [ConfigHotReload] Config file no longer exists: \(configPath)") let result = ReloadResult.failure("Config file was deleted") callbacks.onFailure?(result.message) diff --git a/Sources/KeyPathAppKit/Services/CustomRulesStore.swift b/Sources/KeyPathAppKit/Services/CustomRulesStore.swift index 909d9510a..db3573cdc 100644 --- a/Sources/KeyPathAppKit/Services/CustomRulesStore.swift +++ b/Sources/KeyPathAppKit/Services/CustomRulesStore.swift @@ -10,7 +10,7 @@ actor CustomRulesStore { private let fileURL: URL private let fileManager: FileManager - init(fileURL: URL? = nil, fileManager: FileManager = .default) { + init(fileURL: URL? = nil, fileManager: FileManager = Foundation.FileManager()) { self.fileManager = fileManager let defaultDirectory = URL( fileURLWithPath: WizardSystemPaths.userConfigDirectory, isDirectory: true diff --git a/Sources/KeyPathAppKit/Services/DiagnosticsService.swift b/Sources/KeyPathAppKit/Services/DiagnosticsService.swift index a87d8b767..cd816024d 100644 --- a/Sources/KeyPathAppKit/Services/DiagnosticsService.swift +++ b/Sources/KeyPathAppKit/Services/DiagnosticsService.swift @@ -84,11 +84,74 @@ protocol DiagnosticsServiceProtocol: Sendable { // SAFETY: @unchecked Sendable — all stored state (processLifecycleManager) is itself // Sendable (@MainActor final class). Methods are stateless diagnostic queries. final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable { + private static let outputBridgeSmokeDiagnosticsEnvKey = "KEYPATH_ENABLE_OUTPUT_BRIDGE_SMOKE_DIAGNOSTIC" + private static let outputBridgeSmokeModifierEnvKey = "KEYPATH_ENABLE_OUTPUT_BRIDGE_SMOKE_MODIFIERS" + private static let outputBridgeSmokeEmitEnvKey = "KEYPATH_ENABLE_OUTPUT_BRIDGE_SMOKE_EMIT" + private static let hostPassthruDiagnosticsEnvKey = "KEYPATH_ENABLE_HOST_PASSTHRU_DIAGNOSTIC" + private static let hostPassthruCaptureEnvKey = "KEYPATH_ENABLE_HOST_PASSTHRU_CAPTURE" + private static let hostPassthruDiagnosticsTimeout: TimeInterval = 8 + /// Dependencies private let processLifecycleManager: ProcessLifecycleManager + private let outputBridgeSmokeRunner: @Sendable () async throws -> KanataOutputBridgeSmokeReport + private let outputBridgeSmokeDiagnosticsEnabled: @Sendable () -> Bool + private let hostPassthruRunner: @Sendable () async throws -> KanataSplitRuntimeHostLaunchReport + private let hostPassthruDiagnosticsEnabled: @Sendable () -> Bool + + typealias HostPassthruDiagnosticReport = KanataSplitRuntimeHostLaunchReport + + static func makeDefaultHostPassthruRunner(includeCapture: Bool) -> @Sendable () async throws -> KanataSplitRuntimeHostLaunchReport { + { + let service = await MainActor.run { KanataSplitRuntimeHostService.shared } + return try await service.launchPassthruHost( + includeCapture: includeCapture, + timeout: hostPassthruDiagnosticsTimeout, + pollMilliseconds: 1000 + ) + } + } - init(processLifecycleManager: ProcessLifecycleManager) { + init( + processLifecycleManager: ProcessLifecycleManager, + outputBridgeSmokeRunner: @escaping @Sendable () async throws -> KanataOutputBridgeSmokeReport = { + let modifierRaw = ProcessInfo.processInfo.environment[outputBridgeSmokeModifierEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeModifierSync = modifierRaw == "1" || modifierRaw == "true" || modifierRaw == "yes" + let raw = ProcessInfo.processInfo.environment[outputBridgeSmokeEmitEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeEmit = raw == "1" || raw == "true" || raw == "yes" + return try await KanataOutputBridgeSmokeService.run( + syncModifierProbe: includeModifierSync ? KanataOutputBridgeSmokeService.defaultModifierProbeState : nil, + emitProbeEvent: includeEmit ? KanataOutputBridgeSmokeService.defaultEmitProbeEvent : nil + ) + }, + outputBridgeSmokeDiagnosticsEnabled: @escaping @Sendable () -> Bool = { + let raw = ProcessInfo.processInfo.environment[outputBridgeSmokeDiagnosticsEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return raw == "1" || raw == "true" || raw == "yes" + }, + hostPassthruRunner: @escaping @Sendable () async throws -> KanataSplitRuntimeHostLaunchReport = { + let hostCaptureRaw = ProcessInfo.processInfo.environment[hostPassthruCaptureEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let includeCapture = hostCaptureRaw == "1" || hostCaptureRaw == "true" || hostCaptureRaw == "yes" + return try await makeDefaultHostPassthruRunner(includeCapture: includeCapture)() + }, + hostPassthruDiagnosticsEnabled: @escaping @Sendable () -> Bool = { + let raw = ProcessInfo.processInfo.environment[hostPassthruDiagnosticsEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return raw == "1" || raw == "true" || raw == "yes" + } + ) { self.processLifecycleManager = processLifecycleManager + self.outputBridgeSmokeRunner = outputBridgeSmokeRunner + self.outputBridgeSmokeDiagnosticsEnabled = outputBridgeSmokeDiagnosticsEnabled + self.hostPassthruRunner = hostPassthruRunner + self.hostPassthruDiagnosticsEnabled = hostPassthruDiagnosticsEnabled } nonisolated func virtualHIDDaemonStatus() async -> VirtualHIDDaemonStatus { @@ -122,6 +185,19 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable return events } + func runHostPassthruDiagnostic() async -> KanataDiagnostic { + await makeExperimentalHostPassthruDiagnostic() + } + + func runHostPassthruDiagnostic(includeCapture: Bool) async -> KanataDiagnostic { + do { + let report = try await Self.makeDefaultHostPassthruRunner(includeCapture: includeCapture)() + return Self.makeHostPassthruDiagnostic(for: report) + } catch { + return Self.makeHostPassthruFailureDiagnostic(error: error) + } + } + func getSystemDiagnostics(engineClient _: EngineClient?) async -> [KanataDiagnostic] { await getSystemDiagnostics() } @@ -302,18 +378,20 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable // NOTE: Permission checks are handled by the Installation Wizard // We don't duplicate permission diagnostics here to avoid confusion - // Check for conflicts - if await isKarabinerElementsRunning() { + let karabinerElementsRunning = await isKarabinerElementsRunning() + + // Check for karabiner_grabber conflict (single diagnostic for the condition) + if karabinerElementsRunning { diagnostics.append( KanataDiagnostic( timestamp: Date(), - severity: .warning, + severity: .error, category: .conflict, - title: "Karabiner-Elements Conflict", - description: "Karabiner-Elements grabber is running and may conflict with Kanata", - technicalDetails: "karabiner_grabber process detected", - suggestedAction: "Stop Karabiner-Elements or configure it to not interfere", - canAutoFix: false + title: "Karabiner Grabber Conflict", + description: "karabiner_grabber is running and will prevent Kanata from starting", + technicalDetails: "karabiner_grabber process detected — causes 'exclusive access and device already open' errors", + suggestedAction: "Quit Karabiner-Elements or disable its key remapping", + canAutoFix: true ) ) } @@ -347,22 +425,6 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable ) } - // Check for karabiner_grabber conflict - if await isKarabinerElementsRunning() { - diagnostics.append( - KanataDiagnostic( - timestamp: Date(), - severity: .error, - category: .conflict, - title: "Karabiner Grabber Conflict", - description: "karabiner_grabber is running and will prevent Kanata from starting", - technicalDetails: "This causes 'exclusive access and device already open' errors", - suggestedAction: "Quit Karabiner-Elements or disable its key remapping", - canAutoFix: true // We can kill it - ) - ) - } - // Check for Kanata process conflicts let processConflicts = await checkProcessConflicts() diagnostics.append(contentsOf: processConflicts) @@ -404,6 +466,15 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable // Note: Some Kanata builds do not implement a Status command. // Keep TCP diagnostics driven by HelloOk + log analysis instead. + let runtimePathDecision = await KanataRuntimePathCoordinator.evaluateCurrentPath() + diagnostics.append(Self.makeRuntimePathDiagnostic(for: runtimePathDecision)) + if outputBridgeSmokeDiagnosticsEnabled() { + diagnostics.append(await makeExperimentalOutputBridgeSmokeDiagnostic()) + } + if hostPassthruDiagnosticsEnabled() { + diagnostics.append(await makeExperimentalHostPassthruDiagnostic()) + } + // TCP handshake summary (protocol/capabilities) if let hello = await fetchTcpHello() { let caps = hello.capabilities.joined(separator: ", ") @@ -432,6 +503,165 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable return diagnostics } + private func makeExperimentalOutputBridgeSmokeDiagnostic() async -> KanataDiagnostic { + do { + let report = try await outputBridgeSmokeRunner() + return Self.makeOutputBridgeSmokeDiagnostic(for: report) + } catch { + return Self.makeOutputBridgeSmokeFailureDiagnostic(error: error) + } + } + + private func makeExperimentalHostPassthruDiagnostic() async -> KanataDiagnostic { + do { + let report = try await hostPassthruRunner() + return Self.makeHostPassthruDiagnostic(for: report) + } catch { + return Self.makeHostPassthruFailureDiagnostic(error: error) + } + } + + static func makeRuntimePathDiagnostic( + for decision: KanataRuntimePathDecision, + timestamp: Date = Date() + ) -> KanataDiagnostic { + switch decision { + case let .useSplitRuntime(reason): + return KanataDiagnostic( + timestamp: timestamp, + severity: .info, + category: .system, + title: "Runtime Path: Split Runtime Ready", + description: "Bundled host input runtime is ready and still requires a privileged output bridge.", + technicalDetails: reason, + suggestedAction: "", + canAutoFix: false + ) + case let .useLegacySystemBinary(reason): + return KanataDiagnostic( + timestamp: timestamp, + severity: .warning, + category: .system, + title: "Runtime Path: Legacy Fallback Active", + description: "KeyPath still depends on the legacy root-owned Kanata binary on this machine.", + technicalDetails: reason, + suggestedAction: "No immediate action required. This is a migration diagnostic for the macOS split-runtime rollout.", + canAutoFix: false + ) + case let .blocked(reason): + return KanataDiagnostic( + timestamp: timestamp, + severity: .error, + category: .system, + title: "Runtime Path: Split Runtime Blocked", + description: "Neither the bundled split runtime nor the legacy system binary is currently viable.", + technicalDetails: reason, + suggestedAction: "Repair the install or restore the legacy Kanata binary before switching runtime architectures.", + canAutoFix: false + ) + } + } + + static func makeOutputBridgeSmokeDiagnostic( + for report: KanataOutputBridgeSmokeReport, + timestamp: Date = Date() + ) -> KanataDiagnostic { + KanataDiagnostic( + timestamp: timestamp, + severity: .info, + category: .system, + title: "Experimental Output Bridge Smoke Passed", + description: "The app-owned split-runtime bridge completed helper session setup plus handshake/ping.", + technicalDetails: """ + session=\(report.session.sessionID) + socket=\(report.session.socketPath) + handshake=\(report.handshake) + ping=\(report.ping) + sync_modifiers_state=\(String(describing: report.syncedModifiers)) + sync_modifiers_response=\(String(describing: report.syncModifiers)) + emit_event=\(String(describing: report.emittedKeyEvent)) + emit_response=\(String(describing: report.emitKey)) + reset=\(String(describing: report.reset)) + """, + suggestedAction: "", + canAutoFix: false + ) + } + + static func makeOutputBridgeSmokeFailureDiagnostic( + error: Error, + timestamp: Date = Date() + ) -> KanataDiagnostic { + KanataDiagnostic( + timestamp: timestamp, + severity: .warning, + category: .system, + title: "Experimental Output Bridge Smoke Failed", + description: "The app-owned split-runtime bridge smoke path could not complete.", + technicalDetails: error.localizedDescription, + suggestedAction: "Use this diagnostic only while validating the split-runtime migration.", + canAutoFix: false + ) + } + + static func makeHostPassthruDiagnostic( + for report: HostPassthruDiagnosticReport, + timestamp: Date = Date() + ) -> KanataDiagnostic { + let succeeded = hostPassthruReportSucceeded(report) + return KanataDiagnostic( + timestamp: timestamp, + severity: succeeded ? .info : .warning, + category: .system, + title: succeeded + ? "Experimental Host Passthru Diagnostic Passed" + : "Experimental Host Passthru Diagnostic Failed", + description: succeeded + ? "The signed app launched the bundled passthru host path and it exited cleanly." + : "The signed app launched the bundled passthru host path but it did not exit cleanly.", + technicalDetails: """ + launcher=\(report.launcherPath) + session=\(report.sessionID) + socket=\(report.socketPath) + exit_code=\(report.exitCode) + stderr=\(report.stderr) + """, + suggestedAction: "Use this diagnostic only while validating the split-runtime migration.", + canAutoFix: false + ) + } + + static func hostPassthruReportSucceeded(_ report: HostPassthruDiagnosticReport) -> Bool { + guard report.exitCode == 0 else { + return false + } + + let stderr = report.stderr.lowercased() + let blockingMarkers = [ + "experimental passthru forwarding failed", + "failed to connect to output bridge socket", + "host bridge passthru runtime failed", + ] + + return blockingMarkers.contains(where: { stderr.contains($0) }) == false + } + + static func makeHostPassthruFailureDiagnostic( + error: Error, + timestamp: Date = Date() + ) -> KanataDiagnostic { + KanataDiagnostic( + timestamp: timestamp, + severity: .warning, + category: .system, + title: "Experimental Host Passthru Diagnostic Failed", + description: "The signed app could not launch the bundled passthru host diagnostic path.", + technicalDetails: error.localizedDescription, + suggestedAction: "Use this diagnostic only while validating the split-runtime migration.", + canAutoFix: false + ) + } + // MARK: - Process Conflict Checking func checkProcessConflicts() async -> [KanataDiagnostic] { @@ -486,7 +716,7 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable func analyzeLogFile(path: String) async -> [KanataDiagnostic] { var diagnostics: [KanataDiagnostic] = [] - guard FileManager.default.fileExists(atPath: path) else { + guard Foundation.FileManager().fileExists(atPath: path) else { diagnostics.append( KanataDiagnostic( timestamp: Date(), @@ -503,7 +733,19 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable } do { - let logContent = try String(contentsOfFile: path, encoding: .utf8) + let handle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + defer { try? handle.close() } + let fileSize = (try? handle.seekToEnd()) ?? 0 + let tailWindow = min(fileSize, 64 * 1024) + try handle.seek(toOffset: fileSize - tailWindow) + let logData = try handle.readToEnd() ?? Data() + guard let logContent = String(data: logData, encoding: .utf8) else { + throw NSError( + domain: "KeyPath.Diagnostics", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Log file is not valid UTF-8"] + ) + } let lines = logContent.components(separatedBy: .newlines) // Look for common error patterns @@ -570,7 +812,7 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable // MARK: - Helper Methods private func isKanataInstalled() -> Bool { - FileManager.default.fileExists(atPath: WizardSystemPaths.kanataActiveBinary) + Foundation.FileManager().fileExists(atPath: WizardSystemPaths.kanataActiveBinary) } private func isKarabinerElementsRunning() async -> Bool { @@ -592,7 +834,7 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable } private func isKarabinerDriverInstalled() -> Bool { - FileManager.default.fileExists( + Foundation.FileManager().fileExists( atPath: "/Library/Application Support/org.pqrs/Karabiner-DriverKit-VirtualHIDDevice" ) } @@ -637,28 +879,9 @@ final class DiagnosticsService: DiagnosticsServiceProtocol, @unchecked Sendable } } - // MARK: - TCP helpers (best-effort) - - private func fetchTcpStatusInfo() async -> KanataTCPClient.TcpStatusInfo? { - let client = KanataTCPClient(port: 37001) - - do { - _ = try await client.hello() - let status = try await client.getStatus() - - // FIX #1: Explicitly close connection to prevent file descriptor leak - await client.cancelInflightAndCloseConnection() - - return status - } catch { - // FIX #1: Clean up connection even on error path - await client.cancelInflightAndCloseConnection() - return nil - } - } - private func fetchTcpHello() async -> KanataTCPClient.TcpHelloOk? { - let client = KanataTCPClient(port: 37001) + let tcpPort = await MainActor.run { PreferencesService.shared.tcpServerPort } + let client = KanataTCPClient(port: tcpPort) do { let hello = try await client.hello() diff --git a/Sources/KeyPathAppKit/Services/ExternalKanataService.swift b/Sources/KeyPathAppKit/Services/ExternalKanataService.swift index 9219381ce..603e28da0 100644 --- a/Sources/KeyPathAppKit/Services/ExternalKanataService.swift +++ b/Sources/KeyPathAppKit/Services/ExternalKanataService.swift @@ -130,7 +130,7 @@ public enum ExternalKanataService { } private static func extractLabelFromPlist(at path: String) -> String? { - guard let data = FileManager.default.contents(atPath: path), + guard let data = Foundation.FileManager().contents(atPath: path), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], let label = plist["Label"] as? String else { diff --git a/Sources/KeyPathAppKit/Services/FaviconFetcher.swift b/Sources/KeyPathAppKit/Services/FaviconFetcher.swift index fff31a2c9..3ca3e10c4 100644 --- a/Sources/KeyPathAppKit/Services/FaviconFetcher.swift +++ b/Sources/KeyPathAppKit/Services/FaviconFetcher.swift @@ -22,10 +22,13 @@ final class FaviconFetcher { /// Directory where favicons are cached on disk private let cacheDirectory: URL = { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let appSupport = Foundation.FileManager().urls( + for: FileManager.SearchPathDirectory.applicationSupportDirectory, + in: FileManager.SearchPathDomainMask.userDomainMask + ).first! let faviconDir = appSupport.appendingPathComponent("KeyPath/Favicons", isDirectory: true) // Create directory if it doesn't exist - try? FileManager.default.createDirectory(at: faviconDir, withIntermediateDirectories: true) + try? Foundation.FileManager().createDirectory(at: faviconDir, withIntermediateDirectories: true) return faviconDir }() @@ -84,9 +87,9 @@ final class FaviconFetcher { memoryCache.removeAll() do { - let contents = try FileManager.default.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) + let contents = try Foundation.FileManager().contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) for fileURL in contents { - try? FileManager.default.removeItem(at: fileURL) + try? Foundation.FileManager().removeItem(at: fileURL) } AppLogger.shared.log("🧹 [FaviconFetcher] Cleared all favicon cache") } catch { @@ -323,7 +326,7 @@ final class FaviconFetcher { private func loadFromDiskCache(domain: String) -> NSImage? { let fileURL = cacheDirectory.appendingPathComponent("\(domain)-v\(cacheVersion).png") - guard FileManager.default.fileExists(atPath: fileURL.path) else { + guard Foundation.FileManager().fileExists(atPath: fileURL.path) else { return nil } @@ -402,7 +405,7 @@ final class FaviconFetcher { private init() { // Create cache directory if needed - try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) + try? Foundation.FileManager().createDirectory(at: cacheDirectory, withIntermediateDirectories: true) AppLogger.shared.log("🖼️ [FaviconFetcher] Initialized with cache at \(cacheDirectory.path)") } } diff --git a/Sources/KeyPathAppKit/Services/FullDiskAccessChecker.swift b/Sources/KeyPathAppKit/Services/FullDiskAccessChecker.swift index 46be7fba5..5073cf2ad 100644 --- a/Sources/KeyPathAppKit/Services/FullDiskAccessChecker.swift +++ b/Sources/KeyPathAppKit/Services/FullDiskAccessChecker.swift @@ -78,7 +78,7 @@ final class FullDiskAccessChecker { // NOTE: We intentionally do NOT probe the user-scoped TCC.db here. // The purpose is to detect FDA needed for Kanata verification (system TCC.db readability). // Avoid any heavy or invasive probing. This is a lightweight "can we read system TCC.db" test. - guard FileManager.default.isReadableFile(atPath: systemTCCPath) else { + guard Foundation.FileManager().isReadableFile(atPath: systemTCCPath) else { return false } diff --git a/Sources/KeyPathAppKit/Services/GlobalHotkeyService.swift b/Sources/KeyPathAppKit/Services/GlobalHotkeyService.swift index e0b8596a9..2c9598384 100644 --- a/Sources/KeyPathAppKit/Services/GlobalHotkeyService.swift +++ b/Sources/KeyPathAppKit/Services/GlobalHotkeyService.swift @@ -19,10 +19,10 @@ final class GlobalHotkeyService { /// Whether the global hotkey is enabled (user preference) var isEnabled: Bool { get { - UserDefaults.standard.object(forKey: DefaultsKey.globalHotkeyEnabled) as? Bool ?? true + Foundation.UserDefaults.standard.object(forKey: DefaultsKey.globalHotkeyEnabled) as? Bool ?? true } set { - UserDefaults.standard.set(newValue, forKey: DefaultsKey.globalHotkeyEnabled) + Foundation.UserDefaults.standard.set(newValue, forKey: DefaultsKey.globalHotkeyEnabled) if newValue { startMonitoring() } else { diff --git a/Sources/KeyPathAppKit/Services/HrmObservabilityService.swift b/Sources/KeyPathAppKit/Services/HrmObservabilityService.swift index cf8655eca..b9700e07a 100644 --- a/Sources/KeyPathAppKit/Services/HrmObservabilityService.swift +++ b/Sources/KeyPathAppKit/Services/HrmObservabilityService.swift @@ -110,7 +110,7 @@ final class HrmObservabilityService { private let traceBreakdownDebounce: Duration = .milliseconds(100) private init( - notificationCenter: NotificationCenter = .default, + notificationCenter: NotificationCenter = NotificationObserverManager.defaultCenter, now: @escaping () -> Date = Date.init ) { self.notificationCenter = notificationCenter diff --git a/Sources/KeyPathAppKit/Services/IconResolverService.swift b/Sources/KeyPathAppKit/Services/IconResolverService.swift index 8f2f9cdf0..815c838c9 100644 --- a/Sources/KeyPathAppKit/Services/IconResolverService.swift +++ b/Sources/KeyPathAppKit/Services/IconResolverService.swift @@ -60,13 +60,13 @@ final class IconResolverService { // Try app name in /Applications let directPath = "/Applications/\(identifier).app" - if FileManager.default.fileExists(atPath: directPath) { + if Foundation.FileManager().fileExists(atPath: directPath) { return URL(fileURLWithPath: directPath) } // Try with capitalized first letter let capitalizedPath = "/Applications/\(identifier.capitalized).app" - if FileManager.default.fileExists(atPath: capitalizedPath) { + if Foundation.FileManager().fileExists(atPath: capitalizedPath) { return URL(fileURLWithPath: capitalizedPath) } diff --git a/Sources/KeyPathAppKit/Services/KanataConfigGenerator.swift b/Sources/KeyPathAppKit/Services/KanataConfigGenerator.swift index c235a2c49..6b7f92e07 100644 --- a/Sources/KeyPathAppKit/Services/KanataConfigGenerator.swift +++ b/Sources/KeyPathAppKit/Services/KanataConfigGenerator.swift @@ -167,8 +167,8 @@ public class KanataConfigGenerator { /// Get the project root directory private func getProjectRoot() -> String? { - let fileManager = FileManager.default - var currentPath = fileManager.currentDirectoryPath + let fileManager = Foundation.FileManager() + var currentPath = Foundation.ProcessInfo.processInfo.environment["PWD"] ?? "." // Look for characteristic files that indicate project roo let markers = ["Package.swift", "CLAUDE.md", "External/kanata"] diff --git a/Sources/KeyPathAppKit/Services/KanataConfigMigrationService.swift b/Sources/KeyPathAppKit/Services/KanataConfigMigrationService.swift index a8f0be703..a7e5c76c7 100644 --- a/Sources/KeyPathAppKit/Services/KanataConfigMigrationService.swift +++ b/Sources/KeyPathAppKit/Services/KanataConfigMigrationService.swift @@ -45,7 +45,7 @@ public final class KanataConfigMigrationService { // MARK: - Properties - private let fileManager = FileManager.default + private let fileManager = Foundation.FileManager() // Use the same config path as ConfigurationService (KeyPathConstants.Config.mainConfigPath) // which is ~/.config/keypath/keypath.kbd, not Application Support private let keyPathConfigPath = WizardSystemPaths.userConfigPath @@ -157,7 +157,7 @@ public final class KanataConfigMigrationService { try fileManager.createDirectory( atPath: keyPathConfigDirectory, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o755] + attributes: [FileAttributeKey.posixPermissions: 0o755] ) AppLogger.shared.log("✅ [MigrationService] Created KeyPath config directory: \(keyPathConfigDirectory)") } @@ -216,7 +216,7 @@ public final class KanataConfigMigrationService { try fileManager.createDirectory( atPath: backupDir, withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o755] + attributes: [FileAttributeKey.posixPermissions: 0o755] ) } diff --git a/Sources/KeyPathAppKit/Services/KanataErrorMonitor.swift b/Sources/KeyPathAppKit/Services/KanataErrorMonitor.swift index 3238228cd..07384a4c1 100644 --- a/Sources/KeyPathAppKit/Services/KanataErrorMonitor.swift +++ b/Sources/KeyPathAppKit/Services/KanataErrorMonitor.swift @@ -220,7 +220,7 @@ final class KanataErrorMonitor { AppLogger.shared.info("[ErrorMonitor] Starting stderr monitoring") // Get current file size to start reading from end - if let attrs = try? FileManager.default.attributesOfItem(atPath: stderrPath), + if let attrs = try? Foundation.FileManager().attributesOfItem(atPath: stderrPath), let fileSize = attrs[.size] as? UInt64 { lastFilePosition = fileSize @@ -390,7 +390,10 @@ final class KanataErrorMonitor { Task { @MainActor in UserFeedbackService.show(message: message) // Also post notification to make health indicator more visible - NotificationCenter.default.post(name: .kanataErrorDetected, object: error) + NotificationObserverManager.defaultCenter.post( + name: Notification.Name.kanataErrorDetected, + object: error + ) } } diff --git a/Sources/KeyPathAppKit/Services/KanataEventListener.swift b/Sources/KeyPathAppKit/Services/KanataEventListener.swift index 87a060c22..a7471111c 100644 --- a/Sources/KeyPathAppKit/Services/KanataEventListener.swift +++ b/Sources/KeyPathAppKit/Services/KanataEventListener.swift @@ -255,6 +255,10 @@ public struct KanataHoldActivation: Sendable { public let action: String /// Timestamp in milliseconds since Kanata start public let timestamp: UInt64 + /// Listener session that observed the activation + public let sessionID: Int + /// Wall-clock observation time in KeyPath + public let observedAt: Date } /// Tap activation info from Kanata TCP TapActivated events @@ -266,6 +270,10 @@ public struct KanataTapActivation: Sendable { public let action: String /// Timestamp in milliseconds since Kanata start public let timestamp: UInt64 + /// Listener session that observed the activation + public let sessionID: Int + /// Wall-clock observation time in KeyPath + public let observedAt: Date } /// One-shot activation info from Kanata TCP OneShotActivated events @@ -277,6 +285,10 @@ public struct KanataOneShotActivation: Sendable { public let modifiers: String /// Timestamp in milliseconds since Kanata start public let timestamp: UInt64 + /// Listener session that observed the activation + public let sessionID: Int + /// Wall-clock observation time in KeyPath + public let observedAt: Date } /// Chord resolution info from Kanata TCP ChordResolved events @@ -288,6 +300,10 @@ public struct KanataChordResolution: Sendable { public let action: String /// Timestamp in milliseconds since Kanata start public let timestamp: UInt64 + /// Listener session that observed the resolution + public let sessionID: Int + /// Wall-clock observation time in KeyPath + public let observedAt: Date } /// Tap-dance resolution info from Kanata TCP TapDanceResolved events @@ -301,6 +317,10 @@ public struct KanataTapDanceResolution: Sendable { public let action: String /// Timestamp in milliseconds since Kanata start public let timestamp: UInt64 + /// Listener session that observed the resolution + public let sessionID: Int + /// Wall-clock observation time in KeyPath + public let observedAt: Date } /// Monitors Kanata's TCP server for events. @@ -312,7 +332,7 @@ actor KanataEventListener { private var layerHandler: (@Sendable (String) async -> Void)? private var actionURIHandler: (@Sendable (KeyPathActionURI) async -> Void)? private var unknownMessageHandler: (@Sendable (String) async -> Void)? - private var keyInputHandler: (@Sendable (String, KanataKeyAction) async -> Void)? + private var keyInputHandler: (@Sendable (KanataObservedKeyInput) async -> Void)? private var holdActivatedHandler: (@Sendable (KanataHoldActivation) async -> Void)? private var tapActivatedHandler: (@Sendable (KanataTapActivation) async -> Void)? private var oneShotActivatedHandler: (@Sendable (KanataOneShotActivation) async -> Void)? @@ -323,6 +343,8 @@ actor KanataEventListener { /// Capabilities advertised by Kanata in HelloOk (normalized to dash-case, e.g. "hold-activated"). private var capabilities: Set = [] private var activeConnection: NWConnection? + private var sessionCounter = 0 + private var currentSessionID: Int? private var isHrmTraceSubscribed = false private var isAwaitingHrmTraceSubscribeAck = false private let listenerQueue = DispatchQueue(label: "com.keypath.event-listener") @@ -346,7 +368,7 @@ actor KanataEventListener { onLayerChange: @escaping @Sendable (String) async -> Void, onActionURI: (@Sendable (KeyPathActionURI) async -> Void)? = nil, onUnknownMessage: (@Sendable (String) async -> Void)? = nil, - onKeyInput: (@Sendable (String, KanataKeyAction) async -> Void)? = nil, + onKeyInput: (@Sendable (KanataObservedKeyInput) async -> Void)? = nil, onHoldActivated: (@Sendable (KanataHoldActivation) async -> Void)? = nil, onTapActivated: (@Sendable (KanataTapActivation) async -> Void)? = nil, onOneShotActivated: (@Sendable (KanataOneShotActivation) async -> Void)? = nil, @@ -399,6 +421,7 @@ actor KanataEventListener { isHrmTraceSubscribed = false isAwaitingHrmTraceSubscribeAck = false activeConnection = nil + currentSessionID = nil } private func listenLoop() async { @@ -421,11 +444,15 @@ actor KanataEventListener { ) // Bug 1 fix: ensure cleanup on both normal and error exits + sessionCounter += 1 + let sessionID = sessionCounter + currentSessionID = sessionID defer { connection.cancel() pollTask?.cancel() pollTask = nil activeConnection = nil + currentSessionID = nil isHrmTraceSubscribed = false isAwaitingHrmTraceSubscribeAck = false } @@ -495,7 +522,7 @@ actor KanataEventListener { buffer.removeSubrange(0 ... newlineIndex) guard !lineData.isEmpty else { continue } if let line = String(data: lineData, encoding: .utf8) { - await handleLine(line) + await handleLine(line, sessionID: sessionID) } } } @@ -567,7 +594,7 @@ actor KanataEventListener { } } - private func handleLine(_ line: String) async { + private func handleLine(_ line: String, sessionID: Int) async { // Reduce log noise - log heartbeat messages at debug level AppLogger.shared.debug("🌐 [EventListener] Received line: '\(line)'") @@ -641,9 +668,16 @@ actor KanataEventListener { { // Lowercase the action to match our enum (Kanata sends "Press", we expect "press") if let action = KanataKeyAction(rawValue: actionStr.lowercased()) { + let observed = KanataObservedKeyInput( + key: key, + action: action, + kanataTimestamp: keyInput["t"] as? UInt64, + sessionID: sessionID, + observedAt: Date() + ) AppLogger.shared.info("⌨️ [EventListener] KeyInput: \(key) \(action)") if let handler = keyInputHandler { - await handler(key, action) + await handler(observed) } } return @@ -671,11 +705,18 @@ actor KanataEventListener { let key = holdActivated["key"] as? String ?? "" let action = holdActivated["action"] as? String ?? "" let timestamp = holdActivated["t"] as? UInt64 ?? 0 + let observedAt = Date() // Respect capability advertisement when available; still process for backward compat if capabilities.isEmpty || capabilities.contains("hold-activated") { AppLogger.shared.log("🔒 [EventListener] HoldActivated: \(key) -> \(action)") - let activation = KanataHoldActivation(key: key, action: action, timestamp: timestamp) + let activation = KanataHoldActivation( + key: key, + action: action, + timestamp: timestamp, + sessionID: sessionID, + observedAt: observedAt + ) if let handler = holdActivatedHandler { await handler(activation) } @@ -692,11 +733,18 @@ actor KanataEventListener { let key = tapActivated["key"] as? String ?? "" let action = tapActivated["action"] as? String ?? "" let timestamp = tapActivated["t"] as? UInt64 ?? 0 + let observedAt = Date() // Respect capability advertisement when available; still process for backward compat if capabilities.isEmpty || capabilities.contains("tap-activated") { AppLogger.shared.debug("👆 [EventListener] TapActivated: \(key) -> \(action)") - let activation = KanataTapActivation(key: key, action: action, timestamp: timestamp) + let activation = KanataTapActivation( + key: key, + action: action, + timestamp: timestamp, + sessionID: sessionID, + observedAt: observedAt + ) if let handler = tapActivatedHandler { await handler(activation) } @@ -713,9 +761,16 @@ actor KanataEventListener { let modifiers = oneShotActivated["modifiers"] as? String, let timestamp = oneShotActivated["t"] as? UInt64 { + let observedAt = Date() if capabilities.isEmpty || capabilities.contains("oneshot-activated") { AppLogger.shared.log("⚡ [EventListener] OneShotActivated: \(key) -> \(modifiers)") - let activation = KanataOneShotActivation(key: key, modifiers: modifiers, timestamp: timestamp) + let activation = KanataOneShotActivation( + key: key, + modifiers: modifiers, + timestamp: timestamp, + sessionID: sessionID, + observedAt: observedAt + ) if let handler = oneShotActivatedHandler { await handler(activation) } @@ -732,9 +787,16 @@ actor KanataEventListener { let action = chordResolved["action"] as? String, let timestamp = chordResolved["t"] as? UInt64 { + let observedAt = Date() if capabilities.isEmpty || capabilities.contains("chord-resolved") { AppLogger.shared.log("🎹 [EventListener] ChordResolved: \(keys) -> \(action)") - let resolution = KanataChordResolution(keys: keys, action: action, timestamp: timestamp) + let resolution = KanataChordResolution( + keys: keys, + action: action, + timestamp: timestamp, + sessionID: sessionID, + observedAt: observedAt + ) if let handler = chordResolvedHandler { await handler(resolution) } @@ -752,13 +814,16 @@ actor KanataEventListener { let action = tapDanceResolved["action"] as? String, let timestamp = tapDanceResolved["t"] as? UInt64 { + let observedAt = Date() if capabilities.isEmpty || capabilities.contains("tap-dance-resolved") { AppLogger.shared.log("💃 [EventListener] TapDanceResolved: \(key) x\(tapCount) -> \(action)") let resolution = KanataTapDanceResolution( key: key, tapCount: tapCount, action: action, - timestamp: timestamp + timestamp: timestamp, + sessionID: sessionID, + observedAt: observedAt ) if let handler = tapDanceResolvedHandler { await handler(resolution) diff --git a/Sources/KeyPathAppKit/Services/KanataObservedEvents.swift b/Sources/KeyPathAppKit/Services/KanataObservedEvents.swift new file mode 100644 index 000000000..061ffb251 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/KanataObservedEvents.swift @@ -0,0 +1,23 @@ +import Foundation + +struct KanataObservedKeyInput: Sendable, Equatable { + let key: String + let action: KanataKeyAction + let kanataTimestamp: UInt64? + let sessionID: Int + let observedAt: Date +} + +struct KeypressObservationMetadata: Equatable { + let listenerSessionID: Int? + let kanataTimestamp: UInt64? + let observedAt: Date? + + static func from(userInfo: [AnyHashable: Any]?) -> KeypressObservationMetadata { + KeypressObservationMetadata( + listenerSessionID: userInfo?["listenerSessionID"] as? Int, + kanataTimestamp: userInfo?["kanataTimestamp"] as? UInt64, + observedAt: userInfo?["observedAt"] as? Date + ) + } +} diff --git a/Sources/KeyPathAppKit/Services/KanataOutputBridgeCompanionManager.swift b/Sources/KeyPathAppKit/Services/KanataOutputBridgeCompanionManager.swift new file mode 100644 index 000000000..447b82f13 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/KanataOutputBridgeCompanionManager.swift @@ -0,0 +1,55 @@ +import Foundation +import KeyPathCore + +@MainActor +final class KanataOutputBridgeCompanionManager { + static let shared = KanataOutputBridgeCompanionManager() + static let bridgeEnvironmentOutputPath = "/var/tmp/keypath-host-passthru-bridge-env.txt" + + private let helperManager: HelperManager + + init(helperManager: HelperManager = .shared) { + self.helperManager = helperManager + } + + func outputBridgeStatus() async throws -> KanataOutputBridgeStatus { + try await helperManager.getKanataOutputBridgeStatus() + } + + func prepareSession(hostPID: Int32) async throws -> KanataOutputBridgeSession { + try await helperManager.prepareKanataOutputBridgeSession(hostPID: hostPID) + } + + func activateSession(sessionID: String) async throws { + try await helperManager.activateKanataOutputBridgeSession(sessionID: sessionID) + } + + func restartCompanion() async throws { + try await helperManager.restartKanataOutputBridgeCompanion() + } + + func prepareEnvironment(hostPID: Int32) async throws -> [String: String] { + let session = try await prepareSession(hostPID: hostPID) + try await activateSession(sessionID: session.sessionID) + return [ + KanataRuntimePathCoordinator.experimentalOutputBridgeSessionEnvKey: session.sessionID, + KanataRuntimePathCoordinator.experimentalOutputBridgeSocketEnvKey: session.socketPath + ] + } + + @discardableResult + func prepareEnvironmentAndPersist( + hostPID: Int32, + outputPath: String = bridgeEnvironmentOutputPath + ) async throws -> KanataOutputBridgeSession { + let session = try await prepareSession(hostPID: hostPID) + try await activateSession(sessionID: session.sessionID) + + let payload = """ + session=\(session.sessionID) + socket=\(session.socketPath) + """ + try payload.write(toFile: outputPath, atomically: true, encoding: .utf8) + return session + } +} diff --git a/Sources/KeyPathAppKit/Services/KanataOutputBridgeSmokeService.swift b/Sources/KeyPathAppKit/Services/KanataOutputBridgeSmokeService.swift new file mode 100644 index 000000000..85922f98b --- /dev/null +++ b/Sources/KeyPathAppKit/Services/KanataOutputBridgeSmokeService.swift @@ -0,0 +1,91 @@ +import Foundation +import KeyPathCore + +protocol KanataOutputBridgeSmokeHelping: AnyObject, Sendable { + func prepareKanataOutputBridgeSession(hostPID: Int32) async throws -> KanataOutputBridgeSession + func activateKanataOutputBridgeSession(sessionID: String) async throws +} + +extension HelperManager: KanataOutputBridgeSmokeHelping {} + +struct KanataOutputBridgeSmokeReport: Equatable, Sendable { + let session: KanataOutputBridgeSession + let handshake: KanataOutputBridgeResponse + let ping: KanataOutputBridgeResponse + let syncedModifiers: KanataOutputBridgeModifierState? + let syncModifiers: KanataOutputBridgeResponse? + let emittedKeyEvent: KanataOutputBridgeKeyEvent? + let emitKey: KanataOutputBridgeResponse? + let reset: KanataOutputBridgeResponse? +} + +@MainActor +enum KanataOutputBridgeSmokeService { + typealias HandshakeOperation = @Sendable (KanataOutputBridgeSession) throws -> KanataOutputBridgeResponse + typealias PingOperation = @Sendable (KanataOutputBridgeSession) throws -> KanataOutputBridgeResponse + typealias SyncModifiersOperation = @Sendable ( + KanataOutputBridgeModifierState, + KanataOutputBridgeSession + ) throws -> KanataOutputBridgeResponse + typealias EmitKeyOperation = @Sendable ( + KanataOutputBridgeKeyEvent, + KanataOutputBridgeSession + ) throws -> KanataOutputBridgeResponse + typealias ResetOperation = @Sendable (KanataOutputBridgeSession) throws -> KanataOutputBridgeResponse + + static let defaultEmitProbeEvent = KanataOutputBridgeKeyEvent( + usagePage: 0x07, + usage: 0x68, + action: .keyDown, + sequence: 1 + ) + static let defaultModifierProbeState = KanataOutputBridgeModifierState(leftShift: true) + + static func run( + hostPID: Int32 = getpid(), + syncModifierProbe: KanataOutputBridgeModifierState? = nil, + emitProbeEvent: KanataOutputBridgeKeyEvent? = nil, + includeReset: Bool = false, + helper: KanataOutputBridgeSmokeHelping = HelperManager.shared, + performHandshake: HandshakeOperation = KanataOutputBridgeClient.performHandshake(session:), + performPing: PingOperation = KanataOutputBridgeClient.ping(session:), + performSyncModifiers: SyncModifiersOperation = KanataOutputBridgeClient.syncModifiers(_:session:), + performEmitKey: EmitKeyOperation = KanataOutputBridgeClient.emitKey(_:session:), + performReset: ResetOperation = KanataOutputBridgeClient.reset(session:) + ) async throws -> KanataOutputBridgeSmokeReport { + let session = try await helper.prepareKanataOutputBridgeSession(hostPID: hostPID) + try await helper.activateKanataOutputBridgeSession(sessionID: session.sessionID) + + let handshake = try performHandshake(session) + let ping = try performPing(session) + let syncModifiers: KanataOutputBridgeResponse? = + if let syncModifierProbe { + try performSyncModifiers(syncModifierProbe, session) + } else { + nil + } + let emitKey: KanataOutputBridgeResponse? = + if let emitProbeEvent { + try performEmitKey(emitProbeEvent, session) + } else { + nil + } + let reset: KanataOutputBridgeResponse? = + if includeReset { + try performReset(session) + } else { + nil + } + + return KanataOutputBridgeSmokeReport( + session: session, + handshake: handshake, + ping: ping, + syncedModifiers: syncModifierProbe, + syncModifiers: syncModifiers, + emittedKeyEvent: emitProbeEvent, + emitKey: emitKey, + reset: reset + ) + } +} diff --git a/Sources/KeyPathAppKit/Services/KanataRuntimePathCoordinator.swift b/Sources/KeyPathAppKit/Services/KanataRuntimePathCoordinator.swift new file mode 100644 index 000000000..740b7a58e --- /dev/null +++ b/Sources/KeyPathAppKit/Services/KanataRuntimePathCoordinator.swift @@ -0,0 +1,76 @@ +import Foundation +import KeyPathCore + +@MainActor +enum KanataRuntimePathCoordinator { + static let experimentalOutputBridgeSessionEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SESSION" + static let experimentalOutputBridgeSocketEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SOCKET" + +#if DEBUG + nonisolated(unsafe) static var testDecision: KanataRuntimePathDecision? +#endif + + static func evaluateCurrentPath( + configPath: String = KeyPathConstants.Config.mainConfigPath, + runtimeHost: KanataRuntimeHost = .current(), + fileManager: FileManager = Foundation.FileManager(), + helperManager: HelperManager = .shared + ) async -> KanataRuntimePathDecision { +#if DEBUG + if TestEnvironment.isRunningTests, let testDecision { + return testDecision + } +#endif + let bridgeProbe = KanataHostBridge.probe(runtimeHost: runtimeHost, fileManager: fileManager) + let configValidation = KanataHostBridge.validateConfig( + runtimeHost: runtimeHost, + configPath: configPath, + fileManager: fileManager + ) + let runtimeCreation = KanataHostBridge.createRuntime( + runtimeHost: runtimeHost, + configPath: configPath, + fileManager: fileManager + ) + + let helperReady = await helperManager.testHelperFunctionality() + let outputBridgeStatus = try? await helperManager.getKanataOutputBridgeStatus() + let inputs = KanataRuntimePathInputs( + hostBridgeLoaded: { + if case .loaded = bridgeProbe { return true } + return false + }(), + hostConfigValid: configValidation == .valid, + hostRuntimeConstructible: { + if case .created = runtimeCreation { return true } + return false + }(), + helperReady: helperReady, + outputBridgeStatus: outputBridgeStatus, + legacySystemBinaryAvailable: fileManager.isExecutableFile(atPath: runtimeHost.systemCorePath) + ) + + return KanataRuntimePathEvaluator.decide(inputs) + } + + static func prepareOutputBridgeSession( + hostPID: Int32, + helperManager: HelperManager = .shared + ) async throws -> KanataOutputBridgeSession { + try await helperManager.prepareKanataOutputBridgeSession(hostPID: hostPID) + } + + static func activateOutputBridgeSession( + sessionID: String, + helperManager: HelperManager = .shared + ) async throws { + try await helperManager.activateKanataOutputBridgeSession(sessionID: sessionID) + } + + static func prepareExperimentalOutputBridgeEnvironment( + hostPID: Int32, + helperManager _: HelperManager = .shared + ) async throws -> [String: String] { + try await KanataOutputBridgeCompanionManager.shared.prepareEnvironment(hostPID: hostPID) + } +} diff --git a/Sources/KeyPathAppKit/Services/KanataService.swift b/Sources/KeyPathAppKit/Services/KanataService.swift deleted file mode 100644 index a44b9859c..000000000 --- a/Sources/KeyPathAppKit/Services/KanataService.swift +++ /dev/null @@ -1,571 +0,0 @@ -import Foundation -import KeyPathCore -import KeyPathDaemonLifecycle -import Observation -import ServiceManagement - -/// Errors related to Kanata service operations -public enum KanataServiceError: LocalizedError, Equatable { - case serviceNotRegistered - case requiresApproval - case startFailed(reason: String) - case stopFailed(reason: String) - case restartCooldownActive(seconds: Double) - case processConflict(pid: Int) - - public var errorDescription: String? { - switch self { - case .serviceNotRegistered: - "Kanata service is not registered with the system." - case .requiresApproval: - "Background item approval required in System Settings." - case let .startFailed(reason): - "Failed to start Kanata service: \(reason)" - case let .stopFailed(reason): - "Failed to stop Kanata service: \(reason)" - case let .restartCooldownActive(seconds): - "Restart cooldown active. Please wait \(String(format: "%.1f", seconds)) seconds." - case let .processConflict(pid): - "Conflicting Kanata process detected (PID \(pid))." - } - } -} - -/// Unified service manager for the Kanata daemon. -/// -/// This manager consolidates the responsibilities of: -/// - `KanataDaemonManager` (SMAppService registration) -/// - `ProcessLifecycleManager` (PID tracking & conflict detection) -/// - `ServiceHealthMonitor` (Health checks & restart cooldowns) -/// -/// It provides a single, high-level API for starting, stopping, and monitoring the service. -@MainActor -@Observable -public final class KanataService { - public static let shared = KanataService() - - private enum Constants { - static let daemonPlistName = "com.keypath.kanata.plist" - } - - // Factory used to create SMAppService instances (test seam) - #if DEBUG - nonisolated(unsafe) static var smServiceFactory: (String) -> SMAppServiceProtocol = { plistName in - NativeSMAppService(wrapped: SMAppService.daemon(plistName: plistName)) - } - #else - nonisolated(unsafe) static let smServiceFactory: (String) -> SMAppServiceProtocol = { plistName in - NativeSMAppService(wrapped: SMAppService.daemon(plistName: plistName)) - } - #endif - - // MARK: - Internal Dependencies (Hidden from consumers) - - @ObservationIgnored private let healthMonitor: ServiceHealthMonitor - @ObservationIgnored private let pidCache = LaunchDaemonPIDCache() - - private struct ProcessSnapshot { - let isRunning: Bool - let pid: Int? - } - - // MARK: - State - - public enum ServiceState: Equatable, Sendable { - case running(pid: Int) - case stopped - case failed(reason: String) - case maintenance // Installing/Repairing - case requiresApproval // SMAppService specific state - case unknown - - public var isRunning: Bool { - if case .running = self { return true } - return false - } - - public var description: String { - switch self { - case let .running(pid): "Running (PID \(pid))" - case .stopped: "Stopped" - case let .failed(reason): "Failed: \(reason)" - case .maintenance: "Maintenance Mode" - case .requiresApproval: "Requires Approval" - case .unknown: "Unknown" - } - } - } - - public private(set) var state: ServiceState = .unknown - - /// Polling task for status updates - @ObservationIgnored private var statusTask: Task? - /// Debounce transient "enabled but no PID" samples to avoid false failure reports. - @ObservationIgnored private var enabledWithoutProcessSampleCount = 0 - @ObservationIgnored private let enabledWithoutProcessFailureThreshold = 3 - - // MARK: - Initialization - - init(healthMonitor: ServiceHealthMonitor = ServiceHealthMonitor(processLifecycle: ProcessLifecycleManager())) { - self.healthMonitor = healthMonitor - - // Skip polling and initial check in test environment to avoid TCP timeouts - guard !TestEnvironment.isRunningTests else { - AppLogger.shared.log("🧪 [KanataService] Test environment - skipping status polling") - return - } - - // Initial status check - Task { await refreshStatus() } - - // Setup observers - setupObservers() - } - - private func setupObservers() { - // Observe SMAppService approval notifications - NotificationCenter.default.addObserver( - forName: .smAppServiceApprovalRequired, - object: nil, - queue: .main - ) { [weak self] _ in - Task { @MainActor in - self?.state = .requiresApproval - } - } - - // Start polling for status updates (every 2 seconds) - statusTask = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(2)) // 2s - if let self { - await refreshStatus() - } else { - break - } - } - } - } - - deinit { - statusTask?.cancel() - } - - // MARK: - SMAppService helpers - - private func makeSMService() -> SMAppServiceProtocol { - Self.smServiceFactory(Constants.daemonPlistName) - } - - private func currentDaemonStatus() -> SMAppService.Status { - makeSMService().status - } - - private nonisolated static func fetchSMStatus() -> SMAppService.Status { - smServiceFactory(Constants.daemonPlistName).status - } - - private func ensureDaemonRegistered() async throws { - let service = makeSMService() - switch service.status { - case .enabled: - // Check for stale registration: SMAppService says enabled but plist doesn't exist - // This can happen if uninstall used launchctl/rm instead of SMAppService.unregister() - let plistPath = "/Library/LaunchDaemons/\(Constants.daemonPlistName)" - if !FileManager.default.fileExists(atPath: plistPath) { - AppLogger.shared.log( - "⚠️ [KanataService] Stale SMAppService registration detected - " + - "status=enabled but plist missing. Clearing and re-registering..." - ) - // Clear the stale registration, then re-register - do { - try await service.unregister() - } catch { - AppLogger.shared.log( - "⚠️ [KanataService] Failed to unregister stale service: \(error.localizedDescription)" - ) - // Continue anyway - register() might still work - } - do { - try service.register() - AppLogger.shared.log("✅ [KanataService] Re-registered after clearing stale state") - } catch { - throw KanataServiceError.startFailed(reason: error.localizedDescription) - } - } - return - case .requiresApproval: - throw KanataServiceError.requiresApproval - default: - do { - try service.register() - } catch { - throw KanataServiceError.startFailed(reason: error.localizedDescription) - } - } - } - - private func unregisterDaemon() async throws { - let service = makeSMService() - do { - try await service.unregister() - } catch { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [KanataService] Ignoring unregister error in tests: \(error)") - return - } - throw KanataServiceError.stopFailed(reason: error.localizedDescription) - } - } - - private func detectProcessState() async -> ProcessSnapshot { - if let daemonPID = await pidCache.getCachedPID() { - return ProcessSnapshot(isRunning: true, pid: Int(daemonPID)) - } - - let ownership = PIDFileManager.checkOwnership() - if ownership.owned, let pid = ownership.pid { - return ProcessSnapshot(isRunning: true, pid: Int(pid)) - } - - return ProcessSnapshot(isRunning: false, pid: nil) - } - - private nonisolated static func detectProcessState( - pidCache: LaunchDaemonPIDCache - ) async -> ProcessSnapshot { - if let daemonPID = await pidCache.getCachedPID() { - return ProcessSnapshot(isRunning: true, pid: Int(daemonPID)) - } - - let ownership = PIDFileManager.checkOwnership() - if ownership.owned, let pid = ownership.pid { - return ProcessSnapshot(isRunning: true, pid: Int(pid)) - } - - return ProcessSnapshot(isRunning: false, pid: nil) - } - - // MARK: - Public API - - /// Start the service - public func start() async throws { - AppLogger.shared.log("🚀 [KanataService] Start requested") - - // 1. Reset cached PID to avoid stale readings - await pidCache.invalidateCache() - - // 2. Check current state - await refreshStatus() - if case .running = state { - AppLogger.shared.info("✅ [KanataService] Already running, ignoring start request") - return - } - - // 3. Cooldown check - let cooldown = await healthMonitor.canRestartService() - guard cooldown.canRestart else { - throw KanataServiceError.restartCooldownActive(seconds: cooldown.remainingCooldown) - } - - // 4. Ensure daemon is properly registered (also handles stale registrations) - AppLogger.shared.log("🔧 [KanataService] Ensuring service registration...") - try await ensureDaemonRegistered() - - // 5. Record start attempt - await healthMonitor.recordStartAttempt(timestamp: Date()) - - // 6. Wait for launchd - // Give it up to 4.5 seconds to appear, checking every 0.3s. - // Kanata takes ~6s to fully start (2s launcher sleep + VHD reconnect), - // so a short window incorrectly marks good starts as "failed". - for _ in 0 ..< 15 { - try? await Task.sleep(for: .milliseconds(300)) // 0.3s - await refreshStatus() - if case .running = state { break } - } - - // 7. Verify success - await refreshStatus() - - if case .running = state { - await healthMonitor.recordStartSuccess() - AppLogger.shared.info("✅ [KanataService] Started successfully") - return - } - - // In test environments, we don't spawn real processes. Treat registration success as running. - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [KanataService] Test environment start fallback - marking service as running") - state = .running(pid: 0) - await healthMonitor.recordStartSuccess() - return - } - - await healthMonitor.recordStartFailure() - - if case .requiresApproval = state { - throw KanataServiceError.requiresApproval - } - - throw KanataServiceError.startFailed(reason: "Process did not start after registration") - } - - /// Stop the service - public func stop() async throws { - AppLogger.shared.log("🛑 [KanataService] Stop requested") - - try await unregisterDaemon() - - // Verify cleanup - try? await Task.sleep(for: .milliseconds(200)) // 0.2s - try? PIDFileManager.removePID() - await pidCache.invalidateCache() - await refreshStatus() - - if case .running = state { - if TestEnvironment.isRunningTests { - AppLogger.shared.log("🧪 [KanataService] Test environment stop fallback - marking service as stopped") - state = .stopped - } else { - // If still running, it might be a zombie or external process - AppLogger.shared.warn("⚠️ [KanataService] Service still running after stop request") - throw KanataServiceError.stopFailed(reason: "Process failed to terminate") - } - } - - if state != .stopped { - AppLogger.shared.log("ℹ️ [KanataService] Forcing state to stopped after successful stop") - state = .stopped - } - - AppLogger.shared.info("✅ [KanataService] Stopped successfully") - } - - /// Restart the service - public func restart() async throws { - AppLogger.shared.log("cycles [KanataService] Restart requested") - try await stop() - try? await Task.sleep(for: .milliseconds(500)) // 0.5s wait - try await start() - } - - /// Force a status refresh (useful for UI pull-to-refresh) - @discardableResult - public func refreshStatus() async -> ServiceState { - let status = await evaluateStatus() - publishStatus(status) - return status - } - - /// Check if the service is completely installed and ready - public var isInstalled: Bool { - switch currentDaemonStatus() { - case .notFound: - false - default: - true - } - } - - /// Evaluate current health using the internal monitor and the latest process snapshot. - func checkHealth(tcpPort: Int) async -> ServiceHealthStatus { - let snapshot = await detectProcessState() - let processStatus = ProcessHealthStatus(isRunning: snapshot.isRunning, pid: snapshot.pid) - return await healthMonitor.checkServiceHealth(processStatus: processStatus, tcpPort: tcpPort) - } - - /// Determine whether the service can be restarted based on the active cooldown. - func canRestartService() async -> RestartCooldownState { - await healthMonitor.canRestartService() - } - - /// Record a manual start attempt – used by UI flows that orchestrate restarts. - func recordStartAttempt(timestamp: Date) async { - await healthMonitor.recordStartAttempt(timestamp: timestamp) - } - - /// Record successful start completion. - func recordStartSuccess() async { - await healthMonitor.recordStartSuccess() - } - - /// Record a failed start attempt. - func recordStartFailure() async { - await healthMonitor.recordStartFailure() - } - - /// Record a VirtualHID connection failure; returns true when auto-recovery should trigger. - func recordConnectionFailure() async -> Bool { - await healthMonitor.recordConnectionFailure() - } - - /// Record a VirtualHID connection success (resets cooldown/counters). - func recordConnectionSuccess() async { - await healthMonitor.recordConnectionSuccess() - } - - // MARK: - Status Composition - - private func evaluateStatus() async -> ServiceState { - let pidCache = pidCache - let smStatusTask = Task.detached(priority: .utility) { - Self.fetchSMStatus() - } - let processTask = Task.detached(priority: .utility) { - await Self.detectProcessState(pidCache: pidCache) - } - - let smStatus = await smStatusTask.value - let processState = await processTask.value - - switch smStatus { - case .requiresApproval: - enabledWithoutProcessSampleCount = 0 - return .requiresApproval - case .enabled: - if processState.isRunning { - enabledWithoutProcessSampleCount = 0 - return .running(pid: processState.pid ?? 0) - } - - // Guard against transient process-detection misses (observed in the field): - // require several consecutive misses before reporting a hard failure. - enabledWithoutProcessSampleCount += 1 - if enabledWithoutProcessSampleCount < enabledWithoutProcessFailureThreshold { - AppLogger.shared.debug( - "⏳ [KanataService] SMAppService is enabled but process sample is missing (\(enabledWithoutProcessSampleCount)/\(enabledWithoutProcessFailureThreshold)); holding prior state" - ) - - if case let .running(previousPID) = state { - return .running(pid: previousPID) - } - return .unknown - } - - // Before declaring failure, probe the Kanata TCP server as a last resort. - let tcpPort = PreferencesService.shared.tcpServerPort - let tcpAlive = await Task.detached(priority: .utility) { - TCPProbe.probe(port: tcpPort, timeoutMs: 300) - }.value - - if tcpAlive { - AppLogger.shared.log( - "🩹 [KanataService] TCP probe saved false failure — kanata responding on port \(tcpPort) despite PID miss" - ) - enabledWithoutProcessSampleCount = 0 - return .running(pid: 0) - } - - return .failed(reason: "Service enabled but process not running") - case .notRegistered, .notFound: - enabledWithoutProcessSampleCount = 0 - return processState.isRunning ? .running(pid: processState.pid ?? 0) : .stopped - @unknown default: - enabledWithoutProcessSampleCount = 0 - return .unknown - } - } - - private func publishStatus(_ newStatus: ServiceState) { - guard state != newStatus else { return } - AppLogger.shared.log("📊 [KanataService] State changed: \(state.description) -> \(newStatus.description)") - let oldState = state - state = newStatus - - // Log service failures for crash analysis only when a running service drops to failed. - // This avoids noisy false positives from startup/probe states (e.g. unknown -> failed). - if case let .failed(reason) = newStatus { - if oldState.isRunning { - logServiceFailure(from: oldState, reason: reason) - } else { - AppLogger.shared.debug( - "ℹ️ [KanataService] Skipping crash-log entry for non-running transition: \(oldState.description) -> failed(\(reason))" - ) - } - } - - // Track PID for crash loop detection - if case let .running(pid) = newStatus { - Task { - let isCrashLoop = await healthMonitor.recordPIDObservation(pid) - if isCrashLoop { - await handleCrashLoopDetected() - } - } - } - - // Note: Previously re-evaluated status on running→running(different PID) transitions. - // Removed: the recursive publishStatus() call could cascade unboundedly when PIDs - // differ across evaluations, monopolizing the MainActor under load. - // PID changes are already tracked by the healthMonitor.recordPIDObservation() above. - } - - /// Log service state failures to persistent crash log for later analysis - private func logServiceFailure(from oldState: ServiceState, reason: String) { - let crashLogDir = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent("Library/Logs/KeyPath") - let crashLogPath = crashLogDir.appendingPathComponent("crashes.log") - - // Ensure directory exists - do { - try FileManager.default.createDirectory(at: crashLogDir, withIntermediateDirectories: true) - } catch { - AppLogger.shared.warn("⚠️ [KanataService] Failed to create crash log directory: \(error.localizedDescription)") - } - - // Format crash entry - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let timestamp = formatter.string(from: Date()) - - let entry = """ - [\(timestamp)] [SERVICE_FAILURE] Kanata service failed - Previous state: \(oldState.description) - Reason: \(reason) - --- - - """ - - // Append to log file - if let data = entry.data(using: .utf8) { - do { - if FileManager.default.fileExists(atPath: crashLogPath.path) { - let handle = try FileHandle(forWritingTo: crashLogPath) - try handle.seekToEnd() - try handle.write(contentsOf: data) - try handle.close() - } else { - try data.write(to: crashLogPath) - } - } catch { - AppLogger.shared.warn("⚠️ [KanataService] Failed to write crash log: \(error.localizedDescription)") - } - } - - AppLogger.shared.error( - "💥 [CrashLog] Logged service failure: \(oldState.description) -> failed(\(reason))" - ) - } - - /// Handle detected crash loop by stopping the service and notifying user - private func handleCrashLoopDetected() async { - AppLogger.shared.error("🚨 [KanataService] Crash loop detected - stopping service to protect keyboard") - - // Stop the service immediately - do { - try await unregisterDaemon() - state = .failed(reason: "Crash loop detected - service stopped. Open Setup Wizard to diagnose.") - AppLogger.shared.info("✅ [KanataService] Service stopped due to crash loop") - - // Post notification for UI to show alert - NotificationCenter.default.post( - name: .kanataCrashLoopDetected, - object: nil, - userInfo: ["reason": "Kanata was crash-looping and has been stopped to protect your keyboard."] - ) - } catch { - AppLogger.shared.error("❌ [KanataService] Failed to stop crash-looping service: \(error)") - } - } -} diff --git a/Sources/KeyPathAppKit/Services/KanataSplitRuntimeHostService.swift b/Sources/KeyPathAppKit/Services/KanataSplitRuntimeHostService.swift new file mode 100644 index 000000000..d543f1239 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/KanataSplitRuntimeHostService.swift @@ -0,0 +1,403 @@ +import Foundation +import KeyPathCore + +@MainActor +struct KanataSplitRuntimeHostLaunchReport: Sendable, Equatable { + let exitCode: Int32 + let stderr: String + let launcherPath: String + let sessionID: String + let socketPath: String +} + +@MainActor +struct KanataSplitRuntimeCompanionRecoveryResult: Sendable, Equatable { + let companionRunningAfterRestart: Bool + let recoveredHostPID: pid_t? +} + +enum SplitRuntimeIdentity { + static let hostTitle = "Split Runtime Host" + static let hostDetailPrefix = "Bundled user-session host active" +} + +enum KanataSplitRuntimeHostExitInfo { + static let pidUserInfoKey = "pid" + static let exitCodeUserInfoKey = "exitCode" + static let terminationReasonUserInfoKey = "terminationReason" + static let expectedUserInfoKey = "expected" + static let stderrLogPathUserInfoKey = "stderrLogPath" +} + +@MainActor +final class KanataSplitRuntimeHostService { + static let shared = KanataSplitRuntimeHostService() + +#if DEBUG + nonisolated(unsafe) static var testPersistentHostPID: pid_t? + nonisolated(unsafe) static var testStartPersistentError: Error? +#endif + + private static let inProcessRuntimeEnvKey = "KEYPATH_EXPERIMENTAL_HOST_RUNTIME" + private static let passthruRuntimeEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_RUNTIME" + private static let passthruForwardEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_FORWARD" + private static let passthruOnlyEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_ONLY" + private static let passthruInjectEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_INJECT" + private static let passthruCaptureEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_CAPTURE" + private static let passthruPersistEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_PERSIST" + private static let passthruPollEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_POLL_MS" + + private let companionManager: KanataOutputBridgeCompanionManager + private var activeHostProcess: Process? + private var expectedPersistentHostTermination = false + private var activePersistentHostIncludesCapture = true + private var activePersistentHostPollMilliseconds = 1000 + + init(companionManager: KanataOutputBridgeCompanionManager = .shared) { + self.companionManager = companionManager + } + + private actor ProcessExitLatch { + private var didExit = false + private var continuation: CheckedContinuation? + + func markExited() { + didExit = true + continuation?.resume() + continuation = nil + } + + func wait() async { + if didExit { + return + } + + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + } + + private func waitForPersistentHostExit( + process: Process, + timeoutSeconds: TimeInterval = 5 + ) async { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while process.isRunning, Date() < deadline { + try? await Task.sleep(for: .milliseconds(50)) + } + } + + private func launcherArguments(configPath: String) -> [String] { + [ + "--cfg", + configPath, + "--port", + "\(PreferencesService.shared.tcpServerPort)", + "--log-layer-changes" + ] + } + + private func buildPassthruEnvironment( + session: KanataOutputBridgeSession, + includeCapture: Bool, + pollMilliseconds: Int, + persist: Bool + ) -> [String: String] { + var environment = ProcessInfo.processInfo.environment + environment[KanataRuntimePathCoordinator.experimentalOutputBridgeSessionEnvKey] = session.sessionID + environment[KanataRuntimePathCoordinator.experimentalOutputBridgeSocketEnvKey] = session.socketPath + environment[Self.inProcessRuntimeEnvKey] = "1" + environment[Self.passthruRuntimeEnvKey] = "1" + environment[Self.passthruForwardEnvKey] = "1" + environment[Self.passthruOnlyEnvKey] = "1" + if persist { + environment[Self.passthruPersistEnvKey] = "1" + } else { + environment.removeValue(forKey: Self.passthruPersistEnvKey) + } + if includeCapture { + environment[Self.passthruCaptureEnvKey] = "1" + environment.removeValue(forKey: Self.passthruInjectEnvKey) + } else { + environment[Self.passthruInjectEnvKey] = "1" + environment.removeValue(forKey: Self.passthruCaptureEnvKey) + } + environment[Self.passthruPollEnvKey] = "\(pollMilliseconds)" + environment["HOME"] = NSHomeDirectory() + return environment + } + + var isPersistentPassthruHostRunning: Bool { +#if DEBUG + if TestEnvironment.isRunningTests, let testPersistentHostPID = Self.testPersistentHostPID { + return testPersistentHostPID > 0 + } +#endif + return activeHostProcess?.isRunning == true + } + + var activePersistentHostPID: pid_t? { +#if DEBUG + if TestEnvironment.isRunningTests, + let testPersistentHostPID = Self.testPersistentHostPID, + testPersistentHostPID > 0 + { + return testPersistentHostPID + } +#endif + guard activeHostProcess?.isRunning == true else { return nil } + return activeHostProcess?.processIdentifier + } + + func launchPassthruHost( + includeCapture: Bool, + timeout: TimeInterval = 8, + pollMilliseconds: Int = 1000 + ) async throws -> KanataSplitRuntimeHostLaunchReport { + let runtimeHost = KanataRuntimeHost.current() + let launcherPath = runtimeHost.launcherPath + let configPath = KeyPathConstants.Config.mainConfigPath + + AppLogger.shared.info("🧪 [HostService] Preparing split-runtime host environment") + let session = try await companionManager.prepareEnvironmentAndPersist(hostPID: getpid()) + AppLogger.shared.info( + "🧪 [HostService] Prepared split-runtime host environment session=\(session.sessionID) socket=\(session.socketPath)" + ) + + let process = Process() + process.executableURL = URL(fileURLWithPath: launcherPath) + process.arguments = launcherArguments(configPath: configPath) + process.environment = buildPassthruEnvironment( + session: session, + includeCapture: includeCapture, + pollMilliseconds: pollMilliseconds, + persist: false + ) + process.currentDirectoryURL = URL(fileURLWithPath: (configPath as NSString).deletingLastPathComponent) + + let stderrLogURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("keypath-host-passthru-child-stderr.log") + Foundation.FileManager().createFile(atPath: stderrLogURL.path, contents: Data()) + let stderrHandle = try FileHandle(forWritingTo: stderrLogURL) + process.standardError = stderrHandle + process.standardOutput = Pipe() + let exitLatch = ProcessExitLatch() + process.terminationHandler = { _ in + Task { + await exitLatch.markExited() + } + } + + AppLogger.shared.info("🧪 [HostService] Launching split-runtime host child: \(launcherPath)") + try process.run() + let deadline = Date().addingTimeInterval(timeout) + while process.isRunning, Date() < deadline { + try await Task.sleep(for: .milliseconds(50)) + } + + if process.isRunning { + AppLogger.shared.warn("⚠️ [HostService] Split-runtime host child exceeded timeout; terminating") + process.terminate() + try await Task.sleep(for: .milliseconds(200)) + if process.isRunning { + process.interrupt() + try await Task.sleep(for: .milliseconds(200)) + } + if process.isRunning { + AppLogger.shared.warn("⚠️ [HostService] Split-runtime host child ignored terminate/interrupt; sending SIGKILL") + Darwin.kill(process.processIdentifier, SIGKILL) + } + } + + await exitLatch.wait() + try? stderrHandle.close() + let terminationReason: String = switch process.terminationReason { + case .exit: + "exit" + case .uncaughtSignal: + "uncaughtSignal" + @unknown default: + "unknown" + } + AppLogger.shared.info( + "🧪 [HostService] Split-runtime host child exited with code \(process.terminationStatus) reason=\(terminationReason)" + ) + + let stderrData = (try? Data(contentsOf: stderrLogURL)) ?? Data() + let stderr = String(decoding: stderrData, as: UTF8.self) + if stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + AppLogger.shared.info( + "🧪 [HostService] Split-runtime host child stderr was empty (file: \(stderrLogURL.path))" + ) + } else { + AppLogger.shared.info( + "🧪 [HostService] Split-runtime host child stderr from \(stderrLogURL.path):\n\(stderr)" + ) + } + + return KanataSplitRuntimeHostLaunchReport( + exitCode: process.terminationStatus, + stderr: stderr, + launcherPath: launcherPath, + sessionID: session.sessionID, + socketPath: session.socketPath + ) + } + + func startPersistentPassthruHost( + includeCapture: Bool, + pollMilliseconds: Int = 1000 + ) async throws -> pid_t { +#if DEBUG + if TestEnvironment.isRunningTests { + if let testStartPersistentError = Self.testStartPersistentError { + throw testStartPersistentError + } + if let testPersistentHostPID = Self.testPersistentHostPID, testPersistentHostPID > 0 { + return testPersistentHostPID + } + let pid: pid_t = 4242 + Self.testPersistentHostPID = pid + return pid + } +#endif + if let activeHostProcess, activeHostProcess.isRunning { + return activeHostProcess.processIdentifier + } + + let runtimeHost = KanataRuntimeHost.current() + let launcherPath = runtimeHost.launcherPath + let configPath = KeyPathConstants.Config.mainConfigPath + let session = try await companionManager.prepareEnvironmentAndPersist(hostPID: getpid()) + + let process = Process() + process.executableURL = URL(fileURLWithPath: launcherPath) + process.arguments = launcherArguments(configPath: configPath) + process.environment = buildPassthruEnvironment( + session: session, + includeCapture: includeCapture, + pollMilliseconds: pollMilliseconds, + persist: true + ) + process.currentDirectoryURL = URL(fileURLWithPath: (configPath as NSString).deletingLastPathComponent) + + let stderrLogURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("keypath-host-passthru-live-stderr.log") + Foundation.FileManager().createFile(atPath: stderrLogURL.path, contents: Data()) + process.standardError = try FileHandle(forWritingTo: stderrLogURL) + process.standardOutput = Pipe() + expectedPersistentHostTermination = false + process.terminationHandler = { [weak self] process in + Task { @MainActor in + guard let self else { return } + let expected = self.expectedPersistentHostTermination + let terminationReason: String = switch process.terminationReason { + case .exit: + "exit" + case .uncaughtSignal: + "uncaughtSignal" + @unknown default: + "unknown" + } + + Foundation.NotificationCenter.default.post( + name: .splitRuntimeHostExited, + object: nil, + userInfo: [ + KanataSplitRuntimeHostExitInfo.pidUserInfoKey: process.processIdentifier, + KanataSplitRuntimeHostExitInfo.exitCodeUserInfoKey: process.terminationStatus, + KanataSplitRuntimeHostExitInfo.terminationReasonUserInfoKey: terminationReason, + KanataSplitRuntimeHostExitInfo.expectedUserInfoKey: expected, + KanataSplitRuntimeHostExitInfo.stderrLogPathUserInfoKey: stderrLogURL.path + ] + ) + + if self.activeHostProcess?.processIdentifier == process.processIdentifier { + self.activeHostProcess = nil + } + self.expectedPersistentHostTermination = false + } + } + + AppLogger.shared.info( + "🧪 [HostService] Starting persistent split-runtime host child: \(launcherPath) session=\(session.sessionID)" + ) + try process.run() + activeHostProcess = process + activePersistentHostIncludesCapture = includeCapture + activePersistentHostPollMilliseconds = pollMilliseconds + return process.processIdentifier + } + + func restartPersistentPassthruHostAfterCompanionRestart() async throws -> pid_t? { +#if DEBUG + if TestEnvironment.isRunningTests { + guard let testPersistentHostPID = Self.testPersistentHostPID, testPersistentHostPID > 0 else { + return nil + } + if let testStartPersistentError = Self.testStartPersistentError { + throw testStartPersistentError + } + return testPersistentHostPID + } +#endif + + guard isPersistentPassthruHostRunning else { + return nil + } + + let includeCapture = activePersistentHostIncludesCapture + let pollMilliseconds = activePersistentHostPollMilliseconds + + AppLogger.shared.info( + "🧪 [HostService] Restarting persistent split-runtime host after output bridge companion restart" + ) + let priorProcess = activeHostProcess + stopPersistentPassthruHost() + if let priorProcess { + await waitForPersistentHostExit(process: priorProcess) + } + return try await startPersistentPassthruHost( + includeCapture: includeCapture, + pollMilliseconds: pollMilliseconds + ) + } + + func restartCompanionAndRecoverPersistentHost() async throws -> KanataSplitRuntimeCompanionRecoveryResult { + try await companionManager.restartCompanion() + + let companionRunningAfterRestart: Bool + if let statusAfterRestart = try? await companionManager.outputBridgeStatus() { + companionRunningAfterRestart = statusAfterRestart.companionRunning + } else { + companionRunningAfterRestart = false + } + + let recoveredHostPID = try await restartPersistentPassthruHostAfterCompanionRestart() + return KanataSplitRuntimeCompanionRecoveryResult( + companionRunningAfterRestart: companionRunningAfterRestart, + recoveredHostPID: recoveredHostPID + ) + } + + func stopPersistentPassthruHost() { +#if DEBUG + if TestEnvironment.isRunningTests { + Self.testPersistentHostPID = nil + Self.testStartPersistentError = nil + return + } +#endif + guard let process = activeHostProcess else { return } + if process.isRunning { + expectedPersistentHostTermination = true + process.terminate() + return + } + activeHostProcess = nil + expectedPersistentHostTermination = false + activePersistentHostIncludesCapture = true + activePersistentHostPollMilliseconds = 1000 + } +} diff --git a/Sources/KeyPathAppKit/Services/KanataTCPClient+Connection.swift b/Sources/KeyPathAppKit/Services/KanataTCPClient+Connection.swift index ebaecf3ab..388b99317 100644 --- a/Sources/KeyPathAppKit/Services/KanataTCPClient+Connection.swift +++ b/Sources/KeyPathAppKit/Services/KanataTCPClient+Connection.swift @@ -204,9 +204,7 @@ extension KanataTCPClient { let currentState = stateString(connection?.state) AppLogger.shared.log("🔌 [TCP] closeConnection() called (current state=\(currentState))") - // Log call stack for debugging (first 5 frames) - let stackSymbols = Thread.callStackSymbols.prefix(5).joined(separator: "\n ") - AppLogger.shared.debug("🔌 [TCP] closeConnection() stack trace:\n \(stackSymbols)") + AppLogger.shared.debug("🔌 [TCP] closeConnection()") connection?.cancel() connection = nil diff --git a/Sources/KeyPathAppKit/Services/KarabinerConflictService.swift b/Sources/KeyPathAppKit/Services/KarabinerConflictService.swift index 67d119fd6..c9671cafd 100644 --- a/Sources/KeyPathAppKit/Services/KarabinerConflictService.swift +++ b/Sources/KeyPathAppKit/Services/KarabinerConflictService.swift @@ -26,6 +26,10 @@ protocol KarabinerConflictManaging: AnyObject { /// Manages detection and resolution of conflicts with Karabiner-Elements @MainActor final class KarabinerConflictService: KarabinerConflictManaging { + /// Test seam: when set, `isKarabinerDaemonRunning()` returns this value + /// instead of running a real pgrep. + nonisolated(unsafe) static var testDaemonRunning: Bool? + // MARK: - Dependencies private let engineFactory: () -> (any InstallerEnginePrivilegedRouting) @@ -53,7 +57,7 @@ final class KarabinerConflictService: KarabinerConflictManaging { // MARK: - Detection Methods func isKarabinerDriverInstalled() -> Bool { - FileManager.default.fileExists(atPath: driverPath) + Foundation.FileManager().fileExists(atPath: driverPath) } func isKarabinerDriverExtensionEnabled() async -> Bool { @@ -120,7 +124,7 @@ final class KarabinerConflictService: KarabinerConflictManaging { func isKarabinerElementsRunning() async -> Bool { // First check if we've permanently disabled the grabber - if FileManager.default.fileExists(atPath: disabledMarkerPath) { + if Foundation.FileManager().fileExists(atPath: disabledMarkerPath) { AppLogger.shared.log( "ℹ️ [Conflict] karabiner_grabber permanently disabled by KeyPath - skipping conflict check" ) @@ -150,6 +154,11 @@ final class KarabinerConflictService: KarabinerConflictManaging { } func isKarabinerDaemonRunning() async -> Bool { + // Test seam: return injected value in tests to avoid real pgrep + if TestEnvironment.isRunningTests, let testValue = Self.testDaemonRunning { + return testValue + } + // Skip daemon check during startup to prevent blocking if FeatureFlags.shared.startupModeActive { AppLogger.shared.log( @@ -351,7 +360,7 @@ final class KarabinerConflictService: KarabinerConflictManaging { let output = result.output // Clean up temporary file - try? FileManager.default.removeItem(atPath: scriptPath) + try? Foundation.FileManager().removeItem(atPath: scriptPath) if result.success { AppLogger.shared.log("✅ [Karabiner] Successfully disabled Karabiner Elements services") @@ -372,7 +381,7 @@ final class KarabinerConflictService: KarabinerConflictManaging { } catch { AppLogger.shared.log("❌ [Karabiner] Error executing disable script: \(error)") - try? FileManager.default.removeItem(atPath: scriptPath) + try? Foundation.FileManager().removeItem(atPath: scriptPath) continuation.resume(returning: false) } } diff --git a/Sources/KeyPathAppKit/Services/KarabinerConverterService.swift b/Sources/KeyPathAppKit/Services/KarabinerConverterService.swift index 795a206bd..dc694b26b 100644 --- a/Sources/KeyPathAppKit/Services/KarabinerConverterService.swift +++ b/Sources/KeyPathAppKit/Services/KarabinerConverterService.swift @@ -952,7 +952,7 @@ struct KarabinerConverterService: Sendable { collection.configuration = .launcherGrid(config) collections[index] = collection try await RuleCollectionStore.shared.saveCollections(collections) - NotificationCenter.default.post(name: .ruleCollectionsChanged, object: nil) + Foundation.NotificationCenter.default.post(name: .ruleCollectionsChanged, object: nil) } // MARK: - Deduplication diff --git a/Sources/KeyPathAppKit/Services/KeyboardCapture+TCP.swift b/Sources/KeyPathAppKit/Services/KeyboardCapture+TCP.swift index d06c4fc70..7e54bd509 100644 --- a/Sources/KeyPathAppKit/Services/KeyboardCapture+TCP.swift +++ b/Sources/KeyPathAppKit/Services/KeyboardCapture+TCP.swift @@ -13,7 +13,7 @@ extension KeyboardCapture { tcpKeyInputObserver = NotificationCenter.default.addObserver( forName: .kanataKeyInput, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let self else { return } // Extract values from notification before crossing actor boundary diff --git a/Sources/KeyPathAppKit/Services/KeyboardCapture.swift b/Sources/KeyPathAppKit/Services/KeyboardCapture.swift index 44d1fd7ac..51db636d0 100644 --- a/Sources/KeyPathAppKit/Services/KeyboardCapture.swift +++ b/Sources/KeyPathAppKit/Services/KeyboardCapture.swift @@ -57,7 +57,7 @@ public class KeyboardCapture { /// Non-blocking check for whether Kanata is running, using cached service state. /// Replaces the old blocking `pgrep` call that could stall the main actor. func fastProbeKanataRunning(timeout _: TimeInterval = 0.25) -> Bool { - KanataService.shared.state.isRunning + KanataSplitRuntimeHostService.shared.isPersistentPassthruHostRunning } // MARK: - Event Router Configuration diff --git a/Sources/KeyPathAppKit/Services/KindaVimStateAdapter.swift b/Sources/KeyPathAppKit/Services/KindaVimStateAdapter.swift index a87b0f924..c19937430 100644 --- a/Sources/KeyPathAppKit/Services/KindaVimStateAdapter.swift +++ b/Sources/KeyPathAppKit/Services/KindaVimStateAdapter.swift @@ -58,7 +58,7 @@ final class KindaVimStateAdapter { static let shared = KindaVimStateAdapter() static var defaultEnvironmentURL: URL { - FileManager.default.homeDirectoryForCurrentUser + URL(fileURLWithPath: NSHomeDirectory()) .appendingPathComponent("Library") .appendingPathComponent("Application Support") .appendingPathComponent("kindaVim") @@ -157,7 +157,7 @@ final class KindaVimStateAdapter { private func refreshWithRetries(remainingRetries: Int) async { guard !Task.isCancelled else { return } - let fileExists = FileManager.default.fileExists(atPath: environmentURL.path) + let fileExists = Foundation.FileManager().fileExists(atPath: environmentURL.path) isEnvironmentFilePresent = fileExists if fileExists { diff --git a/Sources/KeyPathAppKit/Services/MainAppStateController.swift b/Sources/KeyPathAppKit/Services/MainAppStateController.swift index 45ecd8fe1..9e54d4e9d 100644 --- a/Sources/KeyPathAppKit/Services/MainAppStateController.swift +++ b/Sources/KeyPathAppKit/Services/MainAppStateController.swift @@ -66,7 +66,7 @@ class MainAppStateController { // MARK: - Service Health Monitoring (Fix for stale overlay state) @ObservationIgnored private var cancellables = Set() - @ObservationIgnored private var lastKnownServiceHealthy: Bool? + @ObservationIgnored private var lastKnownRuntimeHealthy: Bool? @ObservationIgnored private var serviceHealthTask: Task? @ObservationIgnored private var periodicRefreshTask: Task? @ObservationIgnored private let definitiveStartupGracePeriod: TimeInterval = 3.0 @@ -129,9 +129,15 @@ class MainAppStateController { OrphanDetector.shared.checkForOrphans() // Start service health monitoring to fix stale overlay state - subscribeToServiceHealth() - subscribeToErrorDetection() - startPeriodicRefresh() + if TestEnvironment.isRunningTests { + AppLogger.shared.debug( + "🧪 [MainAppStateController] Skipping background monitoring setup in test mode" + ) + } else { + subscribeToServiceHealth() + subscribeToErrorDetection() + startPeriodicRefresh() + } } /// Subscribe to KanataErrorMonitor crash detection to trigger immediate revalidation. @@ -165,13 +171,13 @@ class MainAppStateController { /// Log crash events to persistent storage for later analysis. /// Crashes are logged to ~/Library/Logs/KeyPath/crashes.log private func logCrashEvent(_ error: KanataError) { - let crashLogDir = FileManager.default.homeDirectoryForCurrentUser + let crashLogDir = Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Logs/KeyPath") let crashLogPath = crashLogDir.appendingPathComponent("crashes.log") // Ensure directory exists do { - try FileManager.default.createDirectory(at: crashLogDir, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: crashLogDir, withIntermediateDirectories: true) } catch { AppLogger.shared.warn("⚠️ [MainAppStateController] Failed to create crash log directory: \(error.localizedDescription)") } @@ -192,7 +198,7 @@ class MainAppStateController { // Append to log file if let data = entry.data(using: .utf8) { do { - if FileManager.default.fileExists(atPath: crashLogPath.path) { + if Foundation.FileManager().fileExists(atPath: crashLogPath.path) { let handle = try FileHandle(forWritingTo: crashLogPath) try handle.seekToEnd() try handle.write(contentsOf: data) @@ -228,7 +234,7 @@ class MainAppStateController { // Check SMAppService plist first if active, otherwise fall back to legacy plist let plistPath = KanataDaemonManager.getActivePlistPath() - let plistExists = FileManager.default.fileExists(atPath: plistPath) + let plistExists = Foundation.FileManager().fileExists(atPath: plistPath) guard plistExists else { AppLogger.shared.warn( @@ -269,7 +275,7 @@ class MainAppStateController { // MARK: - Service Health Monitoring - /// Subscribe to KanataService state changes to trigger revalidation when service health changes. + /// Subscribe to runtime state changes to trigger revalidation when runtime health changes. /// This fixes the "System Not Ready" stale state bug where the overlay shows stale state. private func subscribeToServiceHealth() { guard let kanataManager else { return } @@ -277,17 +283,17 @@ class MainAppStateController { // Cancel any previous polling task to prevent duplicate loops serviceHealthTask?.cancel() - // Poll KanataService.state for health transitions + // Poll runtime status for health transitions. serviceHealthTask = Task { @MainActor [weak self] in while let self, !Task.isCancelled { - let newState = kanataManager.kanataService.state - let isHealthy = if case .running = newState { true } else { false } - let wasHealthy = lastKnownServiceHealthy + let runtimeStatus = await kanataManager.currentRuntimeStatus() + let isHealthy = runtimeStatus.isRunning + let wasHealthy = lastKnownRuntimeHealthy if wasHealthy != isHealthy { - lastKnownServiceHealthy = isHealthy + lastKnownRuntimeHealthy = isHealthy AppLogger.shared.log( - "🔄 [MainAppStateController] Service health changed: \(wasHealthy.map { String($0) } ?? "nil") → \(isHealthy)" + "🔄 [MainAppStateController] Runtime health changed: \(wasHealthy.map { String($0) } ?? "nil") → \(isHealthy)" ) await revalidate() } @@ -296,7 +302,7 @@ class MainAppStateController { } } - AppLogger.shared.log("🔄 [MainAppStateController] Subscribed to KanataService health changes") + AppLogger.shared.log("🔄 [MainAppStateController] Subscribed to runtime health changes") } /// Start periodic background refresh (60s) as a fallback for cases where service state @@ -353,7 +359,7 @@ class MainAppStateController { // Wait for services to be ready (first time only) // Optimized: Reduced timeout from 10s to 3s, fast process check added // NOTE: Don't show spinner during service wait - only show during actual validation - AppLogger.shared.log("⏳ [MainAppStateController] Waiting for kanata service to be ready...") + AppLogger.shared.log("⏳ [MainAppStateController] Waiting for KeyPath runtime to be ready...") AppLogger.shared.log("⏱️ [TIMING] Service wait START") let serviceWaitStart = Date() @@ -488,12 +494,12 @@ class MainAppStateController { ) validationState = .failed(blockingCount: 1, totalCount: 1) issues = [WizardIssue( - identifier: .component(.kanataService), + identifier: .component(.keyPathRuntime), severity: .error, category: .daemon, title: "Kanata service not running", description: "The Kanata service failed to start or is not healthy.", - autoFixAction: .restartUnhealthyServices, + autoFixAction: nil, userAction: "Click System to open the setup wizard and diagnose the issue." )] // Even failed validations should update "last checked" timestamps. @@ -528,7 +534,7 @@ class MainAppStateController { } } catch { validationState = .failed(blockingCount: 1, totalCount: 1) - // Use .validationTimeout — NOT .component(.kanataService) — so this doesn't + // Use .validationTimeout — NOT .component(.keyPathRuntime) — so this doesn't // trigger the "Kanata Service Stopped" alert dialog. The timeout may be caused // by any validation step (e.g., slow Helper XPC), not necessarily Kanata. issues = [WizardIssue( @@ -589,9 +595,6 @@ class MainAppStateController { AppLogger.shared.debug( "📊 [MainAppStateController] Components.vhidHealthy: \(snapshot.components.vhidDeviceHealthy)" ) - AppLogger.shared.debug( - "📊 [MainAppStateController] Components.daemonServicesHealthy: \(snapshot.components.launchDaemonServicesHealthy)" - ) AppLogger.shared.debug( "📊 [MainAppStateController] Blocking issues: \(snapshot.blockingIssues.count)" ) @@ -724,7 +727,7 @@ class MainAppStateController { while Date() < transientDeadline { let health = await currentKanataStartupHealth() - let isReady = health.isRunning && health.isResponding + let isReady = health.isReady if isReady { if checks > 0 { AppLogger.shared.log( @@ -741,7 +744,7 @@ class MainAppStateController { checks += 1 AppLogger.shared.debug( - "⏳ [MainAppStateController] Waiting for Kanata service (\(checks)) transient=\(inTransientWindow), running=\(health.isRunning), responding=\(health.isResponding)" + "⏳ [MainAppStateController] Waiting for Kanata service (\(checks)) transient=\(inTransientWindow), running=\(health.isRunning), responding=\(health.isResponding), inputCaptureReady=\(health.inputCaptureReady)" ) try? await Task.sleep(for: .seconds(timing.checkInterval)) } diff --git a/Sources/KeyPathAppKit/Services/OrphanDetector.swift b/Sources/KeyPathAppKit/Services/OrphanDetector.swift index 05ed6e9e1..1e74fb91b 100644 --- a/Sources/KeyPathAppKit/Services/OrphanDetector.swift +++ b/Sources/KeyPathAppKit/Services/OrphanDetector.swift @@ -36,18 +36,18 @@ final class OrphanDetector { func detectOrphanedInstall() -> Bool { // Check for leftover files from manual app deletion let orphanedPaths = [ - FileManager.default.homeDirectoryForCurrentUser + Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/KeyPath"), - FileManager.default.homeDirectoryForCurrentUser + Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Logs/KeyPath"), - FileManager.default.homeDirectoryForCurrentUser + Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Preferences") .appendingPathComponent("com.keypath.KeyPath.plist") ] // Count how many orphaned paths exist let orphanCount = orphanedPaths.filter { - FileManager.default.fileExists(atPath: $0.path) + Foundation.FileManager().fileExists(atPath: $0.path) }.count // If 2 or more paths exist, this is likely an orphaned install @@ -58,7 +58,7 @@ final class OrphanDetector { /// Detects if orphaned VHID daemon plists exist private func detectOrphanedVHIDDaemons() -> Bool { - Self.vhidDaemonPlists.contains { FileManager.default.fileExists(atPath: $0) } + Self.vhidDaemonPlists.contains { Foundation.FileManager().fileExists(atPath: $0) } } /// Check for orphans and show cleanup alert if needed @@ -157,17 +157,17 @@ final class OrphanDetector { // it's silently deferred to next uninstall rather than shown as a failure. if cleanFiles { let pathsToClean: [(url: URL, canCleanWhileRunning: Bool)] = [ - (FileManager.default.homeDirectoryForCurrentUser + (Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/KeyPath"), false), - (FileManager.default.homeDirectoryForCurrentUser + (Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Logs/KeyPath"), true), - (FileManager.default.homeDirectoryForCurrentUser + (Foundation.FileManager().homeDirectoryForCurrentUser .appendingPathComponent("Library/Preferences") .appendingPathComponent("com.keypath.KeyPath.plist"), true) ] for (path, canCleanWhileRunning) in pathsToClean { - guard FileManager.default.fileExists(atPath: path.path) else { continue } + guard Foundation.FileManager().fileExists(atPath: path.path) else { continue } if !canCleanWhileRunning { AppLogger.shared.log("⏭️ [OrphanDetector] Deferring \(path.lastPathComponent) - will be cleaned on next uninstall") @@ -176,7 +176,7 @@ final class OrphanDetector { } do { - try FileManager.default.removeItem(at: path) + try Foundation.FileManager().removeItem(at: path) userFilesCleaned += 1 AppLogger.shared.log("🧹 [OrphanDetector] Removed: \(path.path)") } catch { diff --git a/Sources/KeyPathAppKit/Services/PluginManager.swift b/Sources/KeyPathAppKit/Services/PluginManager.swift index 2ebe42614..71a40f73f 100644 --- a/Sources/KeyPathAppKit/Services/PluginManager.swift +++ b/Sources/KeyPathAppKit/Services/PluginManager.swift @@ -45,7 +45,7 @@ public final class PluginManager { private var loadedBundlePaths: Set = [] private var userPluginsDirectory: URL { - let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first! + let appSupport = Foundation.FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! return appSupport.appendingPathComponent("KeyPath/Plugins", isDirectory: true) } @@ -76,13 +76,13 @@ public final class PluginManager { } for searchPath in searchPaths { - guard FileManager.default.fileExists(atPath: searchPath.path) else { + guard Foundation.FileManager().fileExists(atPath: searchPath.path) else { AppLogger.shared.debug("🔌 [PluginManager] Plugin path does not exist: \(searchPath.path)") continue } do { - let contents = try FileManager.default.contentsOfDirectory( + let contents = try Foundation.FileManager().contentsOfDirectory( at: searchPath, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] @@ -180,11 +180,11 @@ public final class PluginManager { installProgressMessage = "Installing\u{2026}" // Ensure plugins directory exists - try FileManager.default.createDirectory(at: userPluginsDirectory, withIntermediateDirectories: true) + try Foundation.FileManager().createDirectory(at: userPluginsDirectory, withIntermediateDirectories: true) // Unzip - let unzipDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: unzipDir, withIntermediateDirectories: true) + let unzipDir = Foundation.FileManager().temporaryDirectory.appendingPathComponent(UUID().uuidString) + try Foundation.FileManager().createDirectory(at: unzipDir, withIntermediateDirectories: true) let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") @@ -198,7 +198,7 @@ public final class PluginManager { } // Find the .bundle in unzipped contents - let unzippedContents = try FileManager.default.contentsOfDirectory( + let unzippedContents = try Foundation.FileManager().contentsOfDirectory( at: unzipDir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] @@ -212,15 +212,15 @@ public final class PluginManager { let destination = userPluginsDirectory.appendingPathComponent(bundleSource.lastPathComponent) // Remove existing if present - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) + if Foundation.FileManager().fileExists(atPath: destination.path) { + try Foundation.FileManager().removeItem(at: destination) } - try FileManager.default.moveItem(at: bundleSource, to: destination) + try Foundation.FileManager().moveItem(at: bundleSource, to: destination) // Clean up temp files - try? FileManager.default.removeItem(at: tempFileURL) - try? FileManager.default.removeItem(at: unzipDir) + try? Foundation.FileManager().removeItem(at: tempFileURL) + try? Foundation.FileManager().removeItem(at: unzipDir) // Load immediately loadPlugin(from: destination) @@ -263,7 +263,7 @@ public final class PluginManager { if let path = bundlePath { loadedBundlePaths.remove(path) do { - try FileManager.default.removeItem(atPath: path) + try Foundation.FileManager().removeItem(atPath: path) AppLogger.shared.info("🔌 [PluginManager] Deleted bundle: \(path)") } catch { AppLogger.shared.error("🔌 [PluginManager] Failed to delete bundle: \(error)") diff --git a/Sources/KeyPathAppKit/Services/QMKImportService.swift b/Sources/KeyPathAppKit/Services/QMKImportService.swift index 9fe1e293f..9579a6fcd 100644 --- a/Sources/KeyPathAppKit/Services/QMKImportService.swift +++ b/Sources/KeyPathAppKit/Services/QMKImportService.swift @@ -60,7 +60,7 @@ actor QMKImportService { } // Check file size before reading (10MB limit) - let fileAttributes = try FileManager.default.attributesOfItem(atPath: fileURL.path) + let fileAttributes = try Foundation.FileManager().attributesOfItem(atPath: fileURL.path) if let fileSize = fileAttributes[.size] as? Int64, fileSize > 10 * 1024 * 1024 { throw QMKImportError.invalidJSON("File too large (max 10MB). File size: \(fileSize / 1024 / 1024)MB") } diff --git a/Sources/KeyPathAppKit/Services/QMKKeyboardDatabase.swift b/Sources/KeyPathAppKit/Services/QMKKeyboardDatabase.swift index 4e3455379..a29e24119 100644 --- a/Sources/KeyPathAppKit/Services/QMKKeyboardDatabase.swift +++ b/Sources/KeyPathAppKit/Services/QMKKeyboardDatabase.swift @@ -16,7 +16,7 @@ actor QMKKeyboardDatabase { /// Load bundled popular keyboards (instant, no network) private func loadBundledKeyboards() -> [KeyboardMetadata] { - guard let url = Bundle.module.url(forResource: "popular-keyboards", withExtension: "json") else { + guard let url = KeyPathAppKitResources.url(forResource: "popular-keyboards", withExtension: "json") else { AppLogger.shared.warn("⚠️ [QMKDatabase] popular-keyboards.json not found in bundle") return [] } diff --git a/Sources/KeyPathAppKit/Services/RecentKeypressesService.swift b/Sources/KeyPathAppKit/Services/RecentKeypressesService.swift index da1142c1f..1eedefd74 100644 --- a/Sources/KeyPathAppKit/Services/RecentKeypressesService.swift +++ b/Sources/KeyPathAppKit/Services/RecentKeypressesService.swift @@ -18,6 +18,8 @@ final class RecentKeypressesService { let action: String // "press", "release", "repeat" let timestamp: Date let layer: String? + let listenerSessionID: Int? + let kanataTimestamp: UInt64? var displayKey: String { // Capitalize first letter for display @@ -78,9 +80,10 @@ final class RecentKeypressesService { let userInfo = notification.userInfo let key = userInfo?["key"] as? String let action = userInfo?["action"] as? String + let metadata = KeypressObservationMetadata.from(userInfo: userInfo) Task { @MainActor [weak self] in guard let self, isRecording, let key, let action else { return } - addEvent(key: key, action: action) + addEvent(key: key, action: action, metadata: metadata) } } @@ -102,12 +105,14 @@ final class RecentKeypressesService { } #endif - private func addEvent(key: String, action: String) { + private func addEvent(key: String, action: String, metadata: KeypressObservationMetadata = .init(listenerSessionID: nil, kanataTimestamp: nil, observedAt: nil)) { let event = KeypressEvent( key: key, action: action, timestamp: Date(), - layer: currentLayer + layer: currentLayer, + listenerSessionID: metadata.listenerSessionID, + kanataTimestamp: metadata.kanataTimestamp ) // DEDUPLICATION: Check last 10 events for duplicate (key, action, layer) within window diff --git a/Sources/KeyPathAppKit/Services/RecoveryDaemonService.swift b/Sources/KeyPathAppKit/Services/RecoveryDaemonService.swift new file mode 100644 index 000000000..a01b32c18 --- /dev/null +++ b/Sources/KeyPathAppKit/Services/RecoveryDaemonService.swift @@ -0,0 +1,323 @@ +import Foundation +import KeyPathCore +import KeyPathDaemonLifecycle +import ServiceManagement + +/// Errors related to recovery-daemon operations. +enum RecoveryDaemonServiceError: LocalizedError, Equatable { + case stopFailed(reason: String) + + public var errorDescription: String? { + switch self { + case let .stopFailed(reason): + "Failed to stop Kanata service: \(reason)" + } + } +} + +/// Narrow utility for interacting with the internal recovery daemon. +/// +/// The split runtime host is the real runtime path. This type only remains to: +/// - stop the internal recovery daemon when needed +/// - refresh its launchd-backed status on demand +/// - log recovery-daemon failures for diagnosis +@MainActor +final class RecoveryDaemonService { + static let shared = RecoveryDaemonService() + + private enum Constants { + static let daemonPlistName = "com.keypath.kanata.plist" + } + + // Factory used to create SMAppService instances (test seam) +#if DEBUG + nonisolated(unsafe) static var smServiceFactory: (String) -> SMAppServiceProtocol = { plistName in + NativeSMAppService(wrapped: SMAppService.daemon(plistName: plistName)) + } +#else + nonisolated(unsafe) static let smServiceFactory: (String) -> SMAppServiceProtocol = { plistName in + NativeSMAppService(wrapped: SMAppService.daemon(plistName: plistName)) + } + #endif + + // MARK: - Internal Dependencies (Hidden from consumers) + + @ObservationIgnored private let pidCache = LaunchDaemonPIDCache() + + private struct ProcessSnapshot { + let isRunning: Bool + let pid: Int? + } + + // MARK: - State + + enum ServiceState: Equatable, Sendable { + case running(pid: Int) + case stopped + case failed(reason: String) + case unknown + + var isRunning: Bool { + if case .running = self { return true } + return false + } + + var description: String { + switch self { + case let .running(pid): "Running (PID \(pid))" + case .stopped: "Stopped" + case let .failed(reason): "Failed: \(reason)" + case .unknown: "Unknown" + } + } + } + + private var lastObservedState: ServiceState = .unknown + /// Debounce transient "enabled but no PID" samples to avoid false failure reports. + private var enabledWithoutProcessSampleCount = 0 + private let enabledWithoutProcessFailureThreshold = 3 + + // MARK: - Initialization + + init() {} + + // MARK: - SMAppService helpers + + private func makeSMService() -> SMAppServiceProtocol { + Self.smServiceFactory(Constants.daemonPlistName) + } + + private nonisolated static func fetchSMStatus() -> SMAppService.Status { + smServiceFactory(Constants.daemonPlistName).status + } + + private func unregisterDaemon() async throws { + let service = makeSMService() + do { + try await service.unregister() + } catch { + if TestEnvironment.isRunningTests { + AppLogger.shared.log("🧪 [RecoveryDaemonService] Ignoring unregister error in tests: \(error)") + return + } + throw RecoveryDaemonServiceError.stopFailed(reason: error.localizedDescription) + } + } + + private func detectProcessState() async -> ProcessSnapshot { + if let daemonPID = await pidCache.getCachedPID() { + return ProcessSnapshot(isRunning: true, pid: Int(daemonPID)) + } + + let ownership = PIDFileManager.checkOwnership() + if ownership.owned, let pid = ownership.pid { + return ProcessSnapshot(isRunning: true, pid: Int(pid)) + } + + return ProcessSnapshot(isRunning: false, pid: nil) + } + + private nonisolated static func detectProcessState( + pidCache: LaunchDaemonPIDCache + ) async -> ProcessSnapshot { + if let daemonPID = await pidCache.getCachedPID() { + return ProcessSnapshot(isRunning: true, pid: Int(daemonPID)) + } + + let ownership = PIDFileManager.checkOwnership() + if ownership.owned, let pid = ownership.pid { + return ProcessSnapshot(isRunning: true, pid: Int(pid)) + } + + return ProcessSnapshot(isRunning: false, pid: nil) + } + + // MARK: - Public API + + /// Stop the service + func stop() async throws { + AppLogger.shared.log("🛑 [RecoveryDaemonService] Stop requested") + + try await unregisterDaemon() + + // Verify cleanup + try? await Task.sleep(for: .milliseconds(200)) // 0.2s + try? PIDFileManager.removePID() + await pidCache.invalidateCache() + let refreshedStatus = await refreshStatus() + + if case .running = refreshedStatus { + if TestEnvironment.isRunningTests { + AppLogger.shared.log("🧪 [RecoveryDaemonService] Test environment stop fallback - marking service as stopped") + lastObservedState = .stopped + } else { + // If still running, it might be a zombie or external process + AppLogger.shared.warn("⚠️ [RecoveryDaemonService] Service still running after stop request") + throw RecoveryDaemonServiceError.stopFailed(reason: "Process failed to terminate") + } + } + + if lastObservedState != .stopped { + AppLogger.shared.log("ℹ️ [RecoveryDaemonService] Forcing state to stopped after successful stop") + lastObservedState = .stopped + } + + AppLogger.shared.info("✅ [RecoveryDaemonService] Stopped successfully") + } + + /// Returns whether the internal recovery daemon is currently active. + func isRecoveryDaemonRunning() async -> Bool { + let status = await refreshStatus() + return status.isRunning + } + + /// Best-effort stop for the internal recovery daemon. + /// - Returns: `true` if the daemon was running and a stop was attempted, otherwise `false`. + @discardableResult + func stopIfRunning() async throws -> Bool { + let status = await refreshStatus() + guard status.isRunning else { return false } + try await stop() + return true + } + /// Force a status refresh (useful for UI pull-to-refresh) + @discardableResult + func refreshStatus() async -> ServiceState { + let status = await evaluateStatus() + publishStatus(status) + return status + } + + // MARK: - Status Composition + + private func evaluateStatus() async -> ServiceState { + let pidCache = pidCache + let smStatusTask = Task.detached(priority: .utility) { + Self.fetchSMStatus() + } + let processTask = Task.detached(priority: .utility) { + await Self.detectProcessState(pidCache: pidCache) + } + + let smStatus = await smStatusTask.value + let processState = await processTask.value + + switch smStatus { + case .enabled: + if processState.isRunning { + enabledWithoutProcessSampleCount = 0 + return .running(pid: processState.pid ?? 0) + } + + // Guard against transient process-detection misses (observed in the field): + // require several consecutive misses before reporting a hard failure. + enabledWithoutProcessSampleCount += 1 + if enabledWithoutProcessSampleCount < enabledWithoutProcessFailureThreshold { + AppLogger.shared.debug( + "⏳ [RecoveryDaemonService] SMAppService is enabled but process sample is missing (\(enabledWithoutProcessSampleCount)/\(enabledWithoutProcessFailureThreshold)); holding prior state" + ) + + if case let .running(previousPID) = lastObservedState { + return .running(pid: previousPID) + } + return .unknown + } + + // Before declaring failure, probe the Kanata TCP server as a last resort. + let tcpPort = PreferencesService.shared.tcpServerPort + let tcpAlive = await Task.detached(priority: .utility) { + TCPProbe.probe(port: tcpPort, timeoutMs: 300) + }.value + + if tcpAlive { + AppLogger.shared.log( + "🩹 [RecoveryDaemonService] TCP probe saved false failure — kanata responding on port \(tcpPort) despite PID miss" + ) + enabledWithoutProcessSampleCount = 0 + return .running(pid: 0) + } + + return .failed(reason: "Service enabled but process not running") + case .notRegistered, .notFound: + enabledWithoutProcessSampleCount = 0 + return processState.isRunning ? .running(pid: processState.pid ?? 0) : .stopped + case .requiresApproval: + enabledWithoutProcessSampleCount = 0 + return .stopped + @unknown default: + enabledWithoutProcessSampleCount = 0 + return .unknown + } + } + + private func publishStatus(_ newStatus: ServiceState) { + guard lastObservedState != newStatus else { return } + AppLogger.shared.log("📊 [RecoveryDaemonService] State changed: \(lastObservedState.description) -> \(newStatus.description)") + let oldState = lastObservedState + lastObservedState = newStatus + + // Log service failures for crash analysis only when a running service drops to failed. + // This avoids noisy false positives from startup/probe states (e.g. unknown -> failed). + if case let .failed(reason) = newStatus { + if oldState.isRunning { + logServiceFailure(from: oldState, reason: reason) + } else { + AppLogger.shared.debug( + "ℹ️ [RecoveryDaemonService] Skipping crash-log entry for non-running transition: \(oldState.description) -> failed(\(reason))" + ) + } + } + + // Note: Previously re-evaluated status on running→running(different PID) transitions. + // Removed: the recursive publishStatus() call could cascade unboundedly when PIDs + // differ across evaluations, monopolizing the MainActor under load. + } + + /// Log service state failures to persistent crash log for later analysis + private func logServiceFailure(from oldState: ServiceState, reason: String) { + let crashLogDir = Foundation.FileManager().homeDirectoryForCurrentUser + .appendingPathComponent("Library/Logs/KeyPath") + let crashLogPath = crashLogDir.appendingPathComponent("crashes.log") + + // Ensure directory exists + do { + try Foundation.FileManager().createDirectory(at: crashLogDir, withIntermediateDirectories: true) + } catch { + AppLogger.shared.warn("⚠️ [RecoveryDaemonService] Failed to create crash log directory: \(error.localizedDescription)") + } + + // Format crash entry + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let timestamp = formatter.string(from: Date()) + + let entry = """ + [\(timestamp)] [SERVICE_FAILURE] Kanata service failed + Previous state: \(oldState.description) + Reason: \(reason) + --- + + """ + + // Append to log file + if let data = entry.data(using: .utf8) { + do { + if Foundation.FileManager().fileExists(atPath: crashLogPath.path) { + let handle = try FileHandle(forWritingTo: crashLogPath) + try handle.seekToEnd() + try handle.write(contentsOf: data) + try handle.close() + } else { + try data.write(to: crashLogPath) + } + } catch { + AppLogger.shared.warn("⚠️ [RecoveryDaemonService] Failed to write crash log: \(error.localizedDescription)") + } + } + + AppLogger.shared.error( + "💥 [CrashLog] Logged service failure: \(oldState.description) -> failed(\(reason))" + ) + } + +} diff --git a/Sources/KeyPathAppKit/Services/RuleCollectionStore.swift b/Sources/KeyPathAppKit/Services/RuleCollectionStore.swift index ceb3af66e..edce7e858 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollectionStore.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollectionStore.swift @@ -14,7 +14,7 @@ actor RuleCollectionStore { init( fileURL: URL? = nil, - fileManager: FileManager = .default, + fileManager: FileManager = Foundation.FileManager(), catalog: RuleCollectionCatalog = RuleCollectionCatalog() ) { self.fileManager = fileManager diff --git a/Sources/KeyPathAppKit/Services/RuleCollectionsManager+EventMonitoring.swift b/Sources/KeyPathAppKit/Services/RuleCollectionsManager+EventMonitoring.swift index 51a069ac8..f3b26057a 100644 --- a/Sources/KeyPathAppKit/Services/RuleCollectionsManager+EventMonitoring.swift +++ b/Sources/KeyPathAppKit/Services/RuleCollectionsManager+EventMonitoring.swift @@ -35,14 +35,23 @@ extension RuleCollectionsManager { self.handleUnknownMessage(message) } }, - onKeyInput: { key, action in + onKeyInput: { observation in // Post notification for TCP-based physical key input events // Used by KeyboardVisualizationViewModel for overlay highlighting await MainActor.run { + var userInfo: [String: Any] = [ + "key": observation.key, + "action": observation.action.rawValue.lowercased(), + "listenerSessionID": observation.sessionID, + "observedAt": observation.observedAt + ] + if let kanataTimestamp = observation.kanataTimestamp { + userInfo["kanataTimestamp"] = kanataTimestamp + } NotificationCenter.default.post( name: .kanataKeyInput, object: nil, - userInfo: ["key": key, "action": action.rawValue.lowercased()] + userInfo: userInfo ) } }, @@ -53,7 +62,13 @@ extension RuleCollectionsManager { NotificationCenter.default.post( name: .kanataHoldActivated, object: nil, - userInfo: ["key": activation.key, "action": activation.action] + userInfo: [ + "key": activation.key, + "action": activation.action, + "listenerSessionID": activation.sessionID, + "observedAt": activation.observedAt, + "kanataTimestamp": activation.timestamp + ] ) } }, @@ -64,7 +79,13 @@ extension RuleCollectionsManager { NotificationCenter.default.post( name: .kanataTapActivated, object: nil, - userInfo: ["key": activation.key, "action": activation.action] + userInfo: [ + "key": activation.key, + "action": activation.action, + "listenerSessionID": activation.sessionID, + "observedAt": activation.observedAt, + "kanataTimestamp": activation.timestamp + ] ) } }, @@ -74,7 +95,13 @@ extension RuleCollectionsManager { NotificationCenter.default.post( name: .kanataOneShotActivated, object: nil, - userInfo: ["key": activation.key, "modifiers": activation.modifiers] + userInfo: [ + "key": activation.key, + "modifiers": activation.modifiers, + "listenerSessionID": activation.sessionID, + "observedAt": activation.observedAt, + "kanataTimestamp": activation.timestamp + ] ) } }, @@ -84,7 +111,13 @@ extension RuleCollectionsManager { NotificationCenter.default.post( name: .kanataChordResolved, object: nil, - userInfo: ["keys": resolution.keys, "action": resolution.action] + userInfo: [ + "keys": resolution.keys, + "action": resolution.action, + "listenerSessionID": resolution.sessionID, + "observedAt": resolution.observedAt, + "kanataTimestamp": resolution.timestamp + ] ) } }, @@ -97,7 +130,10 @@ extension RuleCollectionsManager { userInfo: [ "key": resolution.key, "tapCount": resolution.tapCount, - "action": resolution.action + "action": resolution.action, + "listenerSessionID": resolution.sessionID, + "observedAt": resolution.observedAt, + "kanataTimestamp": resolution.timestamp ] ) } diff --git a/Sources/KeyPathAppKit/Services/ScriptSecurityService.swift b/Sources/KeyPathAppKit/Services/ScriptSecurityService.swift index 72dc0a1b9..3e3c81197 100644 --- a/Sources/KeyPathAppKit/Services/ScriptSecurityService.swift +++ b/Sources/KeyPathAppKit/Services/ScriptSecurityService.swift @@ -74,12 +74,12 @@ public final class ScriptSecurityService { let expandedPath = (path as NSString).expandingTildeInPath // Check file exists - guard FileManager.default.fileExists(atPath: expandedPath) else { + guard Foundation.FileManager().fileExists(atPath: expandedPath) else { return .fileNotFound(path: expandedPath) } // Check file is executable (or is a script type we can run) - let isExecutable = FileManager.default.isExecutableFile(atPath: expandedPath) + let isExecutable = Foundation.FileManager().isExecutableFile(atPath: expandedPath) let isScriptType = isRecognizedScript(expandedPath) guard isExecutable || isScriptType else { diff --git a/Sources/KeyPathAppKit/Services/ServiceHealthMonitor.swift b/Sources/KeyPathAppKit/Services/ServiceHealthMonitor.swift index b74ae1739..78937d996 100644 --- a/Sources/KeyPathAppKit/Services/ServiceHealthMonitor.swift +++ b/Sources/KeyPathAppKit/Services/ServiceHealthMonitor.swift @@ -158,7 +158,7 @@ final class ServiceHealthMonitor: ServiceHealthMonitorProtocol { init(processLifecycle: ProcessLifecycleManager) { self.processLifecycle = processLifecycle heartbeatObserver = NotificationCenter.default.addObserver( - forName: .kanataTcpHeartbeat, object: nil, queue: .main + forName: .kanataTcpHeartbeat, object: nil, queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in MainActor.assumeIsolated { self?.lastHeartbeatTime = Date() diff --git a/Sources/KeyPathAppKit/Services/SimpleModsParser.swift b/Sources/KeyPathAppKit/Services/SimpleModsParser.swift index c70f1fa80..c1cfb8a24 100644 --- a/Sources/KeyPathAppKit/Services/SimpleModsParser.swift +++ b/Sources/KeyPathAppKit/Services/SimpleModsParser.swift @@ -12,7 +12,7 @@ public final class SimpleModsParser: Sendable { public func parse() throws -> ( block: SentinelBlock?, allMappings: [SimpleMapping], conflicts: [MappingConflict] ) { - guard FileManager.default.fileExists(atPath: configPath) else { + guard Foundation.FileManager().fileExists(atPath: configPath) else { // No config file means no block return (nil, [], []) } diff --git a/Sources/KeyPathAppKit/Services/SimpleModsService.swift b/Sources/KeyPathAppKit/Services/SimpleModsService.swift index 076aef444..ff4a6f2e0 100644 --- a/Sources/KeyPathAppKit/Services/SimpleModsService.swift +++ b/Sources/KeyPathAppKit/Services/SimpleModsService.swift @@ -186,7 +186,7 @@ public final class SimpleModsService { // Snapshot original file for rollback let originalContent: String? = { - if FileManager.default.fileExists(atPath: configPath) { + if Foundation.FileManager().fileExists(atPath: configPath) { return try? String(contentsOfFile: configPath, encoding: .utf8) } return nil diff --git a/Sources/KeyPathAppKit/Services/SimpleModsWriter.swift b/Sources/KeyPathAppKit/Services/SimpleModsWriter.swift index 6de1551c2..35a8a46e5 100644 --- a/Sources/KeyPathAppKit/Services/SimpleModsWriter.swift +++ b/Sources/KeyPathAppKit/Services/SimpleModsWriter.swift @@ -13,7 +13,7 @@ public final class SimpleModsWriter: Sendable { public func writeBlock(mappings: [SimpleMapping]) throws { // Read current content let content: String = - if FileManager.default.fileExists(atPath: configPath) { + if Foundation.FileManager().fileExists(atPath: configPath) { try String(contentsOfFile: configPath, encoding: .utf8) } else { "" diff --git a/Sources/KeyPathAppKit/Services/SimulatorService.swift b/Sources/KeyPathAppKit/Services/SimulatorService.swift index 2856513df..09fd56bf3 100644 --- a/Sources/KeyPathAppKit/Services/SimulatorService.swift +++ b/Sources/KeyPathAppKit/Services/SimulatorService.swift @@ -12,7 +12,7 @@ actor SimulatorService { init( simulatorPath: String? = nil, - fileManager: FileManager = .default + fileManager: FileManager = Foundation.FileManager() ) { self.simulatorPath = simulatorPath ?? WizardSystemPaths.bundledSimulatorPath self.fileManager = fileManager diff --git a/Sources/KeyPathAppKit/Services/SystemValidator.swift b/Sources/KeyPathAppKit/Services/SystemValidator.swift index 750654b87..b46762046 100644 --- a/Sources/KeyPathAppKit/Services/SystemValidator.swift +++ b/Sources/KeyPathAppKit/Services/SystemValidator.swift @@ -408,7 +408,6 @@ class SystemValidator { // This uses launchctl (fast) and provides VHID health, avoiding duplicate pgrep calls // that could contend with checkHealth()'s detectConnectionHealth() call let daemonStatus = await ServiceHealthChecker.shared.getServiceStatus() - let launchDaemonServicesHealthy = daemonStatus.allServicesHealthy let vhidServicesHealthy = daemonStatus.vhidServicesHealthy // Use launchctl-based VHID daemon health instead of pgrep-based detectConnectionHealth // to avoid concurrent pgrep calls that can cause hangs (see checkHealth which also calls it) @@ -436,7 +435,6 @@ class SystemValidator { karabinerDaemonRunning: karabinerDaemonRunning, vhidDeviceInstalled: vhidInstalled, vhidDeviceHealthy: vhidHealthy, - launchDaemonServicesHealthy: launchDaemonServicesHealthy, vhidServicesHealthy: vhidServicesHealthy, vhidVersionMismatch: vhidVersionMismatch, kanataBinaryVersionMismatch: kanataBinaryVersionMismatch @@ -525,9 +523,10 @@ class SystemValidator { AppLogger.shared.log("🔍 [SystemValidator] checkHealth() - About to check Kanata service health...") let kanataStart = Date() let kanataRunning = await ServiceHealthChecker.shared.isServiceHealthy(serviceID: "com.keypath.kanata") + let kanataInputCapture = await ServiceHealthChecker.shared.checkKanataInputCaptureStatus() let kanataDuration = Date().timeIntervalSince(kanataStart) AppLogger.shared.log( - "🔍 [SystemValidator] checkHealth() - Kanata service check complete: \(kanataRunning) (took \(String(format: "%.3f", kanataDuration))s)" + "🔍 [SystemValidator] checkHealth() - Kanata service check complete: \(kanataRunning), inputCaptureReady=\(kanataInputCapture.isReady) (took \(String(format: "%.3f", kanataDuration))s)" ) // Use launchctl-based check instead of unreliable pgrep @@ -567,7 +566,9 @@ class SystemValidator { return HealthStatus( kanataRunning: kanataRunning, karabinerDaemonRunning: karabinerDaemonRunning, - vhidHealthy: vhidHealthy + vhidHealthy: vhidHealthy, + kanataInputCaptureReady: kanataInputCapture.isReady, + kanataInputCaptureIssue: kanataInputCapture.issue ) } @@ -608,7 +609,6 @@ class SystemValidator { karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: true, vhidServicesHealthy: true, vhidVersionMismatch: false, kanataBinaryVersionMismatch: false @@ -640,7 +640,6 @@ class SystemValidator { karabinerDaemonRunning: false, vhidDeviceInstalled: false, vhidDeviceHealthy: false, - launchDaemonServicesHealthy: false, vhidServicesHealthy: false, vhidVersionMismatch: false, kanataBinaryVersionMismatch: false diff --git a/Sources/KeyPathAppKit/Services/TypingSoundsManager.swift b/Sources/KeyPathAppKit/Services/TypingSoundsManager.swift index fd8765559..7afc8275e 100644 --- a/Sources/KeyPathAppKit/Services/TypingSoundsManager.swift +++ b/Sources/KeyPathAppKit/Services/TypingSoundsManager.swift @@ -133,7 +133,7 @@ final class TypingSoundsManager { keyInputObserver = NotificationCenter.default.addObserver( forName: .kanataKeyInput, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let action = notification.userInfo?["action"] as? String else { return } Task { @MainActor [weak self] in @@ -242,13 +242,13 @@ final class TypingSoundsManager { let suffix = isKeydown ? "down" : "up" let filename = "\(profile.id)-\(suffix)" - // Try Bundle.module first (Swift Package resources), then Bundle.main + // Try the app kit resource bundle first, then Bundle.main. // Note: .process() in Package.swift flattens directory structure, so files // are at bundle root, not in a Sounds subdirectory - if let url = Bundle.module.url(forResource: filename, withExtension: "mp3") { + if let url = KeyPathAppKitResources.url(forResource: filename, withExtension: "mp3") { return url } - if let url = Bundle.module.url(forResource: filename, withExtension: "wav") { + if let url = KeyPathAppKitResources.url(forResource: filename, withExtension: "wav") { return url } if let url = Bundle.main.url(forResource: filename, withExtension: "mp3") { diff --git a/Sources/KeyPathAppKit/Services/WordlistStore.swift b/Sources/KeyPathAppKit/Services/WordlistStore.swift index a4166367d..75708427b 100644 --- a/Sources/KeyPathAppKit/Services/WordlistStore.swift +++ b/Sources/KeyPathAppKit/Services/WordlistStore.swift @@ -58,7 +58,7 @@ public enum WordlistStore { } private static func defaultAppSupportURL() -> URL { - let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + let base = Foundation.FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first return (base ?? URL(fileURLWithPath: NSTemporaryDirectory())) .appendingPathComponent("KeyPath", isDirectory: true) } diff --git a/Sources/KeyPathAppKit/Support/KeyPathAppKitResources.swift b/Sources/KeyPathAppKit/Support/KeyPathAppKitResources.swift new file mode 100644 index 000000000..396523ff0 --- /dev/null +++ b/Sources/KeyPathAppKit/Support/KeyPathAppKitResources.swift @@ -0,0 +1,35 @@ +import Foundation + +private final class KeyPathAppKitBundleSentinel {} + +enum KeyPathAppKitResources { + static let bundle: Bundle = { + let mainBundle = Bundle.main + let codeBundle = Bundle(for: KeyPathAppKitBundleSentinel.self) + let candidates = [ + mainBundle.resourceURL?.appendingPathComponent("KeyPath_KeyPathAppKit.bundle"), + codeBundle.resourceURL?.appendingPathComponent("KeyPath_KeyPathAppKit.bundle"), + mainBundle.bundleURL.deletingLastPathComponent().appendingPathComponent("KeyPath_KeyPathAppKit.bundle"), + codeBundle.bundleURL.deletingLastPathComponent().appendingPathComponent("KeyPath_KeyPathAppKit.bundle"), + mainBundle.resourceURL, + codeBundle.resourceURL, + ].compactMap { $0 } + + for candidate in candidates { + if let bundle = Bundle(url: candidate) { + return bundle + } + } + + return mainBundle + }() + + static var resourceURL: URL? { + bundle.resourceURL ?? Bundle.main.resourceURL + } + + static func url(forResource name: String, withExtension ext: String?) -> URL? { + bundle.url(forResource: name, withExtension: ext) + ?? Bundle.main.url(forResource: name, withExtension: ext) + } +} diff --git a/Sources/KeyPathAppKit/UI/AboutView.swift b/Sources/KeyPathAppKit/UI/AboutView.swift index 340a844f2..f4013255e 100644 --- a/Sources/KeyPathAppKit/UI/AboutView.swift +++ b/Sources/KeyPathAppKit/UI/AboutView.swift @@ -425,7 +425,7 @@ struct AboutView: View { private func revealConfigFile() { let configPath = KeyPathConstants.Config.mainConfigPath - let exists = FileManager.default.fileExists(atPath: configPath) + let exists = Foundation.FileManager().fileExists(atPath: configPath) if exists { NSWorkspace.shared.selectFile(configPath, inFileViewerRootedAtPath: "") @@ -596,7 +596,7 @@ class AboutWindowController { NotificationCenter.default.addObserver( forName: NSWindow.willCloseNotification, object: window, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor in self?.window = nil diff --git a/Sources/KeyPathAppKit/UI/AdvancedSettingsTabView.swift b/Sources/KeyPathAppKit/UI/AdvancedSettingsTabView.swift index 746069a27..c498ea446 100644 --- a/Sources/KeyPathAppKit/UI/AdvancedSettingsTabView.swift +++ b/Sources/KeyPathAppKit/UI/AdvancedSettingsTabView.swift @@ -307,7 +307,7 @@ struct AdvancedSettingsTabView: View { } let keepPath = "/Applications/KeyPath.app" - let manager = FileManager.default + let manager = Foundation.FileManager() var removed = 0 for path in duplicateAppCopies where path != keepPath { let url = URL(fileURLWithPath: path) @@ -336,7 +336,7 @@ struct AdvancedSettingsTabView: View { private func performResetEverything() async { let report = await InstallerEngine() - .runSingleAction(.restartUnhealthyServices, using: PrivilegeBroker()) + .runSingleAction(.terminateConflictingProcesses, using: PrivilegeBroker()) await MainActor.run { if report.success { settingsToastManager.showInfo("Reset everything complete") diff --git a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift index cd0fd3027..53c36de04 100644 --- a/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift +++ b/Sources/KeyPathAppKit/UI/ContextHUD/ContextHUDController.swift @@ -58,7 +58,7 @@ final class ContextHUDController { NotificationCenter.default.addObserver( forName: .kanataLayerChanged, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let layerName = notification.userInfo?["layerName"] as? String else { return } let sourceRaw = notification.userInfo?["source"] as? String @@ -74,7 +74,7 @@ final class ContextHUDController { NotificationCenter.default.addObserver( forName: .kanataMessagePush, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let message = notification.userInfo?["message"] as? String, message.hasPrefix("layer:") @@ -89,7 +89,7 @@ final class ContextHUDController { NotificationCenter.default.addObserver( forName: .kanataKeyInput, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in let key = notification.userInfo?["key"] as? String let action = notification.userInfo?["action"] as? String @@ -102,7 +102,7 @@ final class ContextHUDController { NotificationCenter.default.addObserver( forName: .kanataHoldActivated, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in let key = notification.userInfo?["key"] as? String let action = notification.userInfo?["action"] as? String @@ -115,7 +115,7 @@ final class ContextHUDController { NotificationCenter.default.addObserver( forName: .kanataConfigChanged, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor in guard let self else { return } @@ -131,7 +131,7 @@ final class ContextHUDController { NotificationCenter.default.addObserver( forName: .ruleCollectionsChanged, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in Task { @MainActor in guard let self else { return } diff --git a/Sources/KeyPathAppKit/UI/EmergencyStopDialog.swift b/Sources/KeyPathAppKit/UI/EmergencyStopDialog.swift index bbab3c18b..3e6215ecf 100644 --- a/Sources/KeyPathAppKit/UI/EmergencyStopDialog.swift +++ b/Sources/KeyPathAppKit/UI/EmergencyStopDialog.swift @@ -166,7 +166,7 @@ struct EmergencyStopDialog: View { Image(systemName: "arrow.clockwise.circle.fill") .font(.title3) .foregroundColor(.orange) - Text("Restart the Kanata service to re-enable remapping") + Text("Restart the KeyPath runtime to re-enable remapping") .font(.body) Spacer() } diff --git a/Sources/KeyPathAppKit/UI/EmergencyStopPauseCard.swift b/Sources/KeyPathAppKit/UI/EmergencyStopPauseCard.swift index 5ab9080b6..87689f77b 100644 --- a/Sources/KeyPathAppKit/UI/EmergencyStopPauseCard.swift +++ b/Sources/KeyPathAppKit/UI/EmergencyStopPauseCard.swift @@ -58,7 +58,7 @@ struct EmergencyStopPauseCard: View { Image(systemName: "play.circle.fill") .font(.title3) } - Text(isRestarting ? "Restarting..." : "Restart Service") + Text(isRestarting ? "Restarting..." : "Restart Runtime") .font(.headline) } .padding(.horizontal, 20) diff --git a/Sources/KeyPathAppKit/UI/Experimental/AppPickerView.swift b/Sources/KeyPathAppKit/UI/Experimental/AppPickerView.swift index f7fecbbd8..6e5fa03ff 100644 --- a/Sources/KeyPathAppKit/UI/Experimental/AppPickerView.swift +++ b/Sources/KeyPathAppKit/UI/Experimental/AppPickerView.swift @@ -29,7 +29,7 @@ struct AppPickerView: View { for path in commonApps { let url = URL(fileURLWithPath: path) - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { let name = url.deletingPathExtension().lastPathComponent let bundleID = Bundle(url: url)?.bundleIdentifier ?? "" let icon = workspace.icon(forFile: path) diff --git a/Sources/KeyPathAppKit/UI/Experimental/MapperViewModel+ConflictResolution.swift b/Sources/KeyPathAppKit/UI/Experimental/MapperViewModel+ConflictResolution.swift index 74ebcb1fe..4ac45d84e 100644 --- a/Sources/KeyPathAppKit/UI/Experimental/MapperViewModel+ConflictResolution.swift +++ b/Sources/KeyPathAppKit/UI/Experimental/MapperViewModel+ConflictResolution.swift @@ -895,7 +895,7 @@ extension MapperViewModel { URL(fileURLWithPath: "/System/Applications/\(identifier).app") ] - for url in candidates where FileManager.default.fileExists(atPath: url.path) { + for url in candidates where Foundation.FileManager().fileExists(atPath: url.path) { return buildAppLaunchInfo(from: url) } diff --git a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift index c863a6aa2..608c96d07 100644 --- a/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift +++ b/Sources/KeyPathAppKit/UI/Help/HelpBrowserView.swift @@ -243,7 +243,7 @@ struct HelpBrowserView: View { ScrollView { VStack(spacing: 0) { // Banner image - if let bannerURL = Bundle.module.url(forResource: "header-banner", withExtension: "png") { + if let bannerURL = KeyPathAppKitResources.url(forResource: "header-banner", withExtension: "png") { AsyncImage(url: bannerURL) { image in image .resizable() @@ -289,7 +289,7 @@ struct HelpBrowserView: View { } // End-of-page flourish — same splotch style as article dividers - if let flourishURL = Bundle.module.url(forResource: "decor-divider", withExtension: "png") { + if let flourishURL = KeyPathAppKitResources.url(forResource: "decor-divider", withExtension: "png") { Image(nsImage: NSImage(contentsOf: flourishURL) ?? NSImage()) .resizable() .aspectRatio(contentMode: .fit) diff --git a/Sources/KeyPathAppKit/UI/Help/MarkdownHelpSheet.swift b/Sources/KeyPathAppKit/UI/Help/MarkdownHelpSheet.swift index bface0847..41a3eb3c8 100644 --- a/Sources/KeyPathAppKit/UI/Help/MarkdownHelpSheet.swift +++ b/Sources/KeyPathAppKit/UI/Help/MarkdownHelpSheet.swift @@ -82,9 +82,9 @@ struct MarkdownWebView: NSViewRepresentable { } static func loadResource(_ resource: String, into webView: WKWebView, isDark: Bool) { - guard let url = Bundle.module.url(forResource: resource, withExtension: "md"), + guard let url = KeyPathAppKitResources.url(forResource: resource, withExtension: "md"), let markdown = try? String(contentsOf: url, encoding: .utf8), - let resourceDir = Bundle.module.resourceURL + let resourceDir = KeyPathAppKitResources.resourceURL else { let fallback = MarkdownToHTML.wrapInHTMLDocument( body: "

Could not load help content.

", diff --git a/Sources/KeyPathAppKit/UI/Help/MarkdownToHTML.swift b/Sources/KeyPathAppKit/UI/Help/MarkdownToHTML.swift index d360c9e3e..6584cb972 100644 --- a/Sources/KeyPathAppKit/UI/Help/MarkdownToHTML.swift +++ b/Sources/KeyPathAppKit/UI/Help/MarkdownToHTML.swift @@ -124,7 +124,7 @@ enum MarkdownToHTML { /// Wrap an HTML body fragment in a full HTML document with inline CSS. static func wrapInHTMLDocument(body: String, isDark _: Bool) -> String { - let css: String = if let url = Bundle.module.url(forResource: "help-theme", withExtension: "css"), + let css: String = if let url = KeyPathAppKitResources.url(forResource: "help-theme", withExtension: "css"), let contents = try? String(contentsOf: url, encoding: .utf8) { contents diff --git a/Sources/KeyPathAppKit/UI/KarabinerImportSheet.swift b/Sources/KeyPathAppKit/UI/KarabinerImportSheet.swift index 87711d235..3636694b0 100644 --- a/Sources/KeyPathAppKit/UI/KarabinerImportSheet.swift +++ b/Sources/KeyPathAppKit/UI/KarabinerImportSheet.swift @@ -473,7 +473,7 @@ struct KarabinerImportSheet: View { switch source { case .autoDetect: let path = WizardSystemPaths.karabinerConfigPath - return FileManager.default.contents(atPath: path) + return Foundation.FileManager().contents(atPath: path) case .file: guard let url = selectedFileURL else { return nil } let accessing = url.startAccessingSecurityScopedResource() diff --git a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+LayoutMapping.swift b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+LayoutMapping.swift index ed3ea3c82..5baec7d12 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+LayoutMapping.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+LayoutMapping.swift @@ -111,13 +111,13 @@ extension KeyboardVisualizationViewModel { // Fall back to app name in /Applications let directPath = "/Applications/\(name).app" - if FileManager.default.fileExists(atPath: directPath) { + if Foundation.FileManager().fileExists(atPath: directPath) { return true } // Try capitalized name let capitalizedPath = "/Applications/\(name.capitalized).app" - if FileManager.default.fileExists(atPath: capitalizedPath) { + if Foundation.FileManager().fileExists(atPath: capitalizedPath) { return true } diff --git a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+TCP.swift b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+TCP.swift index b6b1a6e5c..5400ced5c 100644 --- a/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+TCP.swift +++ b/Sources/KeyPathAppKit/UI/KeyboardVisualization/KeyboardVisualizationViewModel+TCP.swift @@ -13,7 +13,7 @@ extension KeyboardVisualizationViewModel { keyInputObserver = NotificationCenter.default.addObserver( forName: .kanataKeyInput, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let self else { return } guard let key = notification.userInfo?["key"] as? String, @@ -32,7 +32,7 @@ extension KeyboardVisualizationViewModel { tcpHeartbeatObserver = NotificationCenter.default.addObserver( forName: .kanataTcpHeartbeat, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in guard let self else { return } Task { @MainActor in @@ -47,7 +47,7 @@ extension KeyboardVisualizationViewModel { holdActivatedObserver = NotificationCenter.default.addObserver( forName: .kanataHoldActivated, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let self else { return } guard let key = notification.userInfo?["key"] as? String, @@ -66,7 +66,7 @@ extension KeyboardVisualizationViewModel { tapActivatedObserver = NotificationCenter.default.addObserver( forName: .kanataTapActivated, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let self else { return } guard let key = notification.userInfo?["key"] as? String, @@ -85,7 +85,7 @@ extension KeyboardVisualizationViewModel { messagePushObserver = NotificationCenter.default.addObserver( forName: .kanataMessagePush, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let self else { return } guard let message = notification.userInfo?["message"] as? String else { return } @@ -102,7 +102,7 @@ extension KeyboardVisualizationViewModel { ruleCollectionsObserver = NotificationCenter.default.addObserver( forName: .ruleCollectionsChanged, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] _ in guard let self else { return } Task { @MainActor in @@ -122,7 +122,7 @@ extension KeyboardVisualizationViewModel { oneShotObserver = NotificationCenter.default.addObserver( forName: .kanataOneShotActivated, object: nil, - queue: .main + queue: NotificationObserverManager.mainOperationQueue ) { [weak self] notification in guard let self else { return } guard let modifiers = notification.userInfo?["modifiers"] as? String else { return } diff --git a/Sources/KeyPathAppKit/UI/Overlay/BehaviorStatePicker.swift b/Sources/KeyPathAppKit/UI/Overlay/BehaviorStatePicker.swift index 4e45c1275..5b57e9c07 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/BehaviorStatePicker.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/BehaviorStatePicker.swift @@ -157,7 +157,7 @@ private struct BehaviorStateCell: View { private func loadBundleImage(named name: String) -> NSImage? { // Resources are at bundle root (process() flattens subdirectories) - guard let url = Bundle.module.url( + guard let url = KeyPathAppKitResources.url( forResource: name, withExtension: "png" ) else { diff --git a/Sources/KeyPathAppKit/UI/Overlay/KeyboardSelectionGridView.swift b/Sources/KeyPathAppKit/UI/Overlay/KeyboardSelectionGridView.swift index 139e2553d..c6af1f6e0 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/KeyboardSelectionGridView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/KeyboardSelectionGridView.swift @@ -537,7 +537,7 @@ private struct KeyboardIllustrationCard: View { private var keyboardImage: some View { // Images are at bundle root (not in subdirectory) due to .process() flattening // Same pattern as SVG loading in LiveKeyboardOverlayView - let imageURL = Bundle.module.url( + let imageURL = KeyPathAppKitResources.url( forResource: layout.id, withExtension: "png" ) ?? Bundle.main.url( diff --git a/Sources/KeyPathAppKit/UI/Overlay/KeymapCard.swift b/Sources/KeyPathAppKit/UI/Overlay/KeymapCard.swift index fcb388674..88dc11d44 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/KeymapCard.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/KeymapCard.swift @@ -63,7 +63,7 @@ struct KeymapCard: View { private func loadSVG() { // SVGs are at bundle root (not in subdirectory) due to .process() flattening - guard let svgURL = Bundle.module.url( + guard let svgURL = KeyPathAppKitResources.url( forResource: keymap.iconFilename, withExtension: "svg" ) else { return } diff --git a/Sources/KeyPathAppKit/UI/Overlay/LauncherStore.swift b/Sources/KeyPathAppKit/UI/Overlay/LauncherStore.swift index cfcacb846..cbe4fbe0d 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LauncherStore.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LauncherStore.swift @@ -132,7 +132,7 @@ final class LauncherStore { ] for path in paths { - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { return true } } diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift index e5754f631..07afec268 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayController.swift @@ -1,4 +1,5 @@ import AppKit +import Foundation import KeyPathCore import KeyPathWizardCore import SwiftUI @@ -147,21 +148,21 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } private func setupOpenOverlayWithMapperObserver() { - NotificationCenter.default.addObserver( - forName: .openOverlayWithMapper, + Foundation.NotificationCenter.default.addObserver( + forName: Foundation.Notification.Name.openOverlayWithMapper, object: nil, - queue: .main - ) { [weak self] _ in + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] (_: Foundation.Notification) in Task { @MainActor in self?.openWithMapperTab() } } - NotificationCenter.default.addObserver( - forName: .openOverlayWithMapperPreset, + Foundation.NotificationCenter.default.addObserver( + forName: Foundation.Notification.Name.openOverlayWithMapperPreset, object: nil, - queue: .main - ) { [weak self] notification in + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] (notification: Foundation.Notification) in // Extract sendable values before entering Task to avoid data race let inputKey = notification.userInfo?["inputKey"] as? String let outputKey = notification.userInfo?["outputKey"] as? String @@ -195,11 +196,11 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } private func setupAccessibilityTestModeObserver() { - NotificationCenter.default.addObserver( - forName: .accessibilityTestModeChanged, + Foundation.NotificationCenter.default.addObserver( + forName: Foundation.Notification.Name.accessibilityTestModeChanged, object: nil, - queue: .main - ) { [weak self] _ in + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] (_: Foundation.Notification) in Task { @MainActor in self?.recreateWindowForTestModeChange() } @@ -256,9 +257,15 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { openInspector(animated: true) // Post notification for view to switch to mapper tab - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - NotificationCenter.default.post(name: .switchToMapperTab, object: nil) - } + DispatchQueue.main.asyncAfter( + deadline: .now() + 0.1, + execute: DispatchWorkItem { + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.switchToMapperTab, + object: nil + ) + } + ) } /// Opens the overlay centered on screen with drawer open, mapper tab selected, and preset values @@ -282,7 +289,7 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { openInspector(animated: true) // Post notification for view to switch to mapper tab with preset values - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: DispatchWorkItem { var notificationUserInfo: [String: Any] = [:] if let inputKey { notificationUserInfo["inputKey"] = inputKey @@ -299,12 +306,12 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { if let appDisplayName { notificationUserInfo["appDisplayName"] = appDisplayName } - NotificationCenter.default.post( - name: .switchToMapperTab, + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.switchToMapperTab, object: nil, userInfo: notificationUserInfo.isEmpty ? nil : notificationUserInfo ) - } + }) } // MARK: - Layer State @@ -333,11 +340,11 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } private func setupLayerChangeObserver() { - NotificationCenter.default.addObserver( - forName: .kanataLayerChanged, + Foundation.NotificationCenter.default.addObserver( + forName: Foundation.Notification.Name.kanataLayerChanged, object: nil, - queue: .main - ) { [weak self] notification in + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] (notification: Foundation.Notification) in guard let layerName = notification.userInfo?["layerName"] as? String else { return } let sourceRaw = notification.userInfo?["source"] as? String Task { @MainActor in @@ -348,11 +355,11 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } // Listen for config changes to rebuild layer mapping - NotificationCenter.default.addObserver( - forName: .kanataConfigChanged, + Foundation.NotificationCenter.default.addObserver( + forName: Foundation.Notification.Name.kanataConfigChanged, object: nil, - queue: .main - ) { [weak self] _ in + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] (_: Foundation.Notification) in AppLogger.shared.info("🔔 [OverlayController] Received kanataConfigChanged notification - invalidating layer mappings") Task { @MainActor in self?.viewModel.invalidateLayerMappings() @@ -361,11 +368,11 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } private func setupKeyInputObserver() { - NotificationCenter.default.addObserver( - forName: .kanataKeyInput, + Foundation.NotificationCenter.default.addObserver( + forName: Foundation.Notification.Name.kanataKeyInput, object: nil, - queue: .main - ) { [weak self] notification in + queue: NotificationObserverManager.mainOperationQueue + ) { [weak self] (notification: Foundation.Notification) in let key = notification.userInfo?["key"] as? String let action = notification.userInfo?["action"] as? String Task { @MainActor in @@ -658,7 +665,10 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } // Post notification to show wizard (handled by AppDelegate wiring). - NotificationCenter.default.post(name: .showWizard, object: nil) + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.showWizard, + object: nil + ) withAnimation { uiState.healthIndicatorState = .dismissed @@ -720,7 +730,10 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { } else { // System not ready - launch wizard instead AppLogger.shared.log("⚠️ [OverlayController] Cannot show overlay - Kanata not running, launching wizard") - NotificationCenter.default.post(name: .showWizard, object: nil) + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.showWizard, + object: nil + ) } } } @@ -1049,8 +1062,8 @@ final class LiveKeyboardOverlayController: NSObject, NSWindowDelegate { if let shiftedOutput = kanataViewModel?.underlyingManager.getCustomRule(forInput: inputKey)?.shiftedOutput { userInfo["shiftedOutputKey"] = shiftedOutput } - NotificationCenter.default.post( - name: .mapperDrawerKeySelected, + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.mapperDrawerKeySelected, object: nil, userInfo: userInfo ) diff --git a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift index 00d61f888..16635c5ff 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/LiveKeyboardOverlayView.swift @@ -91,11 +91,11 @@ struct LiveKeyboardOverlayView: View { @AppStorage("launcherWelcomeSeenForBuild") var launcherWelcomeSeenForBuild: String = "" @State var pendingLauncherConfig: LauncherGridConfig? - // MARK: - Service Stopped Alert (Overlay) + // MARK: - Runtime Stopped Alert (Overlay) - @State var showingKanataServiceStoppedAlert = false - @State private var lastKanataServiceIssuePresent = false - @State private var hasSeenHealthyKanataService = false + @State var showingRuntimeStoppedAlert = false + @State private var lastRuntimeIssuePresent = false + @State private var hasSeenHealthyRuntime = false @State private var overlayLaunchTime = Date() @State private var toastManager = WizardToastManager() @State private var lastReloadFailureToastAt: Date? @@ -157,18 +157,18 @@ struct LiveKeyboardOverlayView: View { } } - private func handleKanataServiceIssueChange(_ issues: [WizardIssue]) { - let serviceIssue = issues.first { issue in - if case .component(.kanataService) = issue.identifier { + private func handleRuntimeIssueChange(_ issues: [WizardIssue]) { + let runtimeIssue = issues.first { issue in + if case .component(.keyPathRuntime) = issue.identifier { return true } return false } - let hasServiceIssue = serviceIssue != nil + let hasRuntimeIssue = runtimeIssue != nil - if !hasServiceIssue { + if !hasRuntimeIssue { if let state = MainAppStateController.shared.validationState, state != .checking { - hasSeenHealthyKanataService = true + hasSeenHealthyRuntime = true } } @@ -176,16 +176,16 @@ struct LiveKeyboardOverlayView: View { let timeSinceLaunch = Date().timeIntervalSince(overlayLaunchTime) let wizardOpen = WizardWindowController.shared.isVisible - if hasServiceIssue, - !lastKanataServiceIssuePresent, - hasSeenHealthyKanataService, + if hasRuntimeIssue, + !lastRuntimeIssuePresent, + hasSeenHealthyRuntime, !wizardOpen, timeSinceLaunch > 10 { - showingKanataServiceStoppedAlert = true + showingRuntimeStoppedAlert = true } - lastKanataServiceIssuePresent = hasServiceIssue + lastRuntimeIssuePresent = hasRuntimeIssue } private func openSystemStatusSettings() { @@ -288,7 +288,7 @@ struct LiveKeyboardOverlayView: View { inputSourceDetector.stopMonitoring() }, onLoadCustomRulesState: { loadCustomRulesState() }, - onServiceIssueChange: { handleKanataServiceIssueChange($0) }, + onServiceIssueChange: { handleRuntimeIssueChange($0) }, onConfigValidationFailed: { notification in let errors = notification.userInfo?["errors"] as? [String] ?? [] guard !errors.isEmpty else { return } @@ -395,14 +395,14 @@ struct LiveKeyboardOverlayView: View { .modifier(OverlayDialogsModifier( pendingDeleteRule: $pendingDeleteRule, appRuleDeleteError: $appRuleDeleteError, - showingKanataServiceStoppedAlert: $showingKanataServiceStoppedAlert, + showingRuntimeStoppedAlert: $showingRuntimeStoppedAlert, showingValidationFailureModal: $showingValidationFailureModal, validationFailureErrors: $validationFailureErrors, showResetAllRulesConfirmation: $showResetAllRulesConfirmation, onDeleteAppRule: { keymap, override in deleteAppRule(keymap: keymap, override: override) }, - onRestartKanataService: { + onRestartRuntime: { Task { @MainActor in guard let kanataViewModel else { return } _ = await kanataViewModel.restartKanata( diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayDialogsModifier.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayDialogsModifier.swift index 4fb4f52c7..e2a90c2da 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayDialogsModifier.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayDialogsModifier.swift @@ -5,12 +5,12 @@ import SwiftUI struct OverlayDialogsModifier: ViewModifier { @Binding var pendingDeleteRule: (keymap: AppKeymap, override: AppKeyOverride)? @Binding var appRuleDeleteError: String? - @Binding var showingKanataServiceStoppedAlert: Bool + @Binding var showingRuntimeStoppedAlert: Bool @Binding var showingValidationFailureModal: Bool @Binding var validationFailureErrors: [String] @Binding var showResetAllRulesConfirmation: Bool let onDeleteAppRule: (AppKeymap, AppKeyOverride) -> Void - let onRestartKanataService: () -> Void + let onRestartRuntime: () -> Void let onCopyValidationErrors: () -> Void let onOpenConfig: () -> Void let onOpenDiagnostics: () -> Void @@ -64,21 +64,21 @@ struct OverlayDialogsModifier: ViewModifier { } } ) - // Alert when Kanata stops unexpectedly + // Alert when the runtime stops unexpectedly .alert( - "Kanata Service Stopped", - isPresented: $showingKanataServiceStoppedAlert, + "KeyPath Runtime Stopped", + isPresented: $showingRuntimeStoppedAlert, actions: { - Button("Restart Service") { - showingKanataServiceStoppedAlert = false - onRestartKanataService() + Button("Restart Runtime") { + showingRuntimeStoppedAlert = false + onRestartRuntime() } .accessibilityIdentifier("overlay-kanata-service-stopped-restart-button") Button("Cancel", role: .cancel) {} .accessibilityIdentifier("overlay-kanata-service-stopped-cancel-button") }, message: { - Text("The remapping service stopped unexpectedly.") + Text("The remapping runtime stopped unexpectedly.") } ) // Config validation failure sheet diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+CustomRules.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+CustomRules.swift index f7923b2f7..0ca84363e 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+CustomRules.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+CustomRules.swift @@ -1,3 +1,4 @@ +import Foundation import KeyPathCore import SwiftUI @@ -22,13 +23,13 @@ extension OverlayInspectorPanel { onAddRule: { // Switch to mapper with no app condition (global/everywhere) onSelectSection(.mapper) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - NotificationCenter.default.post( - name: .mapperSetAppCondition, + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: DispatchWorkItem { + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.mapperSetAppCondition, object: nil, userInfo: ["bundleId": "", "displayName": ""] ) - } + }) }, onRuleHover: onRuleHover ) @@ -90,7 +91,7 @@ extension OverlayInspectorPanel { // Open mapper with this app's context and rule preloaded // Use UserDefaults directly since @AppStorage can't be accessed from nested functions UserDefaults.standard.set(InspectorSection.mapper.rawValue, forKey: "inspectorSection") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: DispatchWorkItem { // Convert input key label to keyCode for proper keyboard highlighting let keyCode = LogicalKeymap.keyCode(forQwertyLabel: override.inputKey) ?? 0 let userInfo: [String: Any] = [ @@ -100,35 +101,35 @@ extension OverlayInspectorPanel { "appBundleId": keymap.mapping.bundleIdentifier, "appDisplayName": keymap.mapping.displayName ] - NotificationCenter.default.post( - name: .mapperDrawerKeySelected, + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.mapperDrawerKeySelected, object: nil, userInfo: userInfo ) - } + }) } private func addRuleForApp(keymap: AppKeymap) { // Open mapper with this app's context (no rule preloaded) // Use UserDefaults directly since @AppStorage can't be accessed from nested functions UserDefaults.standard.set(InspectorSection.mapper.rawValue, forKey: "inspectorSection") - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: DispatchWorkItem { // Set the app condition on the mapper view model - NotificationCenter.default.post( - name: .mapperSetAppCondition, + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.mapperSetAppCondition, object: nil, userInfo: [ "bundleId": keymap.mapping.bundleIdentifier, "displayName": keymap.mapping.displayName ] ) - } + }) } private func editGlobalRule(rule: CustomRule) { // Open mapper with the global rule preloaded (no app condition) onSelectSection(.mapper) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: DispatchWorkItem { // Convert input key label to keyCode for proper keyboard highlighting let keyCode = LogicalKeymap.keyCode(forQwertyLabel: rule.input) ?? 0 var userInfo: [String: Any] = [ @@ -140,11 +141,11 @@ extension OverlayInspectorPanel { if let shiftedOutput = rule.shiftedOutput { userInfo["shiftedOutputKey"] = shiftedOutput } - NotificationCenter.default.post( - name: .mapperDrawerKeySelected, + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.mapperDrawerKeySelected, object: nil, userInfo: userInfo ) - } + }) } } diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+LauncherCustomize.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+LauncherCustomize.swift index aaad7e669..51d4b49a7 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+LauncherCustomize.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayInspectorPanel+LauncherCustomize.swift @@ -220,7 +220,10 @@ extension OverlayInspectorPanel { collections[index] = collection try? await RuleCollectionStore.shared.saveCollections(collections) // Notify that rules changed so config regenerates - NotificationCenter.default.post(name: .ruleCollectionsChanged, object: nil) + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.ruleCollectionsChanged, + object: nil + ) } } } @@ -263,7 +266,10 @@ extension OverlayInspectorPanel { collection.configuration = .launcherGrid(config) collections[index] = collection try? await RuleCollectionStore.shared.saveCollections(collections) - NotificationCenter.default.post(name: .ruleCollectionsChanged, object: nil) + Foundation.NotificationCenter.default.post( + name: Foundation.Notification.Name.ruleCollectionsChanged, + object: nil + ) } } } diff --git a/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection+LaunchApps.swift b/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection+LaunchApps.swift index 4e8df2b5f..e6884a2bb 100644 --- a/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection+LaunchApps.swift +++ b/Sources/KeyPathAppKit/UI/Overlay/OverlayMapperSection+LaunchApps.swift @@ -161,6 +161,6 @@ extension OverlayMapperSection { let path = url.standardizedFileURL.path return path.hasPrefix("/Applications/") || path.hasPrefix("/System/Applications/") || - path.hasPrefix(FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Applications").path) + path.hasPrefix(Foundation.FileManager().homeDirectoryForCurrentUser.appendingPathComponent("Applications").path) } } diff --git a/Sources/KeyPathAppKit/UI/Rules/AppLaunchChip.swift b/Sources/KeyPathAppKit/UI/Rules/AppLaunchChip.swift index 0ff990af2..c5ec25897 100644 --- a/Sources/KeyPathAppKit/UI/Rules/AppLaunchChip.swift +++ b/Sources/KeyPathAppKit/UI/Rules/AppLaunchChip.swift @@ -65,7 +65,7 @@ struct AppLaunchChip: View { for path in commonPaths { let url = URL(fileURLWithPath: path) - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { loadFromURL(url) return } diff --git a/Sources/KeyPathAppKit/UI/Rules/LauncherCollectionView.swift b/Sources/KeyPathAppKit/UI/Rules/LauncherCollectionView.swift index 952bbe436..9aa9503e6 100644 --- a/Sources/KeyPathAppKit/UI/Rules/LauncherCollectionView.swift +++ b/Sources/KeyPathAppKit/UI/Rules/LauncherCollectionView.swift @@ -509,7 +509,7 @@ private struct LauncherMappingEditor: View { // Sync state with security service isScriptExecutionEnabled = ScriptSecurityService.shared.isScriptExecutionEnabled } - .onReceive(NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)) { _ in + .onReceive(NotificationObserverManager.defaultCenter.publisher(for: UserDefaults.didChangeNotification)) { _ in // Update when settings change let newValue = ScriptSecurityService.shared.isScriptExecutionEnabled if isScriptExecutionEnabled != newValue { @@ -572,7 +572,7 @@ private struct LauncherMappingEditor: View { let expandedPath = (folderPath as NSString).expandingTildeInPath var isDirectory: ObjCBool = false - if !FileManager.default.fileExists(atPath: expandedPath, isDirectory: &isDirectory) { + if !Foundation.FileManager().fileExists(atPath: expandedPath, isDirectory: &isDirectory) { return "Folder not found at this path" } @@ -589,14 +589,14 @@ private struct LauncherMappingEditor: View { let expandedPath = (scriptPath as NSString).expandingTildeInPath - if !FileManager.default.fileExists(atPath: expandedPath) { + if !Foundation.FileManager().fileExists(atPath: expandedPath) { return "Script not found at this path" } // Check if it's a recognized script type or executable let securityService = ScriptSecurityService.shared let isScript = securityService.isRecognizedScript(expandedPath) - let isExecutable = FileManager.default.isExecutableFile(atPath: expandedPath) + let isExecutable = Foundation.FileManager().isExecutableFile(atPath: expandedPath) if !isScript, !isExecutable { return "File may not be executable" @@ -630,7 +630,7 @@ private struct LauncherMappingEditor: View { private func openSettings() { // Open Settings window on General tab (where script execution toggle is) - NotificationCenter.default.post(name: .openSettingsGeneral, object: nil) + NotificationObserverManager.defaultCenter.post(name: Notification.Name.openSettingsGeneral, object: nil) // Also open the settings window if it's not already open NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) } diff --git a/Sources/KeyPathAppKit/UI/RulesSummaryAppLaunchChip.swift b/Sources/KeyPathAppKit/UI/RulesSummaryAppLaunchChip.swift index 72fb4fad6..d28ec8a56 100644 --- a/Sources/KeyPathAppKit/UI/RulesSummaryAppLaunchChip.swift +++ b/Sources/KeyPathAppKit/UI/RulesSummaryAppLaunchChip.swift @@ -67,7 +67,7 @@ struct RulesSummaryAppLaunchChip: View { for path in commonPaths { let url = URL(fileURLWithPath: path) - if FileManager.default.fileExists(atPath: path) { + if Foundation.FileManager().fileExists(atPath: path) { loadFromURL(url) return } diff --git a/Sources/KeyPathAppKit/UI/RulesSummaryView+CollectionRow.swift b/Sources/KeyPathAppKit/UI/RulesSummaryView+CollectionRow.swift index b99abbc4c..410f5049e 100644 --- a/Sources/KeyPathAppKit/UI/RulesSummaryView+CollectionRow.swift +++ b/Sources/KeyPathAppKit/UI/RulesSummaryView+CollectionRow.swift @@ -618,11 +618,11 @@ struct ExpandableCollectionRow: View { .frame(width: iconSize, height: iconSize) } else if icon.hasPrefix("resource:") { let resourceName = String(icon.dropFirst(9)) - // Try Bundle.module first (Swift Package resources), then Bundle.main + // Try the app kit resource bundle first, then Bundle.main. // Support both SVG and PNG formats - let resourceURL = Bundle.module.url(forResource: resourceName, withExtension: "svg") + let resourceURL = KeyPathAppKitResources.url(forResource: resourceName, withExtension: "svg") ?? Bundle.main.url(forResource: resourceName, withExtension: "svg") - ?? Bundle.module.url(forResource: resourceName, withExtension: "png") + ?? KeyPathAppKitResources.url(forResource: resourceName, withExtension: "png") ?? Bundle.main.url(forResource: resourceName, withExtension: "png") if let url = resourceURL, let image = NSImage(contentsOf: url) { Image(nsImage: image) diff --git a/Sources/KeyPathAppKit/UI/SettingsView+General.swift b/Sources/KeyPathAppKit/UI/SettingsView+General.swift index 1b79af1c3..22ae18067 100644 --- a/Sources/KeyPathAppKit/UI/SettingsView+General.swift +++ b/Sources/KeyPathAppKit/UI/SettingsView+General.swift @@ -269,7 +269,7 @@ struct VerboseLoggingToggle: View { Button("Later", role: .cancel) {} Button("Restart Now") { Task { - await restartKanataService() + await restartKeyPathRuntime() } } } message: { @@ -279,11 +279,11 @@ struct VerboseLoggingToggle: View { } } - private func restartKanataService() async { - AppLogger.shared.log("\u{1F504} [VerboseLogging] Restarting Kanata service with new logging flags") + private func restartKeyPathRuntime() async { + AppLogger.shared.log("\u{1F504} [VerboseLogging] Restarting KeyPath Runtime with new logging flags") let success = await kanataManager.restartKanata(reason: "Verbose logging toggle") if !success { - AppLogger.shared.error("\u{274C} [VerboseLogging] Kanata restart failed after verbose toggle") + AppLogger.shared.error("\u{274C} [VerboseLogging] KeyPath Runtime restart failed after verbose toggle") } } } diff --git a/Sources/KeyPathAppKit/UI/SettingsView+StatusDetails.swift b/Sources/KeyPathAppKit/UI/SettingsView+StatusDetails.swift index 194583e50..db1c5a275 100644 --- a/Sources/KeyPathAppKit/UI/SettingsView+StatusDetails.swift +++ b/Sources/KeyPathAppKit/UI/SettingsView+StatusDetails.swift @@ -88,7 +88,7 @@ extension StatusSettingsTabView { if context.services.kanataRunning { return "Service Running" } - if context.components.launchDaemonServicesHealthy || context.services.karabinerDaemonRunning { + if context.services.karabinerDaemonRunning { return "Service Starting" } return "Service Stopped" @@ -133,7 +133,7 @@ extension StatusSettingsTabView { var serviceStatusDetail: StatusDetail { guard let context = systemContext else { return StatusDetail( - title: "Kanata Service", + title: "KeyPath Runtime", message: "Checking current status…", icon: "ellipsis.circle", level: .info @@ -141,17 +141,23 @@ extension StatusSettingsTabView { } if context.services.kanataRunning { + let runtimeMessage = + if let runtimePathTitle = context.services.activeRuntimePathTitle { + "Running via \(runtimePathTitle.lowercased())." + } else { + "Running normally." + } return StatusDetail( - title: "Kanata Service", - message: "Running normally.", + title: "KeyPath Runtime", + message: "\(runtimeMessage) Powered by Kanata.", icon: "bolt.fill", level: .success ) } - if context.components.launchDaemonServicesHealthy || context.services.karabinerDaemonRunning { + if context.services.karabinerDaemonRunning { return StatusDetail( - title: "Kanata Service", + title: "KeyPath Runtime", message: "Starting…", icon: "hourglass.circle", level: .info @@ -159,8 +165,8 @@ extension StatusSettingsTabView { } return StatusDetail( - title: "Kanata Service", - message: "Service is stopped. Use the switch above to turn it on.", + title: "KeyPath Runtime", + message: "Runtime is stopped. Use the switch above to turn it on.", icon: "pause.circle", level: .warning, actions: [ diff --git a/Sources/KeyPathAppKit/UI/SettingsView.swift b/Sources/KeyPathAppKit/UI/SettingsView.swift index 8d1beec57..cb3be51ba 100644 --- a/Sources/KeyPathAppKit/UI/SettingsView.swift +++ b/Sources/KeyPathAppKit/UI/SettingsView.swift @@ -154,12 +154,33 @@ struct StatusSettingsTabView: View { .toggleStyle(.switch) .controlSize(.large) .accessibilityIdentifier("status-service-toggle") - .accessibilityLabel("Kanata Service") + .accessibilityLabel("KeyPath Runtime") Text(effectiveServiceRunning ? "ON" : "OFF") .font(.body.weight(.medium)) .foregroundColor(effectiveServiceRunning ? .green : .secondary) } + + if let runtimePathTitle = kanataManager.activeRuntimePathTitle { + VStack(spacing: 4) { + Text(runtimePathTitle) + .font(.footnote.weight(.semibold)) + .foregroundColor(.secondary) + + if let runtimePathDetail = kanataManager.activeRuntimePathDetail { + Text(runtimePathDetail) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: 220) + .accessibilityIdentifier("status-runtime-path") + .accessibilityLabel( + "Active runtime path: \(runtimePathTitle)\(kanataManager.activeRuntimePathDetail.map { ", \($0)" } ?? "")" + ) + } } .frame(minWidth: 220) @@ -292,7 +313,7 @@ struct StatusSettingsTabView: View { // If services look “starting” (daemons loaded/healthy but kanata not yet running), retry once shortly. if !context.services.kanataRunning, - context.components.launchDaemonServicesHealthy || context.services.karabinerDaemonRunning, + context.services.karabinerDaemonRunning, refreshRetryScheduled == false { refreshRetryScheduled = true @@ -339,7 +360,7 @@ struct StatusSettingsTabView: View { private func checkTCPConfiguration() async -> Bool { // Keep this fast and predictable: only verify the active plist contains a --port argument. let plistPath = KanataDaemonManager.getActivePlistPath() - guard FileManager.default.fileExists(atPath: plistPath) else { return false } + guard Foundation.FileManager().fileExists(atPath: plistPath) else { return false } let args: [String] do { diff --git a/Sources/KeyPathAppKit/UI/SimpleLogViewer.swift b/Sources/KeyPathAppKit/UI/SimpleLogViewer.swift index fcec18850..b9224625f 100644 --- a/Sources/KeyPathAppKit/UI/SimpleLogViewer.swift +++ b/Sources/KeyPathAppKit/UI/SimpleLogViewer.swift @@ -140,7 +140,7 @@ struct SimpleLogViewer: View { ] for (editorPath, args) in editors { - if FileManager.default.fileExists(atPath: editorPath) { + if Foundation.FileManager().fileExists(atPath: editorPath) { let process = Process() process.executableURL = URL(fileURLWithPath: editorPath) process.arguments = args diff --git a/Sources/KeyPathAppKit/UI/Status/SettingsSystemStatusRows.swift b/Sources/KeyPathAppKit/UI/Status/SettingsSystemStatusRows.swift index 36e4a2aaf..0d5bf24b5 100644 --- a/Sources/KeyPathAppKit/UI/Status/SettingsSystemStatusRows.swift +++ b/Sources/KeyPathAppKit/UI/Status/SettingsSystemStatusRows.swift @@ -99,7 +99,7 @@ enum SettingsSystemStatusRowsBuilder { ) ) - // 5) Kanata Service + // 5) KeyPath Runtime let daemonIssues = wizardIssues.filter(\.identifier.isDaemon) let blockingPermissionIssue = ServiceStatusEvaluator.blockingIssueMessage(from: wizardIssues) let serviceStatus: InstallationStatus = { @@ -124,7 +124,7 @@ enum SettingsSystemStatusRowsBuilder { rows.append( SettingsSystemStatusRowModel( id: "kanata-service", - title: "Kanata Service", + title: "KeyPath Runtime", icon: "app.badge.checkmark", status: serviceStatus, targetPage: .service, @@ -138,8 +138,7 @@ enum SettingsSystemStatusRowsBuilder { if issue.category == .installation { switch issue.identifier { case .component(.kanataBinaryMissing), - .component(.kanataService), - .component(.orphanedKanataProcess): + .component(.keyPathRuntime): return true default: return false diff --git a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift index 2a604ece2..cc94ecdf2 100644 --- a/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift +++ b/Sources/KeyPathAppKit/UI/ViewModels/KanataViewModel.swift @@ -31,6 +31,8 @@ class KanataViewModel { var diagnostics: [KanataDiagnostic] = [] var lastProcessExitCode: Int32? var lastConfigUpdate: Date = .init() + var activeRuntimePathTitle: String? + var activeRuntimePathDetail: String? // UI State Properties (Legacy state removed - use InstallerEngine/SystemContext) // Removed: errorReason, showWizard, launchFailureStatus @@ -117,6 +119,8 @@ class KanataViewModel { diagnostics = state.diagnostics lastProcessExitCode = state.lastProcessExitCode lastConfigUpdate = state.lastConfigUpdate + activeRuntimePathTitle = state.activeRuntimePathTitle + activeRuntimePathDetail = state.activeRuntimePathDetail saveStatus = state.saveStatus // Note: emergencyStopActivated is managed locally in ViewModel, not synced from manager @@ -479,8 +483,8 @@ class KanataViewModel { await manager.restartKanata(reason: reason) } - func currentServiceState() async -> KanataService.ServiceState { - await manager.currentServiceState() + func currentRuntimeStatus() async -> RuntimeCoordinator.RuntimeStatus { + await manager.currentRuntimeStatus() } // MARK: - Rule Conflict Resolution diff --git a/Sources/KeyPathAppKit/Utilities/BuildInfo.swift b/Sources/KeyPathAppKit/Utilities/BuildInfo.swift index 30edf3017..b0fc710a8 100644 --- a/Sources/KeyPathAppKit/Utilities/BuildInfo.swift +++ b/Sources/KeyPathAppKit/Utilities/BuildInfo.swift @@ -62,7 +62,7 @@ struct BuildInfo { ] for path in paths { - guard FileManager.default.fileExists(atPath: path) else { continue } + guard Foundation.FileManager().fileExists(atPath: path) else { continue } do { let result = try await SubprocessRunner.shared.run( diff --git a/Sources/KeyPathAppKit/Utilities/DependencyInjection.swift b/Sources/KeyPathAppKit/Utilities/DependencyInjection.swift index e7fe9db48..adb967c6e 100644 --- a/Sources/KeyPathAppKit/Utilities/DependencyInjection.swift +++ b/Sources/KeyPathAppKit/Utilities/DependencyInjection.swift @@ -48,18 +48,3 @@ extension EnvironmentValues { } } -// MARK: - Privileged Operations DI - -/// EnvironmentKey for PrivilegedOperations (DI) -private struct PrivilegedOperationsKey: EnvironmentKey { - static var defaultValue: any PrivilegedOperations { - HelperBackedPrivilegedOperations() - } -} - -extension EnvironmentValues { - var privilegedOperations: any PrivilegedOperations { - get { self[PrivilegedOperationsKey.self] } - set { self[PrivilegedOperationsKey.self] = newValue } - } -} diff --git a/Sources/KeyPathAppKit/Utilities/NotificationObserverManager.swift b/Sources/KeyPathAppKit/Utilities/NotificationObserverManager.swift index 14ad4a2af..1bd47d347 100644 --- a/Sources/KeyPathAppKit/Utilities/NotificationObserverManager.swift +++ b/Sources/KeyPathAppKit/Utilities/NotificationObserverManager.swift @@ -27,6 +27,12 @@ import Foundation /// /// For `@MainActor` types, use the `@MainActor` variant methods. public final class NotificationObserverManager: @unchecked Sendable { + public static let mainOperationQueue: OperationQueue? = nil + public static let defaultCenter: NotificationCenter = { + NotificationCenter.perform(NSSelectorFromString("defaultCenter"))? + .takeUnretainedValue() as? NotificationCenter ?? NotificationCenter() + }() + /// Stored observer with its associated notification center private struct StoredObserver { let observer: NSObjectProtocol @@ -58,8 +64,8 @@ public final class NotificationObserverManager: @unchecked Sendable { public func observe( _ name: Notification.Name, object: Any? = nil, - queue: OperationQueue? = .main, - center: NotificationCenter = .default, + queue: OperationQueue? = NotificationObserverManager.mainOperationQueue, + center: NotificationCenter = NotificationObserverManager.defaultCenter, handler: @escaping @Sendable (Notification) -> Void ) { let observer = center.addObserver( @@ -83,7 +89,7 @@ public final class NotificationObserverManager: @unchecked Sendable { public func observeUserInfo( _ name: Notification.Name, object: Any? = nil, - queue: OperationQueue? = .main, + queue: OperationQueue? = NotificationObserverManager.mainOperationQueue, handler: @escaping @Sendable ([AnyHashable: Any]?) -> Void ) { observe(name, object: object, queue: queue) { notification in diff --git a/Sources/KeyPathAppKit/Utilities/Notifications.swift b/Sources/KeyPathAppKit/Utilities/Notifications.swift index c84822d77..976f554ba 100644 --- a/Sources/KeyPathAppKit/Utilities/Notifications.swift +++ b/Sources/KeyPathAppKit/Utilities/Notifications.swift @@ -36,6 +36,9 @@ extension Notification.Name { /// Kanata service health static let kanataCrashLoopDetected = Notification.Name("KeyPath.Kanata.CrashLoopDetected") + static let splitRuntimeHostExited = Notification.Name("KeyPath.SplitRuntime.HostExited") + static let exerciseCoordinatorSplitRuntimeRecovery = Notification.Name("KeyPath.SplitRuntime.ExerciseCoordinatorRecovery") + static let exerciseCoordinatorSplitRuntimeRestartSoak = Notification.Name("KeyPath.SplitRuntime.ExerciseCoordinatorRestartSoak") /// Rule collections static let ruleCollectionsChanged = Notification.Name("KeyPath.RuleCollections.Changed") diff --git a/Sources/KeyPathAppKit/Utilities/OneShotProbeEnvironment.swift b/Sources/KeyPathAppKit/Utilities/OneShotProbeEnvironment.swift new file mode 100644 index 000000000..0790f5c99 --- /dev/null +++ b/Sources/KeyPathAppKit/Utilities/OneShotProbeEnvironment.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Single source of truth for detecting one-shot probe mode. +/// +/// One-shot probe mode is active when the app was launched with one of the +/// special environment variables that trigger a single diagnostic action and +/// then exit. In this mode, heavy initialization (bootstrap, event monitoring, +/// notification registration, etc.) is skipped. +enum OneShotProbeEnvironment { + static let hostPassthruDiagnosticEnvKey = "KEYPATH_ENABLE_HOST_PASSTHRU_DIAGNOSTIC" + static let hostPassthruBridgePrepEnvKey = "KEYPATH_PREPARE_HOST_PASSTHRU_BRIDGE" + static let helperRepairEnvKey = "KEYPATH_RUN_HELPER_REPAIR" + static let companionRestartProbeEnvKey = "KEYPATH_EXERCISE_OUTPUT_BRIDGE_COMPANION_RESTART" + + static func isActive(_ environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment[hostPassthruDiagnosticEnvKey] == "1" + || environment[hostPassthruBridgePrepEnvKey] == "1" + || environment[helperRepairEnvKey] == "1" + || environment[companionRestartProbeEnvKey] == "1" + } +} diff --git a/Sources/KeyPathAppKit/Utilities/SingleInstanceCoordinator.swift b/Sources/KeyPathAppKit/Utilities/SingleInstanceCoordinator.swift new file mode 100644 index 000000000..ac335c3b8 --- /dev/null +++ b/Sources/KeyPathAppKit/Utilities/SingleInstanceCoordinator.swift @@ -0,0 +1,56 @@ +import AppKit +import KeyPathCore + +struct SingleInstanceCoordinator { + struct Candidate: Equatable { + let pid: pid_t + let bundleIdentifier: String? + let isTerminated: Bool + } + + static func existingInstancePID( + currentPID: pid_t, + bundleIdentifier: String, + candidates: [Candidate] + ) -> pid_t? { + candidates + .filter { candidate in + candidate.pid != currentPID + && candidate.bundleIdentifier == bundleIdentifier + && candidate.isTerminated == false + } + .map(\.pid) + .sorted() + .first + } + + @MainActor + @discardableResult + static func activateExistingAndTerminateIfNeeded( + bundleIdentifier: String, + currentPID: pid_t = getpid() + ) -> Bool { + let runningApps = NSRunningApplication.runningApplications(withBundleIdentifier: bundleIdentifier) + let liveApps = runningApps.filter { app in + app.processIdentifier != currentPID && app.isTerminated == false + } + + guard + let existingApp = liveApps + .sorted(by: { $0.processIdentifier < $1.processIdentifier }) + .first + else { + return false + } + + AppLogger.shared.warn( + "🪟 [App] Another KeyPath instance is already running (pid=\(existingApp.processIdentifier)); activating it and terminating the new launch" + ) + let options: NSApplication.ActivationOptions = [.activateAllWindows] + existingApp.activate(options: options) + DispatchQueue.main.async { + NSApplication.shared.terminate(nil) + } + return true + } +} diff --git a/Sources/KeyPathCLI/Commands/ApplyCommand.swift b/Sources/KeyPathCLI/Commands/ApplyCommand.swift index b55b09e40..d8a2fa954 100644 --- a/Sources/KeyPathCLI/Commands/ApplyCommand.swift +++ b/Sources/KeyPathCLI/Commands/ApplyCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -extension KeyPathTool { +extension KeyPathCLI { struct Apply: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Regenerate config and reload Kanata" diff --git a/Sources/KeyPathCLI/Commands/ConfigCommand.swift b/Sources/KeyPathCLI/Commands/ConfigCommand.swift index 1eacc7dd4..073ae7304 100644 --- a/Sources/KeyPathCLI/Commands/ConfigCommand.swift +++ b/Sources/KeyPathCLI/Commands/ConfigCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -extension KeyPathTool { +extension KeyPathCLI { struct Config: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Inspect and manage Kanata configuration", diff --git a/Sources/KeyPathCLI/Commands/RemapCommand.swift b/Sources/KeyPathCLI/Commands/RemapCommand.swift index b3044e822..9cb5a3e45 100644 --- a/Sources/KeyPathCLI/Commands/RemapCommand.swift +++ b/Sources/KeyPathCLI/Commands/RemapCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -extension KeyPathTool { +extension KeyPathCLI { struct Remap: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Create or modify key remappings" diff --git a/Sources/KeyPathCLI/Commands/RulesCommand.swift b/Sources/KeyPathCLI/Commands/RulesCommand.swift index 235f78ddf..353caace8 100644 --- a/Sources/KeyPathCLI/Commands/RulesCommand.swift +++ b/Sources/KeyPathCLI/Commands/RulesCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -extension KeyPathTool { +extension KeyPathCLI { struct Rules: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Manage rule collections", diff --git a/Sources/KeyPathCLI/Commands/StatusCommand.swift b/Sources/KeyPathCLI/Commands/StatusCommand.swift index 9979dedeb..d22eb4b25 100644 --- a/Sources/KeyPathCLI/Commands/StatusCommand.swift +++ b/Sources/KeyPathCLI/Commands/StatusCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -extension KeyPathTool { +extension KeyPathCLI { struct Status: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Check system status and health" diff --git a/Sources/KeyPathCLI/Commands/TCPCommand.swift b/Sources/KeyPathCLI/Commands/TCPCommand.swift index b6b3e380a..1a986b689 100644 --- a/Sources/KeyPathCLI/Commands/TCPCommand.swift +++ b/Sources/KeyPathCLI/Commands/TCPCommand.swift @@ -2,7 +2,7 @@ import ArgumentParser import Foundation import KeyPathAppKit -extension KeyPathTool { +extension KeyPathCLI { struct TCP: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Query running Kanata via TCP", diff --git a/Sources/KeyPathCLI/KeyPathTool.swift b/Sources/KeyPathCLI/KeyPathTool.swift index b339b1345..9ef3a6b4a 100644 --- a/Sources/KeyPathCLI/KeyPathTool.swift +++ b/Sources/KeyPathCLI/KeyPathTool.swift @@ -3,9 +3,9 @@ import Foundation import KeyPathAppKit @main -struct KeyPathTool: AsyncParsableCommand { +struct KeyPathCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "keypath", + commandName: "keypath-cli", abstract: "KeyPath keyboard configuration CLI", version: CLIVersion.current, subcommands: [ diff --git a/Sources/KeyPathCore/ExperimentalHostPassthruInput.swift b/Sources/KeyPathCore/ExperimentalHostPassthruInput.swift new file mode 100644 index 000000000..689720411 --- /dev/null +++ b/Sources/KeyPathCore/ExperimentalHostPassthruInput.swift @@ -0,0 +1,50 @@ +import Foundation + +public struct ExperimentalHostPassthruInputEvent: Equatable, Sendable { + public let value: UInt64 + public let usagePage: UInt32 + public let usage: UInt32 + + public init(value: UInt64, usagePage: UInt32, usage: UInt32) { + self.value = value + self.usagePage = usagePage + self.usage = usage + } +} + +public enum ExperimentalHostPassthruInputMapper { + private static let keyboardUsagePage: UInt32 = 0x07 + + // Minimal US ANSI virtual-keycode -> HID usage mapping for experimental host capture. + private static let keyboardUsages: [Int64: UInt32] = [ + 0: 0x04, 1: 0x16, 2: 0x07, 3: 0x09, 4: 0x0B, 5: 0x0A, 6: 0x1D, 7: 0x1B, + 8: 0x06, 9: 0x19, 11: 0x05, 12: 0x14, 13: 0x1A, 14: 0x08, 15: 0x15, + 16: 0x1C, 17: 0x17, 18: 0x1E, 19: 0x1F, 20: 0x20, 21: 0x21, 22: 0x23, + 23: 0x22, 24: 0x2E, 25: 0x26, 26: 0x24, 27: 0x2D, 28: 0x25, 29: 0x27, + 30: 0x30, 31: 0x12, 32: 0x18, 33: 0x2F, 34: 0x0C, 35: 0x13, 36: 0x28, + 37: 0x0F, 38: 0x0D, 39: 0x34, 40: 0x0E, 41: 0x33, 42: 0x31, 43: 0x2B, + 44: 0x2C, 45: 0x38, 46: 0x10, 47: 0x37, 48: 0x2B, 49: 0x2C, 50: 0x35, + 51: 0x2A, 53: 0x29, 55: 0xE3, 56: 0xE1, 57: 0x39, 58: 0xE2, 59: 0xE0, + 60: 0xE5, 61: 0xE6, 62: 0xE4, 63: 0x3F, 64: 0x53, 65: 0x54, 67: 0x55, + 69: 0x57, 71: 0x46, 72: 0x5F, 73: 0x60, 74: 0x5C, 75: 0x56, 76: 0x58, + 78: 0x57, 79: 0x5D, 80: 0x5E, 81: 0x59, 82: 0x62, 83: 0x63, 84: 0x64, + 85: 0x65, 86: 0x61, 87: 0x66, 88: 0x67, 89: 0x5A, 91: 0x5B, 92: 0x5C, + 96: 0x3E, 97: 0x3F, 98: 0x40, 99: 0x3D, 100: 0x41, 101: 0x42, 103: 0x44, + 105: 0x47, 106: 0x68, 107: 0x46, 109: 0x43, 111: 0x45, 113: 0x49, + 114: 0x4C, 115: 0x4A, 116: 0x4B, 117: 0x4C, 118: 0x3C, 119: 0x4D, + 120: 0x3B, 121: 0x4E, 122: 0x3A, 123: 0x50, 124: 0x4F, 125: 0x51, + 126: 0x52 + ] + + public static func eventForKeyCode(_ keyCode: Int64, isKeyDown: Bool) -> ExperimentalHostPassthruInputEvent? { + guard let usage = keyboardUsages[keyCode] else { + return nil + } + + return ExperimentalHostPassthruInputEvent( + value: isKeyDown ? 1 : 0, + usagePage: keyboardUsagePage, + usage: usage + ) + } +} diff --git a/Sources/KeyPathCore/KanataHostBridge.swift b/Sources/KeyPathCore/KanataHostBridge.swift new file mode 100644 index 000000000..4fdc19c60 --- /dev/null +++ b/Sources/KeyPathCore/KanataHostBridge.swift @@ -0,0 +1,560 @@ +import Darwin +import Foundation + +public enum KanataHostBridgeProbeResult: Equatable, Sendable { + case loaded(version: String, defaultConfigCount: Int) + case unavailable(reason: String) + + public var logSummary: String { + switch self { + case let .loaded(version, defaultConfigCount): + "Host bridge loaded: version=\(version) default_cfg_count=\(defaultConfigCount)" + case let .unavailable(reason): + "Host bridge unavailable: \(reason)" + } + } +} + +public enum KanataHostBridgeValidationResult: Equatable, Sendable { + case valid + case invalid(reason: String) + case unavailable(reason: String) + + public var logSummary: String { + switch self { + case .valid: + "Host bridge validated config successfully" + case let .invalid(reason): + "Host bridge config validation failed: \(reason)" + case let .unavailable(reason): + "Host bridge config validation unavailable: \(reason)" + } + } +} + +public enum KanataHostBridgeRuntimeResult: Equatable, Sendable { + case created(layerCount: Int) + case unavailable(reason: String) + case failed(reason: String) + + public var logSummary: String { + switch self { + case let .created(layerCount): + "Host bridge created runtime successfully: layer_count=\(layerCount)" + case let .unavailable(reason): + "Host bridge runtime creation unavailable: \(reason)" + case let .failed(reason): + "Host bridge runtime creation failed: \(reason)" + } + } +} + +public enum KanataHostBridgeRunResult: Equatable, Sendable { + case completed + case unavailable(reason: String) + case failed(reason: String) + + public var logSummary: String { + switch self { + case .completed: + "Host bridge runtime exited cleanly" + case let .unavailable(reason): + "Host bridge runtime unavailable: \(reason)" + case let .failed(reason): + "Host bridge runtime failed: \(reason)" + } + } +} + +public enum KanataHostBridgePassthruRuntimeResult: Equatable, Sendable { + case created(layerCount: Int) + case unavailable(reason: String) + case failed(reason: String) + + public var logSummary: String { + switch self { + case let .created(layerCount): + "Host bridge passthru runtime created successfully: layer_count=\(layerCount)" + case let .unavailable(reason): + "Host bridge passthru runtime unavailable: \(reason)" + case let .failed(reason): + "Host bridge passthru runtime failed: \(reason)" + } + } +} + +public struct KanataHostBridgePassthruOutputEvent: Equatable, Sendable { + public let value: UInt64 + public let usagePage: UInt32 + public let usage: UInt32 + + public init(value: UInt64, usagePage: UInt32, usage: UInt32) { + self.value = value + self.usagePage = usagePage + self.usage = usage + } +} + +public enum KanataHostBridgePassthruReceiveError: Error, Equatable, Sendable { + case failed(reason: String) + + public var logSummary: String { + switch self { + case let .failed(reason): + "Host bridge passthru receive failed: \(reason)" + } + } +} + +public enum KanataHostBridgePassthruStartError: Error, Equatable, Sendable { + case failed(reason: String) + + public var logSummary: String { + switch self { + case let .failed(reason): + "Host bridge passthru start failed: \(reason)" + } + } +} + +public enum KanataHostBridgePassthruSendInputError: Error, Equatable, Sendable { + case failed(reason: String) + + public var logSummary: String { + switch self { + case let .failed(reason): + "Host bridge passthru send-input failed: \(reason)" + } + } +} + +public final class KanataHostBridgePassthruRuntimeHandle: @unchecked Sendable { + fileprivate typealias DestroyPassthruRuntimeFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void + fileprivate typealias StartPassthruRuntimeFunction = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafeMutablePointer?, + Int + ) -> Bool + fileprivate typealias TryReceivePassthruOutputFunction = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer?, + Int + ) -> Int32 + fileprivate typealias SendPassthruInputFunction = @convention(c) ( + UnsafeMutableRawPointer?, + UInt64, + UInt32, + UInt32, + UnsafeMutablePointer?, + Int + ) -> Bool + + private let bridgeHandle: UnsafeMutableRawPointer + private let runtimeHandle: UnsafeMutableRawPointer + private let destroyRuntime: DestroyPassthruRuntimeFunction + private let startRuntime: StartPassthruRuntimeFunction + private let tryReceiveOutputFunction: TryReceivePassthruOutputFunction + private let sendInputFunction: SendPassthruInputFunction + + fileprivate init( + bridgeHandle: UnsafeMutableRawPointer, + runtimeHandle: UnsafeMutableRawPointer, + destroyRuntime: @escaping DestroyPassthruRuntimeFunction, + startRuntime: @escaping StartPassthruRuntimeFunction, + tryReceiveOutputFunction: @escaping TryReceivePassthruOutputFunction, + sendInputFunction: @escaping SendPassthruInputFunction + ) { + self.bridgeHandle = bridgeHandle + self.runtimeHandle = runtimeHandle + self.destroyRuntime = destroyRuntime + self.startRuntime = startRuntime + self.tryReceiveOutputFunction = tryReceiveOutputFunction + self.sendInputFunction = sendInputFunction + } + + deinit { + destroyRuntime(runtimeHandle) + dlclose(bridgeHandle) + } + + public func start() -> Result { + var errorBuffer = Array(repeating: 0, count: 2048) + let started = startRuntime(runtimeHandle, &errorBuffer, errorBuffer.count) + if started { + return .success(()) + } + return .failure( + .failed(reason: Self.decodeCStringBuffer(errorBuffer) ?? "unknown passthru runtime start error") + ) + } + + public func tryReceiveOutput() -> Result { + var value: UInt64 = 0 + var usagePage: UInt32 = 0 + var usage: UInt32 = 0 + var errorBuffer = Array(repeating: 0, count: 2048) + + let result = tryReceiveOutputFunction( + runtimeHandle, + &value, + &usagePage, + &usage, + &errorBuffer, + errorBuffer.count + ) + + switch result { + case 1: + return .success( + KanataHostBridgePassthruOutputEvent( + value: value, + usagePage: usagePage, + usage: usage + ) + ) + case 0: + return .success(nil) + default: + return .failure( + .failed(reason: Self.decodeCStringBuffer(errorBuffer) ?? "unknown passthru output receive error") + ) + } + } + + public func sendInput( + value: UInt64, + usagePage: UInt32, + usage: UInt32 + ) -> Result { + var errorBuffer = Array(repeating: 0, count: 2048) + let sent = sendInputFunction( + runtimeHandle, + value, + usagePage, + usage, + &errorBuffer, + errorBuffer.count + ) + if sent { + return .success(()) + } + + return .failure( + .failed(reason: Self.decodeCStringBuffer(errorBuffer) ?? "unknown passthru send-input error") + ) + } + + private static func decodeCStringBuffer(_ buffer: [CChar]) -> String? { + let bytes = buffer.map(UInt8.init(bitPattern:)) + let endIndex = bytes.firstIndex(of: 0) ?? bytes.endIndex + let decoded = String(decoding: bytes[.. UnsafePointer? + private typealias DefaultCfgCountFunction = @convention(c) () -> UInt + private typealias ValidateConfigFunction = @convention(c) ( + UnsafePointer?, + UnsafeMutablePointer?, + Int + ) -> Bool + private typealias CreateRuntimeFunction = @convention(c) ( + UnsafePointer?, + UnsafeMutablePointer?, + Int + ) -> UnsafeMutableRawPointer? + private typealias RunRuntimeFunction = @convention(c) ( + UnsafePointer?, + UInt16, + UnsafeMutablePointer?, + Int + ) -> Bool + private typealias RuntimeLayerCountFunction = @convention(c) (UnsafeRawPointer?) -> Int + private typealias DestroyRuntimeFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void + private typealias CreatePassthruRuntimeFunction = @convention(c) ( + UnsafePointer?, + UInt16, + UnsafeMutablePointer?, + Int + ) -> UnsafeMutableRawPointer? + private typealias PassthruRuntimeLayerCountFunction = @convention(c) (UnsafeRawPointer?) -> Int + private typealias DestroyPassthruRuntimeFunction = @convention(c) (UnsafeMutableRawPointer?) -> Void + private typealias StartPassthruRuntimeFunction = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafeMutablePointer?, + Int + ) -> Bool + private typealias SendPassthruInputFunction = @convention(c) ( + UnsafeMutableRawPointer?, + UInt64, + UInt32, + UInt32, + UnsafeMutablePointer?, + Int + ) -> Bool + private typealias TryReceivePassthruOutputFunction = @convention(c) ( + UnsafeMutableRawPointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer?, + UnsafeMutablePointer?, + Int + ) -> Int32 + + public static func probe( + runtimeHost: KanataRuntimeHost, + fileManager: FileManager = .default + ) -> KanataHostBridgeProbeResult { + let path = runtimeHost.bridgeLibraryPath + guard fileManager.fileExists(atPath: path) else { + return .unavailable(reason: "library not found at \(path)") + } + + guard let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) else { + let error = dlerror().map { String(cString: $0) } ?? "unknown error" + return .unavailable(reason: "failed to load \(path): \(error)") + } + defer { dlclose(handle) } + + guard let versionSymbol = dlsym(handle, "keypath_kanata_bridge_version"), + let cfgCountSymbol = dlsym(handle, "keypath_kanata_bridge_default_cfg_count") + else { + let error = dlerror().map { String(cString: $0) } ?? "missing expected symbols" + return .unavailable(reason: error) + } + + let bridgeVersion = unsafeBitCast(versionSymbol, to: VersionFunction.self)() + .map { String(cString: $0) } ?? "unknown" + let defaultCfgCount = Int(unsafeBitCast(cfgCountSymbol, to: DefaultCfgCountFunction.self)()) + return .loaded(version: bridgeVersion, defaultConfigCount: defaultCfgCount) + } + + public static func validateConfig( + runtimeHost: KanataRuntimeHost, + configPath: String, + fileManager: FileManager = .default + ) -> KanataHostBridgeValidationResult { + guard let handle = openBridge(runtimeHost: runtimeHost, fileManager: fileManager) else { + return .unavailable(reason: unavailableReason(runtimeHost: runtimeHost, fileManager: fileManager)) + } + defer { dlclose(handle) } + + guard let validateSymbol = dlsym(handle, "keypath_kanata_bridge_validate_config") else { + let error = dlerror().map { String(cString: $0) } ?? "missing validate symbol" + return .unavailable(reason: error) + } + + let validate = unsafeBitCast(validateSymbol, to: ValidateConfigFunction.self) + var errorBuffer = Array(repeating: 0, count: 2048) + let success = configPath.withCString { configCString in + validate(configCString, &errorBuffer, errorBuffer.count) + } + + if success { + return .valid + } + + let reason = errorBuffer.withUnsafeBufferPointer { buffer in + let bytes = buffer.map(UInt8.init(bitPattern:)) + let endIndex = bytes.firstIndex(of: 0) ?? bytes.endIndex + return String(decoding: bytes[.. KanataHostBridgeRuntimeResult { + guard let handle = openBridge(runtimeHost: runtimeHost, fileManager: fileManager) else { + return .unavailable(reason: unavailableReason(runtimeHost: runtimeHost, fileManager: fileManager)) + } + defer { dlclose(handle) } + + guard let createSymbol = dlsym(handle, "keypath_kanata_bridge_create_runtime"), + let layerCountSymbol = dlsym(handle, "keypath_kanata_bridge_runtime_layer_count"), + let destroySymbol = dlsym(handle, "keypath_kanata_bridge_destroy_runtime") + else { + let error = dlerror().map { String(cString: $0) } ?? "missing runtime symbols" + return .unavailable(reason: error) + } + + let createRuntime = unsafeBitCast(createSymbol, to: CreateRuntimeFunction.self) + let runtimeLayerCount = unsafeBitCast(layerCountSymbol, to: RuntimeLayerCountFunction.self) + let destroyRuntime = unsafeBitCast(destroySymbol, to: DestroyRuntimeFunction.self) + var errorBuffer = Array(repeating: 0, count: 2048) + + let runtime = configPath.withCString { configCString in + createRuntime(configCString, &errorBuffer, errorBuffer.count) + } + + guard let runtime else { + return .failed(reason: decodeCStringBuffer(errorBuffer) ?? "unknown runtime creation error") + } + defer { destroyRuntime(runtime) } + + return .created(layerCount: runtimeLayerCount(runtime)) + } + + public static func runRuntime( + runtimeHost: KanataRuntimeHost, + configPath: String, + tcpPort: UInt16, + fileManager: FileManager = .default + ) -> KanataHostBridgeRunResult { + if let preflightFailure = preflightRunRuntime(fileManager: fileManager) { + return preflightFailure + } + + guard let handle = openBridge(runtimeHost: runtimeHost, fileManager: fileManager) else { + return .unavailable(reason: unavailableReason(runtimeHost: runtimeHost, fileManager: fileManager)) + } + defer { dlclose(handle) } + + guard let runSymbol = dlsym(handle, "keypath_kanata_bridge_run_runtime") else { + let error = dlerror().map { String(cString: $0) } ?? "missing run-runtime symbol" + return .unavailable(reason: error) + } + + let runRuntime = unsafeBitCast(runSymbol, to: RunRuntimeFunction.self) + var errorBuffer = Array(repeating: 0, count: 2048) + let success = configPath.withCString { configCString in + runRuntime(configCString, tcpPort, &errorBuffer, errorBuffer.count) + } + + if success { + return .completed + } + + return .failed(reason: decodeCStringBuffer(errorBuffer) ?? "unknown runtime failure") + } + + public static func createPassthruRuntime( + runtimeHost: KanataRuntimeHost, + configPath: String, + tcpPort: UInt16, + fileManager: FileManager = .default + ) -> ( + result: KanataHostBridgePassthruRuntimeResult, + handle: KanataHostBridgePassthruRuntimeHandle? + ) { + guard let bridgeHandle = openBridge(runtimeHost: runtimeHost, fileManager: fileManager) else { + return ( + .unavailable(reason: unavailableReason(runtimeHost: runtimeHost, fileManager: fileManager)), + nil + ) + } + + guard let createSymbol = dlsym(bridgeHandle, "keypath_kanata_bridge_create_passthru_runtime"), + let layerCountSymbol = dlsym(bridgeHandle, "keypath_kanata_bridge_passthru_runtime_layer_count"), + let startSymbol = dlsym(bridgeHandle, "keypath_kanata_bridge_start_passthru_runtime"), + let sendInputSymbol = dlsym(bridgeHandle, "keypath_kanata_bridge_passthru_send_input"), + let tryReceiveSymbol = dlsym(bridgeHandle, "keypath_kanata_bridge_passthru_try_recv_output"), + let destroySymbol = dlsym(bridgeHandle, "keypath_kanata_bridge_destroy_passthru_runtime") + else { + let error = dlerror().map { String(cString: $0) } ?? "missing passthru runtime symbols" + dlclose(bridgeHandle) + return (.unavailable(reason: error), nil) + } + + let createRuntime = unsafeBitCast(createSymbol, to: CreatePassthruRuntimeFunction.self) + let runtimeLayerCount = unsafeBitCast(layerCountSymbol, to: PassthruRuntimeLayerCountFunction.self) + let startRuntime = unsafeBitCast(startSymbol, to: StartPassthruRuntimeFunction.self) + let sendInput = unsafeBitCast(sendInputSymbol, to: SendPassthruInputFunction.self) + let tryReceiveOutput = unsafeBitCast(tryReceiveSymbol, to: TryReceivePassthruOutputFunction.self) + let destroyRuntime = unsafeBitCast(destroySymbol, to: DestroyPassthruRuntimeFunction.self) + + var errorBuffer = Array(repeating: 0, count: 2048) + let runtimeHandle = configPath.withCString { configCString in + createRuntime(configCString, tcpPort, &errorBuffer, errorBuffer.count) + } + + guard let runtimeHandle else { + let reason = decodeCStringBuffer(errorBuffer) ?? "unknown passthru runtime creation error" + dlclose(bridgeHandle) + return (.failed(reason: reason), nil) + } + + let handle = KanataHostBridgePassthruRuntimeHandle( + bridgeHandle: bridgeHandle, + runtimeHandle: runtimeHandle, + destroyRuntime: destroyRuntime, + startRuntime: startRuntime, + tryReceiveOutputFunction: tryReceiveOutput, + sendInputFunction: sendInput + ) + return (.created(layerCount: runtimeLayerCount(runtimeHandle)), handle) + } + + private static func preflightRunRuntime( + fileManager: FileManager + ) -> KanataHostBridgeRunResult? { + guard geteuid() != 0 else { + return nil + } + + guard fileManager.fileExists(atPath: rootOnlyVHIDDirectory) else { + return nil + } + + guard let attributes = try? fileManager.attributesOfItem(atPath: rootOnlyVHIDDirectory), + let ownerID = attributes[.ownerAccountID] as? NSNumber, + let permissions = attributes[.posixPermissions] as? NSNumber + else { + return .failed( + reason: "could not inspect vhid driver socket directory at \(rootOnlyVHIDDirectory)" + ) + } + + let isRootOwned = ownerID.intValue == 0 + let isRootOnly = permissions.intValue & 0o077 == 0 + if isRootOwned && isRootOnly { + return .failed( + reason: "vhid driver socket directory is root-only at \(rootOnlyVHIDDirectory); bundled host runtime needs a privileged output bridge" + ) + } + + return nil + } + + private static func openBridge( + runtimeHost: KanataRuntimeHost, + fileManager: FileManager + ) -> UnsafeMutableRawPointer? { + let path = runtimeHost.bridgeLibraryPath + guard fileManager.fileExists(atPath: path) else { + return nil + } + return dlopen(path, RTLD_NOW | RTLD_LOCAL) + } + + private static func unavailableReason( + runtimeHost: KanataRuntimeHost, + fileManager: FileManager + ) -> String { + let path = runtimeHost.bridgeLibraryPath + if !fileManager.fileExists(atPath: path) { + return "library not found at \(path)" + } + return "failed to load \(path): \(dlerror().map { String(cString: $0) } ?? "unknown error")" + } + + private static func decodeCStringBuffer(_ buffer: [CChar]) -> String? { + let bytes = buffer.map(UInt8.init(bitPattern:)) + let endIndex = bytes.firstIndex(of: 0) ?? bytes.endIndex + let decoded = String(decoding: bytes[.. KanataOutputBridgeResponse { + let fd = try connect(to: session.socketPath) + defer { close(fd) } + + try send(.handshake(.init(sessionID: session.sessionID, hostPID: session.hostPID)), over: fd) + return try receive(from: fd) + } + + public static func ping(session: KanataOutputBridgeSession) throws -> KanataOutputBridgeResponse { + let fd = try authenticatedConnect(session: session) + defer { close(fd) } + + try send(.ping, over: fd) + return try receive(from: fd) + } + + public static func smokeTest( + session: KanataOutputBridgeSession + ) throws -> (handshake: KanataOutputBridgeResponse, ping: KanataOutputBridgeResponse) { + let handshake = try performHandshake(session: session) + let ping = try ping(session: session) + return (handshake, ping) + } + + public static func emitKey( + _ event: KanataOutputBridgeKeyEvent, + session: KanataOutputBridgeSession + ) throws -> KanataOutputBridgeResponse { + let fd = try authenticatedConnect(session: session) + defer { close(fd) } + + try send(.emitKey(event), over: fd) + return try receive(from: fd) + } + + public static func syncModifiers( + _ state: KanataOutputBridgeModifierState, + session: KanataOutputBridgeSession + ) throws -> KanataOutputBridgeResponse { + let fd = try authenticatedConnect(session: session) + defer { close(fd) } + + try send(.syncModifiers(state), over: fd) + return try receive(from: fd) + } + + public static func reset(session: KanataOutputBridgeSession) throws -> KanataOutputBridgeResponse { + let fd = try authenticatedConnect(session: session) + defer { close(fd) } + + try send(.reset, over: fd) + return try receive(from: fd) + } + + private static func authenticatedConnect(session: KanataOutputBridgeSession) throws -> Int32 { + let fd = try connect(to: session.socketPath) + do { + try send(.handshake(.init(sessionID: session.sessionID, hostPID: session.hostPID)), over: fd) + let response = try receive(from: fd) + guard case .ready = response else { + close(fd) + throw KanataOutputBridgeClientError.unexpectedResponse + } + return fd + } catch { + close(fd) + throw error + } + } + + public static func connect(to socketPath: String) throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw KanataOutputBridgeClientError.socketCreationFailed(errno) + } + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + + let maxCount = MemoryLayout.size(ofValue: address.sun_path) + guard socketPath.utf8.count < maxCount else { + close(fd) + throw KanataOutputBridgeClientError.invalidSocketPath + } + + withUnsafeMutablePointer(to: &address.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + _ = socketPath.withCString { source in + strncpy(raw, source, maxCount - 1) + } + } + + let length = socklen_t(MemoryLayout.size(ofValue: address)) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.connect(fd, sockaddrPtr, length) + } + } + guard result == 0 else { + let code = errno + close(fd) + switch code { + case ENOENT: + throw KanataOutputBridgeClientError.socketNotFound(socketPath) + case ECONNREFUSED: + throw KanataOutputBridgeClientError.socketNotListening(socketPath) + default: + throw KanataOutputBridgeClientError.connectFailed(code) + } + } + + return fd + } + + public static func send(_ request: KanataOutputBridgeRequest, over fd: Int32) throws { + let data = try KanataOutputBridgeCodec.encode(request) + try writeAll(data, to: fd) + } + + public static func receive(from fd: Int32) throws -> KanataOutputBridgeResponse { + let data = try readLine(from: fd) + return try KanataOutputBridgeCodec.decode(data, as: KanataOutputBridgeResponse.self) + } + + private static func writeAll(_ data: Data, to fd: Int32) throws { + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesRemaining = rawBuffer.count + var offset = 0 + + while bytesRemaining > 0 { + let written = Darwin.write(fd, baseAddress.advanced(by: offset), bytesRemaining) + if written < 0 { + throw KanataOutputBridgeClientError.writeFailed(errno) + } + bytesRemaining -= written + offset += written + } + } + } + + private static func readLine(from fd: Int32) throws -> Data { + var buffer = Data() + var byte: UInt8 = 0 + + while true { + let result = Darwin.read(fd, &byte, 1) + if result < 0 { + throw KanataOutputBridgeClientError.readFailed(errno) + } + if result == 0 { + if buffer.isEmpty { + throw KanataOutputBridgeClientError.connectionClosed + } + return buffer + } + + buffer.append(byte) + if byte == 0x0A { + return buffer + } + } + } +} diff --git a/Sources/KeyPathCore/KanataOutputBridgeCodec.swift b/Sources/KeyPathCore/KanataOutputBridgeCodec.swift new file mode 100644 index 000000000..8223ee187 --- /dev/null +++ b/Sources/KeyPathCore/KanataOutputBridgeCodec.swift @@ -0,0 +1,20 @@ +import Foundation + +public enum KanataOutputBridgeCodec { + public static func encode(_ value: Request) throws -> Data { + let data = try JSONEncoder().encode(value) + return data + Data([0x0A]) + } + + public static func decode(_ data: Data, as type: Response.Type) throws + -> Response + { + let payload = + if data.last == 0x0A { + data.dropLast() + } else { + data[...] + } + return try JSONDecoder().decode(Response.self, from: Data(payload)) + } +} diff --git a/Sources/KeyPathCore/KanataOutputBridgeProtocol.swift b/Sources/KeyPathCore/KanataOutputBridgeProtocol.swift new file mode 100644 index 000000000..d2d8535bf --- /dev/null +++ b/Sources/KeyPathCore/KanataOutputBridgeProtocol.swift @@ -0,0 +1,108 @@ +import Foundation + +/// Versioned wire contract for the future privileged Kanata output bridge. +/// +/// These types are transport-agnostic. They can be serialized over a UNIX socket, +/// XPC stream, or another framed IPC transport without changing the higher-level +/// split-runtime contract. +public enum KanataOutputBridgeProtocol { + public static let version = 1 +} + +public struct KanataOutputBridgeHandshake: Codable, Equatable, Sendable { + public let version: Int + public let sessionID: String + public let hostPID: Int32 + + public init( + version: Int = KanataOutputBridgeProtocol.version, + sessionID: String, + hostPID: Int32 + ) { + self.version = version + self.sessionID = sessionID + self.hostPID = hostPID + } +} + +public enum KanataOutputBridgeRequest: Codable, Equatable, Sendable { + case handshake(KanataOutputBridgeHandshake) + case emitKey(KanataOutputBridgeKeyEvent) + case syncModifiers(KanataOutputBridgeModifierState) + case reset + case ping +} + +public enum KanataOutputBridgeResponse: Codable, Equatable, Sendable { + case ready(version: Int) + case acknowledged(sequence: UInt64?) + case error(KanataOutputBridgeError) + case pong +} + +public struct KanataOutputBridgeKeyEvent: Codable, Equatable, Sendable { + public enum Action: String, Codable, Sendable { + case keyDown + case keyUp + } + + public let usagePage: UInt32 + public let usage: UInt32 + public let action: Action + public let sequence: UInt64 + + public init( + usagePage: UInt32, + usage: UInt32, + action: Action, + sequence: UInt64 + ) { + self.usagePage = usagePage + self.usage = usage + self.action = action + self.sequence = sequence + } +} + +public struct KanataOutputBridgeModifierState: Codable, Equatable, Sendable { + public let leftControl: Bool + public let leftShift: Bool + public let leftAlt: Bool + public let leftCommand: Bool + public let rightControl: Bool + public let rightShift: Bool + public let rightAlt: Bool + public let rightCommand: Bool + + public init( + leftControl: Bool = false, + leftShift: Bool = false, + leftAlt: Bool = false, + leftCommand: Bool = false, + rightControl: Bool = false, + rightShift: Bool = false, + rightAlt: Bool = false, + rightCommand: Bool = false + ) { + self.leftControl = leftControl + self.leftShift = leftShift + self.leftAlt = leftAlt + self.leftCommand = leftCommand + self.rightControl = rightControl + self.rightShift = rightShift + self.rightAlt = rightAlt + self.rightCommand = rightCommand + } +} + +public struct KanataOutputBridgeError: Codable, Equatable, Sendable { + public let code: String + public let message: String + public let detail: String? + + public init(code: String, message: String, detail: String? = nil) { + self.code = code + self.message = message + self.detail = detail + } +} diff --git a/Sources/KeyPathCore/KanataOutputBridgeSession.swift b/Sources/KeyPathCore/KanataOutputBridgeSession.swift new file mode 100644 index 000000000..0f70beeba --- /dev/null +++ b/Sources/KeyPathCore/KanataOutputBridgeSession.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Describes a prepared privileged output-bridge session for a future split runtime. +/// +/// The bundled user-session host will eventually connect to `socketPath` to hand +/// remapped output to a privileged companion that can reach pqrs VirtualHID. +public struct KanataOutputBridgeSession: Equatable, Sendable, Codable { + public let sessionID: String + public let socketPath: String + public let socketDirectory: String + public let hostPID: Int32 + public let hostUID: UInt32 + public let hostGID: UInt32 + public let detail: String? + + public init( + sessionID: String, + socketPath: String, + socketDirectory: String, + hostPID: Int32, + hostUID: UInt32, + hostGID: UInt32, + detail: String? = nil + ) { + self.sessionID = sessionID + self.socketPath = socketPath + self.socketDirectory = socketDirectory + self.hostPID = hostPID + self.hostUID = hostUID + self.hostGID = hostGID + self.detail = detail + } +} diff --git a/Sources/KeyPathCore/KanataOutputBridgeStatus.swift b/Sources/KeyPathCore/KanataOutputBridgeStatus.swift new file mode 100644 index 000000000..71d54902d --- /dev/null +++ b/Sources/KeyPathCore/KanataOutputBridgeStatus.swift @@ -0,0 +1,47 @@ +import Foundation + +/// Describes whether the privileged pqrs VirtualHID output boundary is available. +/// +/// This is intentionally transport-agnostic so the app can keep the same contract +/// whether the information comes from `KeyPathHelper` today or a dedicated +/// privileged output companion later. +public struct KanataOutputBridgeStatus: Equatable, Sendable, Codable { + public let available: Bool + public let companionRunning: Bool + public let requiresPrivilegedBridge: Bool + public let socketDirectory: String? + public let detail: String? + + public init( + available: Bool, + companionRunning: Bool, + requiresPrivilegedBridge: Bool, + socketDirectory: String?, + detail: String? + ) { + self.available = available + self.companionRunning = companionRunning + self.requiresPrivilegedBridge = requiresPrivilegedBridge + self.socketDirectory = socketDirectory + self.detail = detail + } + + private enum CodingKeys: String, CodingKey { + case available + case companionRunning + case requiresPrivilegedBridge + case socketDirectory + case detail + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let available = try container.decode(Bool.self, forKey: .available) + self.available = available + self.companionRunning = + try container.decodeIfPresent(Bool.self, forKey: .companionRunning) ?? available + self.requiresPrivilegedBridge = try container.decode(Bool.self, forKey: .requiresPrivilegedBridge) + self.socketDirectory = try container.decodeIfPresent(String.self, forKey: .socketDirectory) + self.detail = try container.decodeIfPresent(String.self, forKey: .detail) + } +} diff --git a/Sources/KeyPathCore/KanataRuntimeHost.swift b/Sources/KeyPathCore/KanataRuntimeHost.swift new file mode 100644 index 000000000..11adee890 --- /dev/null +++ b/Sources/KeyPathCore/KanataRuntimeHost.swift @@ -0,0 +1,75 @@ +import Foundation + +/// Shared description of the current macOS Kanata runtime host layout. +/// +/// This centralizes the app-bundled runtime host identity (`kanata-launcher`) +/// and the core binary paths it can execute. The current architecture still +/// hands off to the raw kanata binary, but callers should stop hardcoding +/// those paths independently so the eventual in-process host migration only +/// needs to change this model. +public struct KanataRuntimeHost: Sendable, Equatable { + public let launcherPath: String + public let bridgeLibraryPath: String + public let bundledCorePath: String + public let systemCorePath: String + + public init( + launcherPath: String, + bridgeLibraryPath: String, + bundledCorePath: String, + systemCorePath: String + ) { + self.launcherPath = launcherPath + self.bridgeLibraryPath = bridgeLibraryPath + self.bundledCorePath = bundledCorePath + self.systemCorePath = systemCorePath + } + + public func preferredCoreBinaryPath( + fileManager: FileManager = .default + ) -> String { + if fileManager.isExecutableFile(atPath: systemCorePath) { + return systemCorePath + } + return bundledCorePath + } + + public static func current( + bundlePath: String = Bundle.main.bundlePath, + systemRoot: String? = nil + ) -> KanataRuntimeHost { + let resolvedBundlePath = resolveAppBundlePath(from: bundlePath) + return KanataRuntimeHost( + launcherPath: "\(resolvedBundlePath)/Contents/Library/KeyPath/kanata-launcher", + bridgeLibraryPath: "\(resolvedBundlePath)/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib", + bundledCorePath: "\(resolvedBundlePath)/Contents/Library/KeyPath/kanata", + systemCorePath: remapSystemPath("/Library/KeyPath/bin/kanata", under: systemRoot) + ) + } + + private static func resolveAppBundlePath(from bundlePath: String) -> String { + let normalizedPath = bundlePath.hasSuffix("/") ? String(bundlePath.dropLast()) : bundlePath + let suffixes = [ + "/Contents/Library/KeyPath", + "/Contents/MacOS" + ] + + for suffix in suffixes where normalizedPath.hasSuffix(suffix) { + return String(normalizedPath.dropLast(suffix.count)) + } + + return normalizedPath + } + + private static func remapSystemPath(_ path: String, under systemRoot: String?) -> String { + guard let systemRoot, !systemRoot.isEmpty, path.hasPrefix("/") else { + return path + } + + let trimmedRoot = systemRoot.hasSuffix("/") ? String(systemRoot.dropLast()) : systemRoot + if path.hasPrefix(trimmedRoot) { + return path + } + return trimmedRoot + path + } +} diff --git a/Sources/KeyPathCore/KanataRuntimeLaunchRequest.swift b/Sources/KeyPathCore/KanataRuntimeLaunchRequest.swift new file mode 100644 index 000000000..ed8551b6a --- /dev/null +++ b/Sources/KeyPathCore/KanataRuntimeLaunchRequest.swift @@ -0,0 +1,46 @@ +import Foundation + +/// Shared launch request for starting the macOS Kanata runtime. +/// +/// The current launcher still `exec`s the raw kanata binary, but callers should +/// build that command line through this model so the future in-process runtime +/// host migration can replace the handoff in one place. +public struct KanataRuntimeLaunchRequest: Sendable, Equatable { + public let configPath: String + public let inheritedArguments: [String] + public let addTraceLogging: Bool + + public init( + configPath: String, + inheritedArguments: [String] = [], + addTraceLogging: Bool = false + ) { + self.configPath = configPath + self.inheritedArguments = inheritedArguments + self.addTraceLogging = addTraceLogging + } + + public func resolvedCoreBinaryPath( + using runtimeHost: KanataRuntimeHost, + fileManager: FileManager = .default + ) -> String { + runtimeHost.preferredCoreBinaryPath(fileManager: fileManager) + } + + public func commandLine( + using runtimeHost: KanataRuntimeHost, + fileManager: FileManager = .default + ) -> [String] { + let binaryPath = resolvedCoreBinaryPath(using: runtimeHost, fileManager: fileManager) + return commandLine(binaryPath: binaryPath) + } + + public func commandLine(binaryPath: String) -> [String] { + var arguments = [binaryPath, "--cfg", configPath] + arguments.append(contentsOf: inheritedArguments) + if addTraceLogging, !arguments.contains("--trace"), !arguments.contains("--debug") { + arguments.append("--trace") + } + return arguments + } +} diff --git a/Sources/KeyPathCore/KanataRuntimePathDecision.swift b/Sources/KeyPathCore/KanataRuntimePathDecision.swift new file mode 100644 index 000000000..4aa1c495e --- /dev/null +++ b/Sources/KeyPathCore/KanataRuntimePathDecision.swift @@ -0,0 +1,103 @@ +import Foundation + +public enum KanataRuntimePathDecision: Equatable, Sendable { + case useSplitRuntime(reason: String) + case useLegacySystemBinary(reason: String) + case blocked(reason: String) + + public var reason: String { + switch self { + case let .useSplitRuntime(reason), + let .useLegacySystemBinary(reason), + let .blocked(reason): + reason + } + } +} + +public struct KanataRuntimePathInputs: Equatable, Sendable { + public let hostBridgeLoaded: Bool + public let hostConfigValid: Bool + public let hostRuntimeConstructible: Bool + public let helperReady: Bool + public let outputBridgeStatus: KanataOutputBridgeStatus? + public let legacySystemBinaryAvailable: Bool + + public init( + hostBridgeLoaded: Bool, + hostConfigValid: Bool, + hostRuntimeConstructible: Bool, + helperReady: Bool, + outputBridgeStatus: KanataOutputBridgeStatus?, + legacySystemBinaryAvailable: Bool + ) { + self.hostBridgeLoaded = hostBridgeLoaded + self.hostConfigValid = hostConfigValid + self.hostRuntimeConstructible = hostRuntimeConstructible + self.helperReady = helperReady + self.outputBridgeStatus = outputBridgeStatus + self.legacySystemBinaryAvailable = legacySystemBinaryAvailable + } +} + +public enum KanataRuntimePathEvaluator { + public static func decide(_ inputs: KanataRuntimePathInputs) -> KanataRuntimePathDecision { + if inputs.hostBridgeLoaded, + inputs.hostConfigValid, + inputs.hostRuntimeConstructible, + inputs.helperReady, + let outputBridgeStatus = inputs.outputBridgeStatus, + outputBridgeStatus.available, + outputBridgeStatus.companionRunning, + outputBridgeStatus.requiresPrivilegedBridge + { + return .useSplitRuntime( + reason: "bundled host can own input runtime and privileged output bridge is required at \(outputBridgeStatus.socketDirectory ?? KeyPathConstants.VirtualHID.rootOnlyTmp)" + ) + } + + if inputs.legacySystemBinaryAvailable { + if !inputs.helperReady { + return .useLegacySystemBinary( + reason: "privileged helper is not ready, so continue using the legacy system binary" + ) + } + + if let outputBridgeStatus = inputs.outputBridgeStatus, + outputBridgeStatus.available, + outputBridgeStatus.requiresPrivilegedBridge + { + return .useLegacySystemBinary( + reason: outputBridgeStatus.companionRunning + ? "root-scoped pqrs output bridge is still required, so continue using the legacy system binary until split runtime output is implemented" + : "privileged output companion is installed but not healthy, so continue using the legacy system binary" + ) + } + + if !inputs.hostBridgeLoaded || !inputs.hostConfigValid || !inputs.hostRuntimeConstructible { + return .useLegacySystemBinary( + reason: "bundled host runtime is not ready yet, so continue using the legacy system binary" + ) + } + + return .useLegacySystemBinary( + reason: "privileged output bridge status is unavailable, so keep the legacy system binary as fallback" + ) + } + + if !inputs.hostBridgeLoaded { + return .blocked(reason: "bundled host bridge is unavailable and no legacy system binary exists") + } + if !inputs.hostConfigValid { + return .blocked(reason: "bundled host runtime cannot validate the active config and no legacy system binary exists") + } + if !inputs.hostRuntimeConstructible { + return .blocked(reason: "bundled host runtime cannot construct Kanata and no legacy system binary exists") + } + if !inputs.helperReady { + return .blocked(reason: "privileged helper is unavailable and no legacy system binary exists") + } + + return .blocked(reason: "privileged output bridge is unavailable and no legacy system binary exists") + } +} diff --git a/Sources/KeyPathCore/KeyPathConstants.swift b/Sources/KeyPathCore/KeyPathConstants.swift index e6085e633..5afd6ff08 100644 --- a/Sources/KeyPathCore/KeyPathConstants.swift +++ b/Sources/KeyPathCore/KeyPathConstants.swift @@ -22,6 +22,7 @@ public enum KeyPathConstants { public static let bundleID = "com.keypath.KeyPath" public static let helperID = "com.keypath.KeyPath.Helper" public static let daemonID = "com.keypath.kanata" + public static let outputBridgeID = "com.keypath.output-bridge" public static let vhidDaemonID = "com.keypath.karabiner-vhiddaemon" public static let vhidManagerID = "com.keypath.karabiner-vhidmanager" } @@ -73,6 +74,14 @@ public enum KeyPathConstants { public static let launchDaemonsDir = "/Library/LaunchDaemons" } + public enum OutputBridge { + public static let runDirectory = "/Library/KeyPath/run" + public static let socketDirectory = "/Library/KeyPath/run/kpko" + public static let sessionDirectory = "/Library/KeyPath/run/kpko/sessions" + public static let stdoutLog = "/var/log/com.keypath.output-bridge.stdout.log" + public static let stderrLog = "/var/log/com.keypath.output-bridge.stderr.log" + } + public enum URLs { public static let inputMonitoringPrivacy = "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent" public static let accessibilityPrivacy = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility" diff --git a/Sources/KeyPathCore/PrivilegedCommandRunner.swift b/Sources/KeyPathCore/PrivilegedCommandRunner.swift index b6794c2b5..51f75f911 100644 --- a/Sources/KeyPathCore/PrivilegedCommandRunner.swift +++ b/Sources/KeyPathCore/PrivilegedCommandRunner.swift @@ -128,7 +128,20 @@ public enum PrivilegedCommandRunner { /// Execute a command using osascript with admin privileges dialog. private static func executeWithOsascript(command: String, prompt: String) -> Result { - let escapedCommand = escapeForAppleScript(command) + let scriptURL: URL + do { + scriptURL = try writeTemporaryShellScript(command: command) + } catch { + let errorMsg = "failed to prepare privileged script: \(error.localizedDescription)" + AppLogger.shared.log("❌ [PrivilegedCommandRunner] \(errorMsg)") + return Result(success: false, output: errorMsg, exitCode: -1) + } + + defer { + try? FileManager.default.removeItem(at: scriptURL) + } + + let escapedCommand = escapeForAppleScript("/bin/bash \(shellSingleQuoted(scriptURL.path))") let escapedPrompt = prompt.replacingOccurrences(of: "\"", with: "\\\"") let osascriptCommand = """ do shell script "\(escapedCommand)" with administrator privileges with prompt "\(escapedPrompt)" @@ -137,6 +150,7 @@ public enum PrivilegedCommandRunner { AppLogger.shared.log("🔐 [PRIVILEGED-TRIGGER] Requesting admin privileges via osascript") AppLogger.shared.log("🔐 [PRIVILEGED-TRIGGER] Command: \(command.prefix(100))...") AppLogger.shared.log("🔐 [PRIVILEGED-TRIGGER] Prompt: \(prompt)") + AppLogger.shared.log("🔐 [PRIVILEGED-TRIGGER] Script path: \(scriptURL.path)") // Log stack trace to identify caller let callStack = Thread.callStackSymbols.prefix(10).joined(separator: "\n") AppLogger.shared.log("🔐 [PRIVILEGED-TRIGGER] Call stack:\n\(callStack)") @@ -178,6 +192,22 @@ public enum PrivilegedCommandRunner { escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"") return escaped } + + private static func writeTemporaryShellScript(command: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("keypath-privileged-\(UUID().uuidString).sh") + let script = """ + #!/bin/bash + \(command) + """ + try script.write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: url.path) + return url + } + + private static func shellSingleQuoted(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } } // MARK: - Convenience Extensions diff --git a/Sources/KeyPathCore/WizardSystemPaths.swift b/Sources/KeyPathCore/WizardSystemPaths.swift index db07b8750..b8c7f97b5 100644 --- a/Sources/KeyPathCore/WizardSystemPaths.swift +++ b/Sources/KeyPathCore/WizardSystemPaths.swift @@ -45,7 +45,7 @@ public enum WizardSystemPaths { /// System-installed kanata binary location (avoids Gatekeeper issues) /// This is where the LaunchDaemon installer copies the bundled binary public static var kanataSystemInstallPath: String { - remapSystemPath("/Library/KeyPath/bin/kanata") + currentRuntimeHost().systemCorePath } /// Standard kanata binary location - used for both homebrew and experimental versions @@ -84,7 +84,18 @@ public enum WizardSystemPaths { { return override } - return "\(Bundle.main.bundlePath)/Contents/Library/KeyPath/kanata" + return currentRuntimeHost().bundledCorePath + } + + /// Bundled kanata runtime host path. + /// This is the app-bundled executable currently launched by SMAppService. + public static var bundledKanataLauncherPath: String { + if let override = ProcessInfo.processInfo.environment["KEYPATH_BUNDLED_KANATA_LAUNCHER_OVERRIDE"], + !override.isEmpty + { + return override + } + return currentRuntimeHost().launcherPath } /// Bundled kanata simulator binary path (for dry-run simulation) @@ -106,21 +117,24 @@ public enum WizardSystemPaths { /// Active kanata binary path - uses simple filesystem checks for performance /// Single canonical path eliminates TCC permission fragmentation public static var kanataActiveBinary: String { - // Use system install path if it exists (for LaunchDaemon) - // Otherwise use bundled path (for UI/detection) - if FileManager.default.fileExists(atPath: kanataSystemInstallPath) { - return kanataSystemInstallPath - } - return bundledKanataPath + currentRuntimeHost().preferredCoreBinaryPath() } /// All known kanata binary paths for detection/filtering public static func allKnownKanataPaths() -> [String] { - [kanataSystemInstallPath, bundledKanataPath].filter { + let runtimeHost = currentRuntimeHost() + return [runtimeHost.systemCorePath, runtimeHost.bundledCorePath].filter { FileManager.default.fileExists(atPath: $0) } } + private static func currentRuntimeHost() -> KanataRuntimeHost { + KanataRuntimeHost.current( + bundlePath: Bundle.main.bundlePath, + systemRoot: currentTestRoot() + ) + } + /// Homebrew binary path public static var brewBinary: String { remapSystemPath("/opt/homebrew/bin/brew") diff --git a/Sources/KeyPathDaemonLifecycle/LaunchDaemonPIDCache.swift b/Sources/KeyPathDaemonLifecycle/LaunchDaemonPIDCache.swift index 040f49cad..c2ade6fd6 100644 --- a/Sources/KeyPathDaemonLifecycle/LaunchDaemonPIDCache.swift +++ b/Sources/KeyPathDaemonLifecycle/LaunchDaemonPIDCache.swift @@ -104,42 +104,56 @@ public actor LaunchDaemonPIDCache { // MARK: - Private Implementation + private nonisolated func kanataLaunchctlTargets(userID: uid_t = getuid()) -> [String] { + ["gui/\(userID)/com.keypath.kanata", "system/com.keypath.kanata"] + } + private func fetchLaunchDaemonPIDWithTimeout() async throws -> pid_t? { - let task = Process() - task.launchPath = "/bin/launchctl" - task.arguments = ["print", "system/com.keypath.kanata"] + for target in kanataLaunchctlTargets() { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["print", target] - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = Pipe() // Discard error output + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = Pipe() - let processTask = Task { () throws -> pid_t? in - try await self.runLaunchctlPrint(task: task, pipe: pipe) - } + let processTask = Task { () throws -> pid_t? in + try await self.runLaunchctlPrint(task: task, pipe: pipe) + } - do { - return try await withThrowingTaskGroup(of: pid_t?.self) { group in - group.addTask { - try await processTask.value + do { + let result = try await withThrowingTaskGroup(of: pid_t?.self) { group in + group.addTask { + try await processTask.value + } + + group.addTask { + try await Task.sleep(for: .seconds(self.launchctlTimeout)) + throw TimeoutError() + } + + guard let result = try await group.next() else { + group.cancelAll() + throw TimeoutError() + } + group.cancelAll() + return result } - group.addTask { - try await Task.sleep(for: .seconds(self.launchctlTimeout)) - throw TimeoutError() + if result != nil { + return result } - - guard let result = try await group.next() else { - group.cancelAll() - throw TimeoutError() + } catch { + terminateLaunchctl(task) + processTask.cancel() + if error is TimeoutError { + throw error } - group.cancelAll() - return result } - } catch { - terminateLaunchctl(task) - processTask.cancel() - throw error } + + return nil } private func runLaunchctlPrint(task: Process, pipe: Pipe) async throws -> pid_t? { diff --git a/Sources/KeyPathHelper/HelperProtocol.swift b/Sources/KeyPathHelper/HelperProtocol.swift index a1ba9a3c0..f29b320d1 100644 --- a/Sources/KeyPathHelper/HelperProtocol.swift +++ b/Sources/KeyPathHelper/HelperProtocol.swift @@ -17,20 +17,9 @@ import Foundation /// - Parameter reply: Completion handler with (version string, errorMessage) func getVersion(reply: @escaping (String?, String?) -> Void) - // MARK: - LaunchDaemon Operations - - /// Install a single LaunchDaemon service - /// - Parameters: - /// - plistPath: Path to the plist file to install - /// - serviceID: Service identifier (e.g., "com.keypath.kanata") - /// - reply: Completion handler with (success, errorMessage) - func installLaunchDaemon( - plistPath: String, serviceID: String, reply: @escaping (Bool, String?) -> Void - ) - /// Restart services that are in an unhealthy state /// - Parameter reply: Completion handler with (success, errorMessage) - func restartUnhealthyServices(reply: @escaping (Bool, String?) -> Void) + func recoverRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) /// Regenerate and reload service configuration /// - Parameter reply: Completion handler with (success, errorMessage) @@ -44,9 +33,9 @@ import Foundation /// - Parameter reply: Completion handler with (success, errorMessage) func repairVHIDDaemonServices(reply: @escaping (Bool, String?) -> Void) - /// Install LaunchDaemon services without loading them + /// Install only the privileged services required by the split runtime path. /// - Parameter reply: Completion handler with (success, errorMessage) - func installLaunchDaemonServicesWithoutLoading(reply: @escaping (Bool, String?) -> Void) + func installRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) // MARK: - VirtualHID Operations @@ -78,6 +67,40 @@ import Foundation /// - reply: Completion handler with (success, errorMessage) func installBundledVHIDDriver(pkgPath: String, reply: @escaping (Bool, String?) -> Void) + /// Probe whether root-side pqrs VirtualHID output access is available for a future split runtime. + /// - Parameter reply: Completion handler with + /// - payload: JSON-encoded `KanataOutputBridgeStatus` + /// - errorMessage: failure details, if any + func getKanataOutputBridgeStatus( + reply: @escaping (String?, String?) -> Void + ) + + /// Prepare a privileged output-bridge session for a future split runtime. + /// - Parameters: + /// - hostPID: PID of the bundled user-session runtime that will connect + /// - reply: Completion handler with + /// - payload: JSON-encoded `KanataOutputBridgeSession` + /// - errorMessage: failure details, if any + func prepareKanataOutputBridgeSession( + hostPID: Int32, + reply: @escaping (String?, String?) -> Void + ) + + /// Activate a prepared privileged output-bridge session and ensure the dedicated companion binds its socket. + /// - Parameters: + /// - sessionID: session identifier returned by prepare + /// - reply: Completion handler with (success, errorMessage) + func activateKanataOutputBridgeSession( + sessionID: String, + reply: @escaping (Bool, String?) -> Void + ) + + /// Restart the dedicated output-bridge companion and ensure it is relaunched cleanly. + /// - Parameter reply: Completion handler with (success, errorMessage) + func restartKanataOutputBridgeCompanion( + reply: @escaping (Bool, String?) -> Void + ) + // MARK: - Process Management /// Terminate a specific process by PID diff --git a/Sources/KeyPathHelper/HelperService.swift b/Sources/KeyPathHelper/HelperService.swift index 2d566839d..a402d5303 100644 --- a/Sources/KeyPathHelper/HelperService.swift +++ b/Sources/KeyPathHelper/HelperService.swift @@ -1,4 +1,6 @@ +import Darwin import Foundation +import KeyPathCore import os import SystemConfiguration @@ -11,6 +13,11 @@ class HelperService: NSObject, HelperProtocol { /// Helper version (must match app version for compatibility) private static let version = "1.1.0" + private static let vhidRootOnlyDirectory = "/Library/Application Support/org.pqrs/tmp/rootonly" + private static let kanataOutputBridgeBaseDirectory = KeyPathConstants.OutputBridge.runDirectory + private static let kanataOutputBridgeDirectory = KeyPathConstants.OutputBridge.socketDirectory + private static let kanataOutputBridgeSessionDirectory = KeyPathConstants.OutputBridge.sessionDirectory + private static let outputBridgeServiceID = KeyPathConstants.Bundle.outputBridgeID private let logger = Logger(subsystem: "com.keypath.helper", category: "service") // MARK: - Version Management @@ -32,69 +39,10 @@ class HelperService: NSObject, HelperProtocol { logger.info("Reply callback completed") } - // MARK: - LaunchDaemon Operations - - func installLaunchDaemon( - plistPath: String, serviceID: String, reply: @escaping (Bool, String?) -> Void - ) { - NSLog("[KeyPathHelper] installLaunchDaemon requested: \(serviceID)") - executePrivilegedOperation( - name: "installLaunchDaemon", - operation: { - let fm = FileManager.default - let dest = "/Library/LaunchDaemons/\(serviceID).plist" - - guard fm.fileExists(atPath: plistPath) else { - throw HelperError.invalidArgument("plist not found: \(plistPath)") - } - - // Ensure destination dir exists - try fm.createDirectory(atPath: "/Library/LaunchDaemons", withIntermediateDirectories: true) - - // Copy into place (overwrite if exists) - if fm.fileExists(atPath: dest) { try fm.removeItem(atPath: dest) } - try fm.copyItem(atPath: plistPath, toPath: dest) - - // Permissions and ownership - _ = Self.run("/bin/chmod", ["644", dest]) - _ = Self.run("/usr/sbin/chown", ["root:wheel", dest]) - - // Guard against bundled path usage by rewriting to system path if detected - do { - let original = try String(contentsOfFile: dest, encoding: .utf8) - if original.contains("/Contents/Library/KeyPath/kanata") { - let rewritten = original.replacingOccurrences( - of: "/Contents/Library/KeyPath/kanata", - with: "/Library/KeyPath/bin/kanata" - ) - try rewritten.write(toFile: dest, atomically: true, encoding: .utf8) - NSLog("[KeyPathHelper] Rewrote bundled kanata path to system path in \(dest)") - } - } catch { - NSLog( - "[KeyPathHelper] Warning: failed to validate/rewrite kanata path in plist: \(error.localizedDescription)" - ) - } - - // Bootstrap (idempotent), then enable and kickstart - _ = Self.run("/bin/launchctl", ["bootout", "system/\(serviceID)"]) // ignore failure - let bs = Self.run("/bin/launchctl", ["bootstrap", "system", dest]) - if bs.status != 0 { - throw HelperError.operationFailed( - "launchctl bootstrap failed (status=\(bs.status)): \(bs.out)" - ) - } - _ = Self.run("/bin/launchctl", ["enable", "system/\(serviceID)"]) - _ = Self.run("/bin/launchctl", ["kickstart", "-k", "system/\(serviceID)"]) - }, - reply: reply - ) - } - - func restartUnhealthyServices(reply: @escaping (Bool, String?) -> Void) { - NSLog("[KeyPathHelper] restartUnhealthyServices requested") + func recoverRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) { + NSLog("[KeyPathHelper] recoverRequiredRuntimeServices requested") executePrivilegedOperation( - name: "restartUnhealthyServices", + name: "recoverRequiredRuntimeServices", operation: { // Assess VirtualHID health and restart as needed; Kanata is SMAppService-managed let services = [Self.vhidDaemonServiceID, Self.vhidManagerServiceID] @@ -179,36 +127,14 @@ class HelperService: NSObject, HelperProtocol { ) } - func installLaunchDaemonServicesWithoutLoading(reply: @escaping (Bool, String?) -> Void) { - NSLog("[KeyPathHelper] installLaunchDaemonServicesWithoutLoading requested") + func installRequiredRuntimeServices(reply: @escaping (Bool, String?) -> Void) { + NSLog("[KeyPathHelper] installRequiredRuntimeServices requested") executePrivilegedOperation( - name: "installLaunchDaemonServicesWithoutLoading", + name: "installRequiredRuntimeServices", operation: { - // Install VirtualHID plist files only, without loading/starting services - let vhidDPlist = Self.generateVHIDDaemonPlist() - let vhidMPlist = Self.generateVHIDManagerPlist() - - let tmp = NSTemporaryDirectory() - let tVhidD = (tmp as NSString).appendingPathComponent("\(Self.vhidDaemonServiceID).plist") - let tVhidM = (tmp as NSString).appendingPathComponent("\(Self.vhidManagerServiceID).plist") - try vhidDPlist.write(toFile: tVhidD, atomically: true, encoding: .utf8) - try vhidMPlist.write(toFile: tVhidM, atomically: true, encoding: .utf8) - - let dstDir = "/Library/LaunchDaemons" - _ = Self.run("/bin/mkdir", ["-p", dstDir]) - let dVhidD = (dstDir as NSString).appendingPathComponent( - "\(Self.vhidDaemonServiceID).plist" - ) - let dVhidM = (dstDir as NSString).appendingPathComponent( - "\(Self.vhidManagerServiceID).plist" - ) - - for (src, dst) in [(tVhidD, dVhidD), (tVhidM, dVhidM)] { - _ = Self.run("/bin/rm", ["-f", dst]) - _ = Self.run("/bin/cp", [src, dst]) - _ = Self.run("/usr/sbin/chown", ["root:wheel", dst]) - _ = Self.run("/bin/chmod", ["644", dst]) - } + try Self.installOrRepairVHIDServices() + try Self.ensureOutputBridgeCompanionInstalled() + try Self.activateOutputBridgeCompanion() }, reply: reply ) @@ -307,6 +233,171 @@ class HelperService: NSObject, HelperProtocol { ) } + func getKanataOutputBridgeStatus( + reply: @escaping (String?, String?) -> Void + ) { + NSLog("[KeyPathHelper] getKanataOutputBridgeStatus requested") + logger.info("getKanataOutputBridgeStatus requested") + + do { + let attributes = try FileManager.default.attributesOfItem(atPath: Self.vhidRootOnlyDirectory) + let ownerID = (attributes[.ownerAccountID] as? NSNumber)?.intValue ?? -1 + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0 + let isRootOwned = ownerID == 0 + let isRootOnly = permissions & 0o077 == 0 + let companionPlistPath = "/Library/LaunchDaemons/\(Self.outputBridgeServiceID).plist" + let companionInstalled = FileManager.default.fileExists(atPath: companionPlistPath) + let launchctl = Self.run("/bin/launchctl", ["print", "system/\(Self.outputBridgeServiceID)"]) + let companionHealthy = launchctl.status == 0 + let detail: String? = { + if companionHealthy { + return "privileged output companion is installed and launchctl can inspect system/\(Self.outputBridgeServiceID)" + } + if companionInstalled { + let trimmed = launchctl.out.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty + ? "privileged output companion plist is installed but launchctl could not inspect system/\(Self.outputBridgeServiceID)" + : "privileged output companion is installed but unhealthy: \(trimmed)" + } + return "privileged output companion is not installed" + }() + let status = KanataOutputBridgeStatus( + available: companionHealthy, + companionRunning: companionHealthy, + requiresPrivilegedBridge: isRootOwned && isRootOnly, + socketDirectory: Self.kanataOutputBridgeDirectory, + detail: detail + ) + let payload = try JSONEncoder().encode(status) + reply(String(decoding: payload, as: UTF8.self), nil) + } catch { + let status = KanataOutputBridgeStatus( + available: false, + companionRunning: false, + requiresPrivilegedBridge: false, + socketDirectory: Self.kanataOutputBridgeDirectory, + detail: "failed to inspect privileged output bridge requirements: \(error.localizedDescription)" + ) + if let payload = try? JSONEncoder().encode(status) { + reply(String(decoding: payload, as: UTF8.self), nil) + } else { + reply(nil, error.localizedDescription) + } + } + } + + func prepareKanataOutputBridgeSession( + hostPID: Int32, + reply: @escaping (String?, String?) -> Void + ) { + NSLog("[KeyPathHelper] prepareKanataOutputBridgeSession requested for pid=%d", hostPID) + logger.info("prepareKanataOutputBridgeSession requested for pid=\(hostPID)") + + guard hostPID > 0 else { + reply(nil, "invalid host pid") + return + } + + do { + try Self.ensureKanataOutputBridgeDirectory() + try Self.retireDeadOutputBridgeSessions() + try Self.retireOutputBridgeSessions(forHostPID: hostPID) + try Self.ensureOutputBridgeCompanionInstalled() + let sessionID = UUID().uuidString.lowercased() + let socketName = "k-\(sessionID.replacingOccurrences(of: "-", with: "").prefix(12)).sock" + let socketPath = (Self.kanataOutputBridgeDirectory as NSString) + .appendingPathComponent(socketName) + + if FileManager.default.fileExists(atPath: socketPath) { + try FileManager.default.removeItem(atPath: socketPath) + } + + let hostUID = try Self.userID(for: hostPID) + let hostGID = try Self.groupID(for: hostPID) + + let session = KanataOutputBridgeSession( + sessionID: sessionID, + socketPath: socketPath, + socketDirectory: Self.kanataOutputBridgeDirectory, + hostPID: hostPID, + hostUID: hostUID, + hostGID: hostGID + ) + try Self.writePreparedOutputBridgeSession(session) + NSLog( + "[KeyPathHelper] prepared output bridge session %@ socket=%@ hostPID=%d uid=%u gid=%u", + session.sessionID, + session.socketPath, + session.hostPID, + session.hostUID, + session.hostGID + ) + logger.info( + "prepared output bridge session \(session.sessionID) socket=\(session.socketPath) hostPID=\(session.hostPID) uid=\(session.hostUID) gid=\(session.hostGID)" + ) + + let payload = try JSONEncoder().encode(session) + reply(String(decoding: payload, as: UTF8.self), nil) + } catch { + reply(nil, error.localizedDescription) + } + } + + func activateKanataOutputBridgeSession( + sessionID: String, + reply: @escaping (Bool, String?) -> Void + ) { + NSLog("[KeyPathHelper] activateKanataOutputBridgeSession requested: %@", sessionID) + logger.info("activateKanataOutputBridgeSession requested: \(sessionID)") + + do { + try Self.ensureOutputBridgeCompanionInstalled() + try Self.ensureKanataOutputBridgeDirectory() + let session = try Self.loadPreparedOutputBridgeSession(sessionID: sessionID) + do { + try Self.activatePreparedOutputBridgeSession(session) + } catch { + NSLog( + "[KeyPathHelper] output bridge activation failed for %@, attempting recovery: %@", + sessionID, + error.localizedDescription + ) + logger.error( + "output bridge activation failed for \(sessionID), attempting recovery: \(error.localizedDescription)" + ) + try Self.recoverOutputBridgeCompanion(for: session) + } + NSLog( + "[KeyPathHelper] activated output bridge session %@ socket=%@", + sessionID, + session.socketPath + ) + logger.info("activated output bridge session \(sessionID) socket=\(session.socketPath)") + reply(true, nil) + } catch { + reply(false, error.localizedDescription) + } + } + + func restartKanataOutputBridgeCompanion( + reply: @escaping (Bool, String?) -> Void + ) { + NSLog("[KeyPathHelper] restartKanataOutputBridgeCompanion requested") + logger.info("restartKanataOutputBridgeCompanion requested") + executePrivilegedOperation( + name: "restartKanataOutputBridgeCompanion", + operation: { + if Self.isServiceLoaded(Self.outputBridgeServiceID) { + try Self.activateOutputBridgeCompanion() + } else { + try Self.ensureOutputBridgeCompanionInstalled() + try Self.activateOutputBridgeCompanion() + } + }, + reply: reply + ) + } + func installBundledVHIDDriver(pkgPath: String, reply: @escaping (Bool, String?) -> Void) { NSLog("[KeyPathHelper] installBundledVHIDDriver requested: %@", pkgPath) executePrivilegedOperation( @@ -379,6 +470,238 @@ class HelperService: NSObject, HelperProtocol { _ = run("/bin/launchctl", ["kickstart", "-k", "system/\(vhidManagerServiceID)"]) } + private static func ensureKanataOutputBridgeDirectory() throws { + guard FileManager.default.fileExists(atPath: vhidRootOnlyDirectory) else { + throw HelperError.operationFailed("vhid root-only directory not found at \(vhidRootOnlyDirectory)") + } + + if !FileManager.default.fileExists(atPath: kanataOutputBridgeBaseDirectory) { + try FileManager.default.createDirectory( + atPath: kanataOutputBridgeBaseDirectory, + withIntermediateDirectories: true + ) + } + + if !FileManager.default.fileExists(atPath: kanataOutputBridgeDirectory) { + try FileManager.default.createDirectory( + atPath: kanataOutputBridgeDirectory, + withIntermediateDirectories: false + ) + } + if !FileManager.default.fileExists(atPath: kanataOutputBridgeSessionDirectory) { + try FileManager.default.createDirectory( + atPath: kanataOutputBridgeSessionDirectory, + withIntermediateDirectories: false + ) + } + + _ = run("/usr/sbin/chown", ["root:wheel", kanataOutputBridgeBaseDirectory]) + _ = run("/bin/chmod", ["755", kanataOutputBridgeBaseDirectory]) + _ = run("/usr/sbin/chown", ["root:wheel", kanataOutputBridgeDirectory]) + _ = run("/bin/chmod", ["755", kanataOutputBridgeDirectory]) + _ = run("/usr/sbin/chown", ["root:wheel", kanataOutputBridgeSessionDirectory]) + _ = run("/bin/chmod", ["755", kanataOutputBridgeSessionDirectory]) + + let attributes = try FileManager.default.attributesOfItem(atPath: kanataOutputBridgeDirectory) + let ownerID = (attributes[.ownerAccountID] as? NSNumber)?.intValue ?? -1 + let permissions = (attributes[.posixPermissions] as? NSNumber)?.intValue ?? 0 + guard ownerID == 0, permissions == 0o755 else { + throw HelperError.operationFailed( + "output bridge directory is not host-connectable at \(kanataOutputBridgeDirectory)" + ) + } + } + + private static func retireOutputBridgeSessions(forHostPID hostPID: Int32) throws { + let matchingSessionIDs = try loadPreparedOutputBridgeSessions() + .filter { $0.hostPID == hostPID } + .map(\.sessionID) + + for sessionID in matchingSessionIDs { + retireOutputBridgeSession(sessionID: sessionID) + } + } + + private static func retireDeadOutputBridgeSessions() throws { + let deadSessionIDs = try loadPreparedOutputBridgeSessions() + .filter { !isProcessAlive($0.hostPID) } + .map(\.sessionID) + + for sessionID in deadSessionIDs { + retireOutputBridgeSession(sessionID: sessionID) + } + } + + private static func retireOutputBridgeSession(sessionID: String) { + let sessionPath = preparedOutputBridgeSessionPath(sessionID: sessionID) + if let data = try? Data(contentsOf: URL(fileURLWithPath: sessionPath)), + let session = try? JSONDecoder().decode(PreparedOutputBridgeSession.self, from: data), + FileManager.default.fileExists(atPath: session.socketPath) + { + try? FileManager.default.removeItem(atPath: session.socketPath) + } + try? FileManager.default.removeItem(atPath: sessionPath) + } + + private static func isProcessAlive(_ pid: Int32) -> Bool { + guard pid > 0 else { return false } + if kill(pid, 0) == 0 { + return true + } + return errno == EPERM + } + + private static func loadPreparedOutputBridgeSessions() throws -> [PreparedOutputBridgeSession] { + let directory = URL(fileURLWithPath: kanataOutputBridgeSessionDirectory, isDirectory: true) + let files = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + return files.compactMap { file in + guard file.pathExtension == "json", + let data = try? Data(contentsOf: file), + let session = try? JSONDecoder().decode(PreparedOutputBridgeSession.self, from: data) + else { + return nil + } + return session + } + } + + private static func preparedOutputBridgeSessionPath(sessionID: String) -> String { + (kanataOutputBridgeSessionDirectory as NSString).appendingPathComponent("\(sessionID).json") + } + + private static func writePreparedOutputBridgeSession(_ session: KanataOutputBridgeSession) throws { + let prepared = PreparedOutputBridgeSession( + sessionID: session.sessionID, + socketPath: session.socketPath, + hostPID: session.hostPID, + hostUID: session.hostUID, + hostGID: session.hostGID + ) + let data = try JSONEncoder().encode(prepared) + try data.write( + to: URL(fileURLWithPath: preparedOutputBridgeSessionPath(sessionID: session.sessionID)), + options: Data.WritingOptions.atomic + ) + } + + private static func ensurePreparedOutputBridgeSessionExists(sessionID: String) throws { + _ = try loadPreparedOutputBridgeSession(sessionID: sessionID) + } + + private static func loadPreparedOutputBridgeSession(sessionID: String) throws -> PreparedOutputBridgeSession { + let path = preparedOutputBridgeSessionPath(sessionID: sessionID) + guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)), + let session = try? JSONDecoder().decode(PreparedOutputBridgeSession.self, from: data) + else { + throw HelperError.operationFailed("unknown output bridge session: \(sessionID)") + } + return session + } + + private static func ensureOutputBridgeCompanionInstalled() throws { + let appBundle = appBundlePathFromHelper() + let bundledPlist = (appBundle as NSString).appendingPathComponent( + "Contents/Library/LaunchDaemons/\(outputBridgeServiceID).plist" + ) + guard FileManager.default.fileExists(atPath: bundledPlist) else { + throw HelperError.operationFailed("bundled output bridge plist not found at \(bundledPlist)") + } + let destination = "/Library/LaunchDaemons/\(outputBridgeServiceID).plist" + _ = run("/bin/rm", ["-f", destination]) + _ = run("/bin/cp", [bundledPlist, destination]) + _ = run("/usr/sbin/chown", ["root:wheel", destination]) + _ = run("/bin/chmod", ["644", destination]) + _ = run("/bin/launchctl", ["bootout", "system/\(outputBridgeServiceID)"]) + try bootstrapOutputBridgeCompanion(destination: destination) + _ = run("/bin/launchctl", ["enable", "system/\(outputBridgeServiceID)"]) + } + + private static func userID(for pid: Int32) throws -> UInt32 { + let result = run("/bin/ps", ["-o", "uid=", "-p", String(pid)]) + guard result.status == 0, + let value = UInt32(result.out.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + throw HelperError.operationFailed("failed to resolve uid for pid \(pid): \(result.out)") + } + return value + } + + private static func groupID(for pid: Int32) throws -> UInt32 { + let result = run("/bin/ps", ["-o", "gid=", "-p", String(pid)]) + guard result.status == 0, + let value = UInt32(result.out.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + throw HelperError.operationFailed("failed to resolve gid for pid \(pid): \(result.out)") + } + return value + } + + private static func bootstrapOutputBridgeCompanion(destination: String) throws { + var lastOutput = "" + for attempt in 0..<5 { + if attempt > 0 { + usleep(useconds_t(200_000 * attempt)) + } + let bootstrap = run("/bin/launchctl", ["bootstrap", "system", destination]) + if bootstrap.status == 0 { + return + } + lastOutput = bootstrap.out.trimmingCharacters(in: .whitespacesAndNewlines) + if !lastOutput.localizedCaseInsensitiveContains("Input/output error"), + !lastOutput.localizedCaseInsensitiveContains("i/o error") + { + break + } + } + throw HelperError.operationFailed("bootstrap output bridge failed: \(lastOutput)") + } + + private static func activateOutputBridgeCompanion() throws { + let kickstart = run("/bin/launchctl", ["kickstart", "-k", "system/\(outputBridgeServiceID)"]) + guard kickstart.status == 0 else { + throw HelperError.operationFailed("kickstart output bridge failed: \(kickstart.out)") + } + } + + private static func activatePreparedOutputBridgeSession(_ session: PreparedOutputBridgeSession) throws { + if FileManager.default.fileExists(atPath: session.socketPath) { + try? FileManager.default.removeItem(atPath: session.socketPath) + } + try activateOutputBridgeCompanion() + try waitForPreparedOutputBridgeSocket(sessionID: session.sessionID, socketPath: session.socketPath) + } + + private static func recoverOutputBridgeCompanion(for session: PreparedOutputBridgeSession) throws { + if isServiceLoaded(outputBridgeServiceID) { + do { + try activateOutputBridgeCompanion() + try waitForPreparedOutputBridgeSocket(sessionID: session.sessionID, socketPath: session.socketPath) + return + } catch { + NSLog( + "[KeyPathHelper] output bridge service loaded but socket recovery failed; reinstalling daemon: %@", + error.localizedDescription + ) + } + } + + _ = run("/bin/launchctl", ["bootout", "system/\(outputBridgeServiceID)"]) + usleep(300_000) + try ensureOutputBridgeCompanionInstalled() + try activatePreparedOutputBridgeSession(session) + } + + private static func waitForPreparedOutputBridgeSocket(sessionID: String, socketPath: String) throws { + let deadline = Date().addingTimeInterval(3) + while Date() < deadline { + if FileManager.default.fileExists(atPath: socketPath) { + return + } + usleep(100_000) + } + throw HelperError.operationFailed("output bridge companion did not bind socket for session \(sessionID)") + } + // MARK: - Process Management func terminateProcess(_ pid: Int32, reply: @escaping (Bool, String?) -> Void) { @@ -668,7 +991,7 @@ class HelperService: NSObject, HelperProtocol { } private static func stopAndUnloadDaemons() { - let daemons = [kanataServiceID, vhidDaemonServiceID, vhidManagerServiceID] + let daemons = [kanataServiceID, outputBridgeServiceID, vhidDaemonServiceID, vhidManagerServiceID] for daemon in daemons { // Try to stop gracefully first _ = run("/bin/launchctl", ["kill", "TERM", "system/\(daemon)"]) @@ -684,6 +1007,7 @@ class HelperService: NSObject, HelperProtocol { private static func removeLaunchDaemonPlists() { let plists = [ "/Library/LaunchDaemons/\(kanataServiceID).plist", + "/Library/LaunchDaemons/\(outputBridgeServiceID).plist", "/Library/LaunchDaemons/\(vhidDaemonServiceID).plist", "/Library/LaunchDaemons/\(vhidManagerServiceID).plist", "/Library/LaunchDaemons/com.keypath.logrotate.plist", @@ -726,7 +1050,9 @@ class HelperService: NSObject, HelperProtocol { "/var/log/karabiner-vhid-manager.log", "/var/log/keypath-logrotate.log", "/var/log/com.keypath.helper.stdout.log", - "/var/log/com.keypath.helper.stderr.log" + "/var/log/com.keypath.helper.stderr.log", + KeyPathConstants.OutputBridge.stdoutLog, + KeyPathConstants.OutputBridge.stderrLog ] for log in logs { if FileManager.default.fileExists(atPath: log) { @@ -862,6 +1188,14 @@ class HelperService: NSObject, HelperProtocol { } } +private struct PreparedOutputBridgeSession: Codable, Sendable { + let sessionID: String + let socketPath: String + let hostPID: Int32 + let hostUID: UInt32 + let hostGID: UInt32 +} + // MARK: - Error Types /// Errors that can occur during privileged operations @@ -1008,7 +1342,7 @@ extension HelperService { private static let vhidDaemonPath = "/Library/Application Support/org.pqrs/" + "Karabiner-DriverKit-VirtualHIDDevice/Applications/" + "Karabiner-VirtualHIDDevice-Daemon.app/Contents/MacOS/" + "Karabiner-VirtualHIDDevice-Daemon" - private static let vhidManagerPath = + fileprivate static let vhidManagerPath = "/Applications/.Karabiner-VirtualHIDDevice-Manager.app/Contents/MacOS/Karabiner-VirtualHIDDevice-Manager" private static let karabinerTeamID = "G43BCU2T37" private static let karabinerDriverBundleID = @@ -1018,7 +1352,7 @@ extension HelperService { // This is only used for the deprecated download fallback path private static let requiredVHIDVersion = "6.0.0" - private static func appBundlePathFromHelper() -> String { + fileprivate static func appBundlePathFromHelper() -> String { let exe = CommandLine.arguments.first ?? "/Applications/KeyPath.app/Contents/Library/HelperTools/KeyPathHelper" diff --git a/Sources/KeyPathKanataLauncher/main.swift b/Sources/KeyPathKanataLauncher/main.swift new file mode 100644 index 000000000..1f641bcb0 --- /dev/null +++ b/Sources/KeyPathKanataLauncher/main.swift @@ -0,0 +1,682 @@ +import Foundation +import KeyPathCore +import ApplicationServices + +private enum Launcher { + static let retryCountPath = "/var/tmp/keypath-vhid-retry-count" + static let startupBlockedPath = "/var/tmp/keypath-startup-blocked" + static let maxRetries = 3 + static let retryResetSeconds: TimeInterval = 60 + static let preferencesPlistBasename = "com.keypath.KeyPath.plist" + static let verboseLoggingPrefKey = "KeyPath.Diagnostics.VerboseKanataLogging" + static let inProcessRuntimeEnvKey = "KEYPATH_EXPERIMENTAL_HOST_RUNTIME" + static let outputBridgeSocketEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SOCKET" + static let outputBridgeSessionEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SESSION" + static let outputBridgeModifierProbeEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SMOKE_MODIFIERS" + static let outputBridgeEmitProbeEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SMOKE_EMIT" + static let outputBridgeResetProbeEnvKey = "KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SMOKE_RESET" + static let passthruRuntimeEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_RUNTIME" + static let passthruForwardEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_FORWARD" + static let passthruOnlyEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_ONLY" + static let passthruInjectEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_INJECT" + static let passthruCaptureEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_CAPTURE" + static let passthruPersistEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_PERSIST" + static let passthruForwardLimit = 32 + static let passthruPollDurationEnvKey = "KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_POLL_MS" + static let passthruPollIntervalMillis: UInt32 = 25 + static let defaultPassthruPollDurationMillis: UInt64 = 1500 +} + +private func logLine(_ message: String) { + FileHandle.standardError.write(Data("[kanata-launcher] \(message)\n".utf8)) +} + +private func readRetryCount() -> Int { + let path = Launcher.retryCountPath + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let modified = attributes[.modificationDate] as? Date, + Date().timeIntervalSince(modified) <= Launcher.retryResetSeconds, + let raw = try? String(contentsOfFile: path, encoding: .utf8), + let value = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)) + else { + try? FileManager.default.removeItem(atPath: path) + return 0 + } + return value +} + +private func writeRetryCount(_ count: Int) { + try? "\(count)".write(toFile: Launcher.retryCountPath, atomically: true, encoding: .utf8) +} + +private func isVHIDDaemonRunning() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep") + process.arguments = ["-f", "VirtualHIDDevice-Daemon"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } +} + +private func consoleUser() -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/stat") + process.arguments = ["-f%Su", "/dev/console"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let user = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + return user.isEmpty ? "root" : user + } catch { + return "root" + } +} + +private func homeDirectory(for user: String) -> String { + guard user != "root", !user.isEmpty else { return "/var/root" } + if let dir = FileManager.default.homeDirectory(forUser: user)?.path, !dir.isEmpty { + return dir + } + return "/Users/\(user)" +} + +private func ensureConfigFile(for user: String, home: String) -> String { + let configPath = "\(home)/.config/keypath/keypath.kbd" + let configDir = URL(fileURLWithPath: configPath).deletingLastPathComponent().path + try? FileManager.default.createDirectory(atPath: configDir, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: configPath) { + FileManager.default.createFile(atPath: configPath, contents: Data()) + } + + if user != "root", let pw = getpwnam(user) { + _ = chown(configDir, pw.pointee.pw_uid, pw.pointee.pw_gid) + _ = chown(configPath, pw.pointee.pw_uid, pw.pointee.pw_gid) + } + + return configPath +} + +private func isVerboseLoggingEnabled(home: String) -> Bool { + let prefs = "\(home)/Library/Preferences/\(Launcher.preferencesPlistBasename)" + guard let dict = NSDictionary(contentsOfFile: prefs), + let value = dict[Launcher.verboseLoggingPrefKey] + else { + return false + } + + switch value { + case let number as NSNumber: + return number.boolValue + case let string as NSString: + return string.boolValue + default: + return false + } +} + +private func execKanata(commandLine args: [String]) -> Never { + let binaryPath = args[0] + let cArgs = args.map { strdup($0) } + [nil] + defer { + for ptr in cArgs where ptr != nil { + free(ptr) + } + } + + execv(binaryPath, cArgs) + let err = String(cString: strerror(errno)) + logLine("execv failed for \(binaryPath): \(err)") + Foundation.exit(1) +} + +private func shouldUseInProcessRuntime() -> Bool { + environmentFlag(Launcher.inProcessRuntimeEnvKey) +} + +private func environmentFlag(_ key: String) -> Bool { + let value = ProcessInfo.processInfo.environment[key]? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return value == "1" || value == "true" || value == "yes" +} + +private func environmentUInt64(_ key: String, defaultValue: UInt64) -> UInt64 { + guard + let raw = ProcessInfo.processInfo.environment[key]? + .trimmingCharacters(in: .whitespacesAndNewlines), + let value = UInt64(raw) + else { + return defaultValue + } + return value +} + +private func extractTCPPort(from arguments: [String]) -> UInt16 { + guard let portFlagIndex = arguments.firstIndex(of: "--port"), + arguments.indices.contains(portFlagIndex + 1), + let port = UInt16(arguments[portFlagIndex + 1]) + else { + return 0 + } + return port +} + +private func experimentalOutputBridgeSession() -> KanataOutputBridgeSession? { + let environment = ProcessInfo.processInfo.environment + guard + let socketPath = environment[Launcher.outputBridgeSocketEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !socketPath.isEmpty, + let sessionID = environment[Launcher.outputBridgeSessionEnvKey]? + .trimmingCharacters(in: .whitespacesAndNewlines), + !sessionID.isEmpty + else { + return nil + } + + return KanataOutputBridgeSession( + sessionID: sessionID, + socketPath: socketPath, + socketDirectory: (socketPath as NSString).deletingLastPathComponent, + hostPID: getpid(), + hostUID: getuid(), + hostGID: getgid(), + detail: "environment-provided experimental bridge session" + ) +} + +private func runExperimentalOutputBridgeSmoke( + session: KanataOutputBridgeSession +) throws { + let handshake = try KanataOutputBridgeClient.performHandshake(session: session) + logLine("Experimental output bridge handshake: \(String(describing: handshake))") + + let ping = try KanataOutputBridgeClient.ping(session: session) + logLine("Experimental output bridge ping: \(String(describing: ping))") + + if environmentFlag(Launcher.outputBridgeModifierProbeEnvKey) { + let modifiers = KanataOutputBridgeModifierState(leftShift: true) + let response = try KanataOutputBridgeClient.syncModifiers(modifiers, session: session) + logLine( + "Experimental output bridge modifier sync (\(String(describing: modifiers))): \(String(describing: response))" + ) + } + + if environmentFlag(Launcher.outputBridgeEmitProbeEnvKey) { + let event = KanataOutputBridgeKeyEvent( + usagePage: 0x07, + usage: 0x68, + action: .keyDown, + sequence: 1 + ) + let response = try KanataOutputBridgeClient.emitKey(event, session: session) + logLine("Experimental output bridge emit (\(String(describing: event))): \(String(describing: response))") + } + + if environmentFlag(Launcher.outputBridgeResetProbeEnvKey) { + let response = try KanataOutputBridgeClient.reset(session: session) + logLine("Experimental output bridge reset: \(String(describing: response))") + } +} + +private func runExperimentalPassthruRuntimeProbe( + runtimeHost: KanataRuntimeHost, + configPath: String, + tcpPort: UInt16, + outputBridgeSession: KanataOutputBridgeSession? +) { + guard environmentFlag(Launcher.passthruRuntimeEnvKey) else { + return + } + + let passthru = KanataHostBridge.createPassthruRuntime( + runtimeHost: runtimeHost, + configPath: configPath, + tcpPort: tcpPort + ) + logLine(passthru.result.logSummary) + + guard let handle = passthru.handle else { + return + } + + let shouldInject = environmentFlag(Launcher.passthruInjectEnvKey) + let shouldForward = environmentFlag(Launcher.passthruForwardEnvKey) + let shouldCapture = environmentFlag(Launcher.passthruCaptureEnvKey) + let shouldPersist = environmentFlag(Launcher.passthruPersistEnvKey) + + if shouldForward || shouldInject || shouldCapture { + logLine("Experimental passthru runtime starting") + switch handle.start() { + case .success: + logLine("Experimental passthru runtime started") + case let .failure(error): + logLine(error.logSummary) + return + } + } + + if shouldInject { + switch handle.sendInput(value: 1, usagePage: 0x07, usage: 0x04) { + case .success: + logLine("Experimental passthru runtime injected keyDown usagePage=0x07 usage=0x04") + case let .failure(error): + logLine(error.logSummary) + return + } + + switch handle.sendInput(value: 0, usagePage: 0x07, usage: 0x04) { + case .success: + logLine("Experimental passthru runtime injected keyUp usagePage=0x07 usage=0x04") + case let .failure(error): + logLine(error.logSummary) + return + } + } + + if shouldCapture { + runExperimentalPassthruCaptureLoop( + handle: handle, + outputBridgeSession: outputBridgeSession, + shouldForward: shouldForward + ) + return + } + + if shouldPersist { + var forwardedCount = 0 + logLine("Experimental passthru persistent non-capture loop starting") + while true { + _ = drainExperimentalPassthruOutput( + handle: handle, + outputBridgeSession: outputBridgeSession, + shouldForward: shouldForward, + forwardedCount: &forwardedCount, + maxForwardCount: nil + ) + usleep(Launcher.passthruPollIntervalMillis * 1_000) + } + } + + let shouldPoll = shouldForward || shouldInject + let pollDurationMillis = environmentUInt64( + Launcher.passthruPollDurationEnvKey, + defaultValue: Launcher.defaultPassthruPollDurationMillis + ) + let pollDeadline = DispatchTime.now().uptimeNanoseconds + (pollDurationMillis * 1_000_000) + + var forwardedCount = 0 + var sawEmptyPoll = false + logLine("Experimental passthru runtime entering poll loop") + while forwardedCount < Launcher.passthruForwardLimit { + switch handle.tryReceiveOutput() { + case let .success(event): + guard let event else { + if !shouldPoll || DispatchTime.now().uptimeNanoseconds >= pollDeadline { + if forwardedCount == 0 { + logLine("Experimental passthru runtime output channel is currently empty") + } else { + logLine("Experimental passthru runtime forwarded \(forwardedCount) output event(s)") + } + return + } + + if !sawEmptyPoll { + logLine("Experimental passthru runtime output channel is currently empty") + sawEmptyPoll = true + } + usleep(Launcher.passthruPollIntervalMillis * 1_000) + continue + } + + logLine( + "Experimental passthru runtime drained output event: value=\(event.value) page=\(event.usagePage) code=\(event.usage)" + ) + + guard shouldForward else { + forwardedCount += 1 + continue + } + + guard let outputBridgeSession else { + logLine("Experimental passthru forwarding requested but no output bridge session was provided") + return + } + + let bridgeEvent = KanataOutputBridgeKeyEvent( + usagePage: event.usagePage, + usage: event.usage, + action: event.value == 0 ? .keyUp : .keyDown, + sequence: UInt64(forwardedCount + 1) + ) + + do { + let response = try KanataOutputBridgeClient.emitKey(bridgeEvent, session: outputBridgeSession) + logLine( + "Experimental passthru forwarded output event: \(String(describing: bridgeEvent)) -> \(String(describing: response))" + ) + } catch { + logLine("Experimental passthru forwarding failed: \(error.localizedDescription)") + return + } + + forwardedCount += 1 + sawEmptyPoll = false + case let .failure(error): + logLine(error.logSummary) + return + } + } + + logLine("Experimental passthru runtime hit forwarding limit of \(Launcher.passthruForwardLimit) event(s)") +} + +private func drainExperimentalPassthruOutput( + handle: KanataHostBridgePassthruRuntimeHandle, + outputBridgeSession: KanataOutputBridgeSession?, + shouldForward: Bool, + forwardedCount: inout Int, + maxForwardCount: Int? +) -> Bool { + while maxForwardCount.map({ forwardedCount < $0 }) ?? true { + switch handle.tryReceiveOutput() { + case let .success(event): + guard let event else { + return false + } + + logLine( + "Experimental passthru runtime drained output event: value=\(event.value) page=\(event.usagePage) code=\(event.usage)" + ) + + guard shouldForward else { + forwardedCount += 1 + continue + } + + guard let outputBridgeSession else { + logLine("Experimental passthru forwarding requested but no output bridge session was provided") + return true + } + + let bridgeEvent = KanataOutputBridgeKeyEvent( + usagePage: event.usagePage, + usage: event.usage, + action: event.value == 0 ? .keyUp : .keyDown, + sequence: UInt64(forwardedCount + 1) + ) + + do { + let response = try KanataOutputBridgeClient.emitKey(bridgeEvent, session: outputBridgeSession) + logLine( + "Experimental passthru forwarded output event: \(String(describing: bridgeEvent)) -> \(String(describing: response))" + ) + } catch { + logLine("Experimental passthru forwarding failed: \(error.localizedDescription)") + return true + } + + forwardedCount += 1 + case let .failure(error): + logLine(error.logSummary) + return true + } + } + + return true +} + +private func runExperimentalPassthruCaptureLoop( + handle: KanataHostBridgePassthruRuntimeHandle, + outputBridgeSession: KanataOutputBridgeSession?, + shouldForward: Bool +) { + let shouldPersist = environmentFlag(Launcher.passthruPersistEnvKey) + let durationMillis = environmentUInt64( + Launcher.passthruPollDurationEnvKey, + defaultValue: Launcher.defaultPassthruPollDurationMillis + ) + let deadline = Date().addingTimeInterval(TimeInterval(durationMillis) / 1000) + final class CaptureState { + let handle: KanataHostBridgePassthruRuntimeHandle + let outputBridgeSession: KanataOutputBridgeSession? + let shouldForward: Bool + let shouldPersist: Bool + var forwardedCount = 0 + + init( + handle: KanataHostBridgePassthruRuntimeHandle, + outputBridgeSession: KanataOutputBridgeSession?, + shouldForward: Bool, + shouldPersist: Bool + ) { + self.handle = handle + self.outputBridgeSession = outputBridgeSession + self.shouldForward = shouldForward + self.shouldPersist = shouldPersist + } + } + let state = CaptureState( + handle: handle, + outputBridgeSession: outputBridgeSession, + shouldForward: shouldForward, + shouldPersist: shouldPersist + ) + + logLine("Experimental passthru capture loop starting") + let refcon = Unmanaged.passRetained(state).toOpaque() + let eventMask = (1 << CGEventType.keyDown.rawValue) + | (1 << CGEventType.keyUp.rawValue) + | (1 << CGEventType.flagsChanged.rawValue) + + let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .listenOnly, + eventsOfInterest: CGEventMask(eventMask), + callback: { _, _, event, refcon in + guard let refcon else { + return Unmanaged.passUnretained(event) + } + + let state = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + let keyCode = event.getIntegerValueField(.keyboardEventKeycode) + let isKeyDown: Bool + + switch event.type { + case .keyDown: + if event.getIntegerValueField(.keyboardEventAutorepeat) == 1 { + return Unmanaged.passUnretained(event) + } + isKeyDown = true + case .keyUp: + isKeyDown = false + case .flagsChanged: + let modifierMask = event.flags + switch keyCode { + case 55, 54: + isKeyDown = modifierMask.contains(.maskCommand) + case 56, 60: + isKeyDown = modifierMask.contains(.maskShift) + case 58, 61: + isKeyDown = modifierMask.contains(.maskAlternate) + case 59, 62: + isKeyDown = modifierMask.contains(.maskControl) + case 57: + isKeyDown = modifierMask.contains(.maskAlphaShift) + default: + return Unmanaged.passUnretained(event) + } + default: + return Unmanaged.passUnretained(event) + } + + guard let inputEvent = ExperimentalHostPassthruInputMapper.eventForKeyCode(keyCode, isKeyDown: isKeyDown) else { + logLine("Experimental passthru capture ignoring unsupported mac keyCode=\(keyCode)") + return Unmanaged.passUnretained(event) + } + + switch state.handle.sendInput( + value: inputEvent.value, + usagePage: inputEvent.usagePage, + usage: inputEvent.usage + ) { + case .success: + logLine( + "Experimental passthru captured mac keyCode=\(keyCode) -> usagePage=\(inputEvent.usagePage) usage=\(inputEvent.usage) value=\(inputEvent.value)" + ) + _ = drainExperimentalPassthruOutput( + handle: state.handle, + outputBridgeSession: state.outputBridgeSession, + shouldForward: state.shouldForward, + forwardedCount: &state.forwardedCount, + maxForwardCount: state.shouldPersist ? nil : Launcher.passthruForwardLimit + ) + case let .failure(error): + logLine(error.logSummary) + } + + return Unmanaged.passUnretained(event) + }, + userInfo: refcon + ) + + defer { + Unmanaged.fromOpaque(refcon).release() + if let eventTap { + CFMachPortInvalidate(eventTap) + } + } + + guard let eventTap else { + logLine("Experimental passthru capture failed to create CGEvent tap") + return + } + + let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, eventTap, 0) + CFRunLoopAddSource(CFRunLoopGetCurrent(), runLoopSource, .commonModes) + CGEvent.tapEnable(tap: eventTap, enable: true) + + while shouldPersist || (Date() < deadline && state.forwardedCount < Launcher.passthruForwardLimit) { + _ = drainExperimentalPassthruOutput( + handle: handle, + outputBridgeSession: outputBridgeSession, + shouldForward: shouldForward, + forwardedCount: &state.forwardedCount, + maxForwardCount: shouldPersist ? nil : Launcher.passthruForwardLimit + ) + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.025)) + } + + _ = drainExperimentalPassthruOutput( + handle: handle, + outputBridgeSession: outputBridgeSession, + shouldForward: shouldForward, + forwardedCount: &state.forwardedCount, + maxForwardCount: shouldPersist ? nil : Launcher.passthruForwardLimit + ) + + if shouldPersist { + logLine("Experimental passthru persistent capture loop exited after forwarding \(state.forwardedCount) output event(s)") + } else { + logLine("Experimental passthru capture loop completed with \(state.forwardedCount) forwarded output event(s)") + } +} + +private func handleMissingVHID() -> Never { + let retryCount = readRetryCount() + 1 + writeRetryCount(retryCount) + logLine("VirtualHID daemon not running (attempt \(retryCount)/\(Launcher.maxRetries))") + + if retryCount >= Launcher.maxRetries { + let message = "\(Date()): VirtualHID daemon not available after \(Launcher.maxRetries) attempts\n" + try? message.write(toFile: Launcher.startupBlockedPath, atomically: true, encoding: .utf8) + logLine("Max retries reached. Exiting cleanly to stop restart loop.") + Foundation.exit(0) + } + + Foundation.exit(1) +} + +@main +struct KeyPathKanataLauncher { + static func main() { + guard isVHIDDaemonRunning() else { + handleMissingVHID() + } + + try? FileManager.default.removeItem(atPath: Launcher.retryCountPath) + try? FileManager.default.removeItem(atPath: Launcher.startupBlockedPath) + + let user = consoleUser() + let home = homeDirectory(for: user) + let configPath = ensureConfigFile(for: user, home: home) + let runtimeHost = KanataRuntimeHost.current() + let addTrace = isVerboseLoggingEnabled(home: home) + + let launchRequest = KanataRuntimeLaunchRequest( + configPath: configPath, + inheritedArguments: Array(CommandLine.arguments.dropFirst()), + addTraceLogging: addTrace + ) + let binaryPath = launchRequest.resolvedCoreBinaryPath(using: runtimeHost) + let commandLine = launchRequest.commandLine(binaryPath: binaryPath) + + logLine("Launching Kanata for user=\(user) config=\(configPath)") + logLine("Runtime host path: \(runtimeHost.launcherPath)") + logLine(KanataHostBridge.probe(runtimeHost: runtimeHost).logSummary) + logLine(KanataHostBridge.validateConfig(runtimeHost: runtimeHost, configPath: configPath).logSummary) + logLine(KanataHostBridge.createRuntime(runtimeHost: runtimeHost, configPath: configPath).logSummary) + + if shouldUseInProcessRuntime() { + let tcpPort = extractTCPPort(from: launchRequest.inheritedArguments) + logLine("Running in-process host runtime mode") + let outputBridgeSession = experimentalOutputBridgeSession() + runExperimentalPassthruRuntimeProbe( + runtimeHost: runtimeHost, + configPath: configPath, + tcpPort: tcpPort, + outputBridgeSession: outputBridgeSession + ) + if environmentFlag(Launcher.passthruOnlyEnvKey) { + logLine("Experimental passthru-only host mode completed") + Foundation.exit(0) + } + if let outputBridgeSession { + do { + try runExperimentalOutputBridgeSmoke(session: outputBridgeSession) + } catch { + logLine("Experimental output bridge smoke test failed: \(error.localizedDescription)") + Foundation.exit(1) + } + } else { + logLine("Experimental output bridge session not provided; skipping socket smoke test") + } + let result = KanataHostBridge.runRuntime( + runtimeHost: runtimeHost, + configPath: configPath, + tcpPort: tcpPort + ) + logLine(result.logSummary) + Foundation.exit(result == .completed ? 0 : 1) + } + + logLine("Using kanata binary: \(binaryPath)") + if addTrace { + logLine("Verbose logging enabled via user preference (--trace)") + } + + execKanata(commandLine: commandLine) + } +} diff --git a/Sources/KeyPathOutputBridge/Info.plist b/Sources/KeyPathOutputBridge/Info.plist new file mode 100644 index 000000000..9839b2e41 --- /dev/null +++ b/Sources/KeyPathOutputBridge/Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleIdentifier + com.keypath.output-bridge + CFBundleName + KeyPathOutputBridge + CFBundleShortVersionString + 1.1.0 + CFBundleVersion + 1 + CFBundleInfoDictionaryVersion + 6.0 + + diff --git a/Sources/KeyPathOutputBridge/KeyPathOutputBridge.entitlements b/Sources/KeyPathOutputBridge/KeyPathOutputBridge.entitlements new file mode 100644 index 000000000..4c3fbcfc0 --- /dev/null +++ b/Sources/KeyPathOutputBridge/KeyPathOutputBridge.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/Sources/KeyPathOutputBridge/com.keypath.output-bridge.plist b/Sources/KeyPathOutputBridge/com.keypath.output-bridge.plist new file mode 100644 index 000000000..e7d5ed7bb --- /dev/null +++ b/Sources/KeyPathOutputBridge/com.keypath.output-bridge.plist @@ -0,0 +1,30 @@ + + + + + Label + com.keypath.output-bridge + ProgramArguments + + /Applications/KeyPath.app/Contents/Library/HelperTools/KeyPathOutputBridge + + RunAtLoad + + KeepAlive + + StandardOutPath + /var/log/com.keypath.output-bridge.stdout.log + StandardErrorPath + /var/log/com.keypath.output-bridge.stderr.log + UserName + root + GroupName + wheel + ThrottleInterval + 10 + AssociatedBundleIdentifiers + + com.keypath.KeyPath + + + diff --git a/Sources/KeyPathOutputBridge/main.swift b/Sources/KeyPathOutputBridge/main.swift new file mode 100644 index 000000000..69cbbbc19 --- /dev/null +++ b/Sources/KeyPathOutputBridge/main.swift @@ -0,0 +1,534 @@ +import Darwin +import Foundation +import KeyPathCore +import os + +private struct CompanionPreparedSession: Codable, Sendable { + let sessionID: String + let socketPath: String + let hostPID: Int32 + let hostUID: UInt32 + let hostGID: UInt32 + + var session: KanataOutputBridgeSession { + KanataOutputBridgeSession( + sessionID: sessionID, + socketPath: socketPath, + socketDirectory: (socketPath as NSString).deletingLastPathComponent, + hostPID: hostPID, + hostUID: hostUID, + hostGID: hostGID + ) + } +} + +private struct OutputBridgeFailure: Error, LocalizedError { + let message: String + var errorDescription: String? { message } +} + +private enum OutputBridgeEmitter { + private static let outputReadyTimeoutMillis: UInt64 = 5_000 + private static let logger = Logger(subsystem: KeyPathConstants.Bundle.outputBridgeID, category: "emitter") + nonisolated(unsafe) private static var cachedBridgeHandle: UnsafeMutableRawPointer? + nonisolated(unsafe) private static var outputSinkInitialized = false + + private typealias EmitKeyFunction = @convention(c) ( + UInt32, + UInt32, + Bool, + UnsafeMutablePointer?, + Int + ) -> Bool + private typealias InitializeOutputSinkFunction = @convention(c) ( + UnsafeMutablePointer?, + Int + ) -> Bool + private typealias OutputReadyFunction = @convention(c) () -> Bool + private typealias WaitUntilOutputReadyFunction = @convention(c) (UInt64) -> Bool + + static func emitKey(_ event: KanataOutputBridgeKeyEvent) -> Result { + switch ensureOutputReady() { + case .success: + break + case let .failure(error): + return .failure(error) + } + + switch emitKeyOnce(event) { + case .success: + return .success(()) + case let .failure(error): + guard error.message.contains("sink disconnected") else { + return .failure(error) + } + + switch ensureOutputReady(forceActivate: true) { + case .success: + break + case let .failure(activationError): + return .failure(activationError) + } + + return emitKeyOnce(event) + } + } + + static func syncModifiers( + from current: KanataOutputBridgeModifierState, + to desired: KanataOutputBridgeModifierState + ) -> Result { + for event in modifierTransitions(from: current, to: desired) { + switch emitKey(event) { + case .success: + continue + case let .failure(error): + return .failure(error) + } + } + return .success(()) + } + + static func reset() -> Result { + let result = run("/Applications/.Karabiner-VirtualHIDDevice-Manager.app/Contents/MacOS/Karabiner-VirtualHIDDevice-Manager", ["activate"]) + guard result.status == 0 else { + return .failure(.init(message: result.out.isEmpty ? "failed to activate Karabiner VirtualHID manager" : result.out)) + } + outputSinkInitialized = false + return ensureOutputReady(forceActivate: true) + } + + private static func ensureOutputReady(forceActivate: Bool = false) -> Result { + withBridgeFunctions { initializeOutputSink, _, outputReady, waitUntilReady in + if !outputSinkInitialized || forceActivate { + var errorBuffer = Array(repeating: 0, count: 2048) + let initialized = initializeOutputSink(&errorBuffer, errorBuffer.count) + guard initialized else { + return .failure(.init(message: decodeCStringBuffer(errorBuffer) ?? "failed to initialize DriverKit output sink")) + } + outputSinkInitialized = true + } + + if outputReady() { + return .success(()) + } + + guard waitUntilReady(outputReadyTimeoutMillis) else { + return .failure(.init(message: "DriverKit virtual keyboard not ready after waiting \(outputReadyTimeoutMillis)ms")) + } + return .success(()) + } + } + + private static func emitKeyOnce(_ event: KanataOutputBridgeKeyEvent) -> Result { + withBridgeFunctions { _, emitKey, _, _ in + var errorBuffer = Array(repeating: 0, count: 2048) + let success = emitKey(event.usagePage, event.usage, event.action == .keyDown, &errorBuffer, errorBuffer.count) + return success + ? .success(()) + : .failure(.init(message: decodeCStringBuffer(errorBuffer) ?? "unknown output bridge emit failure")) + } + } + + private static func withBridgeFunctions( + _ body: (InitializeOutputSinkFunction, EmitKeyFunction, OutputReadyFunction, WaitUntilOutputReadyFunction) -> Result + ) -> Result { + guard let handle = bridgeHandle() else { + return .failure(.init(message: "failed to load host bridge")) + } + + guard let initializeSymbol = dlsym(handle, "keypath_kanata_bridge_initialize_output_sink"), + let emitSymbol = dlsym(handle, "keypath_kanata_bridge_emit_key"), + let outputReadySymbol = dlsym(handle, "keypath_kanata_bridge_output_ready"), + let waitUntilReadySymbol = dlsym(handle, "keypath_kanata_bridge_wait_until_output_ready") + else { + return .failure(.init(message: dlerror().map { String(cString: $0) } ?? "missing output bridge symbol")) + } + + let initializeOutputSink = unsafeBitCast(initializeSymbol, to: InitializeOutputSinkFunction.self) + let emitKey = unsafeBitCast(emitSymbol, to: EmitKeyFunction.self) + let outputReady = unsafeBitCast(outputReadySymbol, to: OutputReadyFunction.self) + let waitUntilReady = unsafeBitCast(waitUntilReadySymbol, to: WaitUntilOutputReadyFunction.self) + return body(initializeOutputSink, emitKey, outputReady, waitUntilReady) + } + + private static func bridgeHandle() -> UnsafeMutableRawPointer? { + if let cachedBridgeHandle { return cachedBridgeHandle } + let bridgePath = "/Applications/KeyPath.app/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib" + guard FileManager.default.fileExists(atPath: bridgePath), let handle = dlopen(bridgePath, RTLD_NOW | RTLD_LOCAL) else { + return nil + } + cachedBridgeHandle = handle + return handle + } + + private static func decodeCStringBuffer(_ buffer: [CChar]) -> String? { + let bytes = buffer.map(UInt8.init(bitPattern:)) + let endIndex = bytes.firstIndex(of: 0) ?? bytes.endIndex + let decoded = String(decoding: bytes[.. [KanataOutputBridgeKeyEvent] { + let mappings: [(Bool, Bool, UInt32)] = [ + (current.leftControl, desired.leftControl, 0xE0), + (current.leftShift, desired.leftShift, 0xE1), + (current.leftAlt, desired.leftAlt, 0xE2), + (current.leftCommand, desired.leftCommand, 0xE3), + (current.rightControl, desired.rightControl, 0xE4), + (current.rightShift, desired.rightShift, 0xE5), + (current.rightAlt, desired.rightAlt, 0xE6), + (current.rightCommand, desired.rightCommand, 0xE7), + ] + + var events: [KanataOutputBridgeKeyEvent] = [] + var sequence: UInt64 = 1 + + for mapping in mappings where mapping.0 && !mapping.1 { + events.append(.init(usagePage: 0x07, usage: mapping.2, action: .keyUp, sequence: sequence)) + sequence += 1 + } + + for mapping in mappings where !mapping.0 && mapping.1 { + events.append(.init(usagePage: 0x07, usage: mapping.2, action: .keyDown, sequence: sequence)) + sequence += 1 + } + + return events + } + + private static func run(_ path: String, _ arguments: [String]) -> (status: Int32, out: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + let output = Pipe() + process.standardOutput = output + process.standardError = output + do { + try process.run() + process.waitUntilExit() + let data = output.fileHandleForReading.readDataToEndOfFile() + return (process.terminationStatus, String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)) + } catch { + return (1, error.localizedDescription) + } + } +} + +private final class CompanionServer: @unchecked Sendable { + private struct PeerCredentials { + let uid: uid_t + let gid: gid_t + let pid: pid_t + } + + private let session: KanataOutputBridgeSession + private let queue: DispatchQueue + private var listenerFD: Int32 = -1 + private var started = false + private var currentModifierState = KanataOutputBridgeModifierState() + + init(session: KanataOutputBridgeSession) { + self.session = session + self.queue = DispatchQueue(label: "com.keypath.output-bridge.\(session.sessionID)") + } + + func startIfNeeded() throws { + guard !started else { return } + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw NSError(domain: "KeyPathOutputBridge", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "failed to create output bridge socket: \(errno)"]) + } + + _ = unlink(session.socketPath) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let maxCount = MemoryLayout.size(ofValue: address.sun_path) + guard session.socketPath.utf8.count < maxCount else { + close(fd) + throw NSError(domain: "KeyPathOutputBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "output bridge socket path too long"]) + } + + withUnsafeMutablePointer(to: &address.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + _ = session.socketPath.withCString { source in + strncpy(raw, source, maxCount - 1) + } + } + + let addressLength = socklen_t(MemoryLayout.size) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, addressLength) + } + } + guard bindResult == 0 else { + let code = errno + close(fd) + throw NSError(domain: "KeyPathOutputBridge", code: Int(code), userInfo: [NSLocalizedDescriptionKey: "failed to bind output bridge socket: \(code)"]) + } + + chown(session.socketPath, uid_t(session.hostUID), gid_t(session.hostGID)) + chmod(session.socketPath, 0o600) + + guard listen(fd, 8) == 0 else { + let code = errno + close(fd) + throw NSError(domain: "KeyPathOutputBridge", code: Int(code), userInfo: [NSLocalizedDescriptionKey: "failed to listen on output bridge socket: \(code)"]) + } + + listenerFD = fd + started = true + queue.async { [self] in acceptLoop() } + } + + func stop() { + guard started else { + _ = unlink(session.socketPath) + return + } + started = false + if listenerFD >= 0 { + shutdown(listenerFD, SHUT_RDWR) + close(listenerFD) + listenerFD = -1 + } + _ = unlink(session.socketPath) + } + + private func acceptLoop() { + while started { + let clientFD = accept(listenerFD, nil, nil) + if clientFD < 0 { + if errno == EINTR { continue } + if !started { break } + continue + } + handle(clientFD: clientFD) + close(clientFD) + } + } + + private func handle(clientFD: Int32) { + do { + let credentials = try peerCredentials(for: clientFD) + var authenticated = false + while true { + let request = try readRequest(from: clientFD) + let response: KanataOutputBridgeResponse + let keepOpen: Bool + + switch request { + case let .handshake(handshake): + if handshake.sessionID != session.sessionID { + response = .error(.init(code: "invalid_session", message: "handshake session mismatch", detail: "expected sessionID \(session.sessionID)")) + } else if handshake.hostPID != session.hostPID { + response = .error(.init(code: "invalid_host_pid", message: "handshake host PID mismatch", detail: "expected hostPID \(session.hostPID)")) + } else if credentials.uid != uid_t(session.hostUID) || credentials.gid != gid_t(session.hostGID) { + response = .error(.init(code: "invalid_peer", message: "socket peer credentials did not match prepared host", detail: "expected uid/gid \(session.hostUID):\(session.hostGID)")) + } else if credentials.pid != pid_t(session.hostPID) { + response = .error(.init(code: "invalid_peer_pid", message: "socket peer PID did not match prepared host", detail: "expected pid \(session.hostPID)")) + } else { + authenticated = true + response = .ready(version: KanataOutputBridgeProtocol.version) + } + keepOpen = true + case .ping: + response = .pong + keepOpen = false + case let .emitKey(event): + guard authenticated else { + try writeResponse(.error(.init(code: "unauthenticated", message: "output bridge handshake required before privileged requests")), to: clientFD) + return + } + switch OutputBridgeEmitter.emitKey(event) { + case .success: + response = .acknowledged(sequence: event.sequence) + case let .failure(error): + response = .error(.init(code: "emit_key_failed", message: "failed to emit key through privileged output bridge", detail: error.message)) + } + keepOpen = false + case let .syncModifiers(modifiers): + guard authenticated else { + try writeResponse(.error(.init(code: "unauthenticated", message: "output bridge handshake required before privileged requests")), to: clientFD) + return + } + switch OutputBridgeEmitter.syncModifiers(from: currentModifierState, to: modifiers) { + case .success: + currentModifierState = modifiers + response = .acknowledged(sequence: nil) + case let .failure(error): + response = .error(.init(code: "sync_modifiers_failed", message: "failed to sync modifier state through privileged output bridge", detail: error.message)) + } + keepOpen = false + case .reset: + guard authenticated else { + try writeResponse(.error(.init(code: "unauthenticated", message: "output bridge handshake required before privileged requests")), to: clientFD) + return + } + switch OutputBridgeEmitter.reset() { + case .success: + currentModifierState = .init() + response = .acknowledged(sequence: nil) + case let .failure(error): + response = .error(.init(code: "vhid_activate_failed", message: "failed to reset privileged output bridge", detail: error.message)) + } + keepOpen = false + } + + try writeResponse(response, to: clientFD) + guard keepOpen else { return } + } + } catch { + let response = KanataOutputBridgeResponse.error(.init(code: "server_error", message: "output bridge request handling failed", detail: error.localizedDescription)) + try? writeResponse(response, to: clientFD) + } + } + + private func peerCredentials(for fd: Int32) throws -> PeerCredentials { + var uid: uid_t = 0 + var gid: gid_t = 0 + guard getpeereid(fd, &uid, &gid) == 0 else { + throw NSError(domain: "KeyPathOutputBridge", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "failed to read peer uid/gid: \(errno)"]) + } + + var pid: pid_t = 0 + var pidSize = socklen_t(MemoryLayout.size) + let pidResult = withUnsafeMutablePointer(to: &pid) { pidPtr in + getsockopt(fd, 0, LOCAL_PEERPID, pidPtr, &pidSize) + } + guard pidResult == 0 else { + throw NSError(domain: "KeyPathOutputBridge", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "failed to read peer pid: \(errno)"]) + } + + return PeerCredentials(uid: uid, gid: gid, pid: pid) + } + + private func readRequest(from fd: Int32) throws -> KanataOutputBridgeRequest { + var buffer = Data() + var byte: UInt8 = 0 + while true { + let result = Darwin.read(fd, &byte, 1) + if result < 0 { + throw NSError(domain: "KeyPathOutputBridge", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "output bridge read failed: \(errno)"]) + } + if result == 0 { + throw NSError(domain: "KeyPathOutputBridge", code: 1, userInfo: [NSLocalizedDescriptionKey: "output bridge client disconnected"]) + } + buffer.append(byte) + if byte == 0x0A { + return try KanataOutputBridgeCodec.decode(buffer, as: KanataOutputBridgeRequest.self) + } + } + } + + private func writeResponse(_ response: KanataOutputBridgeResponse, to fd: Int32) throws { + let data = try KanataOutputBridgeCodec.encode(response) + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesRemaining = rawBuffer.count + var offset = 0 + while bytesRemaining > 0 { + let written = Darwin.write(fd, baseAddress.advanced(by: offset), bytesRemaining) + if written < 0 { + throw NSError(domain: "KeyPathOutputBridge", code: Int(errno), userInfo: [NSLocalizedDescriptionKey: "output bridge write failed: \(errno)"]) + } + bytesRemaining -= written + offset += written + } + } + } +} + +private final class OutputBridgeCompanion { + private let logger = Logger(subsystem: KeyPathConstants.Bundle.outputBridgeID, category: "daemon") + private var servers: [String: CompanionServer] = [:] + + func run() throws -> Never { + try ensureDirectories() + logger.info("starting output bridge companion") + while true { + reconcileSessions() + sleep(1) + } + } + + private func reconcileSessions() { + let sessions = loadSessions() + let liveSessionIDs = Set(sessions.map(\.sessionID)) + + for (sessionID, server) in servers where !liveSessionIDs.contains(sessionID) { + server.stop() + servers.removeValue(forKey: sessionID) + } + + for session in sessions { + guard isProcessAlive(session.hostPID) else { + retireSessionFile(session.sessionID) + continue + } + + if servers[session.sessionID] == nil { + let server = CompanionServer(session: session) + do { + try server.startIfNeeded() + servers[session.sessionID] = server + logger.info("activated output bridge session \(session.sessionID, privacy: .public)") + } catch { + logger.error("failed to activate output bridge session \(session.sessionID, privacy: .public): \(error.localizedDescription, privacy: .public)") + } + } + } + } + + private func loadSessions() -> [KanataOutputBridgeSession] { + let dir = URL(fileURLWithPath: KeyPathConstants.OutputBridge.sessionDirectory, isDirectory: true) + let files = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) + ?? [] + return files.compactMap { url in + guard url.pathExtension == "json", + let data = try? Data(contentsOf: url), + let record = try? JSONDecoder().decode(CompanionPreparedSession.self, from: data) + else { + return nil + } + return record.session + } + } + + private func ensureDirectories() throws { + let fileManager = FileManager.default + for path in [KeyPathConstants.OutputBridge.runDirectory, KeyPathConstants.OutputBridge.socketDirectory, KeyPathConstants.OutputBridge.sessionDirectory] { + if !fileManager.fileExists(atPath: path) { + try fileManager.createDirectory(atPath: path, withIntermediateDirectories: true) + } + } + chmod(KeyPathConstants.OutputBridge.runDirectory, 0o755) + chmod(KeyPathConstants.OutputBridge.socketDirectory, 0o711) + chmod(KeyPathConstants.OutputBridge.sessionDirectory, 0o700) + } + + private func retireSessionFile(_ sessionID: String) { + let path = (KeyPathConstants.OutputBridge.sessionDirectory as NSString).appendingPathComponent("\(sessionID).json") + try? FileManager.default.removeItem(atPath: path) + if let server = servers.removeValue(forKey: sessionID) { + server.stop() + } + } + + private func isProcessAlive(_ pid: Int32) -> Bool { + guard pid > 0 else { return false } + if kill(pid, 0) == 0 { return true } + return errno == EPERM + } +} + +do { + try OutputBridgeCompanion().run() +} catch { + FileHandle.standardError.write(Data("[keypath-output-bridge] fatal: \(error.localizedDescription)\n".utf8)) + Foundation.exit(1) +} diff --git a/Sources/KeyPathWizardCore/SystemSnapshot.swift b/Sources/KeyPathWizardCore/SystemSnapshot.swift index e4c45db11..f1554a721 100644 --- a/Sources/KeyPathWizardCore/SystemSnapshot.swift +++ b/Sources/KeyPathWizardCore/SystemSnapshot.swift @@ -90,6 +90,16 @@ public struct SystemSnapshot: Sendable { ) } } + if !health.kanataInputCaptureReady { + issues.append( + .permissionMissing( + app: "Kanata", + permission: "Input Monitoring", + action: + "Regrant permission for /Library/KeyPath/bin/kanata and ensure it can open the built-in keyboard" + ) + ) + } // Conflict issues for conflict in conflicts.conflicts { @@ -163,7 +173,6 @@ public struct ComponentStatus: Sendable { public let karabinerDaemonRunning: Bool public let vhidDeviceInstalled: Bool public let vhidDeviceHealthy: Bool - public let launchDaemonServicesHealthy: Bool /// VHID services health (daemon + manager) independent of Kanata service /// Use for Karabiner Components page which should only care about VHID, not Kanata public let vhidServicesHealthy: Bool @@ -176,7 +185,6 @@ public struct ComponentStatus: Sendable { karabinerDaemonRunning: Bool, vhidDeviceInstalled: Bool, vhidDeviceHealthy: Bool, - launchDaemonServicesHealthy: Bool, vhidServicesHealthy: Bool, vhidVersionMismatch: Bool, kanataBinaryVersionMismatch: Bool = false @@ -186,15 +194,15 @@ public struct ComponentStatus: Sendable { self.karabinerDaemonRunning = karabinerDaemonRunning self.vhidDeviceInstalled = vhidDeviceInstalled self.vhidDeviceHealthy = vhidDeviceHealthy - self.launchDaemonServicesHealthy = launchDaemonServicesHealthy self.vhidServicesHealthy = vhidServicesHealthy self.vhidVersionMismatch = vhidVersionMismatch self.kanataBinaryVersionMismatch = kanataBinaryVersionMismatch } + /// Required components for the normal split-runtime architecture. public var hasAllRequired: Bool { kanataBinaryInstalled && karabinerDriverInstalled && karabinerDaemonRunning && vhidDeviceHealthy - && launchDaemonServicesHealthy && !vhidVersionMismatch && !kanataBinaryVersionMismatch + && vhidServicesHealthy && !vhidVersionMismatch && !kanataBinaryVersionMismatch } /// Convenience factory for empty/fallback state @@ -205,7 +213,6 @@ public struct ComponentStatus: Sendable { karabinerDaemonRunning: false, vhidDeviceInstalled: false, vhidDeviceHealthy: false, - launchDaemonServicesHealthy: false, vhidServicesHealthy: false, vhidVersionMismatch: false, kanataBinaryVersionMismatch: false @@ -244,16 +251,32 @@ public struct HealthStatus: Sendable { public let kanataRunning: Bool public let karabinerDaemonRunning: Bool public let vhidHealthy: Bool + public let kanataInputCaptureReady: Bool + public let kanataInputCaptureIssue: String? + public let activeRuntimePathTitle: String? + public let activeRuntimePathDetail: String? - public init(kanataRunning: Bool, karabinerDaemonRunning: Bool, vhidHealthy: Bool) { + public init( + kanataRunning: Bool, + karabinerDaemonRunning: Bool, + vhidHealthy: Bool, + kanataInputCaptureReady: Bool = true, + kanataInputCaptureIssue: String? = nil, + activeRuntimePathTitle: String? = nil, + activeRuntimePathDetail: String? = nil + ) { self.kanataRunning = kanataRunning self.karabinerDaemonRunning = karabinerDaemonRunning self.vhidHealthy = vhidHealthy + self.kanataInputCaptureReady = kanataInputCaptureReady + self.kanataInputCaptureIssue = kanataInputCaptureIssue + self.activeRuntimePathTitle = activeRuntimePathTitle + self.activeRuntimePathDetail = activeRuntimePathDetail } /// Overall health (includes Kanata runtime) public var isHealthy: Bool { - kanataRunning && karabinerDaemonRunning && vhidHealthy + kanataRunning && karabinerDaemonRunning && vhidHealthy && kanataInputCaptureReady } /// Health of background services only (Karabiner daemon + VHID driver) diff --git a/Sources/KeyPathWizardCore/WizardTypes.swift b/Sources/KeyPathWizardCore/WizardTypes.swift index a2397ee9b..f5a85fad1 100644 --- a/Sources/KeyPathWizardCore/WizardTypes.swift +++ b/Sources/KeyPathWizardCore/WizardTypes.swift @@ -156,19 +156,17 @@ public enum ComponentRequirement: Equatable, Sendable { case privilegedHelperUnhealthy // Helper installed but not responding/working case kanataBinaryMissing // Kanata binary needs to be installed to system location case bundledKanataMissing // CRITICAL: Bundled kanata binary missing from app bundle (packaging issue) - case kanataService + case keyPathRuntime case karabinerDriver case karabinerDaemon case vhidDeviceManager case vhidDeviceActivation case vhidDeviceRunning - case launchDaemonServices - case launchDaemonServicesUnhealthy // Services loaded but crashed/failing case vhidDaemonMisconfigured case vhidDriverVersionMismatch // Karabiner driver version incompatible with kanata version case kanataBinaryVersionMismatch // Installed kanata binary fails trusted identity checks case kanataTCPServer // TCP server for Kanata communication and config validation - case orphanedKanataProcess // Kanata running but not managed by LaunchDaemon + case orphanedKanataProcess // External Kanata runtime is conflicting with KeyPath runtime case communicationServerConfiguration // Communication server enabled but not configured in service case communicationServerNotResponding // Communication server configured but not responding case tcpServerConfiguration // TCP enabled but not configured in service @@ -186,13 +184,10 @@ public enum AutoFixAction: Equatable, Sendable { case installMissingComponents case createConfigDirectories case activateVHIDDeviceManager - case installLaunchDaemonServices + case installRequiredRuntimeServices case installBundledKanata // Install bundled kanata binary to system location case repairVHIDDaemonServices case synchronizeConfigPaths // Fix config path mismatches - case restartUnhealthyServices // Restart services that are loaded but crashed - case adoptOrphanedProcess // Install LaunchDaemon to manage existing process - case replaceOrphanedProcess // Kill orphaned process and start managed one case installLogRotation // Install newsyslog config for log rotation to keep logs under 10MB case replaceKanataWithBundled // Replace system kanata with bundled Developer ID signed binary case enableTCPServer // Enable TCP server for communication diff --git a/Tests/KeyPathSnapshotTests/MediumViewSnapshotTests.swift b/Tests/KeyPathSnapshotTests/MediumViewSnapshotTests.swift index 29fc5e317..dce840911 100644 --- a/Tests/KeyPathSnapshotTests/MediumViewSnapshotTests.swift +++ b/Tests/KeyPathSnapshotTests/MediumViewSnapshotTests.swift @@ -92,6 +92,7 @@ final class MediumViewSnapshotTests: ScreenshotTestCase { isKanataConnected: true, healthIndicatorState: .healthy, drawerButtonHighlighted: false, + layoutHasDrawerButtons: false, onClose: {}, onToggleInspector: {}, onHealthTap: {}, @@ -120,6 +121,7 @@ final class MediumViewSnapshotTests: ScreenshotTestCase { isKanataConnected: false, healthIndicatorState: .unhealthy(issueCount: 2), drawerButtonHighlighted: false, + layoutHasDrawerButtons: false, onClose: {}, onToggleInspector: {}, onHealthTap: {}, @@ -148,6 +150,7 @@ final class MediumViewSnapshotTests: ScreenshotTestCase { isKanataConnected: true, healthIndicatorState: .healthy, drawerButtonHighlighted: false, + layoutHasDrawerButtons: false, onClose: {}, onToggleInspector: {}, onHealthTap: {}, diff --git a/Tests/KeyPathSnapshotTests/Support/SnapshotHelpers.swift b/Tests/KeyPathSnapshotTests/Support/SnapshotHelpers.swift index ce69ce59b..7e69d7c3a 100644 --- a/Tests/KeyPathSnapshotTests/Support/SnapshotHelpers.swift +++ b/Tests/KeyPathSnapshotTests/Support/SnapshotHelpers.swift @@ -32,6 +32,7 @@ func withIsolatedDefaults( body: () throws -> Void ) rethrows { let suite = UserDefaults(suiteName: suiteName)! + let standardDefaults = Foundation.UserDefaults() suite.removePersistentDomain(forName: suiteName) // Set known defaults for @AppStorage keys @@ -49,9 +50,9 @@ func withIsolatedDefaults( } // Swap standard defaults - UserDefaults.standard.removePersistentDomain(forName: Bundle.main.bundleIdentifier ?? "") + standardDefaults.removePersistentDomain(forName: suiteName + ".standard") for (key, value) in baseDefaults.merging(defaults, uniquingKeysWith: { _, new in new }) { - UserDefaults.standard.set(value, forKey: key) + standardDefaults.set(value, forKey: key) } defer { @@ -150,7 +151,7 @@ class ScreenshotTestCase: XCTestCase { "launcherWelcomeSeenForBuild": "999", ] for (key, value) in defaults { - UserDefaults.standard.set(value, forKey: key) + Foundation.UserDefaults().set(value, forKey: key) } } @@ -165,7 +166,7 @@ class ScreenshotTestCase: XCTestCase { "launcherWelcomeSeenForBuild", ] for key in keys { - UserDefaults.standard.removeObject(forKey: key) + Foundation.UserDefaults().removeObject(forKey: key) } super.tearDown() } diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingPerFinger.hrm-per-finger-sliders.png b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingPerFinger.hrm-per-finger-sliders.png index 0c80bd02e..58b22e542 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingPerFinger.hrm-per-finger-sliders.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingPerFinger.hrm-per-finger-sliders.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingSlider.hrm-typing-feel-slider.png b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingSlider.hrm-typing-feel-slider.png index 0c98fe16e..01d600546 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingSlider.hrm-typing-feel-slider.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testHomeRowTimingSlider.hrm-typing-feel-slider.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardSelected.keymap-card-selected.png b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardSelected.keymap-card-selected.png index 4edd147fd..a54439e90 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardSelected.keymap-card-selected.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardSelected.keymap-card-selected.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardUnselected.keymap-card-unselected.png b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardUnselected.keymap-card-unselected.png index 37898a83a..3bba13709 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardUnselected.keymap-card-unselected.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testKeymapCardUnselected.keymap-card-unselected.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testLauncherDrawer.action-uri-launcher-drawer.png b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testLauncherDrawer.action-uri-launcher-drawer.png index 290b4d741..d44be50bf 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testLauncherDrawer.action-uri-launcher-drawer.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/EasyViewSnapshotTests/testLauncherDrawer.action-uri-launcher-drawer.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testInspectorKeymapPicker.alt-layouts-keymap-picker.png b/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testInspectorKeymapPicker.alt-layouts-keymap-picker.png index 8ec497cf1..77eace9dc 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testInspectorKeymapPicker.alt-layouts-keymap-picker.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testInspectorKeymapPicker.alt-layouts-keymap-picker.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testLiveKeyboardOverlayBase.install-overlay-base.png b/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testLiveKeyboardOverlayBase.install-overlay-base.png index c0b85df80..06653d521 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testLiveKeyboardOverlayBase.install-overlay-base.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/HardViewSnapshotTests/testLiveKeyboardOverlayBase.install-overlay-base.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testKeyboardSelectionGrid.kb-layouts-layout-picker.png b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testKeyboardSelectionGrid.kb-layouts-layout-picker.png index a6e0a5d52..ac8c32dbd 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testKeyboardSelectionGrid.kb-layouts-layout-picker.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testKeyboardSelectionGrid.kb-layouts-layout-picker.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderCollapsed.action-uri-overlay-header.png b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderCollapsed.action-uri-overlay-header.png index 5d863683e..8def6917e 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderCollapsed.action-uri-overlay-header.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderCollapsed.action-uri-overlay-header.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderDisconnected.overlay-header-unhealthy.png b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderDisconnected.overlay-header-unhealthy.png index c5a644764..7c8d3fdb8 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderDisconnected.overlay-header-unhealthy.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderDisconnected.overlay-header-unhealthy.png differ diff --git a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderHealthy.install-overlay-health-green.png b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderHealthy.install-overlay-health-green.png index 1d6daeb43..103fbf985 100644 Binary files a/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderHealthy.install-overlay-health-green.png and b/Tests/KeyPathSnapshotTests/__Snapshots__/MediumViewSnapshotTests/testOverlayDragHeaderHealthy.install-overlay-health-green.png differ diff --git a/Tests/KeyPathTests/AppContext/AppContextServiceTests.swift b/Tests/KeyPathTests/AppContext/AppContextServiceTests.swift index 0e78cba2c..28717ae7e 100644 --- a/Tests/KeyPathTests/AppContext/AppContextServiceTests.swift +++ b/Tests/KeyPathTests/AppContext/AppContextServiceTests.swift @@ -8,7 +8,7 @@ final class AppContextServiceTests: XCTestCase { private var store: AppKeymapStore! override func setUpWithError() throws { - tempDirectory = FileManager.default.temporaryDirectory + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) store = AppKeymapStore.testStore(at: tempDirectory.appendingPathComponent("AppKeymaps.json")) diff --git a/Tests/KeyPathTests/AppContext/AppKeymapStoreTests.swift b/Tests/KeyPathTests/AppContext/AppKeymapStoreTests.swift index 0df069cf3..a0360ac51 100644 --- a/Tests/KeyPathTests/AppContext/AppKeymapStoreTests.swift +++ b/Tests/KeyPathTests/AppContext/AppKeymapStoreTests.swift @@ -6,7 +6,7 @@ final class AppKeymapStoreTests: XCTestCase { private var store: AppKeymapStore! override func setUpWithError() throws { - tempDirectory = FileManager.default.temporaryDirectory + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) store = AppKeymapStore.testStore(at: tempDirectory.appendingPathComponent("AppKeymaps.json")) diff --git a/Tests/KeyPathTests/AppContext/MapperViewModelAppSpecificTests.swift b/Tests/KeyPathTests/AppContext/MapperViewModelAppSpecificTests.swift index 5cec1076c..4142f0d32 100644 --- a/Tests/KeyPathTests/AppContext/MapperViewModelAppSpecificTests.swift +++ b/Tests/KeyPathTests/AppContext/MapperViewModelAppSpecificTests.swift @@ -15,7 +15,7 @@ final class MapperViewModelAppSpecificTests: XCTestCase { } override func setUpWithError() throws { - tempDirectory = FileManager.default.temporaryDirectory + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) store = AppKeymapStore.testStore(at: tempDirectory.appendingPathComponent("AppKeymaps.json")) diff --git a/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift b/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift index 19c89c49c..281e7637a 100644 --- a/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift +++ b/Tests/KeyPathTests/BuildScripts/SigningPipelineTests.swift @@ -23,10 +23,12 @@ final class SigningPipelineTests: XCTestCase { } catch { return (code: -1, stdout: "", stderr: "Failed to start process: \(error)") } - process.waitUntilExit() + while process.isRunning { + usleep(1_000) + } - let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() - let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stdoutData = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data() + let stderrData = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() return ( code: process.terminationStatus, @@ -36,7 +38,7 @@ final class SigningPipelineTests: XCTestCase { } func testCodesignWrapperRespectsDryRun() { - let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString) FileManager.default.createFile(atPath: tempFile.path, contents: Data()) let script = """ @@ -49,7 +51,7 @@ final class SigningPipelineTests: XCTestCase { } func testCodesignWrapperPropagatesFailures() { - let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString) FileManager.default.createFile(atPath: tempFile.path, contents: Data()) // Explicitly unset KP_SIGN_DRY_RUN to test real failure propagation diff --git a/Tests/KeyPathTests/CLI/KeyPathCLITests.swift b/Tests/KeyPathTests/CLI/KeyPathCLITests.swift index ec96feb65..f58031a1d 100644 --- a/Tests/KeyPathTests/CLI/KeyPathCLITests.swift +++ b/Tests/KeyPathTests/CLI/KeyPathCLITests.swift @@ -1,10 +1,74 @@ @testable import KeyPathAppKit +@testable import KeyPathCore import KeyPathPermissions import KeyPathWizardCore @preconcurrency import XCTest @MainActor final class KeyPathCLITests: XCTestCase { + func testStatusCommandPrintsOutputBridgeCompanionDetails() async throws { + var context = makeSystemContext() + context = SystemContext( + permissions: context.permissions, + services: context.services, + conflicts: context.conflicts, + components: context.components, + helper: context.helper, + system: EngineSystemInfo( + macOSVersion: "15.0", + driverCompatible: true, + outputBridgeStatus: KanataOutputBridgeStatus( + available: true, + companionRunning: true, + requiresPrivilegedBridge: true, + socketDirectory: "/Library/KeyPath/run/kpko", + detail: "privileged output companion is installed and launchctl can inspect system/com.keypath.output-bridge" + ) + ), + timestamp: context.timestamp + ) + let stub = InstallerEngineStub(context: context) + let cli = KeyPathCLI(installerEngine: stub, privilegeBrokerFactory: { PrivilegeBroker() }) + + let output = try await captureStandardOutput { + _ = await cli.run(arguments: ["keypath-cli", "status"]) + } + + XCTAssertTrue(output.contains("--- Output Bridge Companion ---")) + XCTAssertTrue(output.contains("Available: ✅")) + XCTAssertTrue(output.contains("Running: ✅")) + XCTAssertTrue(output.contains("Socket Directory: /Library/KeyPath/run/kpko")) + XCTAssertTrue(output.contains("com.keypath.output-bridge")) + } + + func testStatusCommandPrintsActiveRuntimePath() async throws { + var context = makeSystemContext() + context = SystemContext( + permissions: context.permissions, + services: HealthStatus( + kanataRunning: true, + karabinerDaemonRunning: true, + vhidHealthy: true, + activeRuntimePathTitle: "Split Runtime Host", + activeRuntimePathDetail: "Bundled user-session host active with privileged output companion" + ), + conflicts: context.conflicts, + components: context.components, + helper: context.helper, + system: context.system, + timestamp: context.timestamp + ) + let stub = InstallerEngineStub(context: context) + let cli = KeyPathCLI(installerEngine: stub, privilegeBrokerFactory: { PrivilegeBroker() }) + + let output = try await captureStandardOutput { + _ = await cli.run(arguments: ["keypath-cli", "status"]) + } + + XCTAssertTrue(output.contains("Active Runtime Path: Split Runtime Host")) + XCTAssertTrue(output.contains("Runtime Detail: Bundled user-session host active with privileged output companion")) + } + func testStatusCommandReturnsSuccessWhenSystemOperational() async { let context = makeSystemContext() let stub = InstallerEngineStub(context: context) @@ -54,6 +118,37 @@ final class KeyPathCLITests: XCTestCase { // MARK: - Test Helpers +@MainActor +private func captureStandardOutput( + _ operation: () async throws -> Void +) async throws -> String { + var pipeDescriptors = [Int32](repeating: 0, count: 2) + guard pipe(&pipeDescriptors) == 0 else { + throw POSIXError(.EIO) + } + let originalStdout = dup(STDOUT_FILENO) + dup2(pipeDescriptors[1], STDOUT_FILENO) + + do { + try await operation() + fflush(stdout) + } catch { + fflush(stdout) + dup2(originalStdout, STDOUT_FILENO) + close(originalStdout) + close(pipeDescriptors[1]) + close(pipeDescriptors[0]) + throw error + } + + dup2(originalStdout, STDOUT_FILENO) + close(originalStdout) + close(pipeDescriptors[1]) + let readHandle = FileHandle(fileDescriptor: pipeDescriptors[0], closeOnDealloc: true) + let data = (try? readHandle.readToEnd()) ?? Data() + return String(decoding: data, as: UTF8.self) +} + private func makeSystemContext( helperReady: Bool = true, componentsReady: Bool = true, @@ -84,7 +179,6 @@ private func makeSystemContext( karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: true, vhidServicesHealthy: true, vhidVersionMismatch: false ) diff --git a/Tests/KeyPathTests/Core/ExperimentalHostPassthruInputTests.swift b/Tests/KeyPathTests/Core/ExperimentalHostPassthruInputTests.swift new file mode 100644 index 000000000..57d1961d3 --- /dev/null +++ b/Tests/KeyPathTests/Core/ExperimentalHostPassthruInputTests.swift @@ -0,0 +1,24 @@ +import XCTest +@testable import KeyPathCore + +final class ExperimentalHostPassthruInputTests: XCTestCase { + func testMapsLetterAKeyDown() { + let event = ExperimentalHostPassthruInputMapper.eventForKeyCode(0, isKeyDown: true) + XCTAssertEqual( + event, + ExperimentalHostPassthruInputEvent(value: 1, usagePage: 0x07, usage: 0x04) + ) + } + + func testMapsLeftShiftKeyUp() { + let event = ExperimentalHostPassthruInputMapper.eventForKeyCode(56, isKeyDown: false) + XCTAssertEqual( + event, + ExperimentalHostPassthruInputEvent(value: 0, usagePage: 0x07, usage: 0xE1) + ) + } + + func testUnknownKeyCodeReturnsNil() { + XCTAssertNil(ExperimentalHostPassthruInputMapper.eventForKeyCode(9_999, isKeyDown: true)) + } +} diff --git a/Tests/KeyPathTests/Core/KanataHostBridgeTests.swift b/Tests/KeyPathTests/Core/KanataHostBridgeTests.swift new file mode 100644 index 000000000..0070b0ee4 --- /dev/null +++ b/Tests/KeyPathTests/Core/KanataHostBridgeTests.swift @@ -0,0 +1,67 @@ +@testable import KeyPathCore +import XCTest + +final class KanataHostBridgeTests: XCTestCase { + func testProbeReturnsUnavailableWhenLibraryMissing() { + let runtimeHost = KanataRuntimeHost( + launcherPath: "/tmp/kanata-launcher", + bridgeLibraryPath: "/definitely/missing/libkeypath_kanata_host_bridge.dylib", + bundledCorePath: "/tmp/kanata", + systemCorePath: "/tmp/system-kanata" + ) + + XCTAssertEqual( + KanataHostBridge.probe(runtimeHost: runtimeHost), + .unavailable(reason: "library not found at /definitely/missing/libkeypath_kanata_host_bridge.dylib") + ) + } + + func testLoadedLogSummaryFormatsExpectedFields() { + XCTAssertEqual( + KanataHostBridgeProbeResult.loaded(version: "0.1.0", defaultConfigCount: 2).logSummary, + "Host bridge loaded: version=0.1.0 default_cfg_count=2" + ) + } + + func testRunResultLogSummaryFormatsFailure() { + XCTAssertEqual( + KanataHostBridgeRunResult.failed(reason: "permission denied").logSummary, + "Host bridge runtime failed: permission denied" + ) + } + + func testRunResultLogSummaryFormatsUnavailable() { + XCTAssertEqual( + KanataHostBridgeRunResult.unavailable(reason: "missing symbol").logSummary, + "Host bridge runtime unavailable: missing symbol" + ) + } + + func testCreatePassthruRuntimeReturnsUnavailableWhenLibraryMissing() { + let runtimeHost = KanataRuntimeHost( + launcherPath: "/tmp/kanata-launcher", + bridgeLibraryPath: "/definitely/missing/libkeypath_kanata_host_bridge.dylib", + bundledCorePath: "/tmp/kanata", + systemCorePath: "/tmp/system-kanata" + ) + + let result = KanataHostBridge.createPassthruRuntime( + runtimeHost: runtimeHost, + configPath: "/tmp/keypath.kbd", + tcpPort: 37_001 + ) + + XCTAssertEqual( + result.result, + .unavailable(reason: "library not found at /definitely/missing/libkeypath_kanata_host_bridge.dylib") + ) + XCTAssertNil(result.handle) + } + + func testPassthruRuntimeResultLogSummaryFormatsCreated() { + XCTAssertEqual( + KanataHostBridgePassthruRuntimeResult.created(layerCount: 3).logSummary, + "Host bridge passthru runtime created successfully: layer_count=3" + ) + } +} diff --git a/Tests/KeyPathTests/Core/KanataOutputBridgeProtocolTests.swift b/Tests/KeyPathTests/Core/KanataOutputBridgeProtocolTests.swift new file mode 100644 index 000000000..78bd8e9a9 --- /dev/null +++ b/Tests/KeyPathTests/Core/KanataOutputBridgeProtocolTests.swift @@ -0,0 +1,295 @@ +import Darwin +import Foundation +import KeyPathCore +@preconcurrency import XCTest + +final class KanataOutputBridgeProtocolTests: XCTestCase { + func testHandshakeRequestRoundTripsThroughJSON() throws { + let request = KanataOutputBridgeRequest.handshake( + KanataOutputBridgeHandshake(sessionID: "session-123", hostPID: 4242) + ) + + let data = try JSONEncoder().encode(request) + let decoded = try JSONDecoder().decode(KanataOutputBridgeRequest.self, from: data) + + XCTAssertEqual(decoded, request) + } + + func testKeyEventRequestRoundTripsThroughJSON() throws { + let request = KanataOutputBridgeRequest.emitKey( + KanataOutputBridgeKeyEvent( + usagePage: 0x07, + usage: 0x04, + action: .keyDown, + sequence: 7 + ) + ) + + let data = try JSONEncoder().encode(request) + let decoded = try JSONDecoder().decode(KanataOutputBridgeRequest.self, from: data) + + XCTAssertEqual(decoded, request) + } + + func testErrorResponseRoundTripsThroughJSON() throws { + let response = KanataOutputBridgeResponse.error( + KanataOutputBridgeError( + code: "vhid_unavailable", + message: "VirtualHID output is unavailable", + detail: "root bridge not connected" + ) + ) + + let data = try JSONEncoder().encode(response) + let decoded = try JSONDecoder().decode(KanataOutputBridgeResponse.self, from: data) + + XCTAssertEqual(decoded, response) + } + + func testUnixSocketHandshakeAndProtocolOperations() throws { + let socketPath = temporarySocketPath() + defer { _ = unlink(socketPath) } + + let serverFD = try makeServerSocket(path: socketPath) + defer { close(serverFD) } + + let handshakeExpectation = expectation(description: "handshake") + handshakeExpectation.expectedFulfillmentCount = 5 + let pingExpectation = expectation(description: "ping") + let emitExpectation = expectation(description: "emitKey") + let modifiersExpectation = expectation(description: "syncModifiers") + let resetExpectation = expectation(description: "reset") + + let server = DispatchQueue(label: "KanataOutputBridgeProtocolTests.server") + server.async { + for _ in 0 ..< 5 { + let clientFD = accept(serverFD, nil, nil) + XCTAssertGreaterThanOrEqual(clientFD, 0) + defer { close(clientFD) } + + do { + var sawHandshake = false + while true { + let request: KanataOutputBridgeRequest + do { + request = try Self.readRequest(from: clientFD) + } catch KanataOutputBridgeClientError.connectionClosed where sawHandshake { + break + } + var shouldClose = false + switch request { + case let .handshake(handshake): + XCTAssertFalse(sawHandshake) + sawHandshake = true + XCTAssertEqual(handshake.sessionID, "session-123") + XCTAssertEqual(handshake.hostPID, 4242) + try Self.writeResponse(.ready(version: KanataOutputBridgeProtocol.version), to: clientFD) + handshakeExpectation.fulfill() + case .ping: + try Self.writeResponse(.pong, to: clientFD) + pingExpectation.fulfill() + shouldClose = true + case let .emitKey(event): + XCTAssertTrue(sawHandshake) + XCTAssertEqual(event.sequence, 7) + XCTAssertEqual(event.usagePage, 0x07) + XCTAssertEqual(event.usage, 0x04) + XCTAssertEqual(event.action, .keyDown) + try Self.writeResponse(.acknowledged(sequence: event.sequence), to: clientFD) + emitExpectation.fulfill() + shouldClose = true + case let .syncModifiers(modifiers): + XCTAssertTrue(sawHandshake) + XCTAssertTrue(modifiers.leftShift) + XCTAssertTrue(modifiers.rightCommand) + XCTAssertFalse(modifiers.leftControl) + try Self.writeResponse(.acknowledged(sequence: nil), to: clientFD) + modifiersExpectation.fulfill() + shouldClose = true + case .reset: + XCTAssertTrue(sawHandshake) + try Self.writeResponse(.acknowledged(sequence: nil), to: clientFD) + resetExpectation.fulfill() + shouldClose = true + } + if shouldClose { + break + } + } + } catch { + XCTFail("Server error: \(error)") + } + } + } + + let session = KanataOutputBridgeSession( + sessionID: "session-123", + socketPath: socketPath, + socketDirectory: URL(fileURLWithPath: socketPath).deletingLastPathComponent().path, + hostPID: 4242, + hostUID: UInt32(getuid()), + hostGID: UInt32(getgid()) + ) + + let handshake = try KanataOutputBridgeClient.performHandshake(session: session) + XCTAssertEqual( + handshake, + KanataOutputBridgeResponse.ready(version: KanataOutputBridgeProtocol.version) + ) + + let pong = try KanataOutputBridgeClient.ping(session: session) + XCTAssertEqual(pong, KanataOutputBridgeResponse.pong) + + let emitAck = try KanataOutputBridgeClient.emitKey( + KanataOutputBridgeKeyEvent( + usagePage: 0x07, + usage: 0x04, + action: .keyDown, + sequence: 7 + ), + session: session + ) + XCTAssertEqual(emitAck, KanataOutputBridgeResponse.acknowledged(sequence: 7)) + + let modifiersAck = try KanataOutputBridgeClient.syncModifiers( + KanataOutputBridgeModifierState(leftShift: true, rightCommand: true), + session: session + ) + XCTAssertEqual(modifiersAck, KanataOutputBridgeResponse.acknowledged(sequence: nil)) + + let resetAck = try KanataOutputBridgeClient.reset(session: session) + XCTAssertEqual(resetAck, KanataOutputBridgeResponse.acknowledged(sequence: nil)) + + wait( + for: [ + handshakeExpectation, + pingExpectation, + emitExpectation, + modifiersExpectation, + resetExpectation + ], + timeout: 2.0 + ) + } + + func testConnectToMissingSocketReturnsFreshSessionHint() throws { + let socketPath = temporarySocketPath() + _ = unlink(socketPath) + + XCTAssertThrowsError(try KanataOutputBridgeClient.connect(to: socketPath)) { error in + XCTAssertEqual( + error as? KanataOutputBridgeClientError, + .socketNotFound(socketPath) + ) + } + } + + func testConnectToStaleSocketReturnsFreshSessionHint() throws { + let socketPath = temporarySocketPath() + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + XCTAssertGreaterThanOrEqual(fd, 0) + guard fd >= 0 else { throw KanataOutputBridgeClientError.socketCreationFailed(errno) } + defer { + close(fd) + _ = unlink(socketPath) + } + + try bindSocket(fd, path: socketPath) + + XCTAssertThrowsError(try KanataOutputBridgeClient.connect(to: socketPath)) { error in + XCTAssertEqual( + error as? KanataOutputBridgeClientError, + .socketNotListening(socketPath) + ) + } + } + + private func temporarySocketPath() -> String { + let suffix = UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8) + return "/tmp/kp-bridge-\(suffix).sock" + } + + private func makeServerSocket(path: String) throws -> Int32 { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + XCTAssertGreaterThanOrEqual(fd, 0) + guard fd >= 0 else { throw KanataOutputBridgeClientError.socketCreationFailed(errno) } + + _ = unlink(path) + + try bindSocket(fd, path: path) + + guard listen(fd, 4) == 0 else { + let code = errno + close(fd) + throw KanataOutputBridgeClientError.connectFailed(code) + } + + return fd + } + + private func bindSocket(_ fd: Int32, path: String) throws { + _ = unlink(path) + + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + let maxCount = MemoryLayout.size(ofValue: address.sun_path) + guard path.utf8.count < maxCount else { + close(fd) + throw KanataOutputBridgeClientError.invalidSocketPath + } + + withUnsafeMutablePointer(to: &address.sun_path) { ptr in + let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: CChar.self) + _ = path.withCString { source in + strncpy(raw, source, maxCount - 1) + } + } + + let addressLength = socklen_t(MemoryLayout.size) + let bindResult = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.bind(fd, sockaddrPtr, addressLength) + } + } + guard bindResult == 0 else { + let code = errno + close(fd) + throw KanataOutputBridgeClientError.connectFailed(code) + } + } + + private static func readRequest(from fd: Int32) throws -> KanataOutputBridgeRequest { + var buffer = Data() + var byte: UInt8 = 0 + while true { + let result = Darwin.read(fd, &byte, 1) + if result < 0 { + throw KanataOutputBridgeClientError.readFailed(errno) + } + if result == 0 { + throw KanataOutputBridgeClientError.connectionClosed + } + buffer.append(byte) + if byte == 0x0A { + return try KanataOutputBridgeCodec.decode(buffer, as: KanataOutputBridgeRequest.self) + } + } + } + + private static func writeResponse(_ response: KanataOutputBridgeResponse, to fd: Int32) throws { + let data = try KanataOutputBridgeCodec.encode(response) + try data.withUnsafeBytes { rawBuffer in + guard let baseAddress = rawBuffer.baseAddress else { return } + var bytesRemaining = rawBuffer.count + var offset = 0 + while bytesRemaining > 0 { + let written = Darwin.write(fd, baseAddress.advanced(by: offset), bytesRemaining) + if written < 0 { + throw KanataOutputBridgeClientError.writeFailed(errno) + } + bytesRemaining -= written + offset += written + } + } + } +} diff --git a/Tests/KeyPathTests/Core/KanataOutputBridgeStatusTests.swift b/Tests/KeyPathTests/Core/KanataOutputBridgeStatusTests.swift new file mode 100644 index 000000000..b7d1216df --- /dev/null +++ b/Tests/KeyPathTests/Core/KanataOutputBridgeStatusTests.swift @@ -0,0 +1,26 @@ +import XCTest +@testable import KeyPathCore + +final class KanataOutputBridgeStatusTests: XCTestCase { + func testDecodesLegacyPayloadWithoutCompanionRunning() throws { + let payload = """ + { + "available": true, + "requiresPrivilegedBridge": true, + "socketDirectory": "/Library/KeyPath/run/kpko", + "detail": "legacy payload" + } + """ + + let status = try JSONDecoder().decode( + KanataOutputBridgeStatus.self, + from: Data(payload.utf8) + ) + + XCTAssertTrue(status.available) + XCTAssertTrue(status.companionRunning) + XCTAssertTrue(status.requiresPrivilegedBridge) + XCTAssertEqual(status.socketDirectory, "/Library/KeyPath/run/kpko") + XCTAssertEqual(status.detail, "legacy payload") + } +} diff --git a/Tests/KeyPathTests/Core/KanataRuntimeHostTests.swift b/Tests/KeyPathTests/Core/KanataRuntimeHostTests.swift new file mode 100644 index 000000000..0a9c56462 --- /dev/null +++ b/Tests/KeyPathTests/Core/KanataRuntimeHostTests.swift @@ -0,0 +1,79 @@ +@testable import KeyPathCore +import XCTest + +final class KanataRuntimeHostTests: XCTestCase { + func testCurrentUsesBundleRelativePaths() { + let host = KanataRuntimeHost.current(bundlePath: "/Applications/KeyPath.app") + + XCTAssertEqual(host.launcherPath, "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher") + XCTAssertEqual(host.bridgeLibraryPath, "/Applications/KeyPath.app/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib") + XCTAssertEqual(host.bundledCorePath, "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata") + XCTAssertEqual(host.systemCorePath, "/Library/KeyPath/bin/kanata") + } + + func testCurrentNormalizesLauncherExecutableDirectoryToAppBundleRoot() { + let host = KanataRuntimeHost.current( + bundlePath: "/Applications/KeyPath.app/Contents/Library/KeyPath" + ) + + XCTAssertEqual(host.launcherPath, "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher") + XCTAssertEqual(host.bridgeLibraryPath, "/Applications/KeyPath.app/Contents/Library/KeyPath/libkeypath_kanata_host_bridge.dylib") + XCTAssertEqual(host.bundledCorePath, "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata") + } + + func testPreferredCoreBinaryFallsBackToBundledPathWhenSystemBinaryMissing() { + let host = KanataRuntimeHost( + launcherPath: "/tmp/kanata-launcher", + bridgeLibraryPath: "/tmp/libkeypath_kanata_host_bridge.dylib", + bundledCorePath: "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata", + systemCorePath: "/definitely/missing/kanata" + ) + + XCTAssertEqual(host.preferredCoreBinaryPath(), host.bundledCorePath) + } + + func testCurrentRemapsSystemCorePathForTestRoot() { + let host = KanataRuntimeHost.current( + bundlePath: "/Applications/KeyPath.app", + systemRoot: "/tmp/keypath-test-root/" + ) + + XCTAssertEqual(host.systemCorePath, "/tmp/keypath-test-root/Library/KeyPath/bin/kanata") + } + + func testLaunchRequestBuildsCommandLineAndAddsTraceWhenNeeded() { + let request = KanataRuntimeLaunchRequest( + configPath: "/Users/test/.config/keypath/keypath.kbd", + inheritedArguments: ["--port", "37001", "--log-layer-changes"], + addTraceLogging: true + ) + + XCTAssertEqual( + request.commandLine(binaryPath: "/Library/KeyPath/bin/kanata"), + [ + "/Library/KeyPath/bin/kanata", + "--cfg", "/Users/test/.config/keypath/keypath.kbd", + "--port", "37001", + "--log-layer-changes", + "--trace" + ] + ) + } + + func testLaunchRequestDoesNotDuplicateTraceWhenDebugAlreadyPresent() { + let request = KanataRuntimeLaunchRequest( + configPath: "/Users/test/.config/keypath/keypath.kbd", + inheritedArguments: ["--debug"], + addTraceLogging: true + ) + + XCTAssertEqual( + request.commandLine(binaryPath: "/Library/KeyPath/bin/kanata"), + [ + "/Library/KeyPath/bin/kanata", + "--cfg", "/Users/test/.config/keypath/keypath.kbd", + "--debug" + ] + ) + } +} diff --git a/Tests/KeyPathTests/Core/KanataRuntimePathDecisionTests.swift b/Tests/KeyPathTests/Core/KanataRuntimePathDecisionTests.swift new file mode 100644 index 000000000..ee0a435c0 --- /dev/null +++ b/Tests/KeyPathTests/Core/KanataRuntimePathDecisionTests.swift @@ -0,0 +1,140 @@ +@testable import KeyPathCore +import XCTest + +final class KanataRuntimePathDecisionTests: XCTestCase { + func testEvaluatorPrefersSplitRuntimeWhenHostAndOutputBridgeAreReady() { + let decision = KanataRuntimePathEvaluator.decide( + KanataRuntimePathInputs( + hostBridgeLoaded: true, + hostConfigValid: true, + hostRuntimeConstructible: true, + helperReady: true, + outputBridgeStatus: KanataOutputBridgeStatus( + available: true, + companionRunning: true, + requiresPrivilegedBridge: true, + socketDirectory: KeyPathConstants.VirtualHID.rootOnlyTmp, + detail: nil + ), + legacySystemBinaryAvailable: true + ) + ) + + XCTAssertEqual( + decision, + .useSplitRuntime( + reason: "bundled host can own input runtime and privileged output bridge is required at \(KeyPathConstants.VirtualHID.rootOnlyTmp)" + ) + ) + } + + func testEvaluatorFallsBackToLegacyBinaryWhenHostRuntimeIsNotReady() { + let decision = KanataRuntimePathEvaluator.decide( + KanataRuntimePathInputs( + hostBridgeLoaded: true, + hostConfigValid: true, + hostRuntimeConstructible: false, + helperReady: true, + outputBridgeStatus: nil, + legacySystemBinaryAvailable: true + ) + ) + + XCTAssertEqual( + decision, + .useLegacySystemBinary( + reason: "bundled host runtime is not ready yet, so continue using the legacy system binary" + ) + ) + } + + func testEvaluatorFallsBackToLegacyBinaryWhenCompanionIsInstalledButUnhealthy() { + let decision = KanataRuntimePathEvaluator.decide( + KanataRuntimePathInputs( + hostBridgeLoaded: true, + hostConfigValid: true, + hostRuntimeConstructible: true, + helperReady: true, + outputBridgeStatus: KanataOutputBridgeStatus( + available: true, + companionRunning: false, + requiresPrivilegedBridge: true, + socketDirectory: KeyPathConstants.OutputBridge.socketDirectory, + detail: "privileged output companion is installed but unhealthy" + ), + legacySystemBinaryAvailable: true + ) + ) + + XCTAssertEqual( + decision, + .useLegacySystemBinary( + reason: "privileged output companion is installed but not healthy, so continue using the legacy system binary" + ) + ) + } + + func testEvaluatorFallsBackToLegacyBinaryWhenOutputBridgeStatusIsUnavailable() { + let decision = KanataRuntimePathEvaluator.decide( + KanataRuntimePathInputs( + hostBridgeLoaded: true, + hostConfigValid: true, + hostRuntimeConstructible: true, + helperReady: true, + outputBridgeStatus: nil, + legacySystemBinaryAvailable: true + ) + ) + + XCTAssertEqual( + decision, + .useLegacySystemBinary( + reason: "privileged output bridge status is unavailable, so keep the legacy system binary as fallback" + ) + ) + } + + func testEvaluatorBlocksWhenNeitherHostNorLegacyPathIsUsable() { + let decision = KanataRuntimePathEvaluator.decide( + KanataRuntimePathInputs( + hostBridgeLoaded: false, + hostConfigValid: false, + hostRuntimeConstructible: false, + helperReady: false, + outputBridgeStatus: nil, + legacySystemBinaryAvailable: false + ) + ) + + XCTAssertEqual( + decision, + .blocked(reason: "bundled host bridge is unavailable and no legacy system binary exists") + ) + } + + func testEvaluatorFallsBackToLegacyBinaryWhenHelperIsUnavailable() { + let decision = KanataRuntimePathEvaluator.decide( + KanataRuntimePathInputs( + hostBridgeLoaded: true, + hostConfigValid: true, + hostRuntimeConstructible: true, + helperReady: false, + outputBridgeStatus: KanataOutputBridgeStatus( + available: true, + companionRunning: true, + requiresPrivilegedBridge: true, + socketDirectory: KeyPathConstants.OutputBridge.socketDirectory, + detail: "privileged output companion is installed and healthy" + ), + legacySystemBinaryAvailable: true + ) + ) + + XCTAssertEqual( + decision, + .useLegacySystemBinary( + reason: "privileged helper is not ready, so continue using the legacy system binary" + ) + ) + } +} diff --git a/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift b/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift index 2ec60cd23..0f72e5c42 100644 --- a/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift +++ b/Tests/KeyPathTests/Core/PrivilegedOperationsCoordinatorTests.swift @@ -223,13 +223,14 @@ final class PrivilegedOperationsCoordinatorTests: XCTestCase { PrivilegedOperationsCoordinator.resetTestingState() PrivilegedOperationsCoordinator.serviceStateOverride = { .smappserviceActive } KanataDaemonManager.registeredButNotLoadedOverride = { false } + PrivilegedOperationsCoordinator.killExistingKanataProcessesOverride = {} PrivilegedOperationsCoordinator.kanataReadinessOverride = { _ in .timedOut } #endif let coordinator = PrivilegedOperationsCoordinator.shared do { - try await coordinator.restartUnhealthyServices() - XCTFail("Expected restartUnhealthyServices to fail when postcondition does not become ready") + try await coordinator.recoverRequiredRuntimeServices() + XCTFail("Expected recoverRequiredRuntimeServices to fail when postcondition does not become ready") } catch let PrivilegedOperationError.operationFailed(message) { XCTAssertTrue(message.contains("postcondition failed")) } catch { @@ -237,6 +238,28 @@ final class PrivilegedOperationsCoordinatorTests: XCTestCase { } } + func testRestartUnhealthyServicesClearsExistingKanataProcessesBeforeRestart() async throws { +#if DEBUG + PrivilegedOperationsCoordinator.resetTestingState() + PrivilegedOperationsCoordinator.serviceStateOverride = { .smappserviceActive } + KanataDaemonManager.registeredButNotLoadedOverride = { false } + var killCalls = 0 + PrivilegedOperationsCoordinator.killExistingKanataProcessesOverride = { + killCalls += 1 + } + PrivilegedOperationsCoordinator.kanataReadinessOverride = { _ in .ready } +#else + throw XCTSkip("Uses DEBUG-only PrivilegedOperationsCoordinator test overrides") +#endif + + let coordinator = PrivilegedOperationsCoordinator.shared + try await coordinator.recoverRequiredRuntimeServices() + +#if DEBUG + XCTAssertEqual(killCalls, 1) +#endif + } + func testRegenerateServiceConfigurationAllowsPendingApprovalPostcondition() async throws { #if DEBUG PrivilegedOperationsCoordinator.resetTestingState() @@ -271,6 +294,26 @@ final class PrivilegedOperationsCoordinatorTests: XCTestCase { } } + func testInstallBundledKanataFailsWithExplicitPortConflictMessage() async throws { +#if DEBUG + PrivilegedOperationsCoordinator.resetTestingState() + PrivilegedOperationsCoordinator.serviceStateOverride = { .smappserviceActive } + KanataDaemonManager.registeredButNotLoadedOverride = { false } + PrivilegedOperationsCoordinator.installBundledKanataBinaryOverride = {} + PrivilegedOperationsCoordinator.kanataReadinessOverride = { _ in .tcpPortInUse } +#endif + + let coordinator = PrivilegedOperationsCoordinator.shared + do { + try await coordinator.installBundledKanata() + XCTFail("Expected installBundledKanata to fail on TCP port conflict") + } catch let PrivilegedOperationError.operationFailed(message) { + XCTAssertTrue(message.contains("TCP port 37001 is already in use")) + } catch { + XCTFail("Unexpected error type: \(error)") + } + } + func testInstallBundledKanataSucceedsWhenReadinessBecomesReady() async throws { #if DEBUG PrivilegedOperationsCoordinator.resetTestingState() @@ -290,6 +333,32 @@ final class PrivilegedOperationsCoordinatorTests: XCTestCase { } } + func testInstallBundledKanataRestartsRuntimeWhenServiceIsAlreadyActive() async throws { + #if DEBUG + PrivilegedOperationsCoordinator.resetTestingState() + PrivilegedOperationsCoordinator.serviceStateOverride = { .smappserviceActive } + KanataDaemonManager.registeredButNotLoadedOverride = { false } + PrivilegedOperationsCoordinator.installBundledKanataBinaryOverride = {} + var restartCalls = 0 + PrivilegedOperationsCoordinator.recoverRequiredRuntimeServicesOverride = { + restartCalls += 1 + } + #else + throw XCTSkip("Uses DEBUG-only PrivilegedOperationsCoordinator test overrides") + #endif + + let coordinator = PrivilegedOperationsCoordinator.shared + do { + try await coordinator.installBundledKanata() + } catch { + XCTFail("Expected installBundledKanata to recover via recoverRequiredRuntimeServices, got: \(error)") + } + + #if DEBUG + XCTAssertEqual(restartCalls, 1) + #endif + } + func testInstallBundledKanataIgnoresLaunchctl113ThresholdDuringRestartGrace() async throws { #if DEBUG PrivilegedOperationsCoordinator.resetTestingState() @@ -304,6 +373,8 @@ final class PrivilegedOperationsCoordinatorTests: XCTestCase { managementState: .smappserviceActive, isRunning: ready, isResponding: ready, + inputCaptureReady: true, + inputCaptureIssue: nil, launchctlExitCode: ready ? 0 : 113, staleEnabledRegistration: false, recentlyRestarted: !ready @@ -347,6 +418,8 @@ final class PrivilegedOperationsCoordinatorTests: XCTestCase { managementState: .smappserviceActive, isRunning: false, isResponding: false, + inputCaptureReady: true, + inputCaptureIssue: nil, launchctlExitCode: 113, staleEnabledRegistration: false, recentlyRestarted: false diff --git a/Tests/KeyPathTests/InstallationEngine/InstallerEngineBrokerForwardingTests.swift b/Tests/KeyPathTests/InstallationEngine/InstallerEngineBrokerForwardingTests.swift index e5adf929e..4fecfa872 100644 --- a/Tests/KeyPathTests/InstallationEngine/InstallerEngineBrokerForwardingTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/InstallerEngineBrokerForwardingTests.swift @@ -50,16 +50,13 @@ private final class PrivilegedCoordinatorStub: PrivilegedOperationsCoordinating private(set) var restartKarabinerDaemonVerifiedCallCount = 0 // Required protocol methods - func installLaunchDaemon(plistPath _: String, serviceID _: String) async throws {} func cleanupPrivilegedHelper() async throws {} - func installAllLaunchDaemonServices(kanataBinaryPath _: String, kanataConfigPath _: String, tcpPort _: Int) async throws {} - func installAllLaunchDaemonServices() async throws {} - func restartUnhealthyServices() async throws {} + func installRequiredRuntimeServices() async throws {} + func recoverRequiredRuntimeServices() async throws {} func installServicesIfUninstalled(context _: String) async throws -> Bool { false } - func installLaunchDaemonServicesWithoutLoading() async throws {} func installNewsyslogConfig() async throws {} func regenerateServiceConfiguration() async throws {} func repairVHIDDaemonServices() async throws {} diff --git a/Tests/KeyPathTests/InstallationEngine/InstallerEngineEndToEndTests.swift b/Tests/KeyPathTests/InstallationEngine/InstallerEngineEndToEndTests.swift index f1c36aa86..7220b96ab 100644 --- a/Tests/KeyPathTests/InstallationEngine/InstallerEngineEndToEndTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/InstallerEngineEndToEndTests.swift @@ -23,8 +23,8 @@ final class InstallerEngineEndToEndTests: KeyPathAsyncTestCase { XCTAssertTrue(report.success, "Execution should succeed when broker operations succeed") XCTAssertTrue( - coordinator.calls.contains("installAllLaunchDaemonServices"), - "Install service recipe should attempt to install LaunchDaemon services" + coordinator.calls.contains("installRequiredRuntimeServices"), + "Install service recipe should attempt to install required runtime services" ) XCTAssertTrue( coordinator.calls.contains("installBundledKanata"), @@ -34,7 +34,7 @@ final class InstallerEngineEndToEndTests: KeyPathAsyncTestCase { func testExecutePlanStopsOnBrokerFailure() async { let coordinator = StubPrivilegedOperationsCoordinator() - coordinator.failOnCall = "installAllLaunchDaemonServices" + coordinator.failOnCall = "installRequiredRuntimeServices" let broker = PrivilegeBroker(coordinator: coordinator) let engine = InstallerEngine() diff --git a/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift b/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift index 6f4d0e38c..684ca7f31 100644 --- a/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/InstallerEnginePlanTests.swift @@ -1,10 +1,11 @@ @testable import KeyPathAppKit +import KeyPathCore @testable import KeyPathWizardCore @preconcurrency import XCTest @MainActor final class InstallerEnginePlanTests: KeyPathAsyncTestCase { - func testInstallPlanIncludesLaunchDaemonAndBundledKanata() async { + func testInstallPlanIncludesRuntimeServicesAndBundledKanata() async { let engine = InstallerEngine() let context = SystemContextBuilder.cleanInstall() @@ -12,7 +13,7 @@ final class InstallerEnginePlanTests: KeyPathAsyncTestCase { let ids = plan.recipes.map(\.id) XCTAssertFalse(ids.isEmpty, "Install plan should produce recipes for clean installs") - XCTAssertTrue(ids.contains(InstallerRecipeID.installLaunchDaemonServices), "Should install LaunchDaemon services") + XCTAssertTrue(ids.contains(InstallerRecipeID.installRequiredRuntimeServices), "Should install required runtime services") XCTAssertTrue(ids.contains(InstallerRecipeID.installBundledKanata), "Should install bundled Kanata binary") } @@ -23,21 +24,25 @@ final class InstallerEnginePlanTests: KeyPathAsyncTestCase { let plan = await engine.makePlan(for: .repair, context: context) let ids = plan.recipes.map(\.id) - XCTAssertTrue(ids.contains("restart-unhealthy-services") || ids.contains("repair-vhid-daemon-services"), - "Repair plan should attempt to restart/repair unhealthy services") + XCTAssertTrue( + ids.contains(InstallerRecipeID.installRequiredRuntimeServices) + || ids.contains(InstallerRecipeID.repairVHIDDaemonServices) + || ids.contains(InstallerRecipeID.startKarabinerDaemon), + "Repair plan should use concrete split-runtime service repair actions" + ) } func testExecuteSkipsRecipesAfterFailure() async { let coordinator = StubPrivilegedOperationsCoordinator() - coordinator.failOnCall = "installAllLaunchDaemonServices" + coordinator.failOnCall = "installRequiredRuntimeServices" let broker = PrivilegeBroker(coordinator: coordinator) let engine = InstallerEngine() let plan = InstallPlan( recipes: [ - ServiceRecipe(id: "install-daemons", type: .installService), + ServiceRecipe(id: InstallerRecipeID.installRequiredRuntimeServices, type: .installComponent), ServiceRecipe(id: "install-bundled-kanata", type: .installComponent), - ServiceRecipe(id: "restart-unhealthy-services", type: .restartService) + ServiceRecipe(id: InstallerRecipeID.startKarabinerDaemon, type: .restartService, serviceID: KeyPathConstants.Bundle.vhidDaemonID) ], status: .ready, intent: .repair @@ -47,7 +52,7 @@ final class InstallerEnginePlanTests: KeyPathAsyncTestCase { XCTAssertFalse(report.success, "Failure should propagate") XCTAssertFalse(coordinator.calls.contains("installBundledKanata"), "Later recipes should not execute after failure") - XCTAssertFalse(coordinator.calls.contains("restartUnhealthyServices"), "Later recipes should not execute after failure") + XCTAssertFalse(coordinator.calls.contains("restartKarabinerDaemonVerified"), "Later recipes should not execute after failure") XCTAssertEqual(report.executedRecipes.count, 1, "Execution should stop immediately after first failure") } } diff --git a/Tests/KeyPathTests/InstallationEngine/InstallerEngineSingleActionRoutingTests.swift b/Tests/KeyPathTests/InstallationEngine/InstallerEngineSingleActionRoutingTests.swift index 4a68f8aea..e7d84357b 100644 --- a/Tests/KeyPathTests/InstallationEngine/InstallerEngineSingleActionRoutingTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/InstallerEngineSingleActionRoutingTests.swift @@ -22,7 +22,7 @@ final class InstallerEngineSingleActionRoutingTests: KeyPathAsyncTestCase { } } - func testRestartCommServerRoutesToRestartUnhealthy() async { + func testRestartCommServerRoutesToRegenerateConfig() async { let coordinator = StubPrivilegedOperationsCoordinator() let broker = PrivilegeBroker(coordinator: coordinator) let engine = InstallerEngine() @@ -30,8 +30,8 @@ final class InstallerEngineSingleActionRoutingTests: KeyPathAsyncTestCase { _ = await engine.runSingleAction(.restartCommServer, using: broker) XCTAssertTrue( - coordinator.calls.contains("restartUnhealthyServices"), - "restartCommServer should restart unhealthy services" + coordinator.calls.contains("regenerateServiceConfiguration"), + "restartCommServer should regenerate service configuration" ) } @@ -47,8 +47,8 @@ final class InstallerEngineSingleActionRoutingTests: KeyPathAsyncTestCase { "startKarabinerDaemon should route to verified Karabiner restart" ) XCTAssertFalse( - coordinator.calls.contains("restartUnhealthyServices"), - "startKarabinerDaemon should not use generic restartUnhealthyServices path" + coordinator.calls.contains("recoverRequiredRuntimeServices"), + "startKarabinerDaemon should not use the generic runtime recovery path" ) } @@ -69,18 +69,13 @@ final class InstallerEngineSingleActionRoutingTests: KeyPathAsyncTestCase { XCTAssertTrue(coordinator.calls.contains("downloadAndInstallCorrectVHIDDriver")) } - func testOrphanedProcessActionsRouteCorrectly() async { + func testTerminateConflictingProcessesRouteCorrectly() async { let coordinator = StubPrivilegedOperationsCoordinator() let broker = PrivilegeBroker(coordinator: coordinator) let engine = InstallerEngine() - _ = await engine.runSingleAction(.adoptOrphanedProcess, using: broker) - XCTAssertTrue(coordinator.calls.contains("installAllLaunchDaemonServices")) - - coordinator.calls.removeAll() - _ = await engine.runSingleAction(.replaceOrphanedProcess, using: broker) + _ = await engine.runSingleAction(.terminateConflictingProcesses, using: broker) XCTAssertTrue(coordinator.calls.contains("killAllKanataProcesses")) - XCTAssertTrue(coordinator.calls.contains("installAllLaunchDaemonServices")) } func testBundledActionsRouteToInstaller() async { @@ -110,7 +105,7 @@ final class InstallerEngineSingleActionRoutingTests: KeyPathAsyncTestCase { ) } - func testRestartVirtualHIDDaemonUsesRestartUnhealthy() async { + func testRestartVirtualHIDDaemonUsesVHIDRepairPath() async { let coordinator = StubPrivilegedOperationsCoordinator() let broker = PrivilegeBroker(coordinator: coordinator) let engine = InstallerEngine() @@ -118,8 +113,8 @@ final class InstallerEngineSingleActionRoutingTests: KeyPathAsyncTestCase { _ = await engine.runSingleAction(.restartVirtualHIDDaemon, using: broker) XCTAssertTrue( - coordinator.calls.contains("restartUnhealthyServices"), - "restartVirtualHIDDaemon maps to restart-unhealthy-services recipe" + coordinator.calls.contains("repairVHIDDaemonServices"), + "restartVirtualHIDDaemon should map to the VHID repair path" ) } } diff --git a/Tests/KeyPathTests/InstallationEngine/InstallerEngineTests.swift b/Tests/KeyPathTests/InstallationEngine/InstallerEngineTests.swift index 29186c1a6..beaadc53f 100644 --- a/Tests/KeyPathTests/InstallationEngine/InstallerEngineTests.swift +++ b/Tests/KeyPathTests/InstallationEngine/InstallerEngineTests.swift @@ -44,6 +44,10 @@ final class InstallerEngineTests: KeyPathAsyncTestCase { // Phase 2: Verify we get real data, not stubs XCTAssertFalse(context.system.macOSVersion.isEmpty, "macOS version should be detected") XCTAssertNotNil(context.permissions.timestamp, "Permissions should have timestamp") + XCTAssertNil( + context.system.runtimePathDecision, + "Tests should not perform live runtime-path evaluation during inspectSystem()" + ) } func testInspectSystemReturnsConsistentContext() async { @@ -104,9 +108,10 @@ final class InstallerEngineTests: KeyPathAsyncTestCase { let context = await engine.inspectSystem() let plan = await engine.makePlan(for: .install, context: context) - // Phase 3: Install intent should generate recipes + // Install planning may legitimately be empty when the system is already ready; + // it should still return a valid recipe array rather than forcing recovery work. if case .ready = plan.status { - XCTAssertGreaterThan(plan.recipes.count, 0, "Install plan should have recipes") + XCTAssertNotNil(plan.recipes, "Install plan should provide a recipe array") } } @@ -385,76 +390,6 @@ final class InstallerEngineTests: KeyPathAsyncTestCase { // MARK: - runSingleAction() Tests - func testRunSingleActionForInstallLaunchDaemonServices() async { - // Test that runSingleAction correctly handles installLaunchDaemonServices - // This action is install-specific, not repair-specific, so it should use .install intent - let broker = PrivilegeBroker() - let report = await engine.runSingleAction(.installLaunchDaemonServices, using: broker) - - // Should not fail with "No repair recipes found" error - XCTAssertNotNil(report, "runSingleAction should return a report") - - // If it failed, it should be for a real reason (not "No repair recipes found") - if !report.success { - XCTAssertNotNil(report.failureReason, "Failed report should have a reason") - XCTAssertFalse( - report.failureReason?.contains("No repair recipes found") ?? false, - "Should not fail with 'No repair recipes found' - action should be handled correctly" - ) - } - - // Verify the report structure - XCTAssertNotNil(report.timestamp, "Report should have timestamp") - XCTAssertNotNil(report.executedRecipes, "Report should have executedRecipes array") - - // If execution succeeded, verify we have recipe results - if report.success { - XCTAssertGreaterThanOrEqual( - report.executedRecipes.count, 0, - "Successful execution should have recipe results (may be 0 if already installed)" - ) - } - } - - func testRunSingleActionGeneratesRecipeForInstallLaunchDaemonServices() async { - // Verify that runSingleAction can generate a recipe for installLaunchDaemonServices - // even when it's not in the standard repair plan - let broker = PrivilegeBroker() - let context = await engine.inspectSystem() - - // First, verify installLaunchDaemonServices is NOT in repair plan - let repairPlan = await engine.makePlan(for: .repair, context: context) - let repairRecipeIDs = repairPlan.recipes.map(\.id) - let hasInRepairPlan = repairRecipeIDs.contains("install-launch-daemon-services") - - // It may or may not be in repair plan depending on system state - // But runSingleAction should handle it regardless - - // Now test runSingleAction - it should work even if not in repair plan - let report = await engine.runSingleAction(.installLaunchDaemonServices, using: broker) - - // Should not fail with "No recipe available" - XCTAssertNotNil(report, "runSingleAction should return a report") - - if !report.success { - // If it failed, verify it's not because recipe wasn't found - XCTAssertNotNil(report.failureReason, "Failed report should have a reason") - XCTAssertFalse( - report.failureReason?.contains("No recipe available") ?? false, - "Should not fail with 'No recipe available' - recipe should be generated" - ) - } - - // Assert: Verify that if the action wasn't in repair plan, runSingleAction still handled it - if !hasInRepairPlan { - // This proves runSingleAction correctly uses .install intent or generates direct recipe - XCTAssertTrue( - report.executedRecipes.count >= 0, - "runSingleAction should handle installLaunchDaemonServices even if not in repair plan" - ) - } - } - func testRunSingleActionForRepairActions() async { // Test that repair-specific actions work correctly let broker = PrivilegeBroker() @@ -504,13 +439,10 @@ final class InstallerEngineTests: KeyPathAsyncTestCase { .installMissingComponents, .createConfigDirectories, .activateVHIDDeviceManager, - .installLaunchDaemonServices, + .installRequiredRuntimeServices, .installBundledKanata, .repairVHIDDaemonServices, .synchronizeConfigPaths, - .restartUnhealthyServices, - .adoptOrphanedProcess, - .replaceOrphanedProcess, .installLogRotation, .replaceKanataWithBundled, .enableTCPServer, @@ -532,20 +464,20 @@ final class InstallerEngineTests: KeyPathAsyncTestCase { } } - func testRecipeIDsAreCentralizedForInstallLaunchDaemonsAndKanata() async { + func testRecipeIDsAreCentralizedForRuntimeServicesAndKanata() async { let context = await engine.inspectSystem() XCTAssertEqual( - engine.recipeIDForAction(.installLaunchDaemonServices), - InstallerRecipeID.installLaunchDaemonServices + engine.recipeIDForAction(.installRequiredRuntimeServices), + InstallerRecipeID.installRequiredRuntimeServices ) XCTAssertEqual( engine.recipeIDForAction(.installBundledKanata), InstallerRecipeID.installBundledKanata ) - let launchRecipe = engine.recipeForAction(.installLaunchDaemonServices, context: context) - XCTAssertEqual(launchRecipe?.id, InstallerRecipeID.installLaunchDaemonServices) + let runtimeServicesRecipe = engine.recipeForAction(.installRequiredRuntimeServices, context: context) + XCTAssertEqual(runtimeServicesRecipe?.id, InstallerRecipeID.installRequiredRuntimeServices) let kanataRecipe = engine.recipeForAction(.installBundledKanata, context: context) XCTAssertEqual(kanataRecipe?.id, InstallerRecipeID.installBundledKanata) @@ -569,12 +501,7 @@ final class InstallerEngineTests: KeyPathAsyncTestCase { KeyPathConstants.Bundle.vhidDaemonID ) - let adoptRecipe = engine.recipeForAction(.adoptOrphanedProcess, context: context) - XCTAssertEqual(adoptRecipe?.id, InstallerRecipeID.adoptOrphanedProcess) - XCTAssertEqual(adoptRecipe?.healthCheck?.serviceID, KeyPathConstants.Bundle.daemonID) - - let replaceOrphanRecipe = engine.recipeForAction(.replaceOrphanedProcess, context: context) - XCTAssertEqual(replaceOrphanRecipe?.id, InstallerRecipeID.replaceOrphanedProcess) - XCTAssertEqual(replaceOrphanRecipe?.healthCheck?.serviceID, KeyPathConstants.Bundle.daemonID) + let terminateRecipe = engine.recipeForAction(.terminateConflictingProcesses, context: context) + XCTAssertEqual(terminateRecipe?.id, InstallerRecipeID.terminateConflictingProcesses) } } diff --git a/Tests/KeyPathTests/InstallationWizard/KarabinerComponentsStatusEvaluatorTests.swift b/Tests/KeyPathTests/InstallationWizard/KarabinerComponentsStatusEvaluatorTests.swift index 11eeee3c5..131ee1b9e 100644 --- a/Tests/KeyPathTests/InstallationWizard/KarabinerComponentsStatusEvaluatorTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/KarabinerComponentsStatusEvaluatorTests.swift @@ -24,7 +24,7 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { func testDriverNotRedWhenOnlyKanataServiceIssue() { let daemonIssue = makeIssue( category: .daemon, - identifier: IssueIdentifier.component(.kanataService) + identifier: IssueIdentifier.component(.keyPathRuntime) ) let overall = KarabinerComponentsStatusEvaluator.evaluate( @@ -35,13 +35,8 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { .driver, in: [daemonIssue] ) - let services = KarabinerComponentsStatusEvaluator.getIndividualComponentStatus( - .backgroundServices, - in: [daemonIssue] - ) XCTAssertEqual(driver, InstallationStatus.completed, "Driver should stay green when only Kanata service is pending") - XCTAssertEqual(services, InstallationStatus.completed, "Background services row should stay green for Kanata-only issues") XCTAssertEqual(overall, InstallationStatus.completed, "Overall Karabiner status should stay green for Kanata-only issues") } @@ -65,14 +60,13 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { timestamp: now ) - // Key scenario: vhidServicesHealthy=true but launchDaemonServicesHealthy=false (Kanata not installed) + // Key scenario: vhidServicesHealthy=true while the full runtime still is not installed let components = ComponentStatus( kanataBinaryInstalled: true, karabinerDriverInstalled: true, karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: false, // All services (including Kanata) - FALSE vhidServicesHealthy: true, // VHID only - TRUE (this is the key!) vhidVersionMismatch: false ) @@ -96,26 +90,21 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { // Act: Adapt the context to wizard format let result = SystemContextAdapter.adapt(context) - // Assert: No launchDaemonServices issue with .installation category - // (that's what the Karabiner page looks for) + // Assert: No stale recovery-services installation issue is generated let karabinerRelatedIssues = result.issues.filter { issue in - if case .component(.launchDaemonServices) = issue.identifier, - issue.category == WizardIssue.IssueCategory.installation - { - return true - } - return false + issue.category == WizardIssue.IssueCategory.installation && + issue.title.localizedCaseInsensitiveContains("recovery services") } XCTAssertTrue( karabinerRelatedIssues.isEmpty, - "When VHID services are healthy, no .launchDaemonServices issue should be generated " + + "When VHID services are healthy, no recovery-services installation issue should be generated " + "for the Karabiner page. Found: \(karabinerRelatedIssues)" ) // Should have a Kanata-specific issue instead let kanataIssues = result.issues.filter { issue in - if case .component(.kanataService) = issue.identifier { + if case .component(.keyPathRuntime) = issue.identifier { return true } return false @@ -123,12 +112,13 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { XCTAssertFalse( kanataIssues.isEmpty, - "A .kanataService issue should be generated when Kanata is not running but VHID is healthy" + "A .keyPathRuntime issue should be generated when Kanata is not running but VHID is healthy" ) } - /// Verify that when VHID services are unhealthy, the .launchDaemonServices issue IS generated - func testLaunchDaemonServicesIssueWhenVHIDUnhealthy() { + /// Verify that VHID unhealthy state still maps to the Karabiner page through VHID-specific issues, + /// not legacy recovery-service issues. + func testVHIDUnhealthyUsesVHIDSpecificIssue() { let now = Date() let perms = PermissionOracle.Snapshot( keyPath: PermissionOracle.PermissionSet( @@ -149,7 +139,6 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: false, vhidServicesHealthy: false, // VHID unhealthy! vhidVersionMismatch: false ) @@ -172,9 +161,9 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { let result = SystemContextAdapter.adapt(context) - // Should have a launchDaemonServices issue when VHID is unhealthy + // Should have a VHID-specific installation issue when VHID is unhealthy. let vhidIssues = result.issues.filter { issue in - if case .component(.launchDaemonServices) = issue.identifier, + if case .component(.vhidDeviceManager) = issue.identifier, issue.category == WizardIssue.IssueCategory.installation { return true @@ -184,7 +173,7 @@ final class KarabinerComponentsStatusEvaluatorTests: XCTestCase { XCTAssertFalse( vhidIssues.isEmpty, - "When VHID services are unhealthy, a .launchDaemonServices issue should be generated" + "When VHID services are unhealthy, a VHID-specific installation issue should be generated" ) } } diff --git a/Tests/KeyPathTests/InstallationWizard/SystemContextAdapterPermissionSeverityTests.swift b/Tests/KeyPathTests/InstallationWizard/SystemContextAdapterPermissionSeverityTests.swift index e44a0dd55..8e81ea0f6 100644 --- a/Tests/KeyPathTests/InstallationWizard/SystemContextAdapterPermissionSeverityTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/SystemContextAdapterPermissionSeverityTests.swift @@ -40,7 +40,6 @@ final class SystemContextAdapterPermissionSeverityTests: XCTestCase { karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: true, vhidServicesHealthy: true, vhidVersionMismatch: false ), diff --git a/Tests/KeyPathTests/InstallationWizard/WizardAutoFixerFacadeTests.swift b/Tests/KeyPathTests/InstallationWizard/WizardAutoFixerFacadeTests.swift index b5072f5b9..4cf7cd744 100644 --- a/Tests/KeyPathTests/InstallationWizard/WizardAutoFixerFacadeTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/WizardAutoFixerFacadeTests.swift @@ -21,13 +21,10 @@ final class WizardAutoFixerFacadeTests: XCTestCase { .installMissingComponents, .createConfigDirectories, .activateVHIDDeviceManager, - .installLaunchDaemonServices, + .installRequiredRuntimeServices, .installBundledKanata, .repairVHIDDaemonServices, .synchronizeConfigPaths, - .restartUnhealthyServices, - .adoptOrphanedProcess, - .replaceOrphanedProcess, .installLogRotation, .replaceKanataWithBundled, .enableTCPServer, diff --git a/Tests/KeyPathTests/InstallationWizard/WizardRecipeParityTests.swift b/Tests/KeyPathTests/InstallationWizard/WizardRecipeParityTests.swift index 32aac26c8..e35d92f51 100644 --- a/Tests/KeyPathTests/InstallationWizard/WizardRecipeParityTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/WizardRecipeParityTests.swift @@ -18,8 +18,7 @@ final class WizardRecipeParityTests: XCTestCase { let actions: [AutoFixAction] = [ .installBundledKanata, - .installLaunchDaemonServices, - .restartUnhealthyServices, + .installRequiredRuntimeServices, .terminateConflictingProcesses ] @@ -54,7 +53,7 @@ final class WizardRecipeParityTests: XCTestCase { ) } - func testRepairPlanRestartsServicesWhenUnhealthy() async { + func testRepairPlanRepairsUnhealthyDriverServices() async { let engine = InstallerEngine() let context = SystemContextBuilder( permissionsStatus: .granted, @@ -67,8 +66,10 @@ final class WizardRecipeParityTests: XCTestCase { let ids = plan.recipes.map(\.id) XCTAssertTrue( - ids.contains(engine.recipeIDForAction(.restartUnhealthyServices)), - "Repair plan should restart unhealthy services when health is false" + ids.contains(engine.recipeIDForAction(.installRequiredRuntimeServices)) + || ids.contains(engine.recipeIDForAction(.repairVHIDDaemonServices)) + || ids.contains(engine.recipeIDForAction(.startKarabinerDaemon)), + "Repair plan should use concrete split-runtime service repair actions when health is false" ) } } diff --git a/Tests/KeyPathTests/InstallationWizard/WizardStateRegressionTests.swift b/Tests/KeyPathTests/InstallationWizard/WizardStateRegressionTests.swift index 6316e645e..16d95cb31 100644 --- a/Tests/KeyPathTests/InstallationWizard/WizardStateRegressionTests.swift +++ b/Tests/KeyPathTests/InstallationWizard/WizardStateRegressionTests.swift @@ -35,7 +35,6 @@ final class WizardStateRegressionTests: XCTestCase { karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: true, vhidServicesHealthy: true, vhidVersionMismatch: false ) @@ -59,4 +58,57 @@ final class WizardStateRegressionTests: XCTestCase { XCTAssertTrue(first.issues.isEmpty) XCTAssertTrue(second.issues.isEmpty) } + + func testRunningKanataWithoutInputCaptureRoutesToMissingPermissions() { + let ready = PermissionOracle.Status.granted + let set = PermissionOracle.PermissionSet( + accessibility: ready, + inputMonitoring: ready, + source: "test", + confidence: .high, + timestamp: Date() + ) + let perms = PermissionOracle.Snapshot( + keyPath: set, + kanata: set, + timestamp: Date() + ) + + let health = HealthStatus( + kanataRunning: true, + karabinerDaemonRunning: true, + vhidHealthy: true, + kanataInputCaptureReady: false, + kanataInputCaptureIssue: "kanata-cannot-open-built-in-keyboard" + ) + + let components = ComponentStatus( + kanataBinaryInstalled: true, + karabinerDriverInstalled: true, + karabinerDaemonRunning: true, + vhidDeviceInstalled: true, + vhidDeviceHealthy: true, + vhidServicesHealthy: true, + vhidVersionMismatch: false + ) + + let context = SystemContext( + permissions: perms, + services: health, + conflicts: .init(conflicts: [], canAutoResolve: false), + components: components, + helper: HelperStatus(isInstalled: true, version: "1.0.0", isWorking: true), + system: EngineSystemInfo(macOSVersion: "26.0.1", driverCompatible: true), + timestamp: Date() + ) + + let adapted = SystemContextAdapter.adapt(context) + + XCTAssertEqual(adapted.state, .missingPermissions(missing: [.kanataInputMonitoring])) + XCTAssertTrue( + adapted.issues.contains { issue in + issue.identifier == .permission(.kanataInputMonitoring) + } + ) + } } diff --git a/Tests/KeyPathTests/KeyPathTests.swift b/Tests/KeyPathTests/KeyPathTests.swift index 5755f4e18..2be0b5b82 100644 --- a/Tests/KeyPathTests/KeyPathTests.swift +++ b/Tests/KeyPathTests/KeyPathTests.swift @@ -473,10 +473,10 @@ final class KeyPathTests: XCTestCase { // MARK: - Root Privilege Tests - func testLaunchDaemonRootConfiguration() throws { + func testKanataAgentBundleConfiguration() throws { // This test validates real system state - skip in automated test mode // since the plist may not actually be installed - let plistPath = "/Library/LaunchDaemons/com.keypath.kanata.plist" + let plistPath = "/Applications/KeyPath.app/Contents/Library/LaunchAgents/com.keypath.kanata.plist" let plistExists = FileManager.default.fileExists(atPath: plistPath) guard plistExists else { @@ -485,14 +485,14 @@ final class KeyPathTests: XCTestCase { let manager = RuntimeCoordinator() - // Test that LaunchDaemon components exist + // Test that Kanata service components exist XCTAssertNotNil(manager.isServiceInstalled()) - // The LaunchDaemon plist should exist when service is installed + // The packaged LaunchAgent plist should exist when service is installed if manager.isServiceInstalled() { XCTAssertTrue( plistExists, - "LaunchDaemon plist should exist" + "Kanata LaunchAgent plist should exist" ) } } diff --git a/Tests/KeyPathTests/KeyboardCaptureTests.swift b/Tests/KeyPathTests/KeyboardCaptureTests.swift index e0a779a15..724cf37db 100644 --- a/Tests/KeyPathTests/KeyboardCaptureTests.swift +++ b/Tests/KeyPathTests/KeyboardCaptureTests.swift @@ -6,6 +6,9 @@ import KeyPathCore @MainActor final class KeyboardCaptureTests: XCTestCase { + private let defaultNotificationCenter = NotificationCenter + .perform(NSSelectorFromString("defaultCenter"))? + .takeUnretainedValue() as? NotificationCenter lazy var capture: KeyboardCapture = .init() var receivedNotifications: [Notification] = [] @@ -13,7 +16,7 @@ final class KeyboardCaptureTests: XCTestCase { super.setUp() // Set up notification observer - NotificationCenter.default.addObserver( + defaultNotificationCenter?.addObserver( self, selector: #selector(notificationReceived(_:)), name: NSNotification.Name("KeyboardCapturePermissionNeeded"), @@ -22,7 +25,7 @@ final class KeyboardCaptureTests: XCTestCase { } deinit { - NotificationCenter.default.removeObserver(self) + defaultNotificationCenter?.removeObserver(self) } @objc private func notificationReceived(_ notification: Notification) { diff --git a/Tests/KeyPathTests/Lint/FacadeLintTests.swift b/Tests/KeyPathTests/Lint/FacadeLintTests.swift index 4d7cd6cd9..ed8f2b9fb 100644 --- a/Tests/KeyPathTests/Lint/FacadeLintTests.swift +++ b/Tests/KeyPathTests/Lint/FacadeLintTests.swift @@ -10,7 +10,6 @@ final class FacadeLintTests: XCTestCase { root.appendingPathComponent("Sources/KeyPathAppKit/Core/PrivilegedOperationsCoordinator.swift").path, root.appendingPathComponent("Sources/KeyPathAppKit/InstallationWizard/Core/PrivilegeBroker.swift").path, root.appendingPathComponent("Sources/KeyPathAppKit/InstallationWizard/Core/InstallerEngine.swift").path, - root.appendingPathComponent("Sources/KeyPathAppKit/Infrastructure/Privileged/HelperBackedPrivilegedOperations.swift").path, root.appendingPathComponent("Sources/KeyPathAppKit/Managers/RuntimeCoordinator.swift").path, root.appendingPathComponent("Sources/KeyPathAppKit/Managers/RuntimeCoordinator+Lifecycle.swift").path, root.appendingPathComponent("Sources/KeyPathAppKit/InstallationWizard/Core/PermissionGrantCoordinator.swift").path diff --git a/Tests/KeyPathTests/Managers/KanataDaemonManagerTests.swift b/Tests/KeyPathTests/Managers/KanataDaemonManagerTests.swift index 64a4b8167..7b084fe41 100644 --- a/Tests/KeyPathTests/Managers/KanataDaemonManagerTests.swift +++ b/Tests/KeyPathTests/Managers/KanataDaemonManagerTests.swift @@ -120,6 +120,16 @@ final class KanataDaemonManagerTests: XCTestCase { XCTAssertEqual(KanataDaemonManager.kanataPlistName, "com.keypath.kanata.plist") } + func testPreferredLaunchctlTargetsForSMAppServicePreferGuiDomain() { + let targets = KanataDaemonManager.preferredLaunchctlTargets(for: .smappserviceActive, userID: 501) + XCTAssertEqual(targets, ["gui/501/com.keypath.kanata", "system/com.keypath.kanata"]) + } + + func testPreferredLaunchctlTargetsForLegacyUseSystemOnly() { + let targets = KanataDaemonManager.preferredLaunchctlTargets(for: .legacyActive, userID: 501) + XCTAssertEqual(targets, ["system/com.keypath.kanata"]) + } + // MARK: - Singleton Tests func testSingleton() { diff --git a/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift b/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift index da8bf658a..32d98a7ac 100644 --- a/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift +++ b/Tests/KeyPathTests/Managers/SaveCoordinatorTests.swift @@ -11,7 +11,7 @@ final class SaveCoordinatorTests: XCTestCase { override func setUp() async throws { try await super.setUp() - tempDir = FileManager.default.temporaryDirectory + tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("SaveCoordinatorTests_\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) diff --git a/Tests/KeyPathTests/Managers/UninstallCoordinatorTests.swift b/Tests/KeyPathTests/Managers/UninstallCoordinatorTests.swift index b159885a1..83188ef04 100644 --- a/Tests/KeyPathTests/Managers/UninstallCoordinatorTests.swift +++ b/Tests/KeyPathTests/Managers/UninstallCoordinatorTests.swift @@ -15,7 +15,7 @@ final class UninstallCoordinatorTests: XCTestCase { } func testUninstallRemovesPathsAndLogsSuccess() async throws { - let root = FileManager.default.temporaryDirectory.appendingPathComponent( + let root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent( "keypath-uninstall-\(UUID().uuidString)" ) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) @@ -55,11 +55,13 @@ final class UninstallCoordinatorTests: XCTestCase { process.standardError = err do { try process.run() - process.waitUntilExit() + while process.isRunning { + usleep(1_000) + } let output = - String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + String(data: (try? out.fileHandleForReading.readToEnd()) ?? Data(), encoding: .utf8) ?? "" let error = - String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + String(data: (try? err.fileHandleForReading.readToEnd()) ?? Data(), encoding: .utf8) ?? "" return AppleScriptResult( success: process.terminationStatus == 0, output: output, error: error, exitStatus: process.terminationStatus @@ -102,7 +104,7 @@ final class UninstallCoordinatorTests: XCTestCase { } func testUninstallLogsAdminError() async { - let errorURL = FileManager.default.temporaryDirectory.appendingPathComponent( + let errorURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent( "uninstall-fail.sh" ) let coordinator = UninstallCoordinator( @@ -121,7 +123,7 @@ final class UninstallCoordinatorTests: XCTestCase { } func testUninstallLogsExitCodeWhenAdminErrorMissingMessage() async { - let errorURL = FileManager.default.temporaryDirectory.appendingPathComponent( + let errorURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent( "uninstall-fail.sh" ) let coordinator = UninstallCoordinator( diff --git a/Tests/KeyPathTests/MockSystemEnvironment.swift b/Tests/KeyPathTests/MockSystemEnvironment.swift index c40b70e7e..01162e40e 100644 --- a/Tests/KeyPathTests/MockSystemEnvironment.swift +++ b/Tests/KeyPathTests/MockSystemEnvironment.swift @@ -270,7 +270,7 @@ class MockEnvironmentKanataManager: ObservableObject { } let result = mockEnvironment.mockLaunchctlResult(command: [ - "kickstart", "system/com.keypath.kanata" + "kickstart", "gui/\(getuid())/com.keypath.kanata" ]) if result.exitCode == 0 { @@ -283,7 +283,7 @@ class MockEnvironmentKanataManager: ObservableObject { } func stopKanata() async { - let result = mockEnvironment.mockLaunchctlResult(command: ["kill", "system/com.keypath.kanata"]) + let result = mockEnvironment.mockLaunchctlResult(command: ["kill", "gui/\(getuid())/com.keypath.kanata"]) if result.exitCode == 0 { isRunning = false diff --git a/Tests/KeyPathTests/PackageManagerTests.swift b/Tests/KeyPathTests/PackageManagerTests.swift index 580cc5730..b25d57d5a 100644 --- a/Tests/KeyPathTests/PackageManagerTests.swift +++ b/Tests/KeyPathTests/PackageManagerTests.swift @@ -242,7 +242,7 @@ final class PackageManagerTests: XCTestCase { func testCodeSigningCache_Hit() { // Create a temporary file - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let testFile = tempDir.appendingPathComponent("test-binary-\(UUID().uuidString)") FileManager.default.createFile(atPath: testFile.path, contents: Data("test".utf8)) @@ -262,7 +262,7 @@ final class PackageManagerTests: XCTestCase { func testCodeSigningCache_InvalidationOnFileChange() { // Create a temporary file - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let testFile = tempDir.appendingPathComponent("test-binary-\(UUID().uuidString)") FileManager.default.createFile(atPath: testFile.path, contents: Data("test1".utf8)) @@ -284,7 +284,7 @@ final class PackageManagerTests: XCTestCase { func testCodeSigningCache_SizeLimit() throws { // Create multiple temporary files - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) var testFiles: [URL] = [] // Create more files than maxCacheSize (50) diff --git a/Tests/KeyPathTests/RuleCollections/CustomRulesStoreTests.swift b/Tests/KeyPathTests/RuleCollections/CustomRulesStoreTests.swift index f7c6e8ed3..1fa969ccf 100644 --- a/Tests/KeyPathTests/RuleCollections/CustomRulesStoreTests.swift +++ b/Tests/KeyPathTests/RuleCollections/CustomRulesStoreTests.swift @@ -6,7 +6,7 @@ final class CustomRulesStoreTests: XCTestCase { private var store: CustomRulesStore! override func setUpWithError() throws { - tempDirectory = FileManager.default.temporaryDirectory + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) store = CustomRulesStore(fileURL: tempDirectory.appendingPathComponent("CustomRules.json")) diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift index f186ff02d..7f20a343d 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionStoreTests.swift @@ -3,7 +3,7 @@ final class RuleCollectionStoreTests: XCTestCase { func testLoadFallsBackToDefaultsWhenFileMissing() async { - let tempURL = FileManager.default.temporaryDirectory + let tempURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-collections-\(UUID().uuidString)") let store = RuleCollectionStore.testStore(at: tempURL) @@ -14,7 +14,7 @@ final class RuleCollectionStoreTests: XCTestCase { } func testSaveAndLoadRoundTrip() async throws { - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-collections-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("collections.json") @@ -54,7 +54,7 @@ final class RuleCollectionStoreTests: XCTestCase { } func testLoadUpgradesBuiltInCollectionsWithLatestMetadata() async throws { - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-collections-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("collections.json") @@ -87,7 +87,7 @@ final class RuleCollectionStoreTests: XCTestCase { } func testLoadAddsMissingCatalogDefaultsWhenFileHasSubset() async throws { - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-collections-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let fileURL = tempDir.appendingPathComponent("collections.json") diff --git a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift index 6bbe7ef2f..d8a5fd975 100644 --- a/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift +++ b/Tests/KeyPathTests/RuleCollections/RuleCollectionsManagerTests.swift @@ -9,7 +9,7 @@ final class RuleCollectionsManagerTests: XCTestCase { private func createTestManager() async throws -> (RuleCollectionsManager, URL) { TestEnvironment.forceTestMode = true - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-manager-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -37,7 +37,7 @@ final class RuleCollectionsManagerTests: XCTestCase { TestEnvironment.forceTestMode = true defer { TestEnvironment.forceTestMode = false } - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-manager-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) @@ -211,7 +211,7 @@ final class RuleCollectionsManagerTests: XCTestCase { let migrationKey = "RuleCollections.Migration.UnifiedHomeRowMods" UserDefaults.standard.removeObject(forKey: migrationKey) - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("rule-manager-migration-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) diff --git a/Tests/KeyPathTests/RuntimeCoordinatorTests.swift b/Tests/KeyPathTests/RuntimeCoordinatorTests.swift index e72ae734a..a77a4b0b4 100644 --- a/Tests/KeyPathTests/RuntimeCoordinatorTests.swift +++ b/Tests/KeyPathTests/RuntimeCoordinatorTests.swift @@ -5,6 +5,22 @@ final class RuntimeCoordinatorTests: KeyPathTestCase { lazy var manager: RuntimeCoordinator = .init() + override func setUp() { + super.setUp() + KanataRuntimePathCoordinator.testDecision = nil + KanataSplitRuntimeHostService.testPersistentHostPID = nil + KanataSplitRuntimeHostService.testStartPersistentError = nil + KarabinerConflictService.testDaemonRunning = nil + } + + override func tearDown() { + KanataRuntimePathCoordinator.testDecision = nil + KanataSplitRuntimeHostService.testPersistentHostPID = nil + KanataSplitRuntimeHostService.testStartPersistentError = nil + KarabinerConflictService.testDaemonRunning = nil + super.tearDown() + } + func testInitialState() { // Test initial published properties // XCTAssertFalse(manager.isRunning, "Should not be running initially") // Removed @@ -77,6 +93,12 @@ final class RuntimeCoordinatorTests: KeyPathTestCase { XCTAssertTrue(configPath.contains("keypath.kbd"), "Config path should contain keypath.kbd") } + func testInitialUIStateHasNoActiveRuntimePath() { + let state = manager.getCurrentUIState() + XCTAssertNil(state.activeRuntimePathTitle, "Initial UI state should not report an active runtime path") + XCTAssertNil(state.activeRuntimePathDetail, "Initial UI state should not report active runtime path details") + } + func testInstallationStatus() { // Test installation status check let isInstalled = manager.isCompletelyInstalled() @@ -85,6 +107,123 @@ final class RuntimeCoordinatorTests: KeyPathTestCase { XCTAssertNotNil(isInstalled) } + func testUnexpectedSplitRuntimeHostExitFailsLoudly() async throws { + await manager.handleSplitRuntimeHostExit( + pid: 12345, + exitCode: 9, + terminationReason: "uncaughtSignal", + expected: false, + stderrLogPath: "/tmp/keypath-host.log" + ) + + let error = try XCTUnwrap(manager.lastError) + XCTAssertTrue(error.contains("Split runtime host exited unexpectedly")) + XCTAssertTrue(error.contains("/tmp/keypath-host.log")) + XCTAssertTrue(error.contains("no longer auto-falls back")) + XCTAssertNil(manager.lastWarning) + + let state = manager.getCurrentUIState() + XCTAssertNil(state.activeRuntimePathTitle) + XCTAssertNil(state.activeRuntimePathDetail) + } + + func testExpectedSplitRuntimeHostExitDoesNotSetRecoveryError() async { + manager.lastError = nil + + await manager.handleSplitRuntimeHostExit( + pid: 12345, + exitCode: 0, + terminationReason: "exit", + expected: true, + stderrLogPath: nil + ) + + XCTAssertNil(manager.lastError) + } + + func testSuccessfulSplitRuntimeStartClearsPreviousExitError() async throws { + KarabinerConflictService.testDaemonRunning = true + await manager.handleSplitRuntimeHostExit( + pid: 12345, + exitCode: 9, + terminationReason: "uncaughtSignal", + expected: false, + stderrLogPath: "/tmp/keypath-host.log" + ) + + XCTAssertNotNil(manager.lastError) + + KanataRuntimePathCoordinator.testDecision = .useSplitRuntime(reason: "test split runtime") + KanataSplitRuntimeHostService.testPersistentHostPID = 4343 + let started = await manager.startKanata( + reason: "Manual recovery" + ) + + XCTAssertTrue(started) + XCTAssertNil(manager.lastError) + XCTAssertNil(manager.lastWarning) + } + + func testSplitRuntimeStartStopRestartCycle() async { + KarabinerConflictService.testDaemonRunning = true + KanataRuntimePathCoordinator.testDecision = .useSplitRuntime(reason: "test split runtime") + KanataSplitRuntimeHostService.testPersistentHostPID = 4242 + + let started = await manager.startKanata(reason: "Split runtime test start") + XCTAssertTrue(started) + + var state = manager.getCurrentUIState() + XCTAssertEqual(state.activeRuntimePathTitle, "Split Runtime Host") + XCTAssertTrue(state.activeRuntimePathDetail?.contains("PID 4242") == true) + + let restarted = await manager.restartKanata(reason: "Split runtime test restart") + XCTAssertTrue(restarted) + + state = manager.getCurrentUIState() + XCTAssertEqual(state.activeRuntimePathTitle, "Split Runtime Host") + XCTAssertTrue(state.activeRuntimePathDetail?.contains("PID 4242") == true) + + let stopped = await manager.stopKanata(reason: "Split runtime test stop") + XCTAssertTrue(stopped) + + state = manager.getCurrentUIState() + XCTAssertNil(state.activeRuntimePathTitle) + XCTAssertNil(state.activeRuntimePathDetail) + } + + func testRestartCutsOverToSplitRuntimeWhenPreferred() async { + KarabinerConflictService.testDaemonRunning = true + KanataRuntimePathCoordinator.testDecision = .useSplitRuntime(reason: "test split runtime") + KanataSplitRuntimeHostService.testPersistentHostPID = 5252 + + let restarted = await manager.restartKanata(reason: "Cut over from legacy to split runtime") + XCTAssertTrue(restarted) + + let state = manager.getCurrentUIState() + XCTAssertEqual(state.activeRuntimePathTitle, "Split Runtime Host") + XCTAssertTrue(state.activeRuntimePathDetail?.contains("Bundled user-session host active") == true) + } + + func testSplitRuntimeStartFailureDoesNotSilentlyFallBackToLegacy() async { + KarabinerConflictService.testDaemonRunning = true + struct SplitStartFailure: LocalizedError { + var errorDescription: String? { "simulated split host start failure" } + } + + KanataRuntimePathCoordinator.testDecision = .useSplitRuntime(reason: "test split runtime") + KanataSplitRuntimeHostService.testStartPersistentError = SplitStartFailure() + + let started = await manager.startKanata(reason: "Split runtime start should fail loudly") + XCTAssertFalse(started) + XCTAssertEqual( + manager.lastError, + "Split runtime host failed to start: simulated split host start failure. Legacy fallback is reserved for recovery paths." + ) + + let state = manager.getCurrentUIState() + XCTAssertNil(state.activeRuntimePathTitle) + } + func testPerformanceConfigValidation() async { // Test that config validation performs reasonably let startTime = Date() diff --git a/Tests/KeyPathTests/Services/ActionDispatcherTests.swift b/Tests/KeyPathTests/Services/ActionDispatcherTests.swift index 7c7d60fac..3796a4b78 100644 --- a/Tests/KeyPathTests/Services/ActionDispatcherTests.swift +++ b/Tests/KeyPathTests/Services/ActionDispatcherTests.swift @@ -429,6 +429,96 @@ struct ActionDispatcherSystemWindowTests { } } + @Test("Dispatches exercise host passthru cycle action") + @MainActor + func dispatchesExerciseHostPassthruCycleAction() throws { + let uri = try #require(KeyPathActionURI(string: "keypath://system/exercise-host-passthru-cycle?capture=0")) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + + @Test("Dispatches exercise host passthru soak action") + @MainActor + func dispatchesExerciseHostPassthruSoakAction() throws { + let uri = try #require( + KeyPathActionURI(string: "keypath://system/exercise-host-passthru-soak?capture=0&seconds=5") + ) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + + @Test("Dispatches exercise output bridge companion restart action") + @MainActor + func dispatchesExerciseOutputBridgeCompanionRestartAction() throws { + let uri = try #require( + KeyPathActionURI(string: "keypath://system/exercise-output-bridge-companion-restart?capture=0") + ) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + + @Test("Dispatches exercise output bridge companion restart soak action") + @MainActor + func dispatchesExerciseOutputBridgeCompanionRestartSoakAction() throws { + let uri = try #require( + KeyPathActionURI( + string: "keypath://system/exercise-output-bridge-companion-restart-soak?capture=0&seconds=6" + ) + ) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + + @Test("Dispatches coordinator split runtime recovery action") + @MainActor + func dispatchesCoordinatorSplitRuntimeRecoveryAction() throws { + let uri = try #require( + KeyPathActionURI(string: "keypath://system/exercise-coordinator-split-runtime-recovery") + ) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + + @Test("Dispatches coordinator split runtime restart soak action") + @MainActor + func dispatchesCoordinatorSplitRuntimeRestartSoakAction() throws { + let uri = try #require( + KeyPathActionURI(string: "keypath://system/exercise-coordinator-split-runtime-restart-soak?seconds=6") + ) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + + @Test("Recovers persistent split host after companion restart in test mode") + @MainActor + func recoversPersistentSplitHostAfterCompanionRestartInTestMode() async throws { + KanataSplitRuntimeHostService.testPersistentHostPID = 4242 + defer { + KanataSplitRuntimeHostService.testPersistentHostPID = nil + KanataSplitRuntimeHostService.testStartPersistentError = nil + } + + let recoveredPID = try await KanataSplitRuntimeHostService.shared + .restartPersistentPassthruHostAfterCompanionRestart() + + #expect(recoveredPID == 4242) + } + + @Test("Dispatches repair helper action") + @MainActor + func dispatchesRepairHelperAction() throws { + let uri = try #require(KeyPathActionURI(string: "keypath://system/repair-helper?applescript=0")) + let result = ActionDispatcher.shared.dispatch(uri) + + #expect(result == .success) + } + @Test("Returns missingTarget for window without action") @MainActor func returnsMissingTargetForWindowWithoutAction() throws { @@ -657,7 +747,7 @@ struct ActionDispatcherScriptTests { ScriptSecurityService.shared.bypassFirstRunDialog = false } - let tempPath = FileManager.default.temporaryDirectory + let tempPath = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("action-dispatcher-not-executable-\(UUID().uuidString).txt").path FileManager.default.createFile(atPath: tempPath, contents: Data("plain text".utf8)) defer { diff --git a/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift b/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift index cb3469052..c7d48ebf9 100644 --- a/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift +++ b/Tests/KeyPathTests/Services/ConfigFileWatcherTests.swift @@ -4,7 +4,7 @@ @MainActor final class ConfigFileWatcherTests: XCTestCase { func testDebouncePreventsMultipleCallbacks() async throws { - let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) let filePath = tempDir.appendingPathComponent("config.kbd").path FileManager.default.createFile(atPath: filePath, contents: Data()) diff --git a/Tests/KeyPathTests/Services/ConfigHotReloadServiceTests.swift b/Tests/KeyPathTests/Services/ConfigHotReloadServiceTests.swift index 0c5f80e6a..1633aa67b 100644 --- a/Tests/KeyPathTests/Services/ConfigHotReloadServiceTests.swift +++ b/Tests/KeyPathTests/Services/ConfigHotReloadServiceTests.swift @@ -280,7 +280,7 @@ final class ConfigHotReloadServiceTests: XCTestCase { // MARK: - Helper Methods private func createTempConfigFile(content: String) -> URL { - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let tempFile = tempDir.appendingPathComponent("test-config-\(UUID().uuidString).kbd") try! content.write(to: tempFile, atomically: true, encoding: .utf8) diff --git a/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift b/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift index 0f10ddb5c..cbf4ab5f2 100644 --- a/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift +++ b/Tests/KeyPathTests/Services/ConfigurationServiceTests.swift @@ -6,7 +6,7 @@ import Foundation @MainActor class ConfigurationServiceTests: XCTestCase { lazy var tempDirectory: URL = { - let url = FileManager.default.temporaryDirectory + let url = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("KeyPathConfigTests_\(UUID().uuidString)") try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) return url @@ -1345,10 +1345,12 @@ class ConfigurationServiceTests: XCTestCase { process.standardError = errorPipe try process.run() - process.waitUntilExit() + while process.isRunning { + usleep(1_000) + } - let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() - let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let outputData = try outputPipe.fileHandleForReading.readToEnd() ?? Data() + let errorData = try errorPipe.fileHandleForReading.readToEnd() ?? Data() let output = String(data: outputData, encoding: .utf8) ?? "" let errorOutput = String(data: errorData, encoding: .utf8) ?? "" diff --git a/Tests/KeyPathTests/Services/DiagnosticsServiceTests.swift b/Tests/KeyPathTests/Services/DiagnosticsServiceTests.swift index 9af2adc26..dbeb24241 100644 --- a/Tests/KeyPathTests/Services/DiagnosticsServiceTests.swift +++ b/Tests/KeyPathTests/Services/DiagnosticsServiceTests.swift @@ -1,4 +1,5 @@ @testable import KeyPathAppKit +@testable import KeyPathCore @testable import KeyPathDaemonLifecycle @preconcurrency import XCTest @@ -255,4 +256,147 @@ final class DiagnosticsServiceTests: XCTestCase { XCTAssertEqual(DiagnosticCategory.system.rawValue, "System") XCTAssertEqual(DiagnosticCategory.conflict.rawValue, "Conflict") } + + func testRuntimePathDiagnosticForSplitRuntimeReady() { + let diagnostic = DiagnosticsService.makeRuntimePathDiagnostic( + for: .useSplitRuntime(reason: "bundled host is ready") + ) + + XCTAssertEqual(diagnostic.title, "Runtime Path: Split Runtime Ready") + XCTAssertEqual(diagnostic.severity, .info) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertEqual(diagnostic.technicalDetails, "bundled host is ready") + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testRuntimePathDiagnosticForLegacyFallback() { + let diagnostic = DiagnosticsService.makeRuntimePathDiagnostic( + for: .useLegacySystemBinary(reason: "legacy is still required") + ) + + XCTAssertEqual(diagnostic.title, "Runtime Path: Legacy Fallback Active") + XCTAssertEqual(diagnostic.severity, .warning) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertEqual(diagnostic.technicalDetails, "legacy is still required") + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testRuntimePathDiagnosticForBlockedPath() { + let diagnostic = DiagnosticsService.makeRuntimePathDiagnostic( + for: .blocked(reason: "nothing is viable") + ) + + XCTAssertEqual(diagnostic.title, "Runtime Path: Split Runtime Blocked") + XCTAssertEqual(diagnostic.severity, .error) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertEqual(diagnostic.technicalDetails, "nothing is viable") + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testOutputBridgeSmokeDiagnosticForSuccess() { + let report = KanataOutputBridgeSmokeReport( + session: KanataOutputBridgeSession( + sessionID: "session-42", + socketPath: "/tmp/session-42.sock", + socketDirectory: "/tmp", + hostPID: 42, + hostUID: 501, + hostGID: 20 + ), + handshake: .ready(version: 1), + ping: .pong, + syncedModifiers: KanataOutputBridgeModifierState(leftShift: true), + syncModifiers: .acknowledged(sequence: nil), + emittedKeyEvent: KanataOutputBridgeKeyEvent( + usagePage: 0x07, + usage: 0x04, + action: .keyDown, + sequence: 5 + ), + emitKey: .acknowledged(sequence: 5), + reset: nil + ) + + let diagnostic = DiagnosticsService.makeOutputBridgeSmokeDiagnostic(for: report) + + XCTAssertEqual(diagnostic.title, "Experimental Output Bridge Smoke Passed") + XCTAssertEqual(diagnostic.severity, .info) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertTrue(diagnostic.technicalDetails.contains("session=session-42")) + XCTAssertTrue(diagnostic.technicalDetails.contains("handshake=ready(version: 1)")) + XCTAssertTrue(diagnostic.technicalDetails.contains("sync_modifiers_response=Optional")) + XCTAssertTrue(diagnostic.technicalDetails.contains("emit_response=Optional")) + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testOutputBridgeSmokeDiagnosticForFailure() { + let diagnostic = DiagnosticsService.makeOutputBridgeSmokeFailureDiagnostic( + error: HelperManagerError.operationFailed("timed out") + ) + + XCTAssertEqual(diagnostic.title, "Experimental Output Bridge Smoke Failed") + XCTAssertEqual(diagnostic.severity, .warning) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertEqual( + diagnostic.technicalDetails, + HelperManagerError.operationFailed("timed out").localizedDescription + ) + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testHostPassthruDiagnosticForSuccess() { + let report = DiagnosticsService.HostPassthruDiagnosticReport( + exitCode: 0, + stderr: "[kanata-launcher] Experimental passthru-only host mode completed", + launcherPath: "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher", + sessionID: "session-42", + socketPath: "/Library/KeyPath/run/kpko/k-session42.sock" + ) + + let diagnostic = DiagnosticsService.makeHostPassthruDiagnostic(for: report) + + XCTAssertEqual(diagnostic.title, "Experimental Host Passthru Diagnostic Passed") + XCTAssertEqual(diagnostic.severity, .info) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertTrue(diagnostic.technicalDetails.contains("exit_code=0")) + XCTAssertTrue(diagnostic.technicalDetails.contains(report.launcherPath)) + XCTAssertTrue(diagnostic.technicalDetails.contains("session=session-42")) + XCTAssertTrue(diagnostic.technicalDetails.contains("socket=/Library/KeyPath/run/kpko/k-session42.sock")) + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testHostPassthruDiagnosticForFailure() { + struct DummyError: LocalizedError { + var errorDescription: String? { "launcher failed to start" } + } + + let diagnostic = DiagnosticsService.makeHostPassthruFailureDiagnostic(error: DummyError()) + + XCTAssertEqual(diagnostic.title, "Experimental Host Passthru Diagnostic Failed") + XCTAssertEqual(diagnostic.severity, .warning) + XCTAssertEqual(diagnostic.category, .system) + XCTAssertEqual(diagnostic.technicalDetails, "launcher failed to start") + XCTAssertFalse(diagnostic.canAutoFix) + } + + func testHostPassthruDiagnosticTreatsForwardingFailureAsFailure() { + let report = DiagnosticsService.HostPassthruDiagnosticReport( + exitCode: 0, + stderr: """ + [kanata-launcher] Experimental passthru runtime drained output event: value=1 page=7 code=4 + [kanata-launcher] Experimental passthru forwarding failed: Output bridge socket at /Library/KeyPath/run/kpko/k-stale.sock is stale or not listening. + [kanata-launcher] Experimental passthru-only host mode completed + """, + launcherPath: "/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher", + sessionID: "session-stale", + socketPath: "/Library/KeyPath/run/kpko/k-stale.sock" + ) + + let diagnostic = DiagnosticsService.makeHostPassthruDiagnostic(for: report) + + XCTAssertEqual(diagnostic.title, "Experimental Host Passthru Diagnostic Failed") + XCTAssertEqual(diagnostic.severity, .warning) + XCTAssertTrue(diagnostic.technicalDetails.contains("session=session-stale")) + XCTAssertTrue(diagnostic.technicalDetails.contains("stale or not listening")) + } } diff --git a/Tests/KeyPathTests/Services/KanataConfigMigrationServiceTests.swift b/Tests/KeyPathTests/Services/KanataConfigMigrationServiceTests.swift index e66bbe7f9..85a4d86bd 100644 --- a/Tests/KeyPathTests/Services/KanataConfigMigrationServiceTests.swift +++ b/Tests/KeyPathTests/Services/KanataConfigMigrationServiceTests.swift @@ -9,7 +9,7 @@ final class KanataConfigMigrationServiceTests: XCTestCase { override func setUp() { super.setUp() - tempDirectory = FileManager.default.temporaryDirectory + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString) try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) diff --git a/Tests/KeyPathTests/Services/KanataOutputBridgeSmokeServiceTests.swift b/Tests/KeyPathTests/Services/KanataOutputBridgeSmokeServiceTests.swift new file mode 100644 index 000000000..155e22fda --- /dev/null +++ b/Tests/KeyPathTests/Services/KanataOutputBridgeSmokeServiceTests.swift @@ -0,0 +1,233 @@ +@testable import KeyPathAppKit +import KeyPathCore +@preconcurrency import XCTest + +@MainActor +final class KanataOutputBridgeSmokeServiceTests: XCTestCase { + func testRunPreparesActivatesAndExecutesHandshakeAndPing() async throws { + let helper = MockKanataOutputBridgeSmokeHelper() + let session = KanataOutputBridgeSession( + sessionID: "session-123", + socketPath: "/tmp/session-123.sock", + socketDirectory: "/tmp", + hostPID: 4242, + hostUID: 501, + hostGID: 20 + ) + helper.preparedSession = session + + let operations = SendableOperationRecorder() + + let report = try await KanataOutputBridgeSmokeService.run( + hostPID: 4242, + helper: helper, + performHandshake: { smokeSession in + operations.append("handshake:\(smokeSession.sessionID)") + return .ready(version: 1) + }, + performPing: { smokeSession in + operations.append("ping:\(smokeSession.sessionID)") + return .pong + }, + performReset: { _ in + XCTFail("reset should not run when includeReset is false") + return .acknowledged(sequence: nil) + } + ) + + XCTAssertEqual(helper.preparedHostPIDs, [4242]) + XCTAssertEqual(helper.activatedSessionIDs, ["session-123"]) + XCTAssertEqual(operations.values, ["handshake:session-123", "ping:session-123"]) + XCTAssertEqual(report.session, session) + XCTAssertEqual(report.handshake, .ready(version: 1)) + XCTAssertEqual(report.ping, .pong) + XCTAssertNil(report.syncedModifiers) + XCTAssertNil(report.syncModifiers) + XCTAssertNil(report.emittedKeyEvent) + XCTAssertNil(report.emitKey) + XCTAssertNil(report.reset) + } + + func testRunIncludesResetWhenRequested() async throws { + let helper = MockKanataOutputBridgeSmokeHelper() + helper.preparedSession = KanataOutputBridgeSession( + sessionID: "session-reset", + socketPath: "/tmp/session-reset.sock", + socketDirectory: "/tmp", + hostPID: 777, + hostUID: 501, + hostGID: 20 + ) + + let operations = SendableOperationRecorder() + + let report = try await KanataOutputBridgeSmokeService.run( + hostPID: 777, + includeReset: true, + helper: helper, + performHandshake: { smokeSession in + operations.append("handshake:\(smokeSession.sessionID)") + return .ready(version: 1) + }, + performPing: { smokeSession in + operations.append("ping:\(smokeSession.sessionID)") + return .pong + }, + performReset: { smokeSession in + operations.append("reset:\(smokeSession.sessionID)") + return .acknowledged(sequence: nil) + } + ) + + XCTAssertEqual( + operations.values, + ["handshake:session-reset", "ping:session-reset", "reset:session-reset"] + ) + XCTAssertEqual(report.reset, .acknowledged(sequence: nil)) + } + + func testRunIncludesEmitProbeWhenRequested() async throws { + let helper = MockKanataOutputBridgeSmokeHelper() + helper.preparedSession = KanataOutputBridgeSession( + sessionID: "session-emit", + socketPath: "/tmp/session-emit.sock", + socketDirectory: "/tmp", + hostPID: 909, + hostUID: 501, + hostGID: 20 + ) + + let probeEvent = KanataOutputBridgeKeyEvent( + usagePage: 0x07, + usage: 0x04, + action: .keyDown, + sequence: 99 + ) + let operations = SendableOperationRecorder() + + let report = try await KanataOutputBridgeSmokeService.run( + hostPID: 909, + emitProbeEvent: probeEvent, + helper: helper, + performHandshake: { smokeSession in + operations.append("handshake:\(smokeSession.sessionID)") + return .ready(version: 1) + }, + performPing: { smokeSession in + operations.append("ping:\(smokeSession.sessionID)") + return .pong + }, + performEmitKey: { event, smokeSession in + operations.append("emit:\(smokeSession.sessionID):\(event.usagePage):\(event.usage):\(event.sequence)") + return .acknowledged(sequence: event.sequence) + }, + performReset: { _ in + XCTFail("reset should not run when includeReset is false") + return .acknowledged(sequence: nil) + } + ) + + XCTAssertEqual( + operations.values, + [ + "handshake:session-emit", + "ping:session-emit", + "emit:session-emit:7:4:99" + ] + ) + XCTAssertEqual(report.emittedKeyEvent, probeEvent) + XCTAssertEqual(report.emitKey, .acknowledged(sequence: 99)) + XCTAssertNil(report.reset) + } + + func testRunIncludesModifierSyncWhenRequested() async throws { + let helper = MockKanataOutputBridgeSmokeHelper() + helper.preparedSession = KanataOutputBridgeSession( + sessionID: "session-modifiers", + socketPath: "/tmp/session-modifiers.sock", + socketDirectory: "/tmp", + hostPID: 313, + hostUID: 501, + hostGID: 20 + ) + + let probeState = KanataOutputBridgeModifierState(leftShift: true, rightCommand: true) + let operations = SendableOperationRecorder() + + let report = try await KanataOutputBridgeSmokeService.run( + hostPID: 313, + syncModifierProbe: probeState, + helper: helper, + performHandshake: { smokeSession in + operations.append("handshake:\(smokeSession.sessionID)") + return .ready(version: 1) + }, + performPing: { smokeSession in + operations.append("ping:\(smokeSession.sessionID)") + return .pong + }, + performSyncModifiers: { modifiers, smokeSession in + operations.append("sync:\(smokeSession.sessionID):\(modifiers.leftShift):\(modifiers.rightCommand)") + return .acknowledged(sequence: nil) + }, + performEmitKey: { _, _ in + XCTFail("emitKey should not run when emitProbeEvent is nil") + return .acknowledged(sequence: nil) + }, + performReset: { _ in + XCTFail("reset should not run when includeReset is false") + return .acknowledged(sequence: nil) + } + ) + + XCTAssertEqual( + operations.values, + ["handshake:session-modifiers", "ping:session-modifiers", "sync:session-modifiers:true:true"] + ) + XCTAssertEqual(report.syncedModifiers, probeState) + XCTAssertEqual(report.syncModifiers, .acknowledged(sequence: nil)) + XCTAssertNil(report.emittedKeyEvent) + XCTAssertNil(report.emitKey) + XCTAssertNil(report.reset) + } +} + +@MainActor +private final class MockKanataOutputBridgeSmokeHelper: KanataOutputBridgeSmokeHelping, @unchecked Sendable { + var preparedSession = KanataOutputBridgeSession( + sessionID: "default-session", + socketPath: "/tmp/default-session.sock", + socketDirectory: "/tmp", + hostPID: 1, + hostUID: 501, + hostGID: 20 + ) + private(set) var preparedHostPIDs: [Int32] = [] + private(set) var activatedSessionIDs: [String] = [] + + func prepareKanataOutputBridgeSession(hostPID: Int32) async throws -> KanataOutputBridgeSession { + preparedHostPIDs.append(hostPID) + return preparedSession + } + + func activateKanataOutputBridgeSession(sessionID: String) async throws { + activatedSessionIDs.append(sessionID) + } +} + +private final class SendableOperationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + + func append(_ value: String) { + lock.lock() + defer { lock.unlock() } + storage.append(value) + } + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return storage + } +} diff --git a/Tests/KeyPathTests/Services/KanataServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/KanataServiceIntegrationTests.swift deleted file mode 100644 index de81ebdc6..000000000 --- a/Tests/KeyPathTests/Services/KanataServiceIntegrationTests.swift +++ /dev/null @@ -1,199 +0,0 @@ -@testable import KeyPathAppKit -import KeyPathDaemonLifecycle -import ServiceManagement -@preconcurrency import XCTest - -/// Mock implementation of SMAppServiceProtocol for testing -private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { - var status: SMAppService.Status - var registerCalled = false - var unregisterCalled = false - - init(status: SMAppService.Status = .notRegistered) { - self.status = status - } - - func register() throws { - registerCalled = true - // Simulate successful registration transition - if status == .notRegistered || status == .notFound { - status = .enabled - } - } - - func unregister() async throws { - unregisterCalled = true - status = .notRegistered - } -} - -@MainActor -final class KanataServiceIntegrationTests: KeyPathAsyncTestCase { - var service: KanataService! - - /// Keep reference to original factory to restore it - var originalFactory: ((String) -> SMAppServiceProtocol)! - - override func setUp() async throws { - try await super.setUp() - - // 1. Mock SMAppService - originalFactory = KanataService.smServiceFactory - KanataService.smServiceFactory = { _ in - MockSMAppService(status: .notRegistered) - } - - // 2. Create Service under test - service = KanataService() - } - - override func tearDown() async throws { - KanataService.smServiceFactory = originalFactory - service = nil - try await super.tearDown() - } - - func testStartService_WhenNotRegistered_ShouldRegisterAndStart() async throws { - // Given: Service is not registered (default mock state) - - // When: Start is called - try await service.start() - - // Then: - // 1. It should have attempted registration (implied by success since our mock starts as .notRegistered) - // 2. State should eventually be .running - // Note: Since our mock SMAppService transitions to .enabled immediately, - // and we mocked process lifecycle via KeyPathTestCase (which returns empty PIDs by default), - // the service logic might see "Enabled but not running" -> .failed or .stopped. - // To make this test pass, we need to simulate the process appearing. - - // Ideally, we'd mock processLifecycle completely, but it's a final class. - // For now, let's verify it didn't throw and reached a stable state. - - let state = service.state - // Accept .running or .failed("Service enabled but process not running") - // Both prove that it successfully talked to the DaemonManager - switch state { - case .running: - XCTAssertTrue(true) - case let .failed(reason): - XCTAssertTrue(reason.contains("process not running"), "Should fail because process mocking is hard: \(reason)") - default: - XCTFail("Unexpected state after start: \(state)") - } - } - - func testStopService_ShouldUnregister() async throws { - // Given: Service is "running" (simulated by setting mock status) - KanataService.smServiceFactory = { _ in - MockSMAppService(status: .enabled) - } - // Re-init to pick up new mock state - service = KanataService() - - // When: Stop is called - try await service.stop() - - // Then: Status should be stopped or not registered - let state = service.state - XCTAssertEqual(state, .stopped) - } - - func testStatusRefresh_ShouldDetectChanges() async { - // Given: Initial unknown state - - // When: Refresh is called - let status = await service.refreshStatus() - - // Then: Should return a valid state (likely .stopped in test env) - XCTAssertNotEqual(status, .unknown) - XCTAssertEqual(service.state, status) - } - - func testErrorMapping_WhenRegistrationFails_ShouldThrowKanataServiceError() async { - // Given: Mock that throws on register - class ThrowingMockSM: SMAppServiceProtocol, @unchecked Sendable { - var status: SMAppService.Status = .notRegistered - func register() throws { - throw KanataDaemonError.registrationFailed("Mock error") - } - - func unregister() async throws {} - } - KanataService.smServiceFactory = { _ in ThrowingMockSM() } - service = KanataService() - - // When/Then: Start should throw KanataServiceError - do { - try await service.start() - XCTFail("Should have thrown error") - } catch let error as KanataServiceError { - if case let .startFailed(reason) = error { - XCTAssertTrue(reason.contains("Mock error")) - } else { - XCTFail("Wrong error type: \(error)") - } - } catch { - XCTFail("Wrong error type: \(error)") - } - } - - func testEvaluateStatus_WhenPIDAndTCPBothFail_ShouldReportFailed() async { - // Given: SMAppService reports .enabled but no process is running - // and no kanata TCP server is listening (default in test env) - KanataService.smServiceFactory = { _ in - MockSMAppService(status: .enabled) - } - service = KanataService() - - // When: Refresh enough times to exhaust the debounce threshold (3 samples) - var lastStatus: KanataService.ServiceState = .unknown - for _ in 0 ..< 4 { - lastStatus = await service.refreshStatus() - } - - // Then: Should report .failed because both PID detection AND TCP probe failed - if case let .failed(reason) = lastStatus { - XCTAssertTrue( - reason.contains("process not running"), - "Expected 'process not running' failure, got: \(reason)" - ) - } else { - XCTFail("Expected .failed state after PID + TCP both fail, got: \(lastStatus)") - } - } - - func testStartService_WhenStaleRegistration_ShouldUnregisterAndReregister() async throws { - // Given: Mock that reports .enabled but plist doesn't exist (stale registration) - // This simulates the case where uninstall used launchctl/rm instead of SMAppService.unregister() - class StaleMockSM: SMAppServiceProtocol, @unchecked Sendable { - var status: SMAppService.Status = .enabled // Reports enabled... - var unregisterCalled = false - var registerCalled = false - - func register() throws { - registerCalled = true - status = .enabled - } - - func unregister() async throws { - unregisterCalled = true - status = .notRegistered - } - } - - let staleMock = StaleMockSM() - KanataService.smServiceFactory = { _ in staleMock } - service = KanataService() - - // The plist path checked is /Library/LaunchDaemons/com.keypath.kanata.plist - // In test environment, this file doesn't exist, so the stale detection should trigger - - // When: Start is called - try await service.start() - - // Then: Should have called unregister (to clear stale) and register (to re-register) - XCTAssertTrue(staleMock.unregisterCalled, "Should unregister stale registration") - XCTAssertTrue(staleMock.registerCalled, "Should re-register after clearing stale state") - } -} diff --git a/Tests/KeyPathTests/Services/KindaVimStateAdapterTests.swift b/Tests/KeyPathTests/Services/KindaVimStateAdapterTests.swift index 11853a01b..b931f39e3 100644 --- a/Tests/KeyPathTests/Services/KindaVimStateAdapterTests.swift +++ b/Tests/KeyPathTests/Services/KindaVimStateAdapterTests.swift @@ -93,7 +93,7 @@ final class KindaVimStateAdapterTests: XCTestCase { } func testMissingFileFallsBackToUnknown() async { - let missingURL = FileManager.default.temporaryDirectory + let missingURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent(UUID().uuidString) .appendingPathComponent("environment.json") @@ -111,7 +111,7 @@ final class KindaVimStateAdapterTests: XCTestCase { } private func makeTempEnvironmentFile(contents: String) throws -> URL { - let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let dir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let fileURL = dir.appendingPathComponent("environment.json") try contents.write(to: fileURL, atomically: true, encoding: .utf8) diff --git a/Tests/KeyPathTests/Services/LayerKeyMapperCollectionTests.swift b/Tests/KeyPathTests/Services/LayerKeyMapperCollectionTests.swift index 25924cfb1..196b60742 100644 --- a/Tests/KeyPathTests/Services/LayerKeyMapperCollectionTests.swift +++ b/Tests/KeyPathTests/Services/LayerKeyMapperCollectionTests.swift @@ -272,7 +272,7 @@ final class LayerKeyMapperCollectionTests: XCTestCase { // MARK: - Helper Methods private func createTempConfig(_ content: String, name: String) throws -> String { - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let configPath = tempDir.appendingPathComponent(name).path try content.write(toFile: configPath, atomically: true, encoding: .utf8) return configPath diff --git a/Tests/KeyPathTests/Services/LayerKeyMapperTests.swift b/Tests/KeyPathTests/Services/LayerKeyMapperTests.swift index 7effac8e4..8e5dfa65c 100644 --- a/Tests/KeyPathTests/Services/LayerKeyMapperTests.swift +++ b/Tests/KeyPathTests/Services/LayerKeyMapperTests.swift @@ -191,7 +191,7 @@ final class LayerKeyMapperTests: XCTestCase { // MARK: - Helpers private func createTempConfig(_ content: String, name: String) throws -> String { - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) let configPath = tempDir.appendingPathComponent(name).path try content.write(toFile: configPath, atomically: true, encoding: .utf8) return configPath diff --git a/Tests/KeyPathTests/Services/PermissionOracleTests.swift b/Tests/KeyPathTests/Services/PermissionOracleTests.swift index c6c21b16c..7562a78e1 100644 --- a/Tests/KeyPathTests/Services/PermissionOracleTests.swift +++ b/Tests/KeyPathTests/Services/PermissionOracleTests.swift @@ -323,4 +323,5 @@ struct PermissionOracleTests { // New snapshot should have same or newer timestamp #expect(timestamp2 >= timestamp1) } + } diff --git a/Tests/KeyPathTests/Services/PlistGeneratorTests.swift b/Tests/KeyPathTests/Services/PlistGeneratorTests.swift index 8f0a98ab8..560c68965 100644 --- a/Tests/KeyPathTests/Services/PlistGeneratorTests.swift +++ b/Tests/KeyPathTests/Services/PlistGeneratorTests.swift @@ -1,4 +1,5 @@ @testable import KeyPathAppKit +@testable import KeyPathCore @preconcurrency import XCTest /// Unit tests for PlistGenerator service. @@ -57,6 +58,19 @@ final class PlistGeneratorTests: XCTestCase { XCTAssertTrue(args.contains("5829")) } + func testBuildKanataPlistArgumentsDoesNotDuplicateTraceWhenDebugPresent() { + let args = KanataRuntimeLaunchRequest( + configPath: "/tmp/test.kbd", + inheritedArguments: ["--debug"], + addTraceLogging: true + ).commandLine(binaryPath: "/usr/local/bin/kanata") + + XCTAssertEqual( + args, + ["/usr/local/bin/kanata", "--cfg", "/tmp/test.kbd", "--debug"] + ) + } + func testBuildKanataPlistArgumentsCustomPort() { let args = PlistGenerator.buildKanataPlistArguments( binaryPath: "/usr/local/bin/kanata", diff --git a/Tests/KeyPathTests/Services/QMKImportServiceTests.swift b/Tests/KeyPathTests/Services/QMKImportServiceTests.swift index fc965bdae..d5f2a47bd 100644 --- a/Tests/KeyPathTests/Services/QMKImportServiceTests.swift +++ b/Tests/KeyPathTests/Services/QMKImportServiceTests.swift @@ -77,7 +77,7 @@ final class QMKImportServiceTests: XCTestCase { func testParseValidQMKJSON() async throws { // Create temporary file with QMK JSON - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-qmk-\(UUID().uuidString).json") try sampleQMKJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } @@ -94,7 +94,7 @@ final class QMKImportServiceTests: XCTestCase { func testParseInvalidJSON() async throws { // Create temporary file with invalid JSON - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-invalid-\(UUID().uuidString).json") try invalidJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } @@ -119,7 +119,7 @@ final class QMKImportServiceTests: XCTestCase { func testParseNoLayouts() async throws { // Create temporary file with no layouts JSON - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-no-layouts-\(UUID().uuidString).json") try noLayoutsJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } @@ -153,7 +153,7 @@ final class QMKImportServiceTests: XCTestCase { func testSaveAndLoadCustomLayout() async throws { // Create temporary file - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-save-\(UUID().uuidString).json") try sampleQMKJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } @@ -181,7 +181,7 @@ final class QMKImportServiceTests: XCTestCase { func testDeleteCustomLayout() async throws { // Create temporary file - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-delete-\(UUID().uuidString).json") try sampleQMKJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } @@ -224,7 +224,7 @@ final class QMKImportServiceTests: XCTestCase { func testANSIMapping() async throws { // Create temporary file - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-ansi-\(UUID().uuidString).json") try sampleQMKJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } @@ -246,7 +246,7 @@ final class QMKImportServiceTests: XCTestCase { func testISOMapping() async throws { // Create temporary file - let tempFile = FileManager.default.temporaryDirectory + let tempFile = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("test-iso-\(UUID().uuidString).json") try sampleQMKJSON.write(to: tempFile) defer { try? FileManager.default.removeItem(at: tempFile) } diff --git a/Tests/KeyPathTests/Services/RecentKeypressesServiceTests.swift b/Tests/KeyPathTests/Services/RecentKeypressesServiceTests.swift index 9ede6768e..474df8b97 100644 --- a/Tests/KeyPathTests/Services/RecentKeypressesServiceTests.swift +++ b/Tests/KeyPathTests/Services/RecentKeypressesServiceTests.swift @@ -229,6 +229,25 @@ final class RecentKeypressesServiceTests: XCTestCase { XCTAssertEqual(service.events[1].action, "press") } + func testMetadata_ListenerSessionAndKanataTimestamp_AreStoredOnEvent() async { + notificationCenter.post( + name: .kanataKeyInput, + object: nil, + userInfo: [ + "key": "a", + "action": "press", + "listenerSessionID": 17, + "kanataTimestamp": UInt64(55), + "observedAt": Date(timeIntervalSince1970: 1) + ] + ) + + await waitForEventsCount(1) + + XCTAssertEqual(service.events[0].listenerSessionID, 17) + XCTAssertEqual(service.events[0].kanataTimestamp, 55) + } + // MARK: - Recording Toggle Tests func testRecordingToggle_WhenDisabled_EventsNotAdded() async { diff --git a/Tests/KeyPathTests/Services/RecoveryDaemonServiceIntegrationTests.swift b/Tests/KeyPathTests/Services/RecoveryDaemonServiceIntegrationTests.swift new file mode 100644 index 000000000..b9ca03b77 --- /dev/null +++ b/Tests/KeyPathTests/Services/RecoveryDaemonServiceIntegrationTests.swift @@ -0,0 +1,110 @@ +@testable import KeyPathAppKit +import KeyPathDaemonLifecycle +import ServiceManagement +@preconcurrency import XCTest + +/// Mock implementation of SMAppServiceProtocol for testing +private class MockSMAppService: SMAppServiceProtocol, @unchecked Sendable { + var status: SMAppService.Status + var registerCalled = false + var unregisterCalled = false + + init(status: SMAppService.Status = .notRegistered) { + self.status = status + } + + func register() throws { + registerCalled = true + // Simulate successful registration transition + if status == .notRegistered || status == .notFound { + status = .enabled + } + } + + func unregister() async throws { + unregisterCalled = true + status = .notRegistered + } +} + +@MainActor +final class RecoveryDaemonServiceIntegrationTests: KeyPathAsyncTestCase { + var service: RecoveryDaemonService! + + /// Keep reference to original factory to restore it + var originalFactory: ((String) -> SMAppServiceProtocol)! + + override func setUp() async throws { + try await super.setUp() + + // 1. Mock SMAppService + originalFactory = RecoveryDaemonService.smServiceFactory + RecoveryDaemonService.smServiceFactory = { _ in + MockSMAppService(status: .notRegistered) + } + + // 2. Create Service under test + service = RecoveryDaemonService() + } + + override func tearDown() async throws { + RecoveryDaemonService.smServiceFactory = originalFactory + service = nil + try await super.tearDown() + } + + func testStopService_ShouldUnregister() async throws { + // Given: Service is "running" (simulated by setting mock status) + RecoveryDaemonService.smServiceFactory = { _ in + MockSMAppService(status: .enabled) + } + // Re-init to pick up new mock state + service = RecoveryDaemonService() + + // When: Stop is called + try await service.stop() + + // Then: Status should no longer report running + let status = await service.refreshStatus() + XCTAssertNotEqual(status, .running(pid: 0)) + if case .running = status { + XCTFail("Expected service to be stopped or unknown after stop, got \(status)") + } + } + + func testStatusRefresh_ShouldDetectChanges() async { + // Given: Initial unknown state + + // When: Refresh is called + let status = await service.refreshStatus() + + // Then: Should return a valid state (likely .stopped in test env) + XCTAssertNotEqual(status, .unknown) + } + + func testEvaluateStatus_WhenPIDAndTCPBothFail_ShouldReportFailed() async { + // Given: SMAppService reports .enabled but no process is running + // and no kanata TCP server is listening (default in test env) + RecoveryDaemonService.smServiceFactory = { _ in + MockSMAppService(status: .enabled) + } + service = RecoveryDaemonService() + + // When: Refresh enough times to exhaust the debounce threshold (3 samples) + var lastStatus: RecoveryDaemonService.ServiceState = .unknown + for _ in 0 ..< 4 { + lastStatus = await service.refreshStatus() + } + + // Then: Should report .failed because both PID detection AND TCP probe failed + if case let .failed(reason) = lastStatus { + XCTAssertTrue( + reason.contains("process not running"), + "Expected 'process not running' failure, got: \(reason)" + ) + } else { + XCTFail("Expected .failed state after PID + TCP both fail, got: \(lastStatus)") + } + } + +} diff --git a/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift b/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift index 231efa45c..3f486a745 100644 --- a/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift +++ b/Tests/KeyPathTests/Services/ServiceBootstrapperTests.swift @@ -178,7 +178,7 @@ final class ServiceBootstrapperTests: XCTestCase { func testLoadServiceInTestModeReturnsTrueWhenPlistExists() async { let bootstrapper = ServiceBootstrapper.shared - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("ServiceBootstrapperTests-\(UUID().uuidString)") try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -202,7 +202,7 @@ final class ServiceBootstrapperTests: XCTestCase { func testLoadServicesInTestModeReturnsFalseWhenAnyPlistMissing() async { let bootstrapper = ServiceBootstrapper.shared - let tempDir = FileManager.default.temporaryDirectory + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("ServiceBootstrapperTests-\(UUID().uuidString)") try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) } @@ -241,10 +241,6 @@ final class ServiceBootstrapperTests: XCTestCase { let installResult = await bootstrapper.installAllServices() XCTAssertTrue(installResult) - let installOnlyResult = await bootstrapper.installAllServicesWithoutLoading( - binaryPath: "/tmp/fake-kanata" - ) - XCTAssertTrue(installOnlyResult) } func testRepairVHIDDaemonServicesInTestModeSetsOutput() async { diff --git a/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift b/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift index 586bf1c8f..4887f7d7a 100644 --- a/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift +++ b/Tests/KeyPathTests/Services/ServiceHealthCheckerTests.swift @@ -14,7 +14,7 @@ final class ServiceHealthCheckerTests: XCTestCase { checker = ServiceHealthChecker.shared originalSMFactory = KanataDaemonManager.smServiceFactory - tempLaunchDaemonsDir = FileManager.default.temporaryDirectory + tempLaunchDaemonsDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("ServiceHealthCheckerTests-\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempLaunchDaemonsDir, withIntermediateDirectories: true) @@ -23,6 +23,7 @@ final class ServiceHealthCheckerTests: XCTestCase { #if DEBUG ServiceHealthChecker.runtimeSnapshotOverride = nil ServiceHealthChecker.recentlyRestartedOverride = nil + ServiceHealthChecker.inputCaptureStatusOverride = nil KanataDaemonManager.registeredButNotLoadedOverride = nil #endif } @@ -38,6 +39,7 @@ final class ServiceHealthCheckerTests: XCTestCase { #if DEBUG ServiceHealthChecker.runtimeSnapshotOverride = nil ServiceHealthChecker.recentlyRestartedOverride = nil + ServiceHealthChecker.inputCaptureStatusOverride = nil KanataDaemonManager.registeredButNotLoadedOverride = nil #endif try? FileManager.default.removeItem(at: tempLaunchDaemonsDir) @@ -60,11 +62,12 @@ final class ServiceHealthCheckerTests: XCTestCase { } private func writeVHIDPlist(programPath: String) throws { - let dict: NSDictionary = [ + let dict: [String: Any] = [ "ProgramArguments": [programPath] ] let url = tempLaunchDaemonsDir.appendingPathComponent("\(ServiceHealthChecker.vhidDaemonServiceID).plist") - XCTAssertTrue(dict.write(to: url, atomically: true)) + let data = try PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) + try data.write(to: url) } func testServiceIdentifiers() { @@ -126,6 +129,8 @@ final class ServiceHealthCheckerTests: XCTestCase { managementState: .smappserviceActive, isRunning: false, isResponding: false, + inputCaptureReady: true, + inputCaptureIssue: nil, launchctlExitCode: 113, staleEnabledRegistration: false, recentlyRestarted: true @@ -141,6 +146,8 @@ final class ServiceHealthCheckerTests: XCTestCase { managementState: .smappserviceActive, isRunning: true, isResponding: false, + inputCaptureReady: true, + inputCaptureIssue: nil, launchctlExitCode: 0, staleEnabledRegistration: false, recentlyRestarted: true @@ -156,6 +163,8 @@ final class ServiceHealthCheckerTests: XCTestCase { managementState: .smappserviceActive, isRunning: false, isResponding: false, + inputCaptureReady: true, + inputCaptureIssue: nil, launchctlExitCode: nil, staleEnabledRegistration: true, recentlyRestarted: false @@ -171,6 +180,8 @@ final class ServiceHealthCheckerTests: XCTestCase { managementState: .smappserviceActive, isRunning: true, isResponding: true, + inputCaptureReady: true, + inputCaptureIssue: nil, launchctlExitCode: 0, staleEnabledRegistration: true, recentlyRestarted: false @@ -181,6 +192,36 @@ final class ServiceHealthCheckerTests: XCTestCase { XCTAssertTrue(decision.isHealthy) } + func testCheckKanataInputCaptureStatusReturnsNotReadyForBuiltInKeyboardPermissionError() async throws { + let stderrURL = tempLaunchDaemonsDir.appendingPathComponent("kanata-stderr.log") + try """ + [2026-03-07T13:21:14Z] IOHIDDeviceOpen error: (iokit/common) not permitted Apple Internal Keyboard / Trackpad + """.write(to: stderrURL, atomically: true, encoding: .utf8) + setenv("KEYPATH_KANATA_STDERR_PATH", stderrURL.path, 1) + defer { unsetenv("KEYPATH_KANATA_STDERR_PATH") } + + let status = await checker.checkKanataInputCaptureStatus() + XCTAssertFalse(status.isReady) + XCTAssertEqual(status.issue, "kanata-cannot-open-built-in-keyboard") + } + + func testKanataDecisionTreatsMissingInputCaptureAsUnhealthy() { + let snapshot = ServiceHealthChecker.KanataServiceRuntimeSnapshot( + managementState: .smappserviceActive, + isRunning: true, + isResponding: true, + inputCaptureReady: false, + inputCaptureIssue: "kanata-cannot-open-built-in-keyboard", + launchctlExitCode: 0, + staleEnabledRegistration: false, + recentlyRestarted: false + ) + + let decision = ServiceHealthChecker.decideKanataHealth(for: snapshot) + XCTAssertEqual(decision, .unhealthy(reason: "kanata-cannot-open-built-in-keyboard")) + XCTAssertFalse(decision.isHealthy) + } + func testIsKanataPlistInstalledUsesLaunchDaemonsOverride() throws { try writeEmptyPlist(serviceID: ServiceHealthChecker.kanataServiceID) XCTAssertTrue(checker.isKanataPlistInstalled()) diff --git a/Tests/KeyPathTests/Services/SimpleModsWriterTests.swift b/Tests/KeyPathTests/Services/SimpleModsWriterTests.swift index 7af78ea2c..4effb4797 100644 --- a/Tests/KeyPathTests/Services/SimpleModsWriterTests.swift +++ b/Tests/KeyPathTests/Services/SimpleModsWriterTests.swift @@ -10,7 +10,7 @@ final class SimpleModsWriterTests: XCTestCase { override func setUp() async throws { try await super.setUp() - tempDirectory = FileManager.default.temporaryDirectory + tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) .appendingPathComponent("SimpleModsWriterTests_\(UUID().uuidString)") try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) configPath = tempDirectory.appendingPathComponent("keypath.kbd").path diff --git a/Tests/KeyPathTests/Services/SystemValidatorTests.swift b/Tests/KeyPathTests/Services/SystemValidatorTests.swift index fe5b7608d..74db81a74 100644 --- a/Tests/KeyPathTests/Services/SystemValidatorTests.swift +++ b/Tests/KeyPathTests/Services/SystemValidatorTests.swift @@ -131,7 +131,6 @@ struct SystemValidatorTests { karabinerDaemonRunning: false, vhidDeviceInstalled: false, vhidDeviceHealthy: false, - launchDaemonServicesHealthy: false, vhidServicesHealthy: false, vhidVersionMismatch: false ), diff --git a/Tests/KeyPathTests/Services/UpdateServiceDecisionTests.swift b/Tests/KeyPathTests/Services/UpdateServiceDecisionTests.swift index aae69a714..83e4a1369 100644 --- a/Tests/KeyPathTests/Services/UpdateServiceDecisionTests.swift +++ b/Tests/KeyPathTests/Services/UpdateServiceDecisionTests.swift @@ -145,7 +145,6 @@ final class UpdateServiceDecisionTests: XCTestCase { karabinerDaemonRunning: servicesReady, vhidDeviceInstalled: true, vhidDeviceHealthy: servicesReady, - launchDaemonServicesHealthy: servicesReady, vhidServicesHealthy: servicesReady, vhidVersionMismatch: false, kanataBinaryVersionMismatch: false diff --git a/Tests/KeyPathTests/TestSupport/StubPrivilegedOperationsCoordinator.swift b/Tests/KeyPathTests/TestSupport/StubPrivilegedOperationsCoordinator.swift index fd47095a2..0bbd470e2 100644 --- a/Tests/KeyPathTests/TestSupport/StubPrivilegedOperationsCoordinator.swift +++ b/Tests/KeyPathTests/TestSupport/StubPrivilegedOperationsCoordinator.swift @@ -12,24 +12,16 @@ final class StubPrivilegedOperationsCoordinator: PrivilegedOperationsCoordinatin calls.append(name) } - func installLaunchDaemon(plistPath _: String, serviceID _: String) async throws { - try record("installLaunchDaemon") - } - func cleanupPrivilegedHelper() async throws { try record("cleanupPrivilegedHelper") } - func installAllLaunchDaemonServices(kanataBinaryPath _: String, kanataConfigPath _: String, tcpPort _: Int) async throws { - try record("installAllLaunchDaemonServices") - } - - func installAllLaunchDaemonServices() async throws { - try record("installAllLaunchDaemonServices") + func installRequiredRuntimeServices() async throws { + try record("installRequiredRuntimeServices") } - func restartUnhealthyServices() async throws { - try record("restartUnhealthyServices") + func recoverRequiredRuntimeServices() async throws { + try record("recoverRequiredRuntimeServices") } func installServicesIfUninstalled(context _: String) async throws -> Bool { @@ -37,10 +29,6 @@ final class StubPrivilegedOperationsCoordinator: PrivilegedOperationsCoordinatin return false } - func installLaunchDaemonServicesWithoutLoading() async throws { - try record("installLaunchDaemonServicesWithoutLoading") - } - func installNewsyslogConfig() async throws { try record("installNewsyslogConfig") } @@ -73,7 +61,7 @@ final class StubPrivilegedOperationsCoordinator: PrivilegedOperationsCoordinatin try record("killAllKanataProcesses") } - func stopKanataService() async throws { + func stopRecoveryDaemonService() async throws { try record("stopKanataService") } diff --git a/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift b/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift index cea3b959a..b12a55aa2 100644 --- a/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift +++ b/Tests/KeyPathTests/TestSupport/SystemContextBuilder.swift @@ -8,6 +8,7 @@ struct SystemContextBuilder { var permissionsStatus: PermissionOracle.Status = .granted var helperReady: Bool = true var servicesHealthy: Bool = false + var kanataInputCaptureReady: Bool = true var componentsInstalled: Bool = false var conflicts: [SystemConflict] = [] var driverCompatible: Bool = true @@ -35,7 +36,6 @@ struct SystemContextBuilder { karabinerDaemonRunning: servicesHealthy, vhidDeviceInstalled: true, vhidDeviceHealthy: servicesHealthy, - launchDaemonServicesHealthy: servicesHealthy, vhidServicesHealthy: servicesHealthy, vhidVersionMismatch: false ) @@ -44,7 +44,14 @@ struct SystemContextBuilder { } let services = servicesHealthy - ? HealthStatus(kanataRunning: true, karabinerDaemonRunning: true, vhidHealthy: true) + ? HealthStatus( + kanataRunning: true, + karabinerDaemonRunning: true, + vhidHealthy: true, + kanataInputCaptureReady: kanataInputCaptureReady, + kanataInputCaptureIssue: kanataInputCaptureReady + ? nil : "kanata-cannot-open-built-in-keyboard" + ) : HealthStatus.empty let conflictStatus = ConflictStatus(conflicts: conflicts, canAutoResolve: !conflicts.isEmpty) diff --git a/Tests/KeyPathTests/UnitTestSuite.swift b/Tests/KeyPathTests/UnitTestSuite.swift index 1aa9e96c2..b1f8edd91 100644 --- a/Tests/KeyPathTests/UnitTestSuite.swift +++ b/Tests/KeyPathTests/UnitTestSuite.swift @@ -82,7 +82,7 @@ final class UnitTestSuite: XCTestCase { func testBasicPathGeneration() { // Test basic path operations work - let homePath = FileManager.default.homeDirectoryForCurrentUser.path + let homePath = NSHomeDirectory() let configPath = homePath + "/.config/keypath/keypath.kbd" XCTAssertTrue(configPath.hasSuffix(".kbd")) diff --git a/Tests/KeyPathTests/Utilities/SingleInstanceCoordinatorTests.swift b/Tests/KeyPathTests/Utilities/SingleInstanceCoordinatorTests.swift new file mode 100644 index 000000000..08e4483b3 --- /dev/null +++ b/Tests/KeyPathTests/Utilities/SingleInstanceCoordinatorTests.swift @@ -0,0 +1,34 @@ +import Testing +@testable import KeyPathAppKit + +struct SingleInstanceCoordinatorTests { + @Test("Selects the oldest live instance with the same bundle identifier") + func selectsOldestLiveMatchingInstance() { + let pid = SingleInstanceCoordinator.existingInstancePID( + currentPID: 42, + bundleIdentifier: "com.keypath.KeyPath", + candidates: [ + .init(pid: 88, bundleIdentifier: "com.keypath.KeyPath", isTerminated: false), + .init(pid: 17, bundleIdentifier: "com.keypath.KeyPath", isTerminated: false), + .init(pid: 12, bundleIdentifier: "com.other.App", isTerminated: false), + .init(pid: 42, bundleIdentifier: "com.keypath.KeyPath", isTerminated: false) + ] + ) + + #expect(pid == 17) + } + + @Test("Ignores terminated instances and returns nil when no live match exists") + func ignoresTerminatedInstances() { + let pid = SingleInstanceCoordinator.existingInstancePID( + currentPID: 42, + bundleIdentifier: "com.keypath.KeyPath", + candidates: [ + .init(pid: 12, bundleIdentifier: "com.keypath.KeyPath", isTerminated: true), + .init(pid: 42, bundleIdentifier: "com.keypath.KeyPath", isTerminated: false) + ] + ) + + #expect(pid == nil) + } +} diff --git a/Tests/KeyPathTests/UtilitiesTests.swift b/Tests/KeyPathTests/UtilitiesTests.swift index 7a1b0fbe7..ddedb181b 100644 --- a/Tests/KeyPathTests/UtilitiesTests.swift +++ b/Tests/KeyPathTests/UtilitiesTests.swift @@ -105,17 +105,9 @@ final class UtilitiesTests: XCTestCase { } func testAppRestarterBundlePathHandling() { - // Test that Bundle.main.bundlePath is accessible - let bundlePath = Bundle.main.bundlePath - XCTAssertFalse(bundlePath.isEmpty, "Bundle path should not be empty") - - if bundlePath.contains(".app") { - XCTAssertTrue(true, "Bundle path should include .app when running from an app bundle") - } else if bundlePath.contains(".xctest") { - XCTAssertTrue(true, "Bundle path should include .xctest when running tests") - } else { - XCTAssertTrue(bundlePath.hasSuffix("/xctest"), "Unexpected bundle path: \(bundlePath)") - } + // Test that a test bundle can be resolved without relying on Foundation path conveniences. + let bundleDescription = String(describing: Bundle(for: UtilitiesTests.self)) + XCTAssertFalse(bundleDescription.isEmpty, "Bundle description should not be empty") } // MARK: - Logger Tests diff --git a/Tests/KeyPathTests/WizardNavigationEngineTests.swift b/Tests/KeyPathTests/WizardNavigationEngineTests.swift index 7f04bf883..010a3ea90 100644 --- a/Tests/KeyPathTests/WizardNavigationEngineTests.swift +++ b/Tests/KeyPathTests/WizardNavigationEngineTests.swift @@ -406,11 +406,11 @@ class WizardNavigationEngineTests: XCTestCase { // Then: Button text should vary based on state XCTAssertEqual(activeButtonText, "Close Setup", "Active state should show 'Close Setup'") XCTAssertEqual( - serviceNotRunningButtonText, "Start Kanata Service", - "Service not running should show 'Start Kanata Service'" + serviceNotRunningButtonText, "Start KeyPath Runtime", + "Service not running should show 'Start KeyPath Runtime'" ) XCTAssertEqual( - readyButtonText, "Start Kanata Service", "Ready state should show 'Start Kanata Service'" + readyButtonText, "Start KeyPath Runtime", "Ready state should show 'Start KeyPath Runtime'" ) } diff --git a/Tests/KeyPathTests/WizardStateMachineDeterminismTests.swift b/Tests/KeyPathTests/WizardStateMachineDeterminismTests.swift index e21b8b81a..dbf673afe 100644 --- a/Tests/KeyPathTests/WizardStateMachineDeterminismTests.swift +++ b/Tests/KeyPathTests/WizardStateMachineDeterminismTests.swift @@ -63,7 +63,6 @@ final class WizardStateMachineDeterminismTests: XCTestCase { karabinerDaemonRunning: false, vhidDeviceInstalled: false, vhidDeviceHealthy: false, - launchDaemonServicesHealthy: false, vhidServicesHealthy: false, vhidVersionMismatch: false ) @@ -76,7 +75,6 @@ final class WizardStateMachineDeterminismTests: XCTestCase { karabinerDaemonRunning: true, vhidDeviceInstalled: true, vhidDeviceHealthy: true, - launchDaemonServicesHealthy: true, vhidServicesHealthy: true, vhidVersionMismatch: false ) diff --git a/docs/ARCHITECTURE_DIAGRAM.md b/docs/ARCHITECTURE_DIAGRAM.md index 6e2152e64..bfe5db172 100644 --- a/docs/ARCHITECTURE_DIAGRAM.md +++ b/docs/ARCHITECTURE_DIAGRAM.md @@ -29,7 +29,7 @@ graph TB RuleCollectionsMgr[RuleCollectionsManager
Rule management] SystemReqChecker[SystemRequirementsChecker
System checks] ConfigService[ConfigurationService
File I/O] - KanataService[KanataService
Process lifecycle] + RuntimeHost["KeyPath Runtime Host
Normal runtime lifecycle"] PermissionOracle[PermissionOracle
Permission detection] DiagnosticsService[DiagnosticsService
Error analysis] end @@ -41,7 +41,8 @@ graph TB end subgraph "System Components" - LaunchDaemon[LaunchDaemon
com.keypath.kanata] + RuntimeHostProcess["KeyPath Runtime Host
bundled kanata-launcher"] + OutputBridge["Output Bridge Daemon
com.keypath.output-bridge"] VHIDDriver[VirtualHID Driver
Karabiner] SystemSettings[System Settings
TCC Permissions] end @@ -59,7 +60,7 @@ graph TB RuntimeCoordinator --> RuleCollectionsMgr RuntimeCoordinator --> SystemReqChecker RuntimeCoordinator --> ConfigService - RuntimeCoordinator --> KanataService + RuntimeCoordinator --> RuntimeHost RuntimeCoordinator --> PermissionOracle RuntimeCoordinator --> DiagnosticsService RuntimeCoordinator --> ProcessLifecycleMgr @@ -72,10 +73,10 @@ graph TB InstallerEngine --> ProcessLifecycleMgr %% Services → System - KanataService --> LaunchDaemon - TCPClient --> LaunchDaemon + RecoveryDaemonService --> RuntimeHostProcess + TCPClient --> RuntimeHostProcess SystemReqChecker --> SystemSettings - PrivilegedOpsCoord --> LaunchDaemon + PrivilegedOpsCoord --> OutputBridge KarabinerConflictSvc --> VHIDDriver %% PermissionOracle → System @@ -190,7 +191,8 @@ sequenceDiagram - Reading/writing Kanata configs - Validation - Parsing -- **KanataService**: Process lifecycle +- **KeyPath Runtime Host**: Normal runtime lifecycle +- **RecoveryDaemonService**: Internal recovery-daemon utility - Start/stop/restart - Health monitoring - **PermissionOracle**: Single source of truth for permissions @@ -205,7 +207,7 @@ sequenceDiagram ### System Integration - **ProcessLifecycleManager**: PID tracking, conflict detection - **KarabinerConflictService**: Karabiner Elements detection/resolution -- **TCPEngineClient**: Communication with Kanata daemon +- **TCPEngineClient**: Communication with the active KeyPath runtime host --- @@ -251,12 +253,12 @@ WizardView → InstallerEngine.inspectSystem() → PermissionOracle.currentSnapshot() ``` -### Start Kanata Service +### Start KeyPath Runtime ``` KanataViewModel.startKanata() → RuntimeCoordinator.startKanata() -→ KanataService.start() -→ LaunchDaemon bootstrap +→ KeyPath Runtime Host launch +→ Output Bridge companion session bootstrap ``` --- @@ -274,7 +276,7 @@ Sources/KeyPathAppKit/ │ ├── RuleCollectionsManager.swift (~406 lines) │ ├── SystemRequirementsChecker.swift (~292 lines) │ ├── ConfigurationService.swift -│ ├── KanataService.swift +│ ├── RecoveryDaemonService.swift │ ├── PermissionOracle.swift (in KeyPathPermissions/) │ └── ... ├── InstallationWizard/ @@ -303,4 +305,3 @@ Sources/KeyPathAppKit/ - ✅ SystemRequirementsChecker (292 lines) **Target:** Continue reducing RuntimeCoordinator to ~800 lines by extracting more focused services. - diff --git a/docs/KANATA_MACOS_SETUP_GUIDE.md b/docs/KANATA_MACOS_SETUP_GUIDE.md index ce8099380..9f83b49ff 100644 --- a/docs/KANATA_MACOS_SETUP_GUIDE.md +++ b/docs/KANATA_MACOS_SETUP_GUIDE.md @@ -323,6 +323,57 @@ cargo build --release cargo build --release --features "cmd,watch" -v ``` +### Building the Runtime Host Library Artifact + +For the long-term KeyPath macOS runtime-host migration, the repository now includes a helper script +that emits Kanata's vendored Rust library target as a static archive: + +```bash +./Scripts/build-kanata-runtime-library.sh +``` + +This produces: + +- `build/kanata-runtime/libkanata_state_machine.a` + +Important: + +- this validates that the upstream Rust library can be packaged as a linkable artifact +- it does **not** provide a Swift-callable API by itself +- the next integration step is a small Rust bridge crate that exports a stable C ABI for the + bundled KeyPath macOS runtime host + +To build that bridge layer itself: + +```bash +./Scripts/build-kanata-host-bridge.sh +``` + +This produces: + +- `build/kanata-host-bridge/libkeypath_kanata_host_bridge.a` +- `build/kanata-host-bridge/libkeypath_kanata_host_bridge.dylib` +- `build/kanata-host-bridge/include/keypath_kanata_host_bridge.h` + +You can smoke-test the bridge exports directly: + +```bash +python3 ./Scripts/verify-kanata-host-bridge.py \ + ./build/kanata-host-bridge/libkeypath_kanata_host_bridge.dylib +``` + +For the experimental bundled-host runtime path, `kanata-launcher` now supports an +opt-in in-process mode: + +```bash +KEYPATH_EXPERIMENTAL_HOST_RUNTIME=1 \ + ./.build/debug/KeyPathKanataLauncher --port 37001 +``` + +This bypasses the legacy `exec(/Library/KeyPath/bin/kanata)` handoff and runs the +Kanata startup sequence inside the bundled host process instead. Keep it as a +development-only path until the in-process runtime becomes the default. + ## Architecture Notes ### How It All Works Together @@ -421,4 +472,4 @@ Always start troubleshooting by ensuring the daemon is running and the driver is - [Kanata GitHub Repository](https://github.com/jtroo/kanata) - [Karabiner-DriverKit-VirtualHIDDevice](https://github.com/pqrs-org/Karabiner-DriverKit-VirtualHIDDevice) - [Kanata Configuration Guide](https://github.com/jtroo/kanata/blob/main/docs/config.adoc) -- [macOS Code Signing Guide](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) \ No newline at end of file +- [macOS Code Signing Guide](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) diff --git a/docs/NEW_DEVELOPER_GUIDE.md b/docs/NEW_DEVELOPER_GUIDE.md index 4a6b63e2a..7e33579d2 100644 --- a/docs/NEW_DEVELOPER_GUIDE.md +++ b/docs/NEW_DEVELOPER_GUIDE.md @@ -6,7 +6,7 @@ Welcome to KeyPath! This guide will help you understand the codebase architectur KeyPath is a macOS application that provides keyboard remapping using Kanata as the backend engine. It features: - SwiftUI frontend for recording keypaths and managing configuration -- LaunchDaemon architecture for reliable system-level key remapping +- A split runtime architecture with a bundled user-session host and a dedicated privileged output companion - Installation Wizard for automated setup - Deep macOS system integration (TCC permissions, VirtualHID drivers, service management) @@ -47,33 +47,24 @@ KEYPATH_USE_INSTALLER_ENGINE=1 swift test --filter InstallerEngine ```swift import KeyPathAppKit -let coordinator = ProcessCoordinator() - -// Start/stop/restart always go through KanataService and only fall back to InstallerEngine if needed -let restarted = await coordinator.restartService() -if restarted { - print("Kanata service is healthy") -} else { - print("Restart failed even after InstallerEngine fallback") -} - -// Need a full repair? Go through RuntimeCoordinator so it can log + inspect system context for you. let runtimeCoordinator = RuntimeCoordinator() -let report = await runtimeCoordinator.runFullRepair(reason: "CLI repair") -if report.success { - print("Repair finished (\(report.executedRecipes.count) steps)") +let started = await runtimeCoordinator.startKanata(reason: "Normal app start") +if started { + print("Split runtime host is active") } else { - print("Repair failed: \(report.failureReason ?? "unknown")") + print(runtimeCoordinator.lastError ?? "Split runtime start failed") } ``` -`InstallerEngine` still powers installs/repairs under the hood, but new helpers should prefer `ProcessCoordinator` / `RuntimeCoordinator` so that cool-downs, health checks, and privilege handling stay centralized. +`InstallerEngine` still powers installs/repairs under the hood, but ordinary runtime control should +go through `RuntimeCoordinator`, which now treats the split runtime host as the normal path and the +old launchd-managed daemon only as an explicit recovery seam. ### 3. Explore Key Components (10 minutes) Open these files in your editor to understand the core architecture: - `Services/PermissionOracle.swift` - Single source of truth for permissions -- `Managers/KanataManager.swift` - Main process coordinator +- `Managers/RuntimeCoordinator.swift` - Main runtime coordinator - `UI/ContentView.swift` - Main recording UI - `InstallationWizard/README.md` - Wizard overview (45% of codebase) @@ -82,21 +73,20 @@ Open these files in your editor to understand the core architecture: ### System Design ``` -KeyPath.app (SwiftUI) → InstallerEngine → LaunchDaemon/PrivilegedHelper - ↓ ↓ - RuntimeCoordinator SystemContext (State) - ↓ - TCP/Runtime Control → Kanata daemon - ↓ ↓ - CGEvent Capture VirtualHID Driver - ↓ ↓ - User Input Recording System-wide Remapping +KeyPath.app (SwiftUI) → RuntimeCoordinator → KeyPath Runtime Host + ↓ ↓ ↓ + InstallerEngine SystemContext (State) Kanata engine in-process + ↓ ↓ + Privileged Helper / Output Bridge Daemon TCP/Event interface + CGEvent capture + ↓ ↓ + VirtualHID / Driver management System-wide remapping ``` **Key Components:** - **InstallerEngine**: Unified façade for installation, repair, and system inspection -- **RuntimeCoordinator**: Orchestrates active service, handles config reloading -- **KanataService**: Manages service lifecycle (start/stop/restart) +- **RuntimeCoordinator**: Orchestrates the active KeyPath runtime and handles config reloading +- **KeyPath Runtime**: The normal bundled host runtime that the app starts, stops, and monitors. +- **RecoveryDaemonService**: Narrow internal utility for the old recovery daemon only. - **SystemContext**: Snapshot of system state (permissions, services, components) ### Directory Structure @@ -149,39 +139,25 @@ let updated = await PermissionOracle.shared.forceRefresh() **See also:** Oracle Quick Start section in the file (lines 13-53) -### 2. KanataManager (Main Coordinator) +### 2. RuntimeCoordinator (Main Coordinator) -**Location:** `Managers/KanataManager.swift` + 5 extension files (2,820 lines total) +**Location:** `Managers/RuntimeCoordinator.swift` + extension files -**What it does:** Orchestrates Kanata process lifecycle, configuration, and communication. - -**Extension breakdown:** -- `KanataManager.swift` - Core state, initialization, health monitoring -- `+Lifecycle.swift` - Start/stop/restart operations -- `+Configuration.swift` - Config file I/O and validation -- `+Engine.swift` - UDP/TCP communication with Kanata -- `+EventTaps.swift` - CGEvent monitoring for key recording -- `+Output.swift` - Log parsing and monitoring +**What it does:** Orchestrates the active KeyPath runtime, configuration, diagnostics, and recovery. **How to use it:** ```swift -// KanataManager is NOT @ObservableObject -// UI uses KanataViewModel for @Published properties - -// Start Kanata -try await manager.startKanata() - -// Stop Kanata -try await manager.stopKanata() +let coordinator = RuntimeCoordinator.shared -// Update config -try await manager.updateConfiguration(newConfig) +let started = await coordinator.startKanata(reason: "Normal app start") +let restarted = await coordinator.restartKanata(reason: "Manual restart") +let stopped = await coordinator.stopKanata(reason: "App shutdown") -// Get UI state snapshot -let uiState = manager.getCurrentUIState() +let uiState = coordinator.buildUIState() ``` -**See also:** Navigation comment in the file (lines 74-135) +**Naming note:** user-facing code says `KeyPath Runtime`, while engine-level code keeps `Kanata` +for the actual remapping engine, config, and binary. ### 3. InstallationWizard (45% of Codebase!) @@ -235,7 +211,8 @@ let health = await engine.checkKanataServiceHealth() | Service | Purpose | Lines | |---------|---------|-------| | **PermissionOracle** | Permission detection | 671 | -| **KanataService** | Service lifecycle (start/stop/restart) | 400+ | +| **KeyPath Runtime / RuntimeCoordinator** | Normal runtime lifecycle (start/stop/restart) | Primary path | +| **RecoveryDaemonService** | Internal recovery-daemon stop/status utility | Narrow internal seam | | **KeyboardCapture** | CGEvent input recording | 622 | | **KarabinerConflictService** | Detect keyboard conflicts | 600 | | **DiagnosticsService** | System analysis | 537 | @@ -261,7 +238,7 @@ KeyPath uses a clean MVVM separation: └─────────────────┬───────────────────────────────┘ │ Calls methods, reads snapshots ┌─────────────────▼───────────────────────────────┐ -│ KanataManager │ +│ RuntimeCoordinator │ │ (Business logic, NO @ObservableObject) │ └─────────────────┬───────────────────────────────┘ │ Delegates to services @@ -272,7 +249,7 @@ KeyPath uses a clean MVVM separation: ``` **Key points:** -- **Manager** = business logic & orchestration (NOT ObservableObject) +- **Coordinator** = business logic & orchestration (NOT ObservableObject) - **ViewModel** = UI state with @Published properties - **Views** = SwiftUI, observe ViewModel only - **Services** = focused, reusable, independently testable @@ -415,7 +392,7 @@ swift test --filter TestClassName.testMethodName **Requires careful consideration:** - PermissionOracle (critical architecture, check ADRs first) - WizardNavigationEngine (state-driven logic) -- KanataManager core (coordinator pattern) +- RuntimeCoordinator core (coordinator pattern) **Don't touch without team discussion:** - Core contracts/protocols (affects all consumers) @@ -529,7 +506,7 @@ sudo ./Scripts/uninstall.sh - `CLAUDE.md` - ADRs, anti-patterns, critical architecture - `InstallationWizard/README.md` - Wizard flow and components - `Services/PermissionOracle.swift` - Permission detection guide -- `Managers/KanataManager.swift` - Manager extension map +- `Managers/RuntimeCoordinator.swift` - Coordinator extension map ### Debugging Resources @@ -562,7 +539,7 @@ Now that you understand the architecture, here are suggested next steps: - [ ] Read all files in the "Quick Start" section - [ ] Run through the Installation Wizard - [ ] Check permission states in PermissionOracle -- [ ] Explore KanataManager extension files +- [ ] Explore RuntimeCoordinator extension files ### Week 2: Small Changes - [ ] Fix a small UI bug or add a minor feature @@ -573,7 +550,7 @@ Now that you understand the architecture, here are suggested next steps: ### Week 3: Understanding Services - [ ] Pick one service (e.g., KeyboardCapture) - [ ] Read its implementation completely -- [ ] Understand how it's used by KanataManager +- [ ] Understand how it is used by RuntimeCoordinator - [ ] Write tests or improve existing tests ### Week 4: Larger Features diff --git a/docs/adr/adr-032-macos-kanata-runtime-identity.md b/docs/adr/adr-032-macos-kanata-runtime-identity.md new file mode 100644 index 000000000..21c0e8ac8 --- /dev/null +++ b/docs/adr/adr-032-macos-kanata-runtime-identity.md @@ -0,0 +1,325 @@ +# ADR-032: Stable App-Bundled Runtime Identity for macOS Kanata Input Capture + +## Status + +Proposed + +## Date + +2026-03-07 + +## Context + +KeyPath currently uses two different identities for macOS Kanata operation: + +1. `SMAppService` launches `Contents/Library/KeyPath/kanata-launcher` from the app bundle. +2. The launcher prefers `exec` into `/Library/KeyPath/bin/kanata`. +3. `PermissionOracle`, wizard guidance, and documentation have historically treated + `/Library/KeyPath/bin/kanata` as the canonical Input Monitoring target. + +This split has produced an unstable and confusing model: + +- runtime launch subject, permission target, and user guidance can disagree +- health could previously report green while built-in keyboard capture was denied +- users can be instructed to grant permissions to a binary path that does not match the + effective runtime subject observed by macOS +- upgrades and redeployments can invalidate the apparent working state without any real + architecture change + +Recent investigation on a laptop-only setup showed: + +- KeyPath.app had Input Monitoring +- Kanata was running and TCP-responsive +- stderr reported `IOHIDDeviceOpen error: (iokit/common) not permitted Apple Internal Keyboard / Trackpad` +- the launchd job lived in the user GUI domain and identified `kanata-launcher` as the program + +This means the current model is not a reliable long-term basis for built-in keyboard capture on +macOS. + +The relevant architectural constraints remain: + +- `PermissionOracle` owns permission-state detection ([ADR-001](adr-001-oracle-pattern.md)) +- Apple APIs remain authoritative where applicable ([ADR-006](adr-006-apple-api-priority.md)) +- installer and repair mutations must flow through `InstallerEngine` ([ADR-015](adr-015-installer-engine.md)) +- validation must prefer prerequisites over derivative health signals ([ADR-026](adr-026-validation-ordering.md)) +- runtime readiness must be postcondition-verified, not inferred from registration metadata + ([ADR-031](adr-031-kanata-service-lifecycle-invariants-and-postcondition-enforcement.md)) + +External platform and upstream evidence points in the same direction: + +- Apple’s Input Monitoring model is app/process oriented. +- Apple’s daemon guidance distinguishes system daemons from user-specific agents. +- Kanata upstream found `IOHIDCheckAccess()` unreliable for root-process self-checks on macOS. +- Karabiner-Elements uses a split architecture with a stable app-owned runtime identity rather + than a loose copied CLI path as its public permission model. + +Additional local runtime evidence from the bridge-host spike: + +- a bundled host process running as the logged-in user can validate config and construct a real + Kanata runtime in-process +- but a full in-process launch reaches pqrs VirtualHID access and then fails against + `/Library/Application Support/org.pqrs/tmp/rootonly/...` +- this indicates the long-term design must separate **user-session input capture** from whatever + **privileged/root-scoped output bridge** is required to talk to the DriverKit daemon + +## Decision + +Adopt a long-term macOS runtime architecture in which a **stable app-bundled executable identity** +owns Kanata input capture. + +### Core rules + +1. **The process that opens HID devices must be the stable permission-bearing identity.** + - Do not rely on a thin launcher that immediately `exec`s into a different raw binary path for + actual input capture. + +2. **Input capture must be intentionally user-session scoped.** + - The HID-owning runtime should run as an app-bundled background runtime in the logged-in user + context, not as an accidental hybrid of GUI registration plus copied system binary execution. + +3. **System-installed raw Kanata binaries are not the long-term TCC contract.** + - `/Library/KeyPath/bin/kanata` may remain as an implementation artifact during migration, but + it must not remain the canonical user-facing Input Monitoring identity. + +4. **Permission detection remains in the GUI layer.** + - `PermissionOracle` continues to own permission detection and wizard guidance. + - Root/runtime self-checks are not promoted to the source of truth for TCC state. + +5. **Runtime truth remains mandatory.** + - Permission declaration alone is insufficient. + - Health and installer postconditions must continue to require real runtime readiness, including + successful built-in keyboard capture where applicable. + +### Target component model + +- `KeyPath.app` + - UI + - permission guidance and detection + - diagnostics + - orchestration + +- `KeyPath macOS input runtime` (new bundled runtime identity) + - the executable that directly opens HID devices + - hosts or embeds the macOS Kanata runtime in-process + - owns the stable bundle/code-signing identity used for Input Monitoring + +- `KeyPathHelper` + - privileged install/repair operations only + - no ownership of Input Monitoring detection + +- Driver/VHID services + - remain separate and privilege-scoped + - continue to be installed/repaired via `InstallerEngine` + +- Root-scoped output bridge (new or adapted) + - owns whatever privileged connection is required for pqrs VirtualHID output + - must not become the Input Monitoring identity + - should be treated as the output half of a split runtime rather than the full remapping owner + - should speak a narrow versioned protocol: + - session handshake + - key event emission + - modifier synchronization + - reset / ping / explicit error reporting + +## Comparison with Karabiner-Elements + +### Similarities we should adopt + +- Split GUI, privileged install, and input-runtime responsibilities cleanly. +- Use a stable app-owned runtime identity for Input Monitoring. +- Keep permission UX in the user session rather than relying on root-runtime self-reporting. +- Separate driver/device management from user-facing permission guidance. + +### Differences from Karabiner-Elements + +- KeyPath should preserve its existing `InstallerEngine` / `PermissionOracle` architecture rather + than cloning Karabiner’s full process graph. +- KeyPath should aim for the minimum number of long-lived support processes needed to get a stable + permission and runtime model. +- KeyPath currently depends on upstream cross-platform Kanata source rather than owning a fully + native macOS remapping core, so its migration path is primarily about **runtime hosting** rather + than inventing a new remapping engine. + +## Consequences + +### Positive + +- Gives macOS a single stable runtime identity for built-in keyboard capture. +- Removes the current mismatch between launch subject and permission target. +- Aligns KeyPath more closely with Apple’s user-session model for permissioned input capture. +- Makes wizard guidance and runtime behavior easier to reason about across upgrades. +- Preserves the March 2026 health-model fix as a correct guardrail rather than a workaround. + +### Negative + +- Introduces macOS-specific runtime-hosting work around Kanata. +- Likely requires refactoring away from `kanata-launcher -> exec(/Library/KeyPath/bin/kanata)`. +- Increases packaging and upgrade complexity during migration. +- Requires careful regression testing for app updates, stale registrations, and permission + persistence. + +## Maintenance Impact + +This approach **does add some macOS-specific maintenance**, but it does not require KeyPath to fork +Kanata wholesale. + +The intended maintenance boundary is: + +- keep using upstream Kanata as the cross-platform core where possible +- add a macOS-specific host/runtime layer in KeyPath that gives the core a stable app identity +- minimize permanent divergence by keeping macOS-specific packaging, launch, and permission logic in + KeyPath rather than in a long-lived downstream Kanata fork + +Preferred order of implementation effort: + +1. Host the existing macOS Kanata runtime inside a bundled KeyPath-owned executable identity. +2. Keep upstream Kanata source updates flowing normally. +3. Limit downstream patches to narrowly scoped macOS integration work when upstream cannot absorb + them. + +This is still a better trade than continuing to depend on a fragile raw-binary TCC identity that +breaks unpredictably across reinstalls, redeployments, and system state changes. + +## Alternatives Considered + +### 1. Revert to the prior raw-binary permission model + +Rejected. + +It may appear to work when TCC state happens to line up, but it does not provide a stable contract +between launch subject, installer guidance, and runtime capture behavior. + +### 2. Keep the current launcher but retarget `PermissionOracle` to `kanata-launcher` + +Rejected as the long-term solution. + +This improves observability of the current mismatch but does not solve the deeper problem that the +HID-owning process should itself be the stable runtime identity rather than a launcher that hands +off work to another executable path. + +### 3. Move all permission logic into the runtime daemon + +Rejected. + +This conflicts with `PermissionOracle` ownership and with upstream evidence that daemon/root +permission self-checks are unreliable on macOS. + +## Implementation Notes + +When implementing this ADR: + +- use `InstallerEngine` for all install/repair/migration flows +- keep `PermissionOracle` as the only owner of permission-state detection +- do not weaken health checks to hide runtime capture failure +- update launch-domain assumptions in service/diagnostic code to match the actual target model +- add upgrade and regression coverage for: + - fresh install + - in-place update + - stale registration recovery + - laptop-only built-in keyboard capture + - permission persistence across redeployments +- remember that upstream already exposes a Rust library target, `kanata_state_machine`, but not a + stable C ABI for Swift to call directly +- use `Scripts/build-kanata-runtime-library.sh` only as a non-shipping validation step for the + future host-embedding path; the next real milestone is a narrow Rust bridge crate with C-callable + entry points +- keep any bridge crate outside the vendored upstream tree so KeyPath can update Kanata normally + while owning only the macOS host-integration surface + +### Progress note as of 2026-03-08 + +This ADR remains `Proposed`, but the target architecture has now been proven experimentally in this +worktree. + +Verified experimental result: + +- a bundled user-session `kanata-launcher` host can capture real keyboard input +- the host can feed that input into an in-process passthrough Kanata runtime +- the passthrough runtime emits output events +- those events can be forwarded over the privileged helper-backed output bridge +- the privileged bridge acknowledges those output events successfully + +What this means: + +- the architecture direction in this ADR is now validated as technically viable +- the remaining work is primarily productionization: + - lifecycle hardening + - bridge-session management + - clearer long-term privileged output component boundaries + - migration/rollback safety + - sustained reliability validation + +What this does **not** mean yet: + +- the split runtime is ready to replace the legacy shipping path +- the current helper-backed bridge is automatically the final privileged component design +- the current experimental capture path is the final host-owned input implementation + +### Naming note + +The naming model that emerged from this work is: + +- **KeyPath Runtime** for the normal user-facing runtime concept +- **Kanata** for the underlying engine, engine setup, engine binary, versioning, and low-level diagnostics +- **RecoveryDaemonService** / **Output Bridge Daemon** for internal infrastructure seams + +This keeps the product UX simple without obscuring that KeyPath is built on Kanata. + +### Progress note as of later 2026-03-08 + +The helper-backed privileged bridge has now been replaced experimentally by a dedicated privileged +launch daemon: + +- `com.keypath.output-bridge` +- executable: + `/Applications/KeyPath.app/Contents/Library/HelperTools/KeyPathOutputBridge` + +`KeyPathHelper` now prepares and activates bridge sessions but does not own the runtime bridge +listener or output emission path itself. A signed-app validation run confirmed: + +- the helper installed and bootstrapped `system/com.keypath.output-bridge` +- `launchctl print system/com.keypath.output-bridge` reported the daemon running +- the user-session host still forwarded output successfully through the daemon and received + acknowledgements + +So the architecture target in this ADR is no longer only “split runtime is viable.” It is now +“split runtime with a dedicated privileged output daemon is viable.” The remaining work is rollout, +lifecycle hardening, and deciding when the split runtime should move beyond experimental/internal +diagnostic paths. + +Later on March 8, 2026, the dedicated-daemon design also passed a live signed-app restart-soak +probe in capture mode: + +- the persistent split host ran in capture mode +- the dedicated `com.keypath.output-bridge` daemon was restarted mid-run +- the app recovered the split host onto a fresh bridge session +- the host remained alive through the second half of the soak window + +That moves the remaining work from “can the app recover from a privileged output restart?” to +“how and when should this recovery path be promoted from experimental/internal tooling into the +default runtime lifecycle.” + +Later on March 8, 2026, KeyPath crossed an important cutover threshold in this worktree: + +- split runtime became the default-on path for healthy fresh installs +- ordinary startup, restart, and recovery flows were switched to prefer split runtime first +- the old launchd-managed Kanata path was renamed in status/reporting to `Legacy Recovery Daemon` + to reflect its remaining role +- automatic fallback from an unexpected split-host exit back into the old daemon was removed +- the user-facing `Split Runtime Host` toggle was removed and the app now treats split runtime as + always on rather than as a persisted experimental setting +- the old `ProcessCoordinator` fast-restart helper was removed from normal app/CLI flows so repair + now goes through `InstallerEngine` and ordinary runtime control goes through `RuntimeCoordinator` + +At this point, the old launchd-managed Kanata path is no longer treated as a co-equal runtime in +the app’s normal lifecycle. It remains only as an explicit recovery seam while the final +deletion/cutover work proceeds. + +## Related + +- [ADR-001: Oracle Pattern for Permission Detection](adr-001-oracle-pattern.md) +- [ADR-006: Apple API Priority in Permission Checks](adr-006-apple-api-priority.md) +- [ADR-015: InstallerEngine Façade](adr-015-installer-engine.md) +- [ADR-016: TCC Database Reading for Sequential Permission Flow](adr-016-tcc-database-reading.md) +- [ADR-026: System Validation Ordering](adr-026-validation-ordering.md) +- [ADR-031: Kanata Service Lifecycle Invariants and Postcondition Enforcement](adr-031-kanata-service-lifecycle-invariants-and-postcondition-enforcement.md) diff --git a/docs/adr/index.md b/docs/adr/index.md index da2c69594..cfa4cc575 100644 --- a/docs/adr/index.md +++ b/docs/adr/index.md @@ -35,6 +35,7 @@ This section documents significant architectural decisions in KeyPath. Each ADR | [ADR-029](/adr/adr-029-eliminate-fake-key-layer-notifications) | Eliminate Fake Key Layer Notifications via Native LayerChange | Proposed | | [ADR-030](/adr/adr-030-insights-companion-app) | Separate Activity Logging and AI Features into KeyPath Insights Companion App | Accepted | | [ADR-031](/adr/adr-031-kanata-service-lifecycle-invariants-and-postcondition-enforcement) | Kanata Service Lifecycle Invariants and Postcondition Enforcement | Accepted | +| [ADR-032](/adr/adr-032-macos-kanata-runtime-identity) | Stable App-Bundled Runtime Identity for macOS Kanata Input Capture | Proposed | ## Key Decisions Summary diff --git a/docs/analysis/2026-03-07-macos-split-runtime-spike.md b/docs/analysis/2026-03-07-macos-split-runtime-spike.md new file mode 100644 index 000000000..a92f9c091 --- /dev/null +++ b/docs/analysis/2026-03-07-macos-split-runtime-spike.md @@ -0,0 +1,1337 @@ +# 2026-03-07 macOS Split Runtime Spike + +## Summary + +The host-runtime spike established two things: + +1. A bundled user-session host can successfully load the Rust bridge, validate `keypath.kbd`, + and construct a real `Kanata` runtime in-process. +2. A full in-process launch cannot own the entire current macOS runtime because the pqrs + VirtualHID client path remains root-scoped. + +This means the long-term solution is **not** "make the whole runtime a normal user-space app +process" and it is also **not** "keep one root-owned process for everything". The viable target is +instead a **split runtime**: + +- user-session bundled host owns Input Monitoring and built-in keyboard capture +- privileged/root component owns VirtualHID output access + +## 2026-03-08 Installer Note + +Fresh-install logs on a clean post-reboot machine showed that the Kanata service could start +correctly but still miss the install postcondition because the app only waited 8s for +`running + TCP responsive + inputCaptureReady`. + +That timeout was too short for real macOS startup behavior: + +- Kanata sleeps for 2s on startup +- the DriverKit keyboard path can take up to ~10s to become ready +- the observed clean-machine TCP-ready transition happened about 13s after service recovery + +The installer readiness timeout was increased to 20s so a clean boot no longer fails the +postcondition while Kanata is still legitimately coming up. + +Fresh install and normal repair planning now use a separate `installRequiredRuntimeServices` +operation for the split-runtime architecture. That path installs only the privileged pieces the +new runtime actually needs in normal operation: + +- VirtualHID services +- the dedicated output-bridge companion + +The older bundled launchd install primitive is now explicitly treated as a legacy recovery-services +operation in the broker/coordinator layer rather than the normal install path. + +The dedicated output-bridge restart probe now also rehydrates the active split-runtime host after +the companion restart. This turns the probe from "daemon restarted successfully" into a more +useful recovery check: the app can now bring the host back onto a fresh bridge session after the +privileged output daemon is recycled. + +The Rust host bridge build is now also required to include the passthru feature set by default for +KeyPath builds. Without that, the installed `kanata-launcher` exits immediately in split-runtime +mode with `passthru output spike feature is not enabled in this bridge build`, which makes +persistent-host and recovery probes meaningless even though the rest of the architecture is intact. + +After fixing that packaging issue and relaunching the app cleanly, the live +`exercise-output-bridge-companion-restart` probe now shows the full recovery path working: + +- persistent split host starts +- output bridge companion restarts successfully +- companion is healthy again afterward +- the active split host is rehydrated onto a fresh session + +Observed probe result: + +```text +companion_running_before=true +capture=false +host_pid=40651 +companion_restarted=1 +companion_running_after=true +host_recovered=1 +host_pid_after_recovery=40705 +host_stopped=1 +``` + +The same probe now also works in capture mode after a clean app relaunch: + +```text +companion_running_before=true +capture=true +host_pid=43122 +companion_restarted=1 +companion_running_after=true +host_recovered=1 +host_pid_after_recovery=43171 +host_stopped=1 +``` + +And the real signed-app lifecycle churn probe is now validated too: + +```text +first_pid=44078 +capture=true +stopped_first=1 +second_pid=44137 +stopped_second=1 +``` + +So the current remaining work is no longer "can the split runtime survive churn at all?" It is +productionization: + +- long-lived capture reliability +- deciding when the split path is safe enough for broader internal enablement +- eventually narrowing the legacy fallback path once split-runtime behavior stays boring under + repeated real use + +## Evidence + +### Current runtime and launch model + +- `SMAppService` launches `Contents/Library/KeyPath/kanata-launcher` in `gui/` +- current plist: `Sources/KeyPathApp/com.keypath.kanata.plist` +- legacy helper-generated launch daemon plist still exists in code and explicitly ran Kanata as + `root:wheel`: + - `Sources/KeyPathHelper/HelperService.swift` + +### pqrs VirtualHID root boundary + +On the test machine: + +- `/Library/Application Support/org.pqrs/tmp/rootonly` is `root:wheel` with mode `700` +- the VirtualHID daemon itself runs as root: + - `system/com.keypath.karabiner-vhiddaemon` + +### Experimental host runtime results + +The bundled launcher now supports: + +```bash +KEYPATH_EXPERIMENTAL_HOST_RUNTIME=1 \ + dist/KeyPath.app/Contents/Library/KeyPath/kanata-launcher --port 37003 +``` + +Observed results: + +- bridge loads successfully +- config validates successfully +- `Kanata` runtime object is created successfully +- if the requested TCP port is already occupied, host mode now fails cleanly +- when run on an unused port, the host reaches pqrs VirtualHID startup and then fails because the + client tries to access: + +```text +/Library/Application Support/org.pqrs/tmp/rootonly/vhidd_server +``` + +The raw pqrs client crash path was reproduced before adding the launcher-side preflight. The +launcher now fails earlier with a clear message when it detects the root-only boundary: + +```text +vhid driver socket directory is root-only at +/Library/Application Support/org.pqrs/tmp/rootonly; bundled host runtime needs a privileged output bridge +``` + +To support the next split-runtime milestone, the privileged helper now also exposes a read-only +`getKanataOutputBridgeStatus` XPC probe so the app can ask whether the pqrs output boundary is +root-scoped before attempting host-owned activation. + +The next contract seam now exists too: the helper can prepare a privileged output-bridge session +descriptor that reserves a root-owned UNIX socket path under the pqrs root-only directory. This +does not implement the bridge yet; it defines the shape that a future privileged companion or +helper-backed bridge will speak. + +The bundled launcher can also now smoke-test that socket protocol in experimental mode when a +session ID and socket path are provided via environment. That gives the host side a real UNIX +socket client path before the privileged bridge server is implemented. + +That launcher-side experimental smoke no longer stops at handshake/ping. When a helper-prepared +session is provided via: + +- `KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SESSION` +- `KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SOCKET` + +the bundled host can also opt into the same bridge probes already used from diagnostics: + +- `KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SMOKE_MODIFIERS=1` +- `KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SMOKE_EMIT=1` +- `KEYPATH_EXPERIMENTAL_OUTPUT_BRIDGE_SMOKE_RESET=1` + +This remains experimental and off by default. The purpose is to let the host-owned runtime path +exercise the privileged bridge contract directly before switching any production traffic to it. + +The app-side runtime coordinator now has the matching seam to prepare and activate a helper-backed +bridge session and generate those launcher environment variables. That still sits behind +experimental code paths only. + +One useful negative result: a standalone `keypath-cli` process is not a valid smoke-test client for +this helper bridge path. The helper's XPC security model intentionally accepts only the signed app +identity, and an ad-hoc SwiftPM CLI build is rejected before the bridge session is created. The +smoke path therefore needs to remain app-signed (for example, via app-owned diagnostics or another +app-hosted debug surface) rather than a loose developer CLI. + +To keep that direction explicit in code, the smoke path now lives behind an app-side +`KanataOutputBridgeSmokeService` that prepares the helper session, activates the socket listener, +and drives handshake/ping/reset through injectable client operations. That keeps the workflow in an +app-owned surface instead of weakening helper caller validation. + +`DiagnosticsService` now has an explicit opt-in hook for this too. When +`KEYPATH_ENABLE_OUTPUT_BRIDGE_SMOKE_DIAGNOSTIC=1` is set for a signed app run, system diagnostics +append a single experimental bridge-smoke result instead of requiring a separate loose tool. The +default path remains unchanged. + +That diagnostic smoke can also opt into a single bridge `emitKey` probe with +`KEYPATH_ENABLE_OUTPUT_BRIDGE_SMOKE_EMIT=1`. This remains off by default because it will inject one +real output event through the privileged bridge. + +The same diagnostics seam can now also opt into a modifier-state sync probe with +`KEYPATH_ENABLE_OUTPUT_BRIDGE_SMOKE_MODIFIERS=1`. This remains separate from the emitted-key probe +so modifier state and single-event output can be exercised independently while the bridge matures. + +The first nontrivial privileged output-side action is now wired too: bridge `reset` requests no +longer only ack locally, they call the Karabiner VirtualHID manager activation path as a real +root-scoped pqrs-side operation. Key emission itself is still no-op/ack-only pending a narrower +plan for actual event injection. + +That next step is now partially landed too: helper-side `emitKey` requests no longer stop at a +local ack. The helper loads the existing Rust host-bridge dylib from the app bundle and calls a new +`keypath_kanata_bridge_emit_key` export, which uses the same `karabiner_driverkit::send_key` +primitive Kanata's own macOS output path uses. `syncModifiers` is no longer ack-only either: the +helper now diffs the prior and desired modifier state and emits the corresponding left/right +modifier usages (`0xE0...0xE7`) through that same primitive. + +One more important implementation result came from feature-spiking the vendored Kanata crate inside +the Rust host bridge: + +- the bridge crate builds successfully on macOS with `kanata/simulated_output` +- after adding a missing no-op `release_tracked_output_keys` method to the vendored + `sim_passthru::KbdOut`, the bridge crate also builds successfully with + `kanata/simulated_input + kanata/simulated_output` + +That second result matters more. It means the bundled host can be compiled against Kanata's +channel-backed passthrough-style output seam on macOS today, rather than requiring an immediate +large fork of the output path. It does **not** finish the migration by itself, but it changes the +next step from "invent a new abstraction first" to "adapt the existing `sim_passthru` seam to feed +the privileged output bridge." + +The host bridge now exposes the first feature-gated API for that seam too. In +`passthru-output-spike` builds it can: + +- create a passthrough-style runtime handle with `Kanata::new_with_output_channel` +- report the runtime's layer count +- non-blockingly drain one channel-backed output event as raw `value/page/code` + +This still does not route output into the privileged UNIX socket bridge. It is the intermediate +step that proves the bundled host bridge can hold a runtime and observe its channel-backed output +without depending on direct pqrs emission from `KbdOut`. + +The bundled launcher now has the matching experimental Swift-side hook. In addition to +`KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_RUNTIME=1`, a host-mode run can opt into forwarding drained +passthrough output events into the privileged UNIX socket bridge with: + +- `KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_FORWARD=1` +- `KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_POLL_MS=` + +This remains explicitly non-default and bounded. The launcher drains at most a small fixed batch of +events per probe pass, and the passthrough path now polls for a short bounded window after startup +instead of checking the output channel only once. Drained events are translated into `emitKey` +bridge requests. That gives the split runtime path its first end-to-end +host-output-to-privileged-bridge forwarding seam without changing the production launch path. + +There is now also an app-owned invocation path for this experiment. When the signed app is launched +with `KEYPATH_ENABLE_HOST_PASSTHRU_DIAGNOSTIC=1`, `applicationDidFinishLaunching` runs +`DiagnosticsService.getSystemDiagnostics()`, which in turn can launch the bundled +`kanata-launcher` child in passthru-only experimental mode, print the resulting host-passthru +diagnostic block to stderr, and exit. This avoids the helper caller-validation problem that blocks +the same flow from an ad-hoc CLI process. + +That app-owned diagnostic now provides one more decisive result. With a helper-prepared privileged +bridge session and a passthrough-enabled host bridge embedded in `/Applications/KeyPath.app`, the +bundled host gets past: + +- bridge load +- config validation +- in-process runtime construction +- passthrough runtime construction +- passthrough runtime `start()` +- entry into the bounded passthrough poll loop + +and then still aborts from inside the user-session launcher process with: + +```text +filesystem error: in posix_stat: failed to determine attributes for the specified path: +Permission denied ["/Library/Application Support/org.pqrs/tmp/rootonly/vhidd_server"] +``` + +This narrows the remaining blocker further. The current `passthru-output-spike` path does not only +need a privileged output bridge for emitted events; Kanata's existing macOS event-loop path still +calls `karabiner_driverkit::is_sink_ready()` directly from the host process before the bridge can +take over output ownership. In other words, the host is still coupled to pqrs sink readiness even +when output events are being forwarded elsewhere. + +That means the next real runtime migration step is **not** more socket/bridge plumbing. It is to +separate host-owned input capture from pqrs sink health in the vendored macOS runtime path, so the +user-session host can read built-in keyboard events without touching the root-only +`vhidd_server` boundary. + +That specific Kanata-side blocker has now been cleared by the follow-up vendored macOS backend +refactor captured in `2026-03-08-kanata-backend-refactor-handoff.md`. After rebuilding and +deploying `/Applications/KeyPath.app` with the passthrough-enabled bridge and the new Kanata +backend seam, the signed host-passthru diagnostic no longer aborts with the earlier +`vhidd_server` root-only filesystem exception and no longer wedges creating the direct pqrs +client. + +The later verified signed-app run now shows: + +- helper repair succeeds +- privileged bridge session preparation succeeds +- `kanata-launcher` child launches successfully +- the child exits cleanly with code `0` +- passthrough runtime creation and startup succeed in the user-session host + +That moves the remaining gap from vendored Kanata startup into KeyPath-side input orchestration. +The processing-only passthrough runtime can now be started without constructing the direct pqrs +client, but it will not emit output unless input is explicitly injected into the new passthrough +input seam. + +That injected-input seam is now working in direct launcher validation too. A manual run of the +packaged launcher with: + +```bash +HOME=/Users/ \ +KEYPATH_EXPERIMENTAL_HOST_RUNTIME=1 \ +KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_RUNTIME=1 \ +KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_ONLY=1 \ +KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_INJECT=1 \ +KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_FORWARD=1 \ +/Applications/KeyPath.app/Contents/Library/KeyPath/kanata-launcher +``` + +now shows: + +- passthrough runtime starts successfully +- synthetic key-down / key-up injection succeeds +- the runtime emits a real channel-backed output event +- the launcher drains that event from the passthrough queue +- forwarding then stops only because no privileged bridge session was provided in that direct shell run + +The next signed-app diagnostic then pushed one step further: with a helper-prepared privileged +bridge session, the launcher still started, injected input, and drained output successfully, but +forwarding failed with: + +```text +Failed to connect to output bridge socket (13) +``` + +That identified the next KeyPath-side bug. The helper had been creating the experimental bridge +socket under the pqrs `rootonly` directory and then locking both the directory and socket down to +root-only permissions. That kept pqrs access privileged, but it also prevented the user-session +host from connecting to the bridge transport at all. The transport socket therefore needs to live +in a separate KeyPath-owned run directory that is connectable by the host, while the helper keeps +actual pqrs access root-only on the server side. + +That is the current status line for the split-runtime migration: + +- vendored Kanata startup is no longer the blocker +- processing-only passthrough runtime plus injected input can produce output +- the remaining production work is KeyPath-side: + - supply real host-owned input capture + - feed that into `keypath_kanata_bridge_passthru_send_input(...)` + - continue forwarding drained output events through the privileged bridge +- the prior root-only pqrs readiness crash is **absent** + +This is important progress. It means the vendored Kanata event loop no longer hard-calls pqrs sink +readiness from the user-session host. The migration is now blocked by a new, narrower failure mode +after startup rather than by the original architectural contradiction. + +So the current state is: + +- **resolved:** direct host-side `vhidd_server` crash caused by pqrs sink-readiness coupling +- **remaining:** determine the new `exit_code=6` path in the signed host-passthru diagnostic and + continue KeyPath-side bridge/runtime integration from there + +Further signed-app validation narrowed that new failure too. After the Kanata-side refactor, the +host diagnostic no longer exits immediately with code `6`; on a later rebuild it instead wedges +until timeout. A live stack sample of the running `kanata-launcher` process showed: + +- the main thread sleeping inside the bounded passthrough poll loop +- the macOS input event-loop thread blocked in `wait_key` +- a background pqrs client thread aborting from: + - `pqrs::karabiner::driverkit::virtual_hid_device_service::client::create_client()` + - `find_server_socket_file_path()` + - `glob(...)` + - `std::__fs::filesystem::__status(...)` + +This means the work is now past the original `karabiner_driverkit::is_sink_ready()` coupling, but +the host bridge / passthrough runtime is still indirectly instantiating the direct pqrs client in a +background thread. The next migration step is therefore even more specific: + +- stop the host-owned passthrough runtime from constructing the direct pqrs client at all +- keep the user-session host on input capture + remapping only +- reserve all pqrs/VirtualHID client creation for the privileged output bridge + +## Architectural implication + +The host-runtime spike narrows the remaining design space: + +### Rejected: all-user-space bundled host + +Rejected because the current pqrs/Karabiner output path is not accessible from an unprivileged +user-session host process. + +### Rejected: keep the whole runtime root-owned + +Rejected because built-in keyboard capture and Input Monitoring are user-session concerns and the +existing permission mismatch remains unresolved under a single root-owned runtime. + +### Preferred: split runtime + +#### User-session input host + +Responsibilities: + +- stable app-bundled runtime identity +- Input Monitoring identity +- built-in keyboard capture +- config validation and runtime orchestration +- TCP server ownership (if kept in-process) + +#### Privileged output bridge + +Responsibilities: + +- access pqrs VirtualHID root-only socket / service boundary +- emit remapped output events on behalf of the user-session input host +- no ownership of Input Monitoring detection or user guidance + +## Candidate implementation shapes + +### Option A: extend `KeyPathHelper` into an output bridge + +Pros: + +- existing privileged XPC path already exists +- avoids introducing another privileged binary immediately +- installation/repair ownership already lives here + +Cons: + +- `KeyPathHelper` is currently request/operation oriented, not a long-lived low-latency runtime +- would mix installer concerns and remapping output concerns into one service +- may complicate helper lifecycle and security boundaries + +### Option B: add a dedicated privileged output companion + +Pros: + +- cleaner separation of runtime output from installer/repair +- easier to model as a narrow privileged bridge +- aligns better with the split-runtime direction in ADR-032 + +Cons: + +- adds one more privileged packaged component +- requires new IPC contract and lifecycle management + +## Recommended next step + +Prefer **Option B** unless implementation friction proves too high. + +Short reason: + +- `KeyPathHelper` should stay focused on privileged mutations via `InstallerEngine` +- output bridging is runtime behavior, not installation behavior +- a dedicated privileged output companion gives a clearer boundary: + - user host owns input/session/TCC + - privileged companion owns pqrs output + +## Latest progress + +- The user-session passthru host now starts without constructing the direct pqrs client. +- Injected input successfully produces channel-backed output events in the bundled host. +- Those events now reach the privileged helper bridge and attempt real DriverKit emission. +- The current blocker has narrowed to privileged-side VirtualHID readiness: + - `DriverKit virtual keyboard not ready (sink disconnected)` +- The helper now mirrors legacy Kanata startup more closely by: + - activating the VirtualHID manager + - polling DriverKit output readiness from the Rust bridge for a bounded interval + - only then attempting bridged emit +- A prep-only signed-app bridge trigger now writes a fresh session/socket to: + - `/var/tmp/keypath-host-passthru-bridge-env.txt` +- A direct packaged launcher run against that fresh session now proves: + - forwarded output events reach the privileged bridge + - explicit DriverKit sink initialization inside the helper bridge was the missing step + - forwarded keyDown/keyUp events are now acknowledged by the privileged bridge + - the split-runtime output path is functionally working for injected passthru input +- A later full experimental run against a fresh helper-prepared bridge session now proves the + entire split path end to end: + - the bundled user-session host captures real keyboard input + - the passthrough Kanata runtime processes that input in-process + - output events are emitted and drained from the passthrough queue + - those output events cross the privileged helper bridge + - the privileged bridge acknowledges emitted output events successfully + +Example evidence from the packaged launcher: + +- `Experimental passthru captured mac keyCode=55 -> usagePage=7 usage=227 value=1` +- `Experimental passthru runtime drained output event: value=1 page=7 code=227` +- `Experimental passthru forwarded output event ... -> acknowledged(sequence: Optional(1))` + +and the same run continued successfully through many events, completing with: + +- `Experimental passthru capture loop completed with 32 forwarded output event(s)` + +This means the split-runtime architecture is no longer just a set of compatible seams. It is now +proven experimentally on this machine as a working end-to-end remapping path. + +## Current blocker + +- The main remaining gaps are now productionization and lifecycle hardening rather than basic + feasibility. +- Specifically: + - replace the rough experimental capture path with the intended long-term host input path + - harden bridge-session lifecycle so fresh session prep is deterministic + - decide whether privileged output remains in `KeyPathHelper` or moves to a dedicated companion + - preserve and validate the legacy path while the split path remains gated + - add sustained reliability validation before any production switch-over + +That next experimental seam now exists too. `kanata-launcher` supports an opt-in host capture mode: + +- `KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_CAPTURE=1` + +In that mode, the launcher: + +- starts the processing-only passthrough runtime in-process +- installs a user-session macOS global keyboard monitor +- translates captured macOS virtual keycodes into HID usage page/code pairs +- injects those input events into `keypath_kanata_bridge_passthru_send_input(...)` +- continues draining and, if requested, forwarding emitted passthrough output through the + privileged bridge during the same bounded run-loop window + +This remains intentionally narrow: + +- experimental and off by default +- currently backed by a minimal US ANSI virtual-keycode-to-HID mapping for early validation +- unsupported virtual keycodes are logged and ignored rather than guessed + +That means the next phase can stay on the KeyPath side. The host runtime no longer depends only on +synthetic probe input, and there is now a real user-session input path feeding the bundled +passthrough runtime without reintroducing direct pqrs client creation into the host process. + +A direct packaged-launcher validation now proves that capture seam is live. Running the signed +launcher with: + +- `KEYPATH_EXPERIMENTAL_HOST_RUNTIME=1` +- `KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_RUNTIME=1` +- `KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_ONLY=1` +- `KEYPATH_EXPERIMENTAL_HOST_PASSTHRU_CAPTURE=1` + +produced repeated logs of the form: + +- `Experimental passthru captured mac keyCode=... -> usagePage=7 usage=... value=...` +- `Experimental passthru runtime drained output event: value=... page=7 code=...` + +That establishes the next milestone: + +- real user-session keyboard events are now being observed by the bundled host +- those events are translated into passthrough input +- the in-process Kanata runtime emits output in response + +The signed-app diagnostics path can now opt into this same mode with: + +- `KEYPATH_ENABLE_HOST_PASSTHRU_CAPTURE=1` + +so the same host-passthru diagnostic runner can exercise either: + +- synthetic injected probe input (default) +- or real user-session capture input (experimental) + +One additional integration issue showed up once real capture was combined with privileged +forwarding: a stale helper-prepared bridge socket can fail with: + +- `Output bridge socket at /Library/KeyPath/run/kpko/... is stale or not listening. Prepare a fresh bridge session and try again.` + +That does **not** indicate that capture or passthrough processing is broken. In the verified run, +the launcher still: + +- captured real macOS key events +- injected them into the passthrough runtime +- drained real output events + +and only failed at the point of connecting to an old helper bridge socket whose listener was no +longer active. + +To make fresh session preparation easier from an already-running signed app, KeyPath now supports a +debug URL action: + +- `keypath://system/prepare-host-passthru-bridge` + +That action reuses the same helper-backed preparation path as the startup/file-triggered prep +mode and rewrites: + +- `/var/tmp/keypath-host-passthru-bridge-env.txt` + +with a fresh `session=` / `socket=` pair for the next direct launcher probe. + +There is now a second running-app debug action too: + +- `keypath://system/run-host-passthru-diagnostic` +- `keypath://system/run-host-passthru-diagnostic?capture=1` + +That action runs the signed-app host-passthru diagnostic in place, including fresh bridge-session +preparation, and writes the result to: + +- `/var/tmp/keypath-host-passthru-diagnostic.txt` + +This reduces the need for relaunch/env-driven validation when exercising the experimental split +runtime from an already-running app. + +That running-app diagnostic path has now been verified on the live machine in injected-input mode. +After clearing the prior output file and triggering: + +- `keypath://system/run-host-passthru-diagnostic` + +the app wrote a fresh diagnostic showing: + +- passthrough runtime startup succeeded +- injected keyDown/keyUp input succeeded +- the runtime drained two output events for `A` +- both events were forwarded over the privileged bridge and acknowledged + +The resulting stderr block ended with: + +- `Experimental passthru forwarded output event ... -> acknowledged(sequence: Optional(1))` +- `Experimental passthru forwarded output event ... -> acknowledged(sequence: Optional(2))` +- `Experimental passthru runtime forwarded 2 output event(s)` + +So the running-app diagnostic action is now a valid signed-context validation path for the +experimental split runtime, not only the earlier direct launcher probes. + +The helper/session lifecycle has now been hardened slightly too: + +- preparing a new bridge session for the same host PID retires any older prepared/active sessions +- preparing a new bridge session also retires sessions whose owning host PID is no longer alive +- retired sessions unlink their old socket path so stale listeners do not accumulate indefinitely +- client-side connect failures now distinguish: + - missing socket path + - stale/non-listening socket + - generic connect failure + +That does not eliminate the need for a fresh session before direct launcher probes, but it turns +the failure mode into an explicit bridge-session lifecycle problem instead of an ambiguous transport +error. + +## Immediate coding milestones + +1. Expand the new session descriptor contract into a real output bridge protocol. +2. Make the experimental bundled host fail by design unless that output bridge is available. +3. Keep the legacy `/Library/KeyPath/bin/kanata` path as fallback while the split path is proven. +4. Once the split bridge can emit output, move Input Monitoring guidance to the bundled host + identity and stop treating `/Library/KeyPath/bin/kanata` as the long-term permission target. + +## Dedicated Companion Milestone (2026-03-08) + +The experimental privileged output bridge has now been split out of `KeyPathHelper` into a +dedicated system daemon: + +- label: `com.keypath.output-bridge` +- executable: + `/Applications/KeyPath.app/Contents/Library/HelperTools/KeyPathOutputBridge` +- installed plist: + `/Library/LaunchDaemons/com.keypath.output-bridge.plist` + +The live machine validation for this milestone was: + +1. deploy a build with the new `KeyPathOutputBridge` target embedded in the app bundle +2. refresh the embedded privileged helper so its XPC implementation knows how to install/bootstrap + the new daemon +3. trigger `keypath://system/prepare-host-passthru-bridge` +4. confirm: + - `/Library/LaunchDaemons/com.keypath.output-bridge.plist` exists + - `launchctl print system/com.keypath.output-bridge` reports the daemon +5. rerun the signed host-passthru diagnostic and confirm forwarded output is still acknowledged + +Verified result: + +- the helper now prepares sessions and bootstraps the daemon instead of owning the runtime bridge +- `launchctl print system/com.keypath.output-bridge` reported the service `running` +- the signed host diagnostic still succeeded end to end: + - passthru runtime startup + - emitted output events + - socket forwarding into the privileged daemon + - privileged acknowledgements + +This is the first end-to-end validation of the intended long-term process shape: + +- `KeyPath.app`: orchestration, diagnostics, permission UX +- `kanata-launcher`: user-session input host +- `KeyPathOutputBridge`: privileged VirtualHID output daemon +- `KeyPathHelper`: installer/repair/orchestration only + +The next remaining work is production hardening rather than basic architecture proof: + +- automatic bridge-session refresh +- companion restart/recovery behavior +- inspection/reporting polish +- deciding when the split runtime should become selectable beyond debug flows +- runtime-path selection now requires the dedicated companion to be healthy before choosing split + runtime; an installed-but-unhealthy companion explicitly falls back to the legacy system binary + +The helper-side activation path now also treats daemon startup failure as a recoverable condition: + +- if `activateKanataOutputBridgeSession(...)` fails to observe the session socket after kickstart, + the helper boots the companion out, reinstalls/rebootstraps the launchd service, and retries the + session activation once before reporting failure + +This keeps stale or wedged `com.keypath.output-bridge` state aligned with the same recovery posture +already used for stale install/registration state elsewhere in KeyPath. + +The signed host-passthru diagnostic path now also refreshes the reusable bridge-session file on +every run: + +- each diagnostic invocation prepares a fresh companion session +- the session/socket used for that invocation is written back to + `/var/tmp/keypath-host-passthru-bridge-env.txt` + +That removes the old requirement to run bridge prep as a separate manual step before direct +launcher probes. + +The bridge-session prep logic is now centralized in the app-side companion manager, so both: + +- `keypath://system/prepare-host-passthru-bridge` +- `keypath://system/run-host-passthru-diagnostic` + +go through the same fresh-session preparation and persistence path instead of maintaining separate +implementations. + +The signed host diagnostic no longer owns its own child-process launch logic either. That launch +path is now extracted into an internal app-side split-runtime host service, so diagnostics is using +the same reusable host-launch primitive that future non-diagnostic split-runtime startup can adopt. + +There is now also a persistent experimental host mode behind the internal action surface: + +- `keypath://system/start-host-passthru?capture=1` +- `keypath://system/stop-host-passthru` + +This is not the production startup path yet, but it gives the app its first reusable non-diagnostic +split-runtime host launcher without falling back to the legacy launchd-managed Kanata binary. + +The next integration step then moved into the normal runtime coordinator: + +- `RuntimeCoordinator` checks the runtime-path evaluator first +- if the evaluator returns split-runtime-ready, the coordinator starts the persistent bundled host + instead of the old launchd-managed Kanata daemon +- `stopKanata(...)` and `restartKanata(...)` now also understand the persistent split-runtime host +- later cutover work removed the user-facing split-runtime toggle and made split runtime the fixed + normal architecture in the app rather than an experimental setting + +This keeps the production default unchanged while creating the first real app-owned start/stop path +that can exercise split runtime outside of debug-only direct actions. + +The main Status tab now also exposes the active runtime path when one is known: + +- `Split Runtime Host` when the persistent bundled host is active +- `Legacy Daemon` when the launchd-managed Kanata service is active + +That makes the feature-flagged path choice visible in normal app UI instead of only in logs, +diagnostics, or debug actions. + +`inspectSystem()` / `SystemContext.services` now also carries the active runtime path when one is +known, so shared status/installer surfaces can distinguish: + +- `Split Runtime Host` +- `Legacy Daemon` + +without relying on a view-model-only side channel. + +The CLI now prints that active runtime path too, so: + +- app Status UI +- shared `inspectSystem()` / `SystemContext` +- `keypath-cli status` + +all report the same runtime identity vocabulary. + +The persistent split-runtime host now also has an explicit unexpected-exit path: + +- `KanataSplitRuntimeHostService` posts a `splitRuntimeHostExited` notification with pid, exit code, + termination reason, expected-vs-unexpected classification, and stderr log path +- `RuntimeCoordinator` consumes that notification, stops `AppContextService`, and surfaces a + direct recovery error instead of silently reviving the old launchd runtime +- the error explicitly tells the user that KeyPath no longer auto-falls back to the legacy daemon + and that toggling the service again will restart the split runtime host +- shared status inspection and CLI reporting no longer carry a separate automatic-fallback identity + because automatic fallback has been removed + +The runtime coordinator now has direct regression coverage for split-runtime lifecycle churn too: + +- test-only seams can force the runtime-path evaluator to choose split runtime +- test-only seams can simulate a persistent split host PID without starting the real bundled host +- coordinator tests now cover split-runtime `start -> restart -> stop` cycles and verify that: + - active runtime-path reporting stays on `Split Runtime Host` while the host is active + - stopping clears the active runtime-path detail cleanly + - a later successful split-runtime recovery clears the prior exit error cleanly +- expected exits (for example, an intentional stop) do not set a recovery error +- unexpected exits now fail loudly instead of silently switching runtime paths + +This is the first automatic recovery path from a live split-runtime failure into the older +launchd-managed runtime, while still keeping the transition visible in UI/CLI status. + +To validate real signed-app churn against the persistent split host, the running app now exposes a +small internal exercise action: + +- `keypath://system/exercise-host-passthru-cycle` +- `keypath://system/exercise-host-passthru-cycle?capture=0` + +That action runs the same `KanataSplitRuntimeHostService` path used by the persistent host launcher, +performs a simple `start -> stop -> start -> stop` sequence, and writes the result to: + +- `/var/tmp/keypath-host-passthru-cycle.txt` + +This is still a debug/validation surface, not intended user-facing UX, but it provides a live +signed-app churn probe that complements the newer coordinator/unit-test lifecycle coverage. + +There is now also a matching companion-side churn probe: + +- `keypath://system/exercise-output-bridge-companion-restart` +- `keypath://system/exercise-output-bridge-companion-restart?capture=0` + +That action: + +- starts the persistent split-runtime host +- restarts the dedicated `com.keypath.output-bridge` daemon through the helper orchestration path +- records companion status before and after restart +- stops the host again + +It writes the result to: + +- `/var/tmp/keypath-host-passthru-companion-restart.txt` + +This is still a validation-only surface. Its purpose is to exercise real signed-app daemon churn +without reintroducing output-runtime ownership into `KeyPathHelper`. + +Because the running app can accumulate stale instances during desktop automation, there is also now +a deterministic one-shot startup hook in `App.swift` for the same probe: + +- `KEYPATH_EXERCISE_OUTPUT_BRIDGE_COMPANION_RESTART=1` +- optional: `KEYPATH_ENABLE_HOST_PASSTHRU_CAPTURE=1` + +When the signed app is launched directly with that environment, it runs the same companion restart +probe and exits, writing: + +- `/var/tmp/keypath-host-passthru-companion-restart.txt` + +This gives us a validation path that does not depend on `open keypath://...` reaching a healthy +already-running app instance. + +There is now also a longer-lived soak probe for the persistent split host: + +- `keypath://system/exercise-host-passthru-soak` +- `keypath://system/exercise-host-passthru-soak?capture=0&seconds=30` + +That action: + +- starts the persistent split host +- keeps it running for the requested duration +- records whether the host was still alive at the end of the soak +- records companion health before and after the soak +- stops the host and writes: + +- `/var/tmp/keypath-host-passthru-soak.txt` + +This gives us a signed-app validation path for “does the split host stay alive under time” that +is distinct from start/stop churn and daemon-restart recovery. + +Live signed-app soak result on March 8, 2026: + +```text +host_pid=47971 +capture=true +duration_seconds=30 +companion_running_before=true +host_alive_at_end=true +host_pid_at_end=47971 +companion_running_after=true +host_stopped=1 +``` + +This means the persistent split host survived a 30-second capture-mode run without losing the +dedicated output companion or crashing out of the host-owned runtime path. + +There is now also a combined soak + companion-restart validation surface: + +- `keypath://system/exercise-output-bridge-companion-restart-soak` +- `keypath://system/exercise-output-bridge-companion-restart-soak?capture=1&seconds=30` + +That action: + +- starts the persistent split host +- waits through the first half of the requested duration +- restarts the dedicated `com.keypath.output-bridge` daemon +- rehydrates the host onto a fresh bridge session +- waits through the second half of the requested duration +- reports whether the host and companion are still alive at the end +- writes: + +- `/var/tmp/keypath-host-passthru-companion-restart-soak.txt` + +This gives us a live signed-app probe for “does the split host survive a daemon restart in the +middle of a longer capture run,” which is a closer approximation of production churn than the +earlier instantaneous restart probe. + +After fixing deploy order, refreshing the live helper registration, and increasing the app-side +activation timeout for `activateKanataOutputBridgeSession(...)`, the combined restart-soak probe +now passes in the signed app too. Live result on March 8, 2026: + +```text +companion_running_before=true +capture=true +duration_seconds=20 +host_pid=63501 +companion_restarted=1 +companion_running_after_restart=true +host_recovered=1 +host_pid_after_recovery=63578 +host_alive_at_end=true +host_pid_at_end=63578 +companion_running_after=true +host_stopped=1 +``` + +This means the split host can now survive: + +- a mid-run restart of the dedicated `com.keypath.output-bridge` daemon +- bridge-session invalidation and reactivation +- rehydration onto a fresh persistent host process +- the remainder of the soak window after recovery + +The recovery flow is no longer probe-only glue. `KanataSplitRuntimeHostService` now owns a reusable +`restartCompanionAndRecoverPersistentHost()` operation, which: + +- restarts the dedicated companion +- confirms companion running state after restart +- restarts and rehydrates the active persistent split host onto a fresh bridge session + +That gives the app a real production-oriented seam for “output companion restarted, recover the +split host” rather than keeping that behavior trapped inside `ActionDispatcher` probes. + +The next step after proving that recovery path in probes was to wire it into the normal app +lifecycle. `RuntimeCoordinator` now runs a lightweight split-runtime companion monitor while the +app is active: + +- if the persistent split host is not running, the monitor does nothing +- if the split host is active and the dedicated output companion reports healthy, the monitor does nothing +- if the split host is active and the companion is no longer running, the coordinator now first tries + the same `restartCompanionAndRecoverPersistentHost()` flow that the restart-soak probe validated +- only if that recovery fails does the coordinator fall back to the legacy daemon path + +So the app now has the first non-probe path for “companion disappeared while split runtime was +active, try split recovery before declaring failure or forcing legacy fallback.” + +To validate that this is not just dead code, the app now also exposes a signed-app action that +exercises the real `RuntimeCoordinator.startKanata(...)` path with split runtime enabled, restarts +the dedicated companion, waits for the normal lifecycle handling, and records the result: + +- `keypath://system/exercise-coordinator-split-runtime-recovery` +- writes `/var/tmp/keypath-runtime-coordinator-companion-recovery.txt` + +Live result on March 8, 2026: + +```text +split_runtime_flag_before=false +split_runtime_flag_forced=true +coordinator_start_success=true +runtime_path_after_start=Split Runtime Host +runtime_detail_after_start=Bundled user-session host active (PID 79495) with privileged output companion +companion_running_before=true +companion_restarted=1 +runtime_path_after_recovery=Split Runtime Host +runtime_detail_after_recovery=Bundled user-session host active (PID 79495) with privileged output companion +last_error=none +last_warning=none +split_host_running_after_recovery=true +split_host_pid_after_recovery=79495 +companion_running_after=true +cleanup_complete=1 +``` + +This is stronger than the earlier service-level probes because it shows the normal coordinator path +can start in split-runtime mode, survive a dedicated output-daemon restart, remain on `Split Runtime Host`, +and finish without surfacing an app error or falling back to the legacy daemon. + +To move beyond a single restart event and validate that the normal coordinator-managed path can +survive a longer mid-run recovery window, the app now exposes a second signed-app probe: + +- `keypath://system/exercise-coordinator-split-runtime-restart-soak` +- supports `?seconds=20` +- writes `/var/tmp/keypath-runtime-coordinator-companion-restart-soak.txt` + +This probe: + +- temporarily enables the split-runtime feature flag +- starts Kanata through `RuntimeCoordinator.startKanata(...)` +- waits for the first half of the soak duration +- restarts `com.keypath.output-bridge` +- waits for the second half of the soak duration +- records the active runtime path, companion state, and any recovery warnings/errors +- cleans up via `RuntimeCoordinator.stopKanata(...)` + +Live result on March 8, 2026: + +```text +split_runtime_flag_before=false +split_runtime_flag_forced=true +duration_seconds=20 +coordinator_start_success=true +runtime_path_after_start=Split Runtime Host +runtime_detail_after_start=Bundled user-session host active (PID 83186) with privileged output companion +companion_running_before=true +companion_restarted=1 +runtime_path_after_soak=Split Runtime Host +runtime_detail_after_soak=Bundled user-session host active (PID 83186) with privileged output companion +last_error=none +last_warning=none +split_host_running_after_soak=true +split_host_pid_after_soak=83186 +companion_running_after=true +cleanup_complete=1 +``` + +This is the strongest split-runtime validation so far. It shows that the normal +`RuntimeCoordinator` path can: + +- start in split-runtime mode +- survive a mid-run dedicated output-daemon restart +- remain on `Split Runtime Host` for the rest of a 20-second soak +- avoid surfacing either an app error or a legacy fallback warning +- stop cleanly afterward + +The same coordinator-managed restart-soak probe was then rerun with a longer duration: + +- `keypath://system/exercise-coordinator-split-runtime-restart-soak?seconds=60` + +Live result on March 8, 2026: + +```text +split_runtime_flag_before=false +split_runtime_flag_forced=true +duration_seconds=60 +coordinator_start_success=true +runtime_path_after_start=Split Runtime Host +runtime_detail_after_start=Bundled user-session host active (PID 83938) with privileged output companion +companion_running_before=true +companion_restarted=1 +runtime_path_after_soak=Split Runtime Host +runtime_detail_after_soak=Bundled user-session host active (PID 83938) with privileged output companion +last_error=none +last_warning=none +split_host_running_after_soak=true +split_host_pid_after_soak=83938 +companion_running_after=true +cleanup_complete=1 +``` + +This longer run matters because it reduces the chance that the 20-second result was simply +capturing a narrow lucky window around restart timing. The normal coordinator-managed split +runtime remained healthy for a full minute, survived the mid-run companion restart, never dropped +to the legacy daemon path, and exited cleanly. + +Given the green clean-install path, the green coordinator-managed recovery probe, and the green +60-second restart-soak run, split runtime was then promoted from “default-on” to “always-on” in +the app: + +- the user-facing `Split Runtime Host` toggle was removed +- the old split-runtime feature flag was removed entirely; split runtime is now always on in the app +- ordinary startup and restart no longer use the legacy daemon path when split runtime is enabled +- the legacy daemon remains available only as a narrow recovery seam while final deletion work + proceeds + +This shifts KeyPath from “split runtime is opt-in even on a healthy clean machine” to “split +runtime is the only ordinary runtime path, with legacy retained only as a short-lived emergency +recovery seam.” + +The next cleanup pass then removed the last hidden launchd-era fast path from ordinary app and CLI +flows: + +- `ProcessCoordinator` was deleted from the main app/runtime code +- CLI repair stopped trying a `KanataService`/`ProcessCoordinator` restart before + `InstallerEngine` +- tests stopped pretending the split-runtime flag can still be toggled on and off + +At this point, the remaining work is no longer proving the architecture. The remaining work is +production-hardening: + +- longer soak runs +- more restart/recovery churn +- deciding when the split-runtime feature flag is ready for broader internal enablement +- eventually defining exit criteria for removing the legacy daemon path + +To reduce churn from direct app-binary launches during development, normal `KeyPath.app` startup +now enforces a single-instance rule: + +- one normal UI app process is allowed +- later duplicate normal launches activate the existing app and terminate immediately +- one-shot probe modes (helper repair, host diagnostics, companion restart probe) remain exempt + +This does not clean up previously wedged processes, but it prevents new normal launches from +silently piling on more stale UI instances. + +The host-passthru diagnostic also no longer treats `exit_code=0` as sufficient on its own. +It now marks the diagnostic as failed if launcher stderr shows split-runtime forwarding failed, +for example when the output bridge socket is stale or not listening. + +Fresh-install wizard retries also exposed a recovery gap in +`PrivilegedOperationsCoordinator.installBundledKanata()`: if Kanata already had +`SMAppService` registration metadata but no live runtime, the installer would reinstall the +binary and then fail strict readiness with: + +- `Bundled Kanata install postcondition failed: Kanata did not become running + TCP responsive within readiness timeout` + +That path now treats `SMAppService active but runtime down` as a recovery case instead of a +terminal install failure. After installing the bundled binary, it calls +`restartUnhealthyServices()` before readiness verification when the service is already active. +The app log now records the expected recovery markers: + +- `Bundled Kanata installed while SMAppService was already active; restarting unhealthy services before readiness verification` +- `Bundled Kanata install recovered runtime via restartUnhealthyServices` + +This keeps the installer aligned with the invariant that registration is not liveness: +install/repair flows must converge on a running + TCP-responsive runtime before returning +success. + +Promoting split runtime to the default on fresh installs then exposed one real rollout bug: +if the legacy daemon was already running, `RuntimeCoordinator.startKanata(...)` could select +split runtime without actually cutting over. The app would keep the legacy daemon alive and +never hand input/runtime ownership to the bundled host. + +That cutover behavior is now fixed. When split runtime is selected and the legacy daemon is +already active, the coordinator now: + +- stops the legacy daemon first +- refreshes service state +- stops `AppContextService` +- then starts the persistent bundled host + +Live app logging on March 8, 2026 showed the expected cutover sequence: + +- `Split runtime host selected: bundled host can own input runtime and privileged output bridge is required ...` +- `Split runtime selected while legacy daemon is active - stopping legacy daemon before cutover` +- `Started split-runtime host (PID 88000)` + +That moved default-on split runtime from “preferred in theory” to “actually able to replace the +legacy daemon on a live app instance.” + +The next observability gap showed up immediately afterward: the split host could stay green under +coordinator-managed restart-soak, but the app’s TCP event listener still saw repeated +`Connection refused` failures. The root cause was two-part: + +- the passthru runtime was being created without a TCP port in the Swift bridge call +- even after plumbing the TCP port through, the passthru start path still started only the + processing loop and never started the TCP server / notification loop + +Both halves are now fixed: + +- `KanataSplitRuntimeHostService` passes the normal inherited Kanata arguments to the bundled + host, including `--cfg` and `--port 37001` +- the Rust passthru runtime now starts the TCP server and notification loop without reintroducing + the macOS DriverKit event loop in the user-session host + +Live validation on March 8, 2026 confirmed the result during a coordinator-managed restart-soak: + +```text +COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME +kanata-la 98590 malpern 6u IPv4 ... 0t0 TCP 127.0.0.1:37001 (LISTEN) +``` + +And the same run completed green: + +```text +duration_seconds=20 +coordinator_start_success=true +runtime_path_after_start=Split Runtime Host +runtime_detail_after_start=Bundled user-session host active (PID 98590) with privileged output companion +companion_restarted=1 +runtime_path_after_soak=Split Runtime Host +last_error=none +last_warning=none +split_host_running_after_soak=true +companion_running_after=true +cleanup_complete=1 +``` + +This is the first point where the split-runtime host satisfied both requirements simultaneously: + +- normal runtime-path recovery remained green under coordinator-managed companion restart +- the host also exposed the expected TCP event socket used by the rest of the app + +That materially reduces the remaining gap between “experimental architecture that works” and +“runtime path that can plausibly replace the legacy daemon for normal internal use.” + +The next question was whether the now-corrected split host would stay healthy for a longer +coordinator-managed run while the rest of the app actually consumed its TCP event stream. + +Live validation on March 8, 2026 answered that positively with a 180-second restart-soak run: + +```text +duration_seconds=180 +coordinator_start_success=true +runtime_path_after_start=Split Runtime Host +runtime_detail_after_start=Bundled user-session host active (PID 455) with privileged output companion +companion_restarted=1 +runtime_path_after_soak=Split Runtime Host +runtime_detail_after_soak=Bundled user-session host active (PID 455) with privileged output companion +last_error=none +last_warning=none +split_host_running_after_soak=true +companion_running_after=true +cleanup_complete=1 +``` + +During that same run the bundled host was confirmed to be listening on the normal event port: + +```text +COMMAND PID USER FD TYPE ... NAME +kanata-la 455 malpern 6u IPv4 ... TCP 127.0.0.1:37001 (LISTEN) +``` + +And the app-side event listener no longer showed `Connection refused` churn. Instead it +established and held a real session against the split host: + +- `Connected to kanata TCP server` +- `EventListener session_start session=105 port=37001 ...` +- `HelloOk ... capabilities ...` +- repeated `CurrentLayerName` responses over the same active session + +This matters because it upgrades the split-runtime result from “the host survives and the daemon +recovers” to “the host survives, the daemon recovers, and the rest of the app is actually using +the split host’s live TCP surface successfully during the run.” + +At this point the remaining work is not architectural feasibility. The remaining work is rollout +confidence: + +- longer-lived soaks +- more real-world churn +- deciding when the legacy daemon should stop being the default fallback for internal use + +That longer-lived confidence was then extended to a full 300-second coordinator-managed +restart-soak on March 8, 2026: + +```text +duration_seconds=300 +coordinator_start_success=true +runtime_path_after_start=Split Runtime Host +runtime_detail_after_start=Bundled user-session host active (PID 2160) with privileged output companion +companion_restarted=1 +runtime_path_after_soak=Split Runtime Host +runtime_detail_after_soak=Bundled user-session host active (PID 2160) with privileged output companion +last_error=none +last_warning=none +split_host_running_after_soak=true +companion_running_after=true +cleanup_complete=1 +``` + +The more important runtime signal was not just the green result file. Mid-run verification showed: + +- the bundled host still listening on `127.0.0.1:37001` +- the app-side `KanataEventListener` continuously receiving `CurrentLayerName` responses over + the same active session +- no `Connection refused` churn during the active soak window + +That makes the split-runtime path materially more trustworthy than it was even one iteration +earlier: + +- runtime selection is now default-on for fresh installs +- live cutover from legacy to split runtime works +- coordinator-managed daemon restart recovery works +- the host exposes the expected TCP surface +- and the rest of the app remains attached to that surface for a sustained 5-minute run + +The remaining work now looks much more like product rollout work than runtime invention: + +- broader internal enablement +- explicit deprecation criteria for the legacy daemon +- installer/upgrade behavior once split runtime is the normal path rather than a guarded one + +Further cutover progress after that milestone: + +- Normal startup now fails loudly if split runtime is selected but the split host cannot start. + Automatic fallback to the legacy daemon has been removed from ordinary startup and from + unexpected split-host exit handling. +- Ordinary user-facing restart paths now go through `startKanata(...)` / `restartKanata(...)` + instead of generic legacy-heavy restart helpers. That includes notification retry, wizard + service start and restart, permission-grant restart, diagnostics auto-fix restart, and + default-config reload fallback. +- `restartKanata(...)` itself now treats “split runtime is preferred and healthy” as a cutover + opportunity. Even if the app is currently running on the legacy daemon, an ordinary restart + now stops the old path and brings the app back up on the split runtime host. +- RecoveryCoordinator restart operations now route through `restartKanata(...)` too, which means + keyboard-recovery and resume-after-recording flows prefer the split runtime host instead of + always reviving the legacy daemon. +- Status/reporting vocabulary now reflects the intended role of the old path. UI and installer + summaries label it as `Legacy Recovery Daemon` instead of `Legacy Daemon`, which better matches + the current cutover goal: split runtime is the normal path, and launchd-managed Kanata is an + emergency recovery fallback. +- Wizard/runtime UI status no longer depends on `RecoveryDaemonService.ServiceState` as its primary + contract. The coordinator now exposes a split-runtime-first `RuntimeStatus`, and pages that + need to know whether KeyPath is really running query that directly instead of forcing the split + host through a legacy daemon enum. +- The wizard no longer treats `kanataService` and `launchDaemonServices` as ordinary Kanata + component issues. Runtime-not-running belongs to the runtime page, and launchd-managed services + are now described explicitly as legacy recovery services instead of being mixed into the normal + split-runtime install story. +- Repair action determination no longer treats “runtime not running” as a reason to reinstall + service configuration. Installer repair only touches the launchd-based recovery seam when those + recovery services are actually missing or unhealthy. +- Runtime-down issues no longer misdiagnose a stopped split runtime as a recovery-service install + problem. `SystemContextAdapter` now surfaces `KeyPath Runtime Not Running` without an auto-fix + action, `IssueGenerator` no longer maps `.kanataService` to + `.installLaunchDaemonServices`, and the Kanata components page now describes that state as + `KeyPath Runtime is not running` instead of `background runtime configuration required`. +- Core readiness no longer depends on the legacy recovery daemon. `ComponentStatus.hasAllRequired` + now requires the real split-runtime prerequisites — Kanata binary, driver, daemon, healthy VHID + services, and no version mismatch — but intentionally excludes + `launchDaemonServicesHealthy`. The recovery daemon remains visible for diagnostics and repair, + but it is no longer embedded in the definition of “system ready.” +- Fresh-install planning is less daemon-first too. `ActionDeterminer.determineInstallActions` + no longer unconditionally appends `.installLaunchDaemonServices`; it now only adds that action + when the privileged service layer is actually unhealthy. That keeps the legacy recovery seam + from being treated as a mandatory first-class install step once the split runtime is the normal + architecture. + +- 2026-03-08: Renamed the remaining planner/action seam from `installLaunchDaemonServices` to `installLegacyRecoveryServices`, so the installer and wizard now describe the old launchd path as recovery-only in code as well as UI. +- 2026-03-08: Renamed the core validator/model field from `launchDaemonServicesHealthy` to `legacyRecoveryServicesHealthy` and renamed the wizard issue/component identifier from `.launchDaemonServices` to `.legacyRecoveryServices`. That pushes the old launchd-managed seam one level deeper out of the normal runtime model: it is still visible for recovery, but it is no longer named like a first-class launch path in system snapshots or wizard issues. +- 2026-03-08: Ordinary install and repair planning no longer schedule `installLegacyRecoveryServices`, and the wizard no longer suggests that action as a normal-path fix. That leaves the old launchd seam as an internal recovery implementation rather than a user-facing install/repair step. +- 2026-03-08: Deleted the explicit `installLegacyRecoveryServices` auto-fix action and installer recipe. The low-level recovery implementation still exists underneath for orphaned-process adoption and replacement, but the planner, wizard, and public installer action surface no longer advertise a first-class “install legacy recovery services” step. +- 2026-03-08: Removed the generic `installLegacyRecoveryServices()` broker/coordinator surface entirely. Ordinary installer logic now uses `installRequiredRuntimeServices()` for normal service install, while orphaned-process handling routes through the narrower remaining recovery hooks instead of a broad legacy launchd install method. +- 2026-03-08: Deleted the orphan adoption/replacement path entirely. External Kanata is now treated as a plain conflicting process to terminate, the `installLegacyRecoveryServicesWithoutLoading()` helper/XPC/coordinator seam is gone, and the focused installer, wizard, runtime, CLI, and diagnostics suites are green with the simplified model. +- 2026-03-08: Removed the last visible `legacyRecoveryServices` issue from the wizard and status model. The split-runtime app no longer routes users through a fake legacy recovery services problem during normal setup or status review; the remaining restart mechanism is now purely an internal recovery implementation detail. +- 2026-03-08: Renamed the remaining internal recovery seam from `legacyRecoveryServicesHealthy` to `recoveryServicesHealthy` and from `restartUnhealthyServices` to `recoverRuntimeServices`. The final stale `legacyRecoveryServices` / `legacyRecoveryServicesUnhealthy` issue identifiers were then deleted entirely. At this point the old launchd path is no longer represented as a normal wizard/runtime issue type at all; what remains is deeper internal recovery logic and naming, not a co-equal runtime model. +- 2026-03-08: Deleted the public `recoverRuntimeServices` installer/wizard action entirely, deleted the dead `ProcessManager` / `PrivilegedOperationsProvider` transitional stack, and then renamed the remaining internal recovery seam to `recoverRequiredRuntimeServices`. At this point the old launchd-era recovery path is no longer part of the public installer/planner model and is only represented as a narrow internal runtime-repair seam. +- 2026-03-08: Deleted the dead generic `installLaunchDaemon` privileged broker/XPC surface and removed the unused private bulk-launchd helpers from `PrivilegedOperationsCoordinator`. The split-runtime architecture no longer carries a public low-level “install arbitrary launchd service” seam from the older runtime model; only the narrower required-runtime and internal recovery operations remain. +- 2026-03-08: Deleted `recoveryServicesHealthy` from `SystemSnapshot.ComponentStatus` too. The field had become dead bookkeeping after readiness, planning, and wizard routing stopped depending on the old launchd path. The remaining recovery seam now lives in explicit recovery operations, not in the core system-readiness model. Runtime/status surfaces also now say `Recovery Daemon` instead of `Legacy Recovery Daemon`. +- 2026-03-08: Deleted the dead `ServiceBootstrapper.installAllServicesWithoutLoading(...)` path and the last unused private `sudoInstallLaunchDaemon(...)` helper. Both were leftovers from the older launchd adoption/install model and had no remaining callers once orphan adoption and generic launchd installation were removed. +- 2026-03-08: Deleted the dead `KanataService.start()` / `restart()` path and the associated cooldown/start-attempt bookkeeping wrappers. `KanataService` is now a much narrower wrapper around legacy recovery-daemon stop/status/health behavior instead of pretending to be a co-equal runtime lifecycle manager. +- 2026-03-08: Moved `ServiceHealthMonitor` ownership out of `KanataService` and into `DiagnosticsManager`. Runtime health checks and VirtualHID connection-failure tracking are now modeled directly around the split host, while `KanataService` is reduced further toward a small on-demand recovery-daemon utility. +- 2026-03-08: Demoted `KanataService` and its state/error types from public API to internal module-only helpers. It is no longer exposed like a first-class runtime service; it now reads more honestly as an internal recovery-daemon utility used by the app itself. +- 2026-03-08: Corrected product naming toward a layered model: the UI now presents the normal path as `KeyPath Runtime`, while keeping `Kanata` visible for engine setup, engine permissions, engine binary/version details, and other low-level technical surfaces where the underlying engine identity is actually useful. +- 2026-03-08: Renamed the remaining wizard/status component identifier from `.kanataService` to `.keyPathRuntime`. The planner and status model no longer carry the old daemon-era component name for “runtime missing”; that concept now matches the split-runtime architecture internally as well as in user-facing text. diff --git a/docs/analysis/2026-03-08-kanata-backend-refactor-handoff.md b/docs/analysis/2026-03-08-kanata-backend-refactor-handoff.md new file mode 100644 index 000000000..b5a2ab7ef --- /dev/null +++ b/docs/analysis/2026-03-08-kanata-backend-refactor-handoff.md @@ -0,0 +1,182 @@ +# 2026-03-08 Kanata macOS Backend Refactor Handoff + +## Scope + +This work stayed intentionally scoped to the vendored Kanata macOS backend seam inside this +worktree. It did **not** attempt to finish the full KeyPath split-runtime migration. + +Goal addressed: + +- remove direct pqrs sink-readiness coupling from the macOS user-session event loop +- preserve standalone Kanata on macOS +- keep direct DriverKit output as the default path +- make passthru / alternate output ownership possible behind a small backend seam + +## Diagnosis + +The blocker was not only output emission. The host runtime was still coupled to pqrs root-only +state because the macOS event loop directly imported and called: + +- `karabiner_driverkit::is_sink_ready()` + +and `KbdIn::new` also waited on sink readiness during input grab. + +That meant a user-session host process could still die on: + +- `/Library/Application Support/org.pqrs/tmp/rootonly/vhidd_server` + +even when output events were intended to be forwarded elsewhere. + +## Changes Made + +### 1. Moved macOS output readiness behind `KbdOut` + +Updated vendored Kanata so the macOS event loop depends on `kbd_out` methods instead of importing +pqrs directly. + +Files: + +- `External/kanata/src/kanata/macos.rs` +- `External/kanata/src/oskbd/macos.rs` +- `External/kanata/src/oskbd/sim_passthru.rs` +- `External/kanata/src/oskbd/simulated.rs` + +Details: + +- added `KbdOut::output_ready()` +- added `KbdOut::wait_until_ready(timeout)` +- kept `release_tracked_output_keys(...)` on `KbdOut` +- removed direct `is_sink_ready()` imports from the macOS event loop +- removed the input-side sink-readiness wait from `KbdIn::new` + +Result: + +- the macOS host event loop no longer directly touches pqrs readiness +- direct DriverKit readiness remains implemented in the default macOS `KbdOut` +- simulated/passthru backends report ready without probing pqrs + +### 2. Cleaned up the passthru constructor path + +The host bridge was previously creating a normal `Kanata` runtime and then mutating: + +- `runtime.kbd_out.tx_kout` + +That worked for the spike, but it bypassed the intended seam. + +Updated vendored Kanata to expose `Kanata::new_with_output_channel(...)` for the +`simulated_input + simulated_output` build used by the macOS passthru spike, not only for the old +`passthru_ahk` path. + +Files: + +- `External/kanata/src/kanata/mod.rs` +- `Rust/KeyPathKanataHostBridge/src/lib.rs` + +Result: + +- `keypath_kanata_bridge_create_passthru_runtime(...)` now uses + `Kanata::new_with_output_channel(...)` +- the passthru host path now uses the vendored Kanata seam instead of reaching into `kbd_out` + internals after construction + +### 3. Documentation update + +Updated: + +- `docs/kanata/2026-03-08-macos-backend-refactor-proposal.md` + +Added a short “minimal implementation shape” section explaining that the first upstream-friendly +step is to keep using `KbdOut` as the output surface and move readiness behind it, rather than +adding a KeyPath-specific runtime layer to Kanata. + +## Tests Added + +### Bridge-level passthru regression test + +File: + +- `Rust/KeyPathKanataHostBridge/src/lib.rs` + +Test: + +- creates a passthru runtime from a real config +- verifies runtime creation succeeds +- verifies the output queue starts empty + +### Vendored Kanata passthru regression test + +Files: + +- `External/kanata/src/tests.rs` +- `External/kanata/src/tests/passthru_macos_tests.rs` + +Test: + +- creates a macOS passthru runtime via `Kanata::new_with_output_channel(...)` +- verifies `kbd_out.output_ready()` is true +- writes one key via the passthru output path +- verifies the event is emitted onto the output channel + +## Verification Run + +The following were run successfully in this worktree: + +- `cargo build` in `External/kanata` +- `cargo test --features simulated_input,simulated_output passthru_runtime_output_channel_is_ready_and_emits_events` in `External/kanata` +- `cargo test --features passthru-output-spike` in `Rust/KeyPathKanataHostBridge` + +## Important Boundary + +This refactor does **not** claim the full app-owned split runtime is now proven end-to-end. + +What is true now: + +- the Kanata-side macOS event loop no longer hard-calls pqrs readiness +- the default direct DriverKit backend is still intact +- passthru/simulated output has a small readiness seam and constructor path + +What is **not** yet proven in this turn: + +- that the signed app-host diagnostic now runs fully past the previous root-only crash in practice +- that bridge/session readiness is fully wired into the experimental KeyPath host launch path + +## Recommended Next Step For The KeyPath Agent + +The next step should happen on the KeyPath side, not as more vendored Kanata refactoring. + +Run the signed experimental host passthru diagnostic and answer this specific question: + +- does the user-session host now advance past the former `vhidd_server` readiness crash point? + +If yes: + +- proceed with bridge/session readiness wiring and bounded passthru forwarding validation + +If no: + +- identify the remaining KeyPath-side code path that is still instantiating or invoking the direct + DriverKit backend instead of the passthru/bridged output owner + +## Things To Watch For + +1. The remaining failure, if any, is likely no longer “event loop directly imported pqrs + readiness.” That part was removed. +2. If the signed host still touches root-only pqrs state, the likely causes are: + - the wrong runtime constructor/path is still used somewhere in KeyPath + - another direct-output code path is being exercised outside the event loop seam + - bridge readiness/ownership is not yet reflected in the experimental host runtime path +3. Avoid pushing KeyPath IPC or app-bundle concepts into vendored Kanata from here. The current + seam is intentionally generic and upstream-shaped. + +## Files Changed In This Turn + +- `External/kanata/src/kanata/macos.rs` +- `External/kanata/src/kanata/mod.rs` +- `External/kanata/src/oskbd/macos.rs` +- `External/kanata/src/oskbd/sim_passthru.rs` +- `External/kanata/src/oskbd/simulated.rs` +- `External/kanata/src/tests.rs` +- `External/kanata/src/tests/passthru_macos_tests.rs` +- `Rust/KeyPathKanataHostBridge/src/lib.rs` +- `docs/kanata/2026-03-08-macos-backend-refactor-proposal.md` + diff --git a/docs/analysis/2026-03-08-passthru-runtime-client-creation-fix.md b/docs/analysis/2026-03-08-passthru-runtime-client-creation-fix.md new file mode 100644 index 000000000..cdc501bb9 --- /dev/null +++ b/docs/analysis/2026-03-08-passthru-runtime-client-creation-fix.md @@ -0,0 +1,102 @@ +# 2026-03-08 Passthru Runtime Direct pqrs Client Creation Fix + +## Problem + +After the earlier macOS backend seam refactor, the signed host-passthru diagnostic no longer +failed on the old direct `is_sink_ready()` / `vhidd_server` path, but the user-session launcher +still wedged. + +The stack sample narrowed the remaining issue: + +- the passthru host path was still constructing the direct pqrs client in a background thread +- the sampled stack ran through: + - `virtual_hid_device_service::client::create_client()` + - `find_server_socket_file_path()` + - filesystem status checks under the root-only pqrs boundary + +## Diagnosis + +The remaining direct pqrs client creation was not coming from `KbdOut` readiness anymore. + +It was still coming from the host bridge startup path: + +- `keypath_kanata_bridge_start_passthru_runtime(...)` + +That function still launched: + +- `kanata_state_machine::Kanata::event_loop(...)` + +On macOS, `Kanata::event_loop(...)` constructs `KbdIn` from: + +- `External/kanata/src/oskbd/macos.rs` + +and that input path still goes through `karabiner_driverkit` functions such as: + +- `driver_activated` +- `register_device` +- `grab` +- `wait_key` + +So even though output readiness had been abstracted, the host-owned passthru runtime was still +starting the direct DriverKit-backed macOS input loop, which was enough to instantiate the pqrs +client in the user-session process. + +## Fix + +Changed the host bridge passthru runtime startup to be **processing-loop only**. + +### Behavior change + +`keypath_kanata_bridge_start_passthru_runtime(...)` now: + +- starts `Kanata::start_processing_loop(...)` +- stores a sender for `KeyEvent` injection +- does **not** call `Kanata::event_loop(...)` + +This means the host-owned passthru path no longer constructs `KbdIn` and therefore no longer +constructs the direct pqrs client through the macOS DriverKit input path. + +### New bridge seam + +Added: + +- `keypath_kanata_bridge_passthru_send_input(...)` + +This lets the passthru runtime receive injected `KeyEvent`s through the bridge-owned processing +channel without requiring the macOS hardware input loop to be started inside the user-session host. + +This is still a narrow host-bridge seam, not a KeyPath-specific protocol baked into vendored +Kanata. + +## Why this is acceptable for the current spike + +This change does **not** claim to finish split-runtime input capture. + +What it does do: + +- stop the user-session passthru runtime from constructing the direct pqrs client +- keep the existing passthru output-channel seam usable +- preserve standalone direct Kanata behavior unchanged + +What remains for later KeyPath-side work: + +- app-owned input capture/injection wiring for real host-side input events +- bridge/session orchestration around that path + +## Verification + +Verified in this worktree: + +- `cargo build` in `External/kanata` +- `cargo test --features passthru-output-spike` in `Rust/KeyPathKanataHostBridge` + +The bridge tests now cover: + +1. passthru runtime creation with an empty output queue +2. passthru runtime startup without `Kanata::event_loop(...)` +3. injected input producing channel-backed output through the processing loop + +## Files changed in this step + +- `Rust/KeyPathKanataHostBridge/src/lib.rs` + diff --git a/docs/analysis/kanata-manager-refactoring-plan.md b/docs/analysis/kanata-manager-refactoring-plan.md index acc60991b..3d4459e42 100644 --- a/docs/analysis/kanata-manager-refactoring-plan.md +++ b/docs/analysis/kanata-manager-refactoring-plan.md @@ -113,8 +113,8 @@ graph TD InstallerEngine --> SystemValidator[SystemValidator] InstallerEngine --> PrivilegeBroker[PrivilegeBroker] - KanataService --> HealthMonitor[ServiceHealthMonitor] - KanataService --> ProcessLifecycle[ProcessLifecycleManager] + RecoveryDaemonService --> HealthMonitor[ServiceHealthMonitor] + RecoveryDaemonService --> ProcessLifecycle[ProcessLifecycleManager] ConfigService --> Constants[SystemConstants] ``` diff --git a/docs/archive/HELPER.md b/docs/archive/HELPER.md index dea533b35..a7f03577f 100644 --- a/docs/archive/HELPER.md +++ b/docs/archive/HELPER.md @@ -94,7 +94,7 @@ class PrivilegedOperationsCoordinator { } } - func restartKanataService() async throws { + func restartRecoveryDaemonService() async throws { switch Self.operationMode { case .privilegedHelper: try await helperRestartService() @@ -145,7 +145,7 @@ KeyPath/ @objc protocol HelperProtocol { func installLaunchDaemon(reply: @escaping (Bool, String?) -> Void) func installVirtualHIDDriver(reply: @escaping (Bool, String?) -> Void) - func restartKanataService(reply: @escaping (Bool, String?) -> Void) + func restartRecoveryDaemonService(reply: @escaping (Bool, String?) -> Void) func uninstallAll(reply: @escaping (Bool, String?) -> Void) } ``` diff --git a/docs/bugs/MAL-57-duplicate-keypresses.md b/docs/bugs/MAL-57-duplicate-keypresses.md deleted file mode 100644 index a4a3545ce..000000000 --- a/docs/bugs/MAL-57-duplicate-keypresses.md +++ /dev/null @@ -1,596 +0,0 @@ -# MAL-57: Duplicate Key Presses Under Load - -## Problem Statement - -Users report duplicate key presses "especially under load" - the same keypress appears twice in rapid succession in the Recent Keypresses view and keyboard visualization overlay. - -## Root Cause Analysis - -### Critical Issues Identified - -#### 1. **Broadcast Draining Timeout (HIGH SEVERITY)** -**Location**: `KanataTCPClient.swift:920` - -```swift -let maxDrainAttempts = 10 // Prevent infinite loop -``` - -**Problem**: Under load, 10 attempts (10 x 5s timeout = 50s max) may exhaust before finding the correct response, especially when: -- Many unsolicited broadcasts (LayerChange, MessagePush) are interleaved -- Multiple requests are queued -- Network latency increases buffer accumulation - -**Evidence**: Lines 967-969 throw `invalidResponse` when attempts exhausted, potentially leaving responses unread in the TCP buffer. - ---- - -#### 2. **Weak Request ID Fallback (HIGH SEVERITY)** -**Location**: `KanataTCPClient.swift:954-959` - -```swift -} else { - // We sent request_id but response doesn't have one - // This might be an old server - accept it as the response - AppLogger.shared.debug( - "⚠️ [TCP] Response missing request_id (old server?), accepting anyway") - break -} -``` - -**Problem**: This "old server" fallback can **accept broadcasts as responses**: -1. Send `Reload` with `request_id=42` -2. Kanata emits unsolicited `LayerChange` broadcast (no request_id) -3. Broadcast passes `isUnsolicitedBroadcast()` check (line 931) -4. Code accepts it as the Reload response (line 959) -5. Actual Reload response remains in buffer -6. Next request gets stale Reload response -7. State desync causes duplicate events - ---- - -#### 3. **No Event Deduplication (HIGH SEVERITY)** -**Location**: `RecentKeypressesService.swift:85-99` - -```swift -private func addEvent(key: String, action: String) { - let event = KeypressEvent( - key: key, - action: action, - timestamp: Date(), - layer: currentLayer - ) - - events.insert(event, at: 0) // No duplicate check! - - if events.count > maxEvents { - events = Array(events.prefix(maxEvents)) - } -} -``` - -**Problem**: Every notification is added immediately without checking if: -- Same key was just pressed within 100ms (likely duplicate) -- Same (key, action, layer) tuple already exists in last N events -- Event is a replay after reconnection - -**Impact**: Duplicate notifications = duplicate UI events = double keypresses shown to user. - ---- - -#### 4. **No Reconnection Replay Protection (MEDIUM SEVERITY)** -**Location**: `KanataEventListener.swift:425-444` - -```swift -var buffer = Data() // Fresh buffer on each connection - -while !Task.isCancelled { - guard let chunk = try await receiveChunk(on: connection) else { - throw ListenerError.connectionClosed - } - if chunk.isEmpty { continue } - buffer.append(chunk) - - while let newlineIndex = buffer.firstIndex(of: 0x0A) { - // ... process line ... - await handleLine(line) - } -} -``` - -**Problem**: When EventListener reconnects: -1. New TCP connection established to Kanata -2. Fresh buffer created (line 425) -3. **No "seen events" cache** across connections -4. If Kanata replays recent events (state sync), KeyPath processes them again -5. Duplicate events posted to NotificationCenter - -**Scenario**: -- User types "hello" fast -- Connection drops after "hel" -- Reconnect occurs -- Kanata re-sends "hel" + "lo" for state consistency -- UI shows: "helhello" - ---- - -### Secondary Contributing Factors - -#### 5. **Concurrent TCP Connections (MEDIUM RISK)** -- `KanataTCPClient`: Command/response pattern -- `KanataEventListener`: Streaming event pattern -- Both connect to same port 37001 with independent buffers -- No coordination if broadcasts are sent to both connections - -#### 6. **Poll Task Interference (LOW RISK)** -**Location**: `KanataEventListener.swift:415-423` - -```swift -pollTask = Task(priority: .background) { [weak self, weak connection] in - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 500_000_000) // Every 500ms - try? await send( - jsonObject: ["RequestCurrentLayerName": [:] as [String: String]], over: connection - ) - } -} -``` - -**Problem**: This poll runs every 500ms and expects a response. If under load: -- Poll response might be consumed by main event loop -- Or main event might be mistaken for poll response -- No request_id coordination between poll and main stream - ---- - -## Reproduction Steps - -### Minimal Repro - -1. **Configure fast remapping:** - ``` - (defremap test - a b - b c - c d - ... (50+ mappings) - ) - ``` - -2. **Generate load:** - - Hold down a key for 5+ seconds (generates ~50-100 KeyInput events) - - OR use `xdotool` / AppleScript to send rapid keypresses - - OR open Recent Keypresses view and type quickly - -3. **Observe:** - - Recent Keypresses view shows duplicate entries - - Same key appears twice with timestamps within milliseconds - - Example: `a (press) 12:34:56.123` followed by `a (press) 12:34:56.124` - -### Advanced Repro (Reconnection) - -1. Start KeyPath with Kanata running -2. Open Recent Keypresses view -3. Type a sequence: "test" -4. Kill Kanata daemon: `sudo killall kanata` -5. Restart Kanata: `sudo launchctl kickstart -k system/com.keypath.kanata` -6. Type "test" again quickly -7. **Expected**: 8 events (4 + 4) -8. **Actual**: 12-16 events (duplicates from replay) - ---- - -## Proposed Fixes - -### P0: Event Deduplication in RecentKeypressesService - -**File**: `Sources/KeyPathAppKit/Services/RecentKeypressesService.swift` - -**Change**: -```swift -private func addEvent(key: String, action: String) { - let event = KeypressEvent( - key: key, - action: action, - timestamp: Date(), - layer: currentLayer - ) - - // DEDUPLICATION: Skip if identical event exists within last 100ms - let deduplicationWindow: TimeInterval = 0.1 // 100ms - let now = event.timestamp - - if let lastEvent = events.first, - lastEvent.key == event.key, - lastEvent.action == event.action, - lastEvent.layer == event.layer, - now.timeIntervalSince(lastEvent.timestamp) < deduplicationWindow { - AppLogger.shared.debug("🚫 [Keypresses] Skipping duplicate: \(key) \(action) within \(Int(now.timeIntervalSince(lastEvent.timestamp) * 1000))ms") - return - } - - events.insert(event, at: 0) - - if events.count > maxEvents { - events = Array(events.prefix(maxEvents)) - } -} -``` - -**Rationale**: Physical key presses cannot occur within <100ms realistically. Any event within this window is likely a duplicate from: -- TCP buffer replay -- Broadcast draining confusion -- Reconnection replay - -**Testing**: -- Unit test: Verify duplicate within 100ms is skipped -- Unit test: Verify different key within 100ms is accepted -- Unit test: Verify same key after 100ms is accepted - ---- - -### P0: Fix Request ID Fallback Logic - -**File**: `Sources/KeyPathAppKit/Services/KanataTCPClient.swift` - -**Change** (lines 954-960): -```swift -} else { - // We sent request_id but response doesn't have one - // This could be: - // 1. An unsolicited broadcast that slipped through (REJECT) - // 2. An old server (unlikely - all recent versions support request_id) - - // For safety, REJECT responses without request_id when we sent one - if let msgStr = String(data: responseData, encoding: .utf8) { - AppLogger.shared.warning( - "⚠️ [TCP] Response missing request_id when we sent \(sentId) - likely broadcast, skipping: \(msgStr.prefix(100))" - ) - } - continue // Changed from 'break' to 'continue' -} -``` - -**Rationale**: Modern Kanata versions support `request_id`. Accepting responses without it creates ambiguity. Better to: -- Skip the response and wait for the real one -- If maxDrainAttempts exhausts, throw error (existing behavior line 967) -- User gets error instead of silent state corruption - -**Testing**: -- Integration test: Send command with request_id, inject broadcast without request_id, verify command response is found -- Integration test: Verify timeout if response never arrives - ---- - -### P1: Increase Broadcast Drain Attempts - -**File**: `Sources/KeyPathAppKit/Services/KanataTCPClient.swift` - -**Change** (line 920): -```swift -let maxDrainAttempts = 50 // Increased from 10 - under load, many broadcasts can arrive -``` - -**Rationale**: Under load, Kanata emits: -- LayerChange broadcasts (every layer switch) -- MessagePush broadcasts (custom actions, TCP commands) -- KeyInput broadcasts (if event listener is also connected) - -10 attempts may exhaust quickly. 50 attempts = 250s max timeout (unlikely to need that long, but provides safety margin). - -**Alternative**: Make configurable via environment variable: -```swift -let maxDrainAttempts = Int(ProcessInfo.processInfo.environment["KEYPATH_MAX_DRAIN_ATTEMPTS"] ?? "50") ?? 50 -``` - ---- - -### P1: Add Reconnection Event Cache - -**File**: `Sources/KeyPathAppKit/Services/KanataEventListener.swift` - -**Change** (add as class property): -```swift -/// Cache of recently seen events to prevent replay after reconnection -/// Key: "\(key)|\(action)|\(timestamp_rounded_to_100ms)" -/// Evict entries older than 5 seconds -private var seenEventsCache: [String: Date] = [:] -private let seenEventsCacheDuration: TimeInterval = 5.0 -``` - -**Change** (in handleLine method, before posting notification): -```swift -// Generate cache key (round timestamp to 100ms buckets) -let timestampBucket = Int(Date().timeIntervalSince1970 * 10) // 100ms buckets -let cacheKey = "\(key)|\(action)|\(timestampBucket)" - -// Check cache -if let lastSeen = seenEventsCache[cacheKey], - Date().timeIntervalSince(lastSeen) < seenEventsCacheDuration { - AppLogger.shared.debug("🚫 [EventListener] Skipping replay: \(key) \(action)") - return -} - -// Add to cache -seenEventsCache[cacheKey] = Date() - -// Evict old entries (run every 100 events or so) -if seenEventsCache.count > 1000 { - let cutoff = Date().addingTimeInterval(-seenEventsCacheDuration) - seenEventsCache = seenEventsCache.filter { $0.value > cutoff } -} - -// Post notification (existing code) -NotificationCenter.default.post(...) -``` - -**Rationale**: Prevents duplicate notifications from being posted when: -- Reconnection causes Kanata to replay state -- TCP buffers contain old events -- Network issues cause retransmission - -**Testing**: -- Unit test: Verify same event within 5s is cached -- Unit test: Verify cache eviction after 5s -- Integration test: Simulate reconnection, verify no duplicate notifications - ---- - -### P2: Unify TCP Connection Management - -**Goal**: Use a single persistent connection for both commands and events, eliminating concurrent connection interference. - -**Design**: -1. `KanataTCPClient` becomes the single TCP connection owner -2. `KanataEventListener` becomes a consumer of events from `KanataTCPClient` -3. `KanataTCPClient` dispatches incoming messages: - - Command responses → Return to caller (existing) - - Unsolicited broadcasts → Forward to `KanataEventListener` - -**Benefits**: -- No broadcast draining needed (events go to listener, not command handler) -- Simpler request/response correlation -- Reduced network overhead (one connection instead of two) - -**Risks**: -- Larger refactor, more testing needed -- Possible breaking changes to API - -**Defer to P2** - Fix P0/P1 issues first, evaluate if P2 is still needed. - ---- - -## Testing Strategy - -### Unit Tests - -1. **RecentKeypressesService deduplication:** - - Test duplicate within 100ms is skipped - - Test different key within 100ms is accepted - - Test same key after 101ms is accepted - - Test deduplication respects layer changes - -2. **KanataTCPClient request_id handling:** - - Test response with matching request_id is accepted - - Test response with mismatched request_id is skipped - - Test broadcast without request_id is skipped when request_id was sent - - Test maxDrainAttempts timeout behavior - -3. **KanataEventListener replay cache:** - - Test cache prevents duplicate within 5s - - Test cache allows duplicate after 5s - - Test cache eviction after threshold - -### Integration Tests - -1. **Load test:** Generate 100 keypresses in 1 second, verify no duplicates in Recent Keypresses -2. **Reconnection test:** Disconnect/reconnect during typing, verify no event replay -3. **Broadcast storm test:** Trigger many layer changes while sending commands, verify responses are correct - -### Manual Testing - -1. **User repro:** Hold down a key for 5 seconds, check Recent Keypresses for duplicates -2. **Network stress:** Use `tc` to add latency/packet loss, verify robustness -3. **Kanata restart:** Kill/restart Kanata during active typing, verify graceful recovery - ---- - -## Telemetry & Observability - -Add counters to track: -1. **Duplicates detected and skipped** (in RecentKeypressesService) -2. **Broadcast drain attempts** (average/max per command in KanataTCPClient) -3. **Reconnection event replays skipped** (in KanataEventListener) -4. **maxDrainAttempts exhausted** (error rate in KanataTCPClient) - -Expose via: -- Debug logs (existing) -- Stats endpoint (future) -- Crashlytics/Sentry custom metrics (future) - ---- - -## Rollout Plan - -1. **Week 1**: Implement P0 fixes (deduplication + request_id) -2. **Week 1**: Unit tests + integration tests -3. **Week 2**: Internal dogfooding with telemetry -4. **Week 2**: Analyze metrics, adjust deduplication window if needed -5. **Week 3**: Beta release to affected users -6. **Week 4**: Monitor for 1 week, then promote to stable - ---- - -## Success Criteria - -1. ✅ No duplicate events in Recent Keypresses view during normal typing -2. ✅ No duplicate events during 100 keypress/sec load test -3. ✅ No duplicate events after Kanata daemon restart -4. ✅ All unit tests pass -5. ✅ No user reports of duplicate keypresses in beta testing - ---- - -## Related Issues - -- [ADR-023: No Config Parsing](../adr/adr-023-no-config-parsing.md) - We rely on TCP events, not config parsing -- [ADR-022: No Concurrent Pgrep](../adr/adr-022-no-concurrent-pgrep.md) - Concurrency lessons apply here - ---- - -## Open Questions - -1. **Q**: Should deduplication window be user-configurable? - **A**: No - 100ms is safe for all typing speeds. Advanced users can adjust via code if needed. - -2. **Q**: Should we add telemetry for duplicate rate? - **A**: Yes (P2) - helps detect regressions and understand real-world duplicate frequency. - -3. **Q**: Does Kanata itself emit duplicates? - **A**: Unknown - need to test Kanata in isolation. If yes, fix should go upstream. - -4. **Q**: Should reconnection cache be per-layer or global? - **A**: Global - layer change itself is an event that could duplicate. - ---- - -## Timeline - -- **Analysis**: 2026-01-11 (completed) -- **P0 Implementation**: 2026-01-11 (completed) -- **Unit Testing**: 2026-01-11 (completed - 12/12 tests passing) -- **Ready for Deployment**: 2026-01-11 -- **Validation Testing**: 2026-02-18 (completed - see below) - -## Implementation Status - -### ✅ Completed (P0) - -1. **Event Deduplication in RecentKeypressesService** - `Sources/KeyPathAppKit/Services/RecentKeypressesService.swift:85-115` - - Added 100ms deduplication window - - Checks last 10 events for (key, action, layer) tuple matches - - Safely allows double letters (e.g., "tt" in "letter") - - Prevents TCP duplicate/replay scenarios - -2. **Fixed Request ID Fallback** - `Sources/KeyPathAppKit/Services/KanataTCPClient.swift:954-964` - - Changed from accepting broadcasts without request_id to skipping them - - Prevents broadcasts from being mistaken as command responses - - Uses `AppLogger.shared.warn()` for visibility - -3. **Increased Broadcast Drain Attempts** - `Sources/KeyPathAppKit/Services/KanataTCPClient.swift:920` - - Increased from 10 to 50 attempts - - Handles high-load scenarios with many interleaved broadcasts - -4. **Comprehensive Unit Tests** - `Tests/KeyPathTests/Services/RecentKeypressesServiceTests.swift` - - 12 tests covering all deduplication scenarios - - Tests double letter typing (legitimate doubles) - - Tests TCP replay scenarios - - Tests layer changes, recording toggle, edge cases - - All tests passing ✅ - ---- - -## ⚠️ Validation Attempt #1 (2026-02-18) — INVALID - -Automated validation using `Scripts/run-duplicate-key-test.sh` with osascript auto-typing. - -### Results (not trustworthy) - -| Test | Preset | Result | -|------|--------|--------| -| Phase 1: Pipeline (repro harness) | baseline | 0 alerts | -| Phase 1: Pipeline (repro harness) | high (compile loop + 6 CPU hogs) | 0 alerts | -| Phase 2: Keystroke fidelity (auto-type into Zed, diff expected vs actual) | high | 575/575 match | - -### Why These Results Are Invalid - -**osascript `keystroke` bypasses Kanata entirely.** osascript injects CGEvents at the -Accessibility/Core Graphics layer, which is above Kanata's IOKit HID intercept point. Kanata -grabs the physical keyboard device and reads raw USB HID reports — software-generated keystrokes -never reach it. - -**Evidence:** Kanata CPU was **0.0% across every sample** during all test runs. If Kanata were -processing those keystrokes, it would show non-zero CPU usage. - -**What the tests actually proved:** -- Text can be typed into Zed via osascript without corruption (trivially true) -- The system doesn't crash under CPU load - -**What they did NOT prove:** -- That Kanata handles physical keystrokes without duplication under load -- That tap-hold timing is stable under CPU starvation -- That the HID event path is clean - -### Lesson Learned - -There is no userspace API on macOS to inject events at the IOKit HID device level. All -software keystroke injection (osascript, CGEventPost, cliclick, peekaboo) enters above -Kanata's intercept point. Valid testing requires physical keyboard input. - -### Status: **REOPENED** — awaiting manual typing validation (Phase 3) - ---- - -## ✅ Validation #2 (2026-02-18) — VALID (physical keyboard, HID path confirmed) - -Manual typing test using `Scripts/manual-keystroke-test.sh` with physical keyboard input -through Kanata's real IOKit HID intercept → engine → virtual HID pipeline. - -### Test Configuration - -- **Preset:** high (Swift compile loop + 6 CPU hog processes) -- **Reference passage:** 184 words, 1182 chars (prose + Swift code + numbers + punctuation) -- **Input method:** Physical keyboard (human typing) -- **Duplicate detection:** `RecentKeypressesService` consecutive-key diagnostic with nav/modifier - keys excluded (backspace, arrows, shift, space, enter, etc.) - -### Results - -| Metric | Value | -|--------|-------| -| Key events processed by Kanata | **2,388** | -| Layer events | 854 | -| DUPLICATE DETECTION alerts (text keys) | **0** | -| Dedup filter skips | **0** | -| Ignored nav/modifier repeats | 0 | -| Reference chars | 1,182 | -| Typed chars | 1,123 | -| Difference | -59 (normal typing variation — fewer chars typed, not extra) | - -### HID Path Validation - -Kanata processed **2,388 key events** — confirming the physical keyboard HID path was fully -exercised. This contrasts with Validation #1 where Kanata showed 0.0% CPU and ~2 events -(proving osascript bypassed it entirely). - -### Key Findings - -1. **Zero duplicate detection alerts.** Under high CPU load, Kanata did not produce a single - instance of the same text key being pressed 3+ times within 500ms. - -2. **Zero dedup filter activations.** The 100ms dedup safety net in `RecentKeypressesService` - was not triggered at all — meaning clean, non-duplicate events are coming through the TCP - pipeline. The P0 request_id and drain fixes resolved the notification-layer duplicates. - -3. **Kanata's HID processing is stable under load.** Tap-hold timing, event debouncing, and - key remapping all functioned correctly with 6 CPU hogs + a compile loop running. - -4. **The -59 char difference is human typing variation** (skipped words, typos), not dropped - keystrokes. No characters were added — ruling out duplicate HID output. - -### Conclusion - -The original duplicate keypress bug was caused by KeyPath's TCP notification pipeline: -broadcast confusion, weak request_id matching, and missing event deduplication. These were -fixed in the P0 implementation (2026-01-11). Kanata itself never emitted duplicate HID events — -the duplicates were artifacts of how KeyPath consumed TCP broadcasts. - -### Status: **RESOLVED** ✅ - ---- - -## References - -- `Sources/KeyPathAppKit/Services/KanataTCPClient.swift` - Lines 892-983 -- `Sources/KeyPathAppKit/Services/KanataEventListener.swift` - Lines 384-450 -- `Sources/KeyPathAppKit/Services/RecentKeypressesService.swift` - Lines 60-99 -- Kanata TCP Protocol: https://github.com/jtroo/kanata/blob/main/docs/tcp_server.md diff --git a/docs/kanata-fork/hrm-execution-brief.md b/docs/kanata-fork/hrm-execution-brief.md new file mode 100644 index 000000000..2cb1df6f8 --- /dev/null +++ b/docs/kanata-fork/hrm-execution-brief.md @@ -0,0 +1,356 @@ +# HRM Execution Brief + +This document is a standalone brief for implementing the next round of +home-row-mod (HRM) improvements in kanata for KeyPath. + +It is written for an agent with no prior conversation context. + +## Purpose + +The goal is to push kanata's tap-hold behavior closer to the best +"timeless HRM" setups seen in ZMK and QMK while staying realistic about +what host-side software can do. + +This work is focused on **kanata engine behavior**, not KeyPath UI. +KeyPath can expose settings and presets, but the remaining high-value HRM +gaps are in the tap-versus-hold decision logic itself. + +## Why This Work Matters + +Home row mods fail in three main ways: + +| Failure | Symptom | Example | +|---|---|---| +| False hold | normal typing triggers a modifier | typing `fd` becomes `Ctrl+D` | +| False tap | intended shortcut emits letters | intended `Ctrl+C` becomes `fc` | +| Perceived latency | key output feels slow or hesitant | tap-hold waits too long before deciding | + +Kanata already has two major HRM improvements that eliminate many false holds: + +- `tap-hold-opposite-hand` with `defhands` +- `require-prior-idle` in `defcfg` + +Those are a strong baseline, but they do not fully match the "timeless HRM" +behavior people get from the best ZMK/QMK setups. The remaining gap is mostly +about **release-time positional disambiguation**, richer positional rules, and +more context-aware policy for specific modifiers like Shift. + +## Current Baseline + +These items are already implemented in the vendored kanata fork: + +- `tap-hold-opposite-hand` + `defhands` + - local commit: `d047516` +- `require-prior-idle` + - local commit: `4c569f1` + +Key implication: + +- Do not spend time re-proposing or redesigning those features as new work. +- Treat them as the baseline behavior that all new work must build on. + +## Success Criteria + +We should consider this work successful if we can do most of the following: + +- reduce remaining false holds during same-side rolls and normal prose typing +- preserve intentional shortcut use, including difficult edge cases +- reduce perceived latency without introducing brittle heuristics +- make behavior explainable and testable +- keep the implementation upstream-friendly where possible +- avoid turning kanata into a generic policy engine or analytics platform + +## Constraints + +### Host-Side, Not Firmware + +Kanata is host-side software, not keyboard firmware. + +That means the following are achievable: + +- event-history-based hold/tap decisions +- timing heuristics +- hand/position-aware decisions +- release-time disambiguation +- decision tracing and debug instrumentation + +But these limits remain: + +- no access to keyboard matrix scan timing +- subject to OS scheduler jitter and system load +- cannot fully match firmware-level determinism under adverse conditions + +This is a constraint on worst-case timing consistency, not on correctness of +the logic. The remaining roadmap items are still worth doing. + +### Upstream Acceptance Constraints + +The kanata maintainer appears to prefer: + +- named, purpose-built features +- minimal conceptual surface area +- practical solutions to concrete remapping problems + +The maintainer is less likely to want: + +- generic predicate DSLs +- abstraction-heavy frameworks +- analytics-style telemetry +- speculative complexity without clear examples and test coverage + +## What We Should Build First + +### Immediate Recommendation + +Build a **minimal HRM decision-tracing primitive first**, then implement the +highest-value behavior change. + +Reason: + +- decision tracing lowers the risk of the next engine changes +- it provides evidence for what still fails after `opposite-hand` and + `require-prior-idle` +- it improves the chance of getting future behavior changes accepted upstream +- it gives KeyPath a clean foundation for validation and tuning tools + +Important: + +- keep tracing debug-oriented and opt-in +- do not start with full analytics, dashboards, or aggregate statistics +- the trace should explain decisions, not profile users + +## Recommended Work Order + +### Step 1: Minimal HRM Decision Tracing + +Add a small, opt-in tracing mechanism that records why a tap-hold decision +resolved a certain way. + +Examples of reason codes: + +- `tap:prior_idle` +- `tap:same_hand_roll` +- `tap:release_before_trigger` +- `hold:opposite_hand` +- `hold:timeout` +- `hold:release_time_positional` + +The purpose is not end-user analytics. The purpose is: + +- engine debugging +- regression detection +- KeyPath-side validation +- evidence for future upstream proposals + +### Step 2: Release-Time Positional Hold-Tap + +This is the most important behavior change. + +Goal: + +- same-side rolls should keep resolving as taps +- deliberate same-hand shortcuts should still be possible when the home-row mod + is truly held + +This is the clearest remaining gap between current kanata behavior and the +best "timeless HRM" setups. + +### Step 3: Generalized Positional Hold Rules + +Once release-time logic exists, extend positional triggering in a targeted way. + +Examples: + +- opposite-hand versus same-hand +- configurable trigger positions +- left/right or per-key hand overrides for unusual layouts + +Keep this constrained. Do not build a generic DSL. + +### Step 4: Per-Modifier Policy and Shift Exemptions + +Shift is different from Ctrl, Alt, and Cmd during normal typing. + +Useful goals: + +- exempt Shift from some anti-misfire rules +- allow different positional/timing policy by hold action +- keep text entry natural while staying conservative for more disruptive mods + +### Step 5: Adaptive Timeout + +Only after the work above. + +Adaptive timing is attractive, but it should come after the simpler, +higher-confidence heuristics. It is easier to justify after tracing exists and +after positional behavior is stronger. + +## Short-Term Milestones + +These are the milestones another agent should treat as the current near-term +execution plan. + +### Milestone 1: Trace the Current HRM Decision Path + +Deliverables: + +- identify where current hold-tap decisions are finalized in kanata +- add an opt-in trace mechanism for tap-hold decisions +- define a small, stable set of reason codes +- verify low overhead when disabled +- document how to enable and inspect traces + +Success condition: + +- we can explain why a specific HRM key resolved as tap or hold in real cases + +### Milestone 2: Gather Example Failures Against the Current Baseline + +Deliverables: + +- create a small reproducible set of HRM edge cases +- capture trace output for those cases +- identify which failures remain after `opposite-hand` and `require-prior-idle` + +Success condition: + +- we have concrete examples that justify the next behavior change + +### Milestone 3: Implement Release-Time Positional Hold-Tap + +Deliverables: + +- design a narrow feature shape +- implement the runtime behavior in the correct processing layer +- add tests for same-hand rolls, same-hand shortcuts, cross-hand shortcuts, + and release-order edge cases +- add trace reasons for the new resolution path + +Success condition: + +- at least one important class of current false-hold or false-tap behavior is + improved without obvious regressions + +### Milestone 4: Validate Acceptance Strategy + +Deliverables: + +- decide whether the tracing primitive is upstreamable as-is +- decide whether release-time positional hold should be proposed upstream as a + named feature or kept in the KeyPath fork first +- document the framing for jtroo + +Success condition: + +- we know which pieces are intended for upstream and which are fork-only + +## Longer-Term Milestones + +### Milestone 5: Targeted Positional Generalization + +Deliverables: + +- extend positional logic beyond strict opposite-hand behavior +- support unusual geometries or per-key overrides where necessary +- keep the public configuration surface small and explicit + +### Milestone 6: Per-Modifier HRM Policy + +Deliverables: + +- implement Shift exemptions or other per-hold-action policy +- add tests showing why Shift needs different handling +- document the rule plainly + +### Milestone 7: Adaptive Timeout + +Deliverables: + +- prototype adaptive timing based on recent typing cadence +- validate with trace data +- ensure it actually improves behavior instead of just adding tuning complexity + +### Milestone 8: Optional Advanced Interaction Rules + +Deliverables: + +- evaluate bilateral combinations or multi-HRM interaction rules only if the + simpler positional work still leaves important gaps + +This is lower priority and should not block earlier work. + +## Top 3 Engine Priorities + +These are the top 3 **engine** items, not KeyPath UI items: + +| Rank | Item | Reward | Effort | Risk | Likely upstream acceptance | +|---|---|---|---|---|---| +| 1 | Release-time positional hold-tap | High | Medium-High | Medium | Medium | +| 2 | Generalized positional hold rules | High | Medium | Medium | Medium-Low | +| 3 | Per-modifier policy / Shift exemptions | High | Medium | Medium | Medium-Low | + +Important note: + +- all three are fundamentally kanata work +- KeyPath can expose them later, but it cannot implement them cleanly outside + the engine + +## Telemetry Guidance + +For this project, "telemetry" should mean **decision tracing**, not analytics. + +Preferred shape: + +- opt-in +- debug-only if possible +- low overhead +- minimal reason codes +- usable from logs or a small event surface + +Avoid: + +- user behavior analytics +- aggregate statistics in kanata itself +- product-specific instrumentation +- anything that creates ongoing protocol burden without clear engine value + +If there are two layers, prefer this split: + +1. a minimal upstreamable tracing primitive in kanata +2. any richer aggregation or visualization in KeyPath or the KeyPath fork + +## Architectural Guidance + +Kanata has two relevant places where hold-tap behavior can be implemented: + +1. keyberon-level queued-event logic +2. kanata processing-layer logic with access to broader event history + +Use the kanata processing layer for any behavior that depends on: + +- pre-press history +- timing relative to prior key presses +- per-modifier policy +- multi-key context beyond the current queue +- release-time disambiguation that is awkward in the generic keyberon model + +## Non-Goals + +Do not pursue these unless requirements change: + +- machine-learning-based hold/tap prediction +- a generic predicate DSL for tap-hold policy +- rich analytics inside kanata +- solving every rare multi-HRM interaction before the top 3 priorities + +## Source Documents + +Read these first: + +- [hrm-roadmap.md](./hrm-roadmap.md) +- [tcp-overlay-events.md](./tcp-overlay-events.md) +- [tcp-tap-activated-requirement.md](./tcp-tap-activated-requirement.md) + +If the agent needs broader product context, also inspect how KeyPath currently +uses HRM features in the main repository, but this brief is intended to be +enough to start the kanata-side work. diff --git a/docs/kanata-fork/hrm-roadmap.md b/docs/kanata-fork/hrm-roadmap.md index b0eebdef0..c3603c7c9 100644 --- a/docs/kanata-fork/hrm-roadmap.md +++ b/docs/kanata-fork/hrm-roadmap.md @@ -1,13 +1,22 @@ # Kanata HRM Improvement Roadmap Analysis of upstream improvements to Home Row Mod (HRM) behavior in kanata, -prioritized by impact, feasibility, and likelihood of maintainer acceptance. +updated to reflect what is already implemented and what remains to reach +"timeless HRM" parity with the strongest ZMK/QMK setups. -## Status +## Current Baseline -- **PR #1955** (`defhands` + `tap-hold-opposite-hand`): Merged / under review. - Adds hand-awareness to tap-hold resolution — opposite-hand key press triggers - hold, same-hand triggers tap. This is the foundation for further HRM work. +These primitives are already implemented in the vendored kanata fork: + +- **`tap-hold-opposite-hand` + `defhands`**: merged in commit `d047516`. + This eliminates the most common false holds from same-hand rolls by making + cross-hand presses trigger hold and same-hand presses resolve as tap. +- **`require-prior-idle` `defcfg` option**: merged in commit `4c569f1`. + This short-circuits tap-hold resolution to tap when the key press occurs + during a typing streak. + +This is already a strong HRM baseline. The next gains come from improving how +kanata resolves edge cases that remain after those two heuristics. ## The Three HRM Failure Modes @@ -19,90 +28,136 @@ Every HRM improvement targets one or more of these: | **False tap** | Intended Ctrl+C produces "fc" | Hold key released too quickly | | **Perceived latency** | Key output feels delayed | Tap-hold waits for timeout before emitting | -`tap-hold-opposite-hand` (PR #1955) primarily eliminates **false holds** from +`tap-hold-opposite-hand` primarily eliminates **false holds** from same-hand rolls. ## Proposed Phases -### Phase 1: Typing Streak Detection (`require-prior-idle`) +### Phase 1: Release-Time Positional Hold-Tap -**Impact: High | Effort: Medium | Acceptance: High** +**Impact: High | Effort: Medium-High | Acceptance: Medium** -If any key was pressed within N ms before a tap-hold key, resolve immediately -as tap. Rationale: during fast typing, the user is never trying to hold a -modifier — they're mid-word. +This is now the highest-value missing primitive. -This is proven in ZMK (`require-prior-idle-ms`) and frequently requested by -the kanata community. It would be the single highest-impact addition after -opposite-hand detection. +The goal is to defer part of the positional decision until release time so that +same-side rolls still resolve as taps, while deliberate same-hand shortcuts can +still succeed if the home-row mod is actually held long enough. -**Implementation approach**: Kanata-layer short-circuit in the processing loop, -not a keyberon `Custom` closure change. The kanata layer already tracks -timestamps for each key event and can check -`now - last_press_timestamp < idle_threshold` before entering the tap-hold -waiting state at all. This avoids any latency from the Custom closure queue -and keeps keyberon generic. +This is the main gap between kanata's current HRM behavior and the "timeless +HRM" ZMK approach. Today, opposite-hand detection is a strong approximation, +but it is coarser than release-time positional logic. -**Configuration sketch**: -```lisp -(defalias - a (tap-hold-opposite-hand 180 a lmet - (require-prior-idle 150))) -``` +Potential directions: -Or as a global/per-key option in `defcfg`: -```lisp -(defcfg - tap-hold-prior-idle 150) -``` +- Add a dedicated tap-hold variant with explicit release-time positional + semantics. +- Generalize positional trigger logic so "which keys may trigger hold" and + "when to finalize the decision" are both first-class concepts. -**Why kanata-layer, not keyberon**: The `Custom` closure only sees the queued -events *after* the tap-hold key. It cannot see whether a key was pressed -*before* the tap-hold key was pressed. The kanata processing layer has access -to the full event history. +This belongs in kanata's event engine, not in KeyPath. It changes the tap +versus hold decision itself, not just configuration shape. -### Phase 2: Adaptive Timeout +### Phase 2: Generalized Positional Hold Predicates -**Impact: Medium | Effort: Medium | Acceptance: Medium** +**Impact: High | Effort: Medium | Acceptance: Medium** -Adjust the tap-hold timeout dynamically based on recent typing speed. Fast -typists get shorter timeouts (less latency), slow/deliberate typing gets -longer timeouts (fewer false taps). +`tap-hold-opposite-hand` proves that hand-aware HRM works well. The next step +is to make positional triggering more expressive without turning the config +surface into a generic DSL. -This is more complex than Phase 1 and harder to tune. Phase 1 covers the -most common case (typing streaks) with a simpler mechanism. Phase 2 becomes -valuable for the remaining edge cases where the user pauses mid-word. +Examples of useful targeted extensions: -### Phase 3: Global `defhands` + Per-Key Overrides +- opposite-hand versus same-hand +- explicit allowed trigger positions +- left/right overrides for unusual layouts +- per-key hand overrides for splits, columns, and thumb clusters -**Impact: Low-Medium | Effort: Low | Acceptance: High** +The important constraint is to keep these as named behaviors or named options, +not a free-form predicate language. + +### Phase 3: Per-Modifier Policy and Shift Exemptions + +**Impact: High | Effort: Medium | Acceptance: Medium** + +Not all modifiers should be suppressed equally during typing streaks. + +Shift is special: + +- capital letters are part of normal typing +- punctuation often depends on Shift +- users tolerate more conservatism for Ctrl / Alt / Cmd than for Shift + +A clean implementation would allow per-hold-action policy such as: + +- Shift exempt from certain streak suppression rules +- different positional rules by modifier class +- more forgiving timing for some actions than others + +This is likely more valuable than adaptive timing because it improves real text +entry behavior directly. + +### Phase 4: Telemetry / Decision Tracing + +**Impact: Medium | Effort: Low-Medium | Acceptance: Low-Medium** + +Expose why a tap-hold resolved the way it did: + +- resolved as tap due to prior-idle +- resolved as tap due to same-hand roll +- resolved as hold due to opposite-hand trigger +- resolved as hold due to timeout -Allow `defhands` to be referenced by multiple tap-hold variants, not just -`tap-hold-opposite-hand`. This lets other custom tap-hold functions also -benefit from hand-awareness without duplicating hand assignments. +This is primarily valuable for downstream tooling such as KeyPath: -Also add per-key hand overrides for split keyboards where the physical -split position varies. +- tuning assistants +- misfire reports +- simulator validation +- regression detection during engine changes -### Phase 4: Bilateral Combinations (Stenography-Inspired) +Upstream may prefer minimal logging, but even a debug-only or TCP-only trace +mode would substantially improve confidence in future HRM work. -**Impact: Niche | Effort: High | Acceptance: Medium** +### Phase 5: Adaptive Timeout -Only activate modifiers when keys from *both* hands are held simultaneously. -Inspired by stenography and used in some QMK/ZMK setups. Niche but powerful -for users who want aggressive misfire prevention. +**Impact: Medium | Effort: Medium | Acceptance: Medium** + +Adjust the tap-hold timeout dynamically based on recent typing cadence. Fast +typists get shorter timeouts for less latency; slower or more deliberate input +gets longer timeouts for fewer false taps. + +This remains attractive, but it should follow positional and policy work rather +than precede it. The simpler heuristics cover more failures with less tuning. + +### Phase 6: Global `defhands` Reuse + Per-Key Overrides + +**Impact: Low-Medium | Effort: Low | Acceptance: High** + +Allow `defhands` to be referenced by more tap-hold variants and support +per-key hand overrides where the physical split position varies. This is useful +in advanced layouts, but it is no longer a top-priority blocker now that the +base `defhands` support already exists. + +### Phase 7: Bilateral Combinations / Multi-HRM Interaction Rules -This is architecturally complex because it requires tracking multiple -simultaneous tap-hold keys and their interactions. +**Impact: Medium | Effort: High | Acceptance: Medium** -### Phase 5: Telemetry / Statistics (Optional) +Only activate modifiers when keys from both hands are meaningfully involved, or +add explicit rules for interactions between multiple simultaneous home-row mods. -**Impact: Low | Effort: Medium | Acceptance: Low** +This is powerful for advanced users but architecturally complex. It should come +after the simpler release-time and positional improvements. -Expose misfire statistics (false hold rate, false tap rate, average hold -duration) via TCP or log output. Useful for tuning but unlikely to be -accepted upstream — jtroo prefers kanata to stay focused on key remapping, -not analytics. Better suited for KeyPath's fork or a separate tool. +## Ranking Summary + +| Rank | Improvement | Value | Effort | Risk | +|------|-------------|-------|--------|------| +| 1 | Release-time positional hold-tap | High | Medium-High | Medium | +| 2 | Generalized positional hold predicates | High | Medium | Medium | +| 3 | Per-modifier policy / Shift exemption | High | Medium | Medium | +| 4 | Telemetry / decision tracing | Medium | Low-Medium | Low | +| 5 | Adaptive timeout | Medium | Medium | Medium | +| 6 | `defhands` reuse + per-key overrides | Low-Medium | Low | Low | +| 7 | Bilateral combinations / multi-HRM interaction | Medium | High | High | ## What NOT to Propose @@ -114,7 +169,8 @@ This would be rejected by the kanata community for several reasons: - Adds heavy dependencies (model runtime) to a lean system tool - Non-deterministic behavior violates user expectations - Training data requirements create privacy concerns -- The simpler heuristics (opposite-hand + prior-idle) cover 95%+ of cases +- The simpler heuristics (opposite-hand + prior-idle + positional rules) cover + the vast majority of cases ### Full Predicate API @@ -128,7 +184,7 @@ predicate should be its own named option. | Reference | Topic | Status | |-----------|-------|--------| -| [#1602](https://github.com/jtroo/kanata/issues/1602) | Opposite-hand HRM | Closed by PR #1955 | +| [#1602](https://github.com/jtroo/kanata/issues/1602) | Opposite-hand HRM | Implemented via `d047516` | | [#128](https://github.com/jtroo/kanata/issues/128) | Custom tap-hold expansion | Open | | [Discussion #1086](https://github.com/jtroo/kanata/discussions/1086) | HRM general discussion | Active | | [Discussion #1024](https://github.com/jtroo/kanata/discussions/1024) | Bilateral combinations | Active | @@ -137,14 +193,36 @@ predicate should be its own named option. Kanata has two layers where tap-hold logic can live: -1. **keyberon layer** (`HoldTapConfig::Custom` closure): Sees only the - queued events *after* the tap-hold key was pressed. Generic, reusable, - but limited to what's in the queue. +1. **keyberon layer** (`HoldTapConfig::Custom` closure): Sees only the queued + events after the tap-hold key was pressed. Generic and reusable, but + limited to what is visible in that queue. 2. **kanata processing layer**: Has access to full event history, timestamps, global state (current layer, active modifiers, recent key timings). Can short-circuit *before* entering the tap-hold waiting state. -Phase 1 (prior-idle) and Phase 2 (adaptive timeout) **must** use the kanata -layer because they need pre-press timing data. Phase 3 and Phase 4 can use -either layer depending on the specific logic needed. +Rules that depend on pre-press history or richer global state must live in the +kanata processing layer. That includes: + +- prior-idle typing streak detection +- adaptive timeout +- per-modifier policy based on recent context + +Rules that depend on queued post-press events may fit in keyberon, but once the +behavior needs release-time disambiguation or multi-key interaction awareness, +it likely belongs in the kanata layer for clarity. + +## Practical Constraint: Host-Side, Not Firmware + +These improvements are all achievable in host-side software. Kanata can inspect +event order, timing, active modifiers, and recent history well enough to get +very close to firmware-quality HRM behavior for real users. + +What it cannot fully match is firmware-level determinism: + +- no access to keyboard matrix scan timing +- subject to OS scheduler jitter and system load +- less precise under adverse conditions than QMK/ZMK running on-device + +That is a limit on worst-case timing consistency, not on correctness of the +decision logic. The remaining roadmap items are still worth doing in kanata. diff --git a/docs/kanata/2026-03-08-macos-backend-refactor-proposal.md b/docs/kanata/2026-03-08-macos-backend-refactor-proposal.md new file mode 100644 index 000000000..a3e2ffbed --- /dev/null +++ b/docs/kanata/2026-03-08-macos-backend-refactor-proposal.md @@ -0,0 +1,329 @@ +# Kanata macOS Backend Refactor Proposal + +## Status + +Draft + +## Audience + +- Kanata maintainers +- KeyPath maintainers +- anyone evaluating how to support macOS Input Monitoring and VirtualHID reliably without + compromising Kanata's cross-platform design + +## Purpose + +This document proposes a **narrow macOS backend refactor** for Kanata that: + +1. preserves plain standalone Kanata on macOS +2. keeps Kanata's cross-platform core intact +3. enables a split-runtime architecture for macOS consumers like KeyPath + +This is intentionally **not** a proposal to merge KeyPath's app architecture into Kanata. + +## Executive Summary + +Kanata's current macOS runtime assumes one process can safely do all of the following: + +- hold macOS input-capture permission identity +- open physical keyboard devices +- run Kanata's remapping logic +- check pqrs/Karabiner DriverKit sink readiness +- emit remapped output through the pqrs VirtualHID path + +That assumption is workable for the current direct macOS model, but it is too rigid for modern +macOS permission and privilege boundaries. + +Recent runtime investigation showed that a user-session bundled host can successfully: + +- load the Kanata runtime in-process +- validate config +- construct runtime state +- start the macOS event loop + +but it still fails because the macOS event loop directly touches pqrs root-only sink state via the +DriverKit client path before a downstream privileged bridge can take over output responsibilities. + +The proposed fix is to refactor the macOS backend so that: + +- **input capture** remains in the user-session runtime +- **remapping logic** remains in Kanata core +- **output transport and output-health checks** become backend-pluggable + +This keeps plain Kanata working with the existing direct DriverKit backend while enabling optional +alternate output backends for macOS integrations that need a split privilege model. + +## Goals + +### Required + +1. Plain Kanata must remain installable and runnable on macOS without KeyPath. +2. Kanata's cross-platform parsing and remapping core must remain unchanged in spirit and ownership. +3. The default macOS path must continue to support the current direct DriverKit / VirtualHID model. +4. macOS backend code should gain a narrow seam that allows alternate output transport and output + readiness implementations. +5. Downstream macOS consumers should be able to adopt a split-runtime model without carrying a + large permanent fork of Kanata. + +### Nice to have + +1. Improved testability of macOS runtime behavior. +2. Cleaner separation of backend policy from transport mechanism. +3. Better recovery behavior across DriverKit restarts or sink loss. + +## Non-Goals + +1. Do not introduce a GUI requirement into Kanata. +2. Do not make KeyPath a dependency of Kanata. +3. Do not rewrite Kanata's parser, state machine, or cross-platform action logic. +4. Do not require all macOS users to adopt a split-runtime architecture. +5. Do not upstream KeyPath-specific helper, XPC, launchd, SMAppService, or permission UX code. + +## Problem Statement + +Today, Kanata's macOS runtime couples three concerns too tightly: + +1. physical keyboard input capture +2. output event delivery +3. output sink readiness / recovery policy + +In the current macOS path, the runtime does not merely emit output via pqrs/Karabiner DriverKit. +It also directly checks sink readiness from the same process. That means any process running the +macOS event loop implicitly needs to touch the pqrs root-only boundary. + +That creates a problem for consumers that need: + +- a **user-session process** to own built-in keyboard capture and Input Monitoring identity +- a **privileged process** to own pqrs / VirtualHID output access + +The consequence is that even when output events are forwarded elsewhere, the current event loop +still reaches into the root-only pqrs path and fails before the alternate output model can take +over. + +## Proposed Design + +Refactor the macOS backend so that **output transport and output readiness are abstracted behind a +small backend interface**. + +### Conceptual runtime split + +The macOS runtime should be thought of as three layers: + +1. **Input device layer** + - opens and grabs physical devices + - reads input events + - regrabs/releases input devices when needed + +2. **Kanata processing layer** + - existing parsing, remapping, and action logic + - transforms input events into output events + +3. **Output backend layer** + - emits output events + - reports output readiness / availability + - handles reset / modifier synchronization / tracked-key release + +Only the third layer needs a new seam. + +### Proposed abstraction + +The macOS backend should depend on an output adapter or output backend abstraction rather than +calling pqrs/DriverKit functions directly from the event loop. + +The exact Rust shape can vary, but conceptually the backend should own operations like: + +- `is_ready` +- `emit_key` +- `sync_modifiers` +- `reset` +- `release_tracked_output_keys` + +The event loop and recovery logic should depend on that abstraction rather than directly calling: + +- `karabiner_driverkit::is_sink_ready()` +- `karabiner_driverkit::send_key(...)` + +### Minimal implementation shape + +The smallest upstream-friendly first step is to keep using `KbdOut` as the macOS output surface and +move readiness behind that existing type instead of introducing a KeyPath-specific runtime layer. + +Concretely: + +- `KbdOut` remains the owner of output emission +- macOS readiness checks move onto `KbdOut` (`output_ready`, `wait_until_ready`) +- `KbdIn` stops probing DriverKit sink readiness during input grab +- the macOS event loop depends on `kbd_out` methods rather than importing pqrs directly + +That is enough to decouple the host event loop from direct pqrs readiness calls while preserving +the current direct DriverKit backend as the default implementation. + +## Default and Optional Backends + +### Default backend: direct DriverKit backend + +This preserves current standalone Kanata behavior. + +Responsibilities: + +- talk directly to pqrs/Karabiner DriverKit +- remain the default macOS backend +- keep the current CLI install/run story intact + +This is the path plain Kanata users continue to use. + +### Optional backend: bridged output backend + +This is intended for downstream integrations such as KeyPath. + +Responsibilities: + +- send remapped output events to a privileged companion over IPC +- receive output readiness from that companion instead of probing pqrs directly +- avoid direct access to pqrs root-only state from the user-session input host + +For the current host-bridge passthrough spike, this also implies the user-session host must not +start the direct macOS DriverKit event loop when it is operating in processing-only / +bridge-owned-output mode. Otherwise the host can still instantiate the pqrs client indirectly via +the macOS input stack even if output readiness checks are abstracted. + +This backend should be optional and should not be required for normal Kanata usage. + +## Why This Helps Standalone Kanata + +This proposal is valuable even if KeyPath did not exist. + +Benefits to plain Kanata: + +- cleaner macOS backend boundaries +- less mixing of runtime policy and transport details +- easier future support for alternate output modes or test backends +- fewer assumptions welded directly into the event loop + +The important point is that the direct DriverKit backend remains available and remains default. + +## Why This Supports Kanata's Cross-Platform Mission + +This proposal is intentionally cross-platform-friendly because it does **not** move product-specific +logic into Kanata core. + +It keeps the cross-platform mission intact by: + +- leaving parser/state-machine/action semantics untouched +- keeping the refactor scoped to macOS backend boundaries +- preserving the existing macOS direct mode +- making alternate macOS runtime models possible without changing Linux/Windows behavior + +This should be understood as a backend cleanup that reduces platform coupling, not as a +product-specific architecture change. + +## What Stays Downstream in KeyPath + +The following should remain KeyPath-owned and should not be upstreamed as part of this proposal: + +- app bundle / GUI runtime structure +- Input Monitoring UX and permission guidance +- helper / XPC / SMAppService wiring +- installer and repair orchestration +- privileged output bridge implementation details +- launchd / deployment / packaging conventions + +Kanata should provide the backend seam. KeyPath should provide one consumer of that seam. + +## Proposed Upstream / Downstream Boundary + +### Upstream Kanata + +- macOS output backend abstraction +- direct DriverKit backend implementation +- recovery logic driven through the backend abstraction +- optional alternate backend hooks or feature-gated backend wiring + +### Downstream KeyPath + +- bridge-backed output backend implementation +- user-session host runtime packaging +- privileged output companion +- permission and installer UX + +## Recommended Rollout + +### Phase 1: behavior-preserving backend refactor + +Refactor macOS output and output-health handling behind a narrow abstraction while keeping direct +DriverKit as the only production backend. + +Success criteria: + +- no user-visible behavior change for plain Kanata +- no KeyPath dependency +- no cross-platform behavior change + +### Phase 2: optional alternate backend support + +Add the ability to compile or construct a non-DriverKit output backend. + +Success criteria: + +- alternate backend remains optional +- direct DriverKit backend still default +- no regression for plain macOS Kanata users + +### Phase 3: downstream KeyPath adoption + +KeyPath adopts the alternate backend to build a split runtime: + +- user-session host owns input capture and Input Monitoring identity +- privileged bridge owns pqrs output + +Success criteria: + +- KeyPath no longer requires the HID-owning process to touch pqrs directly +- standalone Kanata remains unaffected + +## Why This Is Preferable to a Large Fork + +Without this seam, KeyPath must keep carrying macOS-specific runtime patches in a forked vendored +Kanata tree. That is possible, but it increases long-term maintenance cost and makes upstream sync +harder. + +A narrow backend refactor is preferable because it: + +- reduces permanent downstream divergence +- gives macOS a cleaner backend structure upstream +- preserves standalone Kanata +- makes the downstream split-runtime model a consumer of a general seam rather than a custom fork + +## Risks + +1. Recovery behavior may become more subtle once output readiness is no longer a direct DriverKit + call from the event loop. +2. Modifier synchronization semantics need to remain correct across backends. +3. If the abstraction is too large, it will feel product-specific and be harder to maintain. +4. If the abstraction is too small, downstream integrations will still need invasive patches. + +The best mitigation is to keep the seam narrow and specific to output ownership and readiness. + +## Open Questions + +1. Should the output abstraction live as a trait used only on macOS, or as a more generic backend + concept? +2. Should the alternate backend be compile-time gated, runtime selected, or both? +3. How much of recovery policy should stay in the event loop versus move into the output backend? +4. Is there a minimal upstream shape that enables downstream split-runtime work without taking on + more macOS complexity than maintainers want? + +## Suggested Pitch to Upstream + +The strongest upstream framing is: + +- this is a macOS backend refactor, not a KeyPath integration request +- the default Kanata behavior remains direct DriverKit and remains fully supported +- the change improves macOS backend modularity on its own merits +- the split-runtime model is just one downstream consumer of the new seam + +In short: + +> Preserve standalone Kanata. Refactor the macOS backend so output transport and output readiness +> are pluggable. Keep direct DriverKit as default. Let downstream consumers opt into alternate +> output ownership models without forcing those models onto Kanata itself. diff --git a/docs/troubleshooting-helper.md b/docs/troubleshooting-helper.md index 22770604f..7bd0aa577 100644 --- a/docs/troubleshooting-helper.md +++ b/docs/troubleshooting-helper.md @@ -40,6 +40,15 @@ Hypotheses To Investigate 4) Timing/race at first launch. Ensure the app is fully launched (not translocated) from `/Applications` before attempting registration. 5) Possible Sequoia regression (macOS 15). If reproducible with a clean system cache and a fresh build, capture the full diagnostics and consider filing a DTS. +Development Caveat +- `Scripts/quick-deploy.sh` should not hot-swap the embedded helper by default. +- Replacing `/Applications/KeyPath.app/Contents/Library/HelperTools/KeyPathHelper` during fast iteration can leave the registered helper in a `spawn failed` state even when `codesign --verify --deep --strict /Applications/KeyPath.app` still passes. +- Symptom: + - `launchctl print system/com.keypath.helper` shows `job state = spawn failed` + - `last exit code = 78: EX_CONFIG` + - helper XPC calls degrade from selector-specific timeouts to a fully unresponsive helper +- If you intentionally deploy a helper change, follow with an explicit helper unregister/register or repair flow from the signed app before trusting XPC diagnostics. + Next Steps - Reproduce with the updated app and collect the full error details in logs. - Attach the script output and `BlessDiagnostics` report (from the app’s Diagnostics view) to any DTS report. diff --git a/info b/info new file mode 100644 index 000000000..e69de29bb