diff --git a/Cargo.lock b/Cargo.lock index 3aeec73..20ffa48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,9 +140,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d809780667f4410e7c41b07f52439b94d2bdf8528eeedc287fa38d3b7f95d82" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bindgen" @@ -208,9 +208,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "byteorder" @@ -235,9 +235,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.47" +version = "1.2.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd405d82c84ff7f35739f175f67d8b9fb7687a0e84ccdc78bd3568839827cf07" +checksum = "755d2fce177175ffca841e9a06afdb2c4ab0f593d53b4dee48147dfaade85932" dependencies = [ "find-msvc-tools", "jobserver", @@ -285,9 +285,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", @@ -295,9 +295,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", @@ -319,9 +319,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.6" +version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" [[package]] name = "clap_mangen" @@ -335,9 +335,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] @@ -383,12 +383,46 @@ dependencies = [ "libc", ] +[[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.10.0", + "core-foundation 0.10.1", + "core-graphics-types", + "foreign-types 0.5.0", + "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.10.0", + "core-foundation 0.10.1", + "libc", +] + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -657,27 +691,26 @@ checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "8591b0bcc8a98a64310a2fae1bb3e9b8564dd10e381e6e28010fde8e8e8568db" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", "miniz_oxide", @@ -695,7 +728,28 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "foreign-types-shared", + "foreign-types-shared 0.1.1", +] + +[[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 0.3.1", +] + +[[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]] @@ -704,6 +758,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -740,11 +800,30 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", @@ -789,9 +868,9 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hmac-sha256" -version = "1.1.12" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425" +checksum = "d0f0ae375a85536cac3a243e3a9cda80a47910348abdea7e2c22f8ec556d586d" [[package]] name = "hound" @@ -931,9 +1010,9 @@ checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -950,19 +1029,6 @@ dependencies = [ "libc", ] -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "futures-core", - "inotify-sys", - "libc", - "tokio", -] - [[package]] name = "inotify-sys" version = "0.1.5" @@ -1036,9 +1102,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -1072,9 +1138,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -1088,13 +1154,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.7.0", ] [[package]] @@ -1120,15 +1186,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lzma-rust2" -version = "0.15.6" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17f7337d278fec032975dc884152491580dd23750ee957047856735fe0e61ede" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" [[package]] name = "mach2" @@ -1219,9 +1285,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -1269,9 +1335,9 @@ dependencies = [ [[package]] name = "ndarray" -version = "0.17.1" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c7c9125e8f6f10c9da3aad044cc918cf8784fa34de857b1aa68038eb05a50a9" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" dependencies = [ "matrixmultiply", "num-complex", @@ -1356,7 +1422,7 @@ dependencies = [ "crossbeam-channel", "filetime", "fsevent-sys", - "inotify 0.9.6", + "inotify", "kqueue", "libc", "log", @@ -1509,7 +1575,7 @@ checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ "bitflags 2.10.0", "cfg-if", - "foreign-types", + "foreign-types 0.3.2", "libc", "once_cell", "openssl-macros", @@ -1609,7 +1675,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1652,6 +1718,12 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "pkg-config" version = "0.3.32" @@ -1716,23 +1788,23 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" dependencies = [ - "toml_edit 0.23.7", + "toml_edit 0.23.10+spec-1.0.0", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] @@ -1771,9 +1843,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", ] @@ -1824,13 +1896,22 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags 2.10.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.16", + "getrandom 0.2.17", "libredox", "thiserror 1.0.69", ] @@ -1872,7 +1953,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -1917,9 +1998,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -1945,18 +2026,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "ring", "rustls-pki-types", @@ -2006,7 +2087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "core-foundation-sys", "libc", "security-framework-sys", @@ -2091,10 +2172,11 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -2104,6 +2186,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" @@ -2175,9 +2263,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.111" +version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", @@ -2203,9 +2291,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.23.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", @@ -2225,11 +2313,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -2245,9 +2333,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", @@ -2300,7 +2388,7 @@ dependencies = [ "serde", "serde_json", "spm_precompiled", - "thiserror 2.0.17", + "thiserror 2.0.18", "unicode-normalization-alignments", "unicode-segmentation", "unicode_categories", @@ -2308,13 +2396,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", - "mio 1.1.0", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -2357,9 +2445,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] @@ -2380,21 +2468,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.23.7" +version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6485ef6d0d9b5d0ec17244ff7eb05310113c3f316f2d14200d4de56b3cb98f8d" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ "indexmap", - "toml_datetime 0.7.3", + "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "winnow", ] [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] @@ -2407,9 +2495,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", @@ -2429,9 +2517,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -2450,9 +2538,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -2613,11 +2701,12 @@ dependencies = [ "async-trait", "clap", "clap_mangen", + "core-foundation 0.10.1", + "core-graphics", "cpal", "directories", "evdev", "hound", - "inotify 0.10.2", "libc", "nix 0.29.0", "notify", @@ -2655,18 +2744,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -2677,11 +2766,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -2690,9 +2780,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2700,9 +2790,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", @@ -2713,18 +2803,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -3144,9 +3234,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" [[package]] name = "writeable" @@ -3275,6 +3365,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.12" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" +checksum = "dfcd145825aace48cff44a8844de64bf75feec3080e0aa5cdbde72961ae51a65" diff --git a/Cargo.toml b/Cargo.toml index df16aa1..f8cbe7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,8 @@ regex = "1" # Async traits async-trait = "0.1" -# Input handling (evdev for kernel-level key events) -evdev = "0.12" +# Cross-platform system interface libc = "0.2" -inotify = "0.10" # Watch /dev/input for device hotplug nix = { version = "0.29", features = ["signal", "process"] } # Unix signals for IPC # Audio capture @@ -64,12 +62,21 @@ parakeet-rs = { version = "0.2.9", optional = true } # CPU count for thread detection num_cpus = "1.16" -# File watching for status --follow +# File and device watching (status --follow, device hotplug) notify = "6" # Single instance check pidlock = "0.1" +# Linux-specific dependencies +[target.'cfg(target_os = "linux")'.dependencies] +evdev = "0.12" # Input handling (kernel-level key events) + +# macOS-specific dependencies +[target.'cfg(target_os = "macos")'.dependencies] +core-graphics = "0.24" # CGEvent for hotkey capture and text injection +core-foundation = "0.10" # Core Foundation types + [features] default = [] gpu-vulkan = ["whisper-rs/vulkan"] diff --git a/src/cli.rs b/src/cli.rs index 891bafb..e154ad5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -797,7 +797,10 @@ mod tests { let cli = Cli::parse_from(["voxtype", "record", "start", "--file=out.txt"]); match cli.command { Some(Commands::Record { action }) => { - assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File)); + assert_eq!( + action.output_mode_override(), + Some(OutputModeOverride::File) + ); assert_eq!(action.file_path(), Some("out.txt")); } _ => panic!("Expected Record command"), @@ -821,7 +824,10 @@ mod tests { let cli = Cli::parse_from(["voxtype", "record", "start", "--file"]); match cli.command { Some(Commands::Record { action }) => { - assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File)); + assert_eq!( + action.output_mode_override(), + Some(OutputModeOverride::File) + ); assert_eq!(action.file_path(), Some("")); // Empty string means use config path } _ => panic!("Expected Record command"), @@ -845,7 +851,10 @@ mod tests { let cli = Cli::parse_from(["voxtype", "record", "start", "--file=/tmp/output.txt"]); match cli.command { Some(Commands::Record { action }) => { - assert_eq!(action.output_mode_override(), Some(OutputModeOverride::File)); + assert_eq!( + action.output_mode_override(), + Some(OutputModeOverride::File) + ); assert_eq!(action.file_path(), Some("/tmp/output.txt")); } _ => panic!("Expected Record command"), @@ -904,17 +913,9 @@ mod tests { #[test] fn test_record_start_file_mutually_exclusive_with_paste() { - let result = Cli::try_parse_from([ - "voxtype", - "record", - "start", - "--file=out.txt", - "--paste", - ]); - assert!( - result.is_err(), - "Should not allow both --file and --paste" - ); + let result = + Cli::try_parse_from(["voxtype", "record", "start", "--file=out.txt", "--paste"]); + assert!(result.is_err(), "Should not allow both --file and --paste"); } #[test] @@ -934,17 +935,9 @@ mod tests { #[test] fn test_record_start_file_mutually_exclusive_with_type() { - let result = Cli::try_parse_from([ - "voxtype", - "record", - "start", - "--file=out.txt", - "--type", - ]); - assert!( - result.is_err(), - "Should not allow both --file and --type" - ); + let result = + Cli::try_parse_from(["voxtype", "record", "start", "--file=out.txt", "--type"]); + assert!(result.is_err(), "Should not allow both --file and --type"); } #[test] diff --git a/src/config.rs b/src/config.rs index 9cf9594..8de0f5b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -26,9 +26,11 @@ state_file = "auto" [hotkey] # Key to hold for push-to-talk -# Common choices: SCROLLLOCK, PAUSE, RIGHTALT, F13-F24 -# Use `evtest` to find key names for your keyboard -key = "SCROLLLOCK" +# Default: FN/Globe on macOS, SCROLLLOCK on Linux +# macOS options: FN/GLOBE, RIGHTOPTION, CAPSLOCK, F13-F20 +# Linux options: SCROLLLOCK, PAUSE, RIGHTALT, F13-F24 +# key = "FN" # macOS default +# key = "SCROLLLOCK" # Linux default # Optional modifier keys that must also be held # Example: modifiers = ["LEFTCTRL", "LEFTALT"] @@ -295,8 +297,10 @@ pub struct Config { /// Hotkey detection configuration #[derive(Debug, Clone, Deserialize, Serialize)] pub struct HotkeyConfig { - /// Key name (evdev KEY_* constant name, without the KEY_ prefix) - /// Examples: "SCROLLLOCK", "RIGHTALT", "PAUSE", "F24" + /// Key name (without KEY_ prefix) + /// Default: "FN" on macOS, "SCROLLLOCK" on Linux + /// macOS: "FN", "GLOBE", "RIGHTOPTION", "CAPSLOCK", "F13"-"F20" + /// Linux: "SCROLLLOCK", "PAUSE", "RIGHTALT", "F13"-"F24" #[serde(default = "default_hotkey_key")] pub key: String, @@ -362,7 +366,14 @@ pub struct AudioFeedbackConfig { } fn default_hotkey_key() -> String { - "SCROLLLOCK".to_string() + #[cfg(target_os = "macos")] + { + "FN".to_string() + } + #[cfg(not(target_os = "macos"))] + { + "SCROLLLOCK".to_string() + } } fn default_sound_theme() -> String { @@ -887,7 +898,7 @@ pub struct NotificationConfig { pub on_recording_stop: bool, /// Notify with transcribed text after transcription completes - #[serde(default = "default_true")] + #[serde(default)] pub on_transcription: bool, /// Show engine icon in notification title (🦜 for Parakeet, 🗣️ for Whisper) diff --git a/src/cpu.rs b/src/cpu.rs index c061af2..2f32533 100644 --- a/src/cpu.rs +++ b/src/cpu.rs @@ -4,17 +4,21 @@ //! particularly in virtualized environments where the hypervisor may not //! expose all host CPU features. //! -//! The SIGILL handler is installed via a .init_array constructor, which runs -//! before main() - this is critical because AVX-512 instructions can appear -//! in library initialization code, before our Rust main() even starts. +//! On Linux, the SIGILL handler is installed via a .init_array constructor, +//! which runs before main() - this is critical because AVX-512 instructions +//! can appear in library initialization code, before our Rust main() even starts. +//! +//! On macOS, this functionality is not needed as macOS builds target different +//! CPU instruction sets. use std::sync::atomic::{AtomicBool, Ordering}; static SIGILL_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); -/// Constructor function that runs before main() via .init_array +/// Constructor function that runs before main() via .init_array (Linux only) /// This ensures the SIGILL handler is installed before any library /// initialization code that might use unsupported instructions. +#[cfg(target_os = "linux")] #[used] #[link_section = ".init_array"] static INIT_SIGILL_HANDLER: extern "C" fn() = { diff --git a/src/daemon.rs b/src/daemon.rs index 84d010e..6d81c84 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -458,23 +458,23 @@ impl Daemon { if let Some(t) = transcriber { self.transcription_task = Some(tokio::task::spawn_blocking(move || t.transcribe(&samples))); - return true; + true } else { tracing::error!("No transcriber available"); self.play_feedback(SoundEvent::Error); self.reset_to_idle(state).await; - return false; + false } } Err(e) => { tracing::warn!("Recording error: {}", e); self.reset_to_idle(state).await; - return false; + false } } } else { self.reset_to_idle(state).await; - return false; + false } } @@ -585,18 +585,15 @@ impl Daemon { }; let file_mode = &self.config.output.file_mode; - match write_transcription_to_file(&output_path, &final_text, file_mode).await + match write_transcription_to_file(&output_path, &final_text, file_mode) + .await { Ok(()) => { let mode_str = match file_mode { FileMode::Overwrite => "wrote", FileMode::Append => "appended", }; - tracing::info!( - "{} transcription to {:?}", - mode_str, - output_path - ); + tracing::info!("{} transcription to {:?}", mode_str, output_path); } Err(e) => { tracing::error!( @@ -718,8 +715,7 @@ impl Daemon { return Err(crate::error::VoxtypeError::Config(format!( "Another voxtype instance is already running (lock error: {:?})", e - )) - .into()); + ))); } } @@ -1688,7 +1684,8 @@ mod tests { // Should not panic }); } - + + #[allow(dead_code)] fn test_pidlock_acquisition_succeeds() { with_test_runtime_dir(|dir| { let lock_path = dir.join("voxtype.lock"); diff --git a/src/error.rs b/src/error.rs index 75be313..6fe50bd 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,6 +41,9 @@ pub enum HotkeyError { #[error("evdev error: {0}")] Evdev(String), + + #[error("Hotkey detection not supported: {0}")] + NotSupported(String), } /// Errors related to audio capture @@ -127,6 +130,7 @@ pub enum OutputError { /// Result type alias using VoxtypeError pub type Result = std::result::Result; +#[cfg(target_os = "linux")] impl From for HotkeyError { fn from(e: evdev::Error) -> Self { HotkeyError::Evdev(e.to_string()) diff --git a/src/hotkey/evdev_listener.rs b/src/hotkey/evdev_listener.rs index fdb2ed4..2013a62 100644 --- a/src/hotkey/evdev_listener.rs +++ b/src/hotkey/evdev_listener.rs @@ -3,8 +3,9 @@ //! Uses the Linux evdev interface to detect key presses at the kernel level. //! This works on all Wayland compositors because it bypasses the display server. //! -//! Uses inotify to detect device changes (hotplug, screenlock, suspend/resume) -//! and automatically re-enumerates devices when needed. +//! Uses the notify crate to detect device changes (hotplug, screenlock, suspend/resume) +//! and automatically re-enumerates devices when needed. The notify crate provides +//! cross-platform filesystem watching (inotify on Linux, FSEvents on macOS). //! //! The user must be in the 'input' group to access /dev/input/* devices. @@ -12,10 +13,11 @@ use super::{HotkeyEvent, HotkeyListener}; use crate::config::HotkeyConfig; use crate::error::HotkeyError; use evdev::{Device, InputEventKind, Key}; -use inotify::{Inotify, WatchMask}; +use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; use std::collections::{HashMap, HashSet}; use std::os::unix::io::AsRawFd; use std::path::PathBuf; +use std::sync::mpsc as std_mpsc; use std::time::{Duration, Instant}; use tokio::sync::{mpsc, oneshot}; @@ -119,35 +121,49 @@ impl HotkeyListener for EvdevListener { } } -/// Manages input devices with hotplug detection via inotify +/// Manages input devices with hotplug detection via notify crate struct DeviceManager { /// Map of device path to opened device devices: HashMap, - /// inotify instance watching /dev/input - inotify: Inotify, - /// Buffer for inotify events - inotify_buffer: [u8; 1024], + /// Filesystem watcher for /dev/input (kept alive to maintain watch) + #[allow(dead_code)] + watcher: RecommendedWatcher, + /// Receiver for filesystem events + fs_event_rx: std_mpsc::Receiver>, /// Last time we did a full validation last_validation: Instant, } impl DeviceManager { - /// Create a new device manager with inotify watcher + /// Create a new device manager with filesystem watcher fn new() -> Result { - let inotify = Inotify::init().map_err(|e| { - HotkeyError::DeviceAccess(format!("Failed to initialize inotify: {}", e)) + // Set up channel for filesystem events + let (tx, rx) = std_mpsc::channel(); + + // Create filesystem watcher using the notify crate + // On Linux this uses inotify internally, on macOS it uses FSEvents + let mut watcher = RecommendedWatcher::new( + move |res| { + let _ = tx.send(res); + }, + NotifyConfig::default(), + ) + .map_err(|e| { + HotkeyError::DeviceAccess(format!("Failed to initialize filesystem watcher: {}", e)) })?; // Watch /dev/input for device creation and deletion - inotify - .watches() - .add("/dev/input", WatchMask::CREATE | WatchMask::DELETE) + watcher + .watch( + std::path::Path::new("/dev/input"), + RecursiveMode::NonRecursive, + ) .map_err(|e| HotkeyError::DeviceAccess(format!("Failed to watch /dev/input: {}", e)))?; let mut manager = Self { devices: HashMap::new(), - inotify, - inotify_buffer: [0u8; 1024], + watcher, + fs_event_rx: rx, last_validation: Instant::now(), }; @@ -233,45 +249,51 @@ impl DeviceManager { } } - /// Check inotify for device changes (non-blocking) + /// Check for device changes (non-blocking) /// Returns true if devices changed fn check_for_device_changes(&mut self) -> bool { - // Set inotify to non-blocking for this check - let fd = self.inotify.as_raw_fd(); - unsafe { - let flags = libc::fcntl(fd, libc::F_GETFL); - if flags != -1 { - libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK); - } - } + let mut changed = false; - let events = match self.inotify.read_events(&mut self.inotify_buffer) { - Ok(events) => events, - Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { - return false; - } - Err(e) => { - tracing::warn!("inotify read error: {}", e); - return false; - } - }; + // Drain all pending filesystem events (non-blocking) + loop { + match self.fs_event_rx.try_recv() { + Ok(Ok(event)) => { + // Check if this is a create or remove event for an event* device + for path in event.paths { + let is_event_device = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")); + + if !is_event_device { + continue; + } - let mut changed = false; - for event in events { - if let Some(name) = event.name { - let name_str = name.to_string_lossy(); - if name_str.starts_with("event") { - let path = PathBuf::from("/dev/input").join(&*name_str); - - if event.mask.contains(inotify::EventMask::CREATE) { - tracing::debug!("Device created: {:?}", path); - changed = true; - } else if event.mask.contains(inotify::EventMask::DELETE) { - tracing::debug!("Device removed: {:?}", path); - self.devices.remove(&path); - changed = true; + match event.kind { + notify::EventKind::Create(_) => { + tracing::debug!("Device created: {:?}", path); + changed = true; + } + notify::EventKind::Remove(_) => { + tracing::debug!("Device removed: {:?}", path); + self.devices.remove(&path); + changed = true; + } + _ => {} + } } } + Ok(Err(e)) => { + tracing::warn!("Filesystem watch error: {}", e); + } + Err(std_mpsc::TryRecvError::Empty) => { + // No more events pending + break; + } + Err(std_mpsc::TryRecvError::Disconnected) => { + tracing::warn!("Filesystem watcher disconnected"); + break; + } } } @@ -417,7 +439,7 @@ fn evdev_listener_loop( Err(oneshot::error::TryRecvError::Empty) => {} } - // Check inotify for device changes + // Check for device changes (filesystem events) if manager.check_for_device_changes() { // Clear state when devices change active_modifiers.clear(); @@ -661,4 +683,59 @@ mod tests { fn test_parse_key_name_error() { assert!(parse_key_name("INVALID_KEY_NAME").is_err()); } + + #[test] + fn test_is_event_device_filter() { + // Test the device path filtering logic used in check_for_device_changes + let test_cases = [ + ("/dev/input/event0", true), + ("/dev/input/event123", true), + ("/dev/input/mouse0", false), + ("/dev/input/js0", false), + ("/dev/input/by-id/usb-keyboard", false), + ("/dev/input/eventfoo", true), // starts with event + ]; + + for (path_str, expected) in test_cases { + let path = PathBuf::from(path_str); + let is_event = path + .file_name() + .and_then(|n| n.to_str()) + .is_some_and(|n| n.starts_with("event")); + assert_eq!( + is_event, expected, + "Path {} should be event device: {}", + path_str, expected + ); + } + } + + #[test] + fn test_notify_event_kind_matching() { + // Verify we correctly identify Create and Remove event kinds + // This tests the pattern matching used in check_for_device_changes + use notify::event::{CreateKind, RemoveKind}; + + let create_event = notify::EventKind::Create(CreateKind::File); + let remove_event = notify::EventKind::Remove(RemoveKind::File); + let modify_event = notify::EventKind::Modify(notify::event::ModifyKind::Any); + + // Test Create matching + let is_create = matches!(create_event, notify::EventKind::Create(_)); + assert!(is_create, "Create event should match Create pattern"); + + // Test Remove matching + let is_remove = matches!(remove_event, notify::EventKind::Remove(_)); + assert!(is_remove, "Remove event should match Remove pattern"); + + // Test that Modify does not match Create or Remove + let is_create_or_remove = matches!( + modify_event, + notify::EventKind::Create(_) | notify::EventKind::Remove(_) + ); + assert!( + !is_create_or_remove, + "Modify event should not match Create or Remove" + ); + } } diff --git a/src/hotkey/macos.rs b/src/hotkey/macos.rs new file mode 100644 index 0000000..45b5513 --- /dev/null +++ b/src/hotkey/macos.rs @@ -0,0 +1,784 @@ +//! macOS-based hotkey listener using CGEventTap +//! +//! Uses the macOS Quartz Event Services (CGEventTap) to capture global key events. +//! For the FN/Globe key, monitors the SecondaryFn modifier flag changes. +//! +//! This approach requires Accessibility permissions to be granted to the application. +//! The user must grant Accessibility access in System Preferences > Security & Privacy > +//! Privacy > Accessibility for voxtype to receive global key events. + +use super::{HotkeyEvent, HotkeyListener}; +use crate::config::HotkeyConfig; +use crate::error::HotkeyError; +use core_foundation::runloop::{kCFRunLoopCommonModes, kCFRunLoopDefaultMode, CFRunLoop}; +use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement, + CGEventType, EventField, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc as std_mpsc; +use std::sync::Arc; +use tokio::sync::{mpsc, oneshot}; + +/// macOS virtual key codes +/// These are defined in Carbon HIToolbox Events.h (kVK_* constants) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u16)] +#[allow(non_camel_case_types)] +pub enum VirtualKeyCode { + // Letter keys (kVK_ANSI_*) + KEY_A = 0x00, + KEY_S = 0x01, + KEY_D = 0x02, + KEY_F = 0x03, + KEY_H = 0x04, + KEY_G = 0x05, + KEY_Z = 0x06, + KEY_X = 0x07, + KEY_C = 0x08, + KEY_V = 0x09, + KEY_B = 0x0B, + KEY_Q = 0x0C, + KEY_W = 0x0D, + KEY_E = 0x0E, + KEY_R = 0x0F, + KEY_Y = 0x10, + KEY_T = 0x11, + KEY_O = 0x1F, + KEY_U = 0x20, + KEY_I = 0x22, + KEY_P = 0x23, + KEY_L = 0x25, + KEY_J = 0x26, + KEY_K = 0x28, + KEY_N = 0x2D, + KEY_M = 0x2E, + + // Number keys (kVK_ANSI_*) + KEY_1 = 0x12, + KEY_2 = 0x13, + KEY_3 = 0x14, + KEY_4 = 0x15, + KEY_5 = 0x17, + KEY_6 = 0x16, + KEY_7 = 0x1A, + KEY_8 = 0x1C, + KEY_9 = 0x19, + KEY_0 = 0x1D, + + // Function keys (kVK_F*) + KEY_F1 = 0x7A, + KEY_F2 = 0x78, + KEY_F3 = 0x63, + KEY_F4 = 0x76, + KEY_F5 = 0x60, + KEY_F6 = 0x61, + KEY_F7 = 0x62, + KEY_F8 = 0x64, + KEY_F9 = 0x65, + KEY_F10 = 0x6D, + KEY_F11 = 0x67, + KEY_F12 = 0x6F, + KEY_F13 = 0x69, + KEY_F14 = 0x6B, + KEY_F15 = 0x71, + KEY_F16 = 0x6A, + KEY_F17 = 0x40, + KEY_F18 = 0x4F, + KEY_F19 = 0x50, + KEY_F20 = 0x5A, + + // Modifier keys (kVK_*) + KEY_CAPSLOCK = 0x39, + KEY_SHIFT = 0x38, + KEY_RIGHTSHIFT = 0x3C, + KEY_CONTROL = 0x3B, + KEY_RIGHTCONTROL = 0x3E, + KEY_OPTION = 0x3A, // Left Alt/Option + KEY_RIGHTOPTION = 0x3D, // Right Alt/Option + KEY_COMMAND = 0x37, // Left Command + KEY_RIGHTCOMMAND = 0x36, + KEY_FN = 0x3F, + + // Special keys + KEY_RETURN = 0x24, + KEY_TAB = 0x30, + KEY_SPACE = 0x31, + KEY_DELETE = 0x33, // Backspace + KEY_ESCAPE = 0x35, + KEY_FORWARDDELETE = 0x75, + KEY_HOME = 0x73, + KEY_END = 0x77, + KEY_PAGEUP = 0x74, + KEY_PAGEDOWN = 0x79, + + // Arrow keys + KEY_LEFTARROW = 0x7B, + KEY_RIGHTARROW = 0x7C, + KEY_DOWNARROW = 0x7D, + KEY_UPARROW = 0x7E, + + // Misc (kVK_ANSI_*) + KEY_GRAVE = 0x32, // ` or ~ + KEY_MINUS = 0x1B, + KEY_EQUAL = 0x18, + KEY_LEFTBRACKET = 0x21, + KEY_RIGHTBRACKET = 0x1E, + KEY_BACKSLASH = 0x2A, + KEY_SEMICOLON = 0x29, + KEY_QUOTE = 0x27, + KEY_COMMA = 0x2B, + KEY_PERIOD = 0x2F, + KEY_SLASH = 0x2C, + + // Media keys (on keyboards with them) + KEY_MUTE = 0x4A, + KEY_VOLUMEDOWN = 0x49, + KEY_VOLUMEUP = 0x48, + + // Help/Insert key (not present on most Mac keyboards but exists in code) + KEY_HELP = 0x72, +} + +impl VirtualKeyCode { + /// Convert a raw key code to a VirtualKeyCode enum variant + /// This is useful for debugging key events + #[allow(dead_code)] + fn from_u16(code: u16) -> Option { + // Match against known values + match code { + 0x00 => Some(Self::KEY_A), + 0x01 => Some(Self::KEY_S), + 0x02 => Some(Self::KEY_D), + 0x03 => Some(Self::KEY_F), + 0x04 => Some(Self::KEY_H), + 0x05 => Some(Self::KEY_G), + 0x06 => Some(Self::KEY_Z), + 0x07 => Some(Self::KEY_X), + 0x08 => Some(Self::KEY_C), + 0x09 => Some(Self::KEY_V), + 0x0B => Some(Self::KEY_B), + 0x0C => Some(Self::KEY_Q), + 0x0D => Some(Self::KEY_W), + 0x0E => Some(Self::KEY_E), + 0x0F => Some(Self::KEY_R), + 0x10 => Some(Self::KEY_Y), + 0x11 => Some(Self::KEY_T), + 0x1F => Some(Self::KEY_O), + 0x20 => Some(Self::KEY_U), + 0x22 => Some(Self::KEY_I), + 0x23 => Some(Self::KEY_P), + 0x25 => Some(Self::KEY_L), + 0x26 => Some(Self::KEY_J), + 0x28 => Some(Self::KEY_K), + 0x2D => Some(Self::KEY_N), + 0x2E => Some(Self::KEY_M), + 0x12 => Some(Self::KEY_1), + 0x13 => Some(Self::KEY_2), + 0x14 => Some(Self::KEY_3), + 0x15 => Some(Self::KEY_4), + 0x17 => Some(Self::KEY_5), + 0x16 => Some(Self::KEY_6), + 0x19 => Some(Self::KEY_9), + 0x1A => Some(Self::KEY_7), + 0x1C => Some(Self::KEY_8), + 0x1D => Some(Self::KEY_0), + 0x7A => Some(Self::KEY_F1), + 0x78 => Some(Self::KEY_F2), + 0x63 => Some(Self::KEY_F3), + 0x76 => Some(Self::KEY_F4), + 0x60 => Some(Self::KEY_F5), + 0x61 => Some(Self::KEY_F6), + 0x62 => Some(Self::KEY_F7), + 0x64 => Some(Self::KEY_F8), + 0x65 => Some(Self::KEY_F9), + 0x6D => Some(Self::KEY_F10), + 0x67 => Some(Self::KEY_F11), + 0x6F => Some(Self::KEY_F12), + 0x69 => Some(Self::KEY_F13), + 0x6B => Some(Self::KEY_F14), + 0x71 => Some(Self::KEY_F15), + 0x6A => Some(Self::KEY_F16), + 0x40 => Some(Self::KEY_F17), + 0x4F => Some(Self::KEY_F18), + 0x50 => Some(Self::KEY_F19), + 0x5A => Some(Self::KEY_F20), + 0x39 => Some(Self::KEY_CAPSLOCK), + 0x38 => Some(Self::KEY_SHIFT), + 0x3C => Some(Self::KEY_RIGHTSHIFT), + 0x3B => Some(Self::KEY_CONTROL), + 0x3E => Some(Self::KEY_RIGHTCONTROL), + 0x3A => Some(Self::KEY_OPTION), + 0x3D => Some(Self::KEY_RIGHTOPTION), + 0x37 => Some(Self::KEY_COMMAND), + 0x36 => Some(Self::KEY_RIGHTCOMMAND), + 0x3F => Some(Self::KEY_FN), + 0x24 => Some(Self::KEY_RETURN), + 0x30 => Some(Self::KEY_TAB), + 0x31 => Some(Self::KEY_SPACE), + 0x33 => Some(Self::KEY_DELETE), + 0x35 => Some(Self::KEY_ESCAPE), + 0x75 => Some(Self::KEY_FORWARDDELETE), + 0x73 => Some(Self::KEY_HOME), + 0x77 => Some(Self::KEY_END), + 0x74 => Some(Self::KEY_PAGEUP), + 0x79 => Some(Self::KEY_PAGEDOWN), + 0x7B => Some(Self::KEY_LEFTARROW), + 0x7C => Some(Self::KEY_RIGHTARROW), + 0x7D => Some(Self::KEY_DOWNARROW), + 0x7E => Some(Self::KEY_UPARROW), + 0x32 => Some(Self::KEY_GRAVE), + 0x1B => Some(Self::KEY_MINUS), + 0x18 => Some(Self::KEY_EQUAL), + 0x21 => Some(Self::KEY_LEFTBRACKET), + 0x1E => Some(Self::KEY_RIGHTBRACKET), + 0x2A => Some(Self::KEY_BACKSLASH), + 0x29 => Some(Self::KEY_SEMICOLON), + 0x27 => Some(Self::KEY_QUOTE), + 0x2B => Some(Self::KEY_COMMA), + 0x2F => Some(Self::KEY_PERIOD), + 0x2C => Some(Self::KEY_SLASH), + 0x4A => Some(Self::KEY_MUTE), + 0x49 => Some(Self::KEY_VOLUMEDOWN), + 0x48 => Some(Self::KEY_VOLUMEUP), + 0x72 => Some(Self::KEY_HELP), + _ => None, + } + } +} + +/// macOS-based hotkey listener using CGEventTap +pub struct MacOSListener { + /// The key to listen for + target_key: VirtualKeyCode, + /// Required modifier flags + modifier_flags: CGEventFlags, + /// Optional cancel key + cancel_key: Option, + /// Signal to stop the listener task + stop_signal: Option>, + /// Flag to signal stop from callback + stop_flag: Arc, +} + +impl MacOSListener { + /// Create a new macOS listener for the configured hotkey + pub fn new(config: &HotkeyConfig) -> Result { + let target_key = parse_key_name(&config.key)?; + + let modifier_flags = config + .modifiers + .iter() + .map(|m| parse_modifier_name(m)) + .collect::, _>>()? + .into_iter() + .fold(CGEventFlags::empty(), |acc, flag| acc | flag); + + let cancel_key = config + .cancel_key + .as_ref() + .map(|k| parse_key_name(k)) + .transpose()?; + + // Check for Accessibility permissions + if !check_accessibility_permissions() { + return Err(HotkeyError::DeviceAccess( + "Accessibility permissions required. Please grant access in:\n \ + System Settings > Privacy & Security > Accessibility\n\n \ + Add your terminal application (e.g., Terminal.app, iTerm2, or your IDE) \ + to the list of allowed apps.\n\n \ + After granting access, restart voxtype." + .to_string(), + )); + } + + Ok(Self { + target_key, + modifier_flags, + cancel_key, + stop_signal: None, + stop_flag: Arc::new(AtomicBool::new(false)), + }) + } +} + +/// Check if the application has Accessibility permissions +fn check_accessibility_permissions() -> bool { + // Use the AXIsProcessTrusted function from ApplicationServices framework + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + } + unsafe { AXIsProcessTrusted() } +} + +#[async_trait::async_trait] +impl HotkeyListener for MacOSListener { + async fn start(&mut self) -> Result, HotkeyError> { + let (tx, rx) = mpsc::channel(32); + let (stop_tx, stop_rx) = oneshot::channel(); + self.stop_signal = Some(stop_tx); + self.stop_flag.store(false, Ordering::SeqCst); + + let target_key = self.target_key; + let modifier_flags = self.modifier_flags; + let cancel_key = self.cancel_key; + let stop_flag = self.stop_flag.clone(); + + // Spawn the listener in a blocking task since CFRunLoop blocks + tokio::task::spawn_blocking(move || { + if let Err(e) = macos_listener_loop( + target_key, + modifier_flags, + cancel_key, + tx, + stop_rx, + stop_flag, + ) { + tracing::error!("macOS hotkey listener error: {}", e); + } + }); + + Ok(rx) + } + + async fn stop(&mut self) -> Result<(), HotkeyError> { + self.stop_flag.store(true, Ordering::SeqCst); + if let Some(stop) = self.stop_signal.take() { + let _ = stop.send(()); + } + // Give the run loop a moment to stop + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + tracing::debug!("macOS hotkey listener stopping"); + Ok(()) + } +} + +/// Main listener loop running in a blocking task +fn macos_listener_loop( + target_key: VirtualKeyCode, + modifier_flags: CGEventFlags, + cancel_key: Option, + tx: mpsc::Sender, + _stop_rx: oneshot::Receiver<()>, + stop_flag: Arc, +) -> Result<(), HotkeyError> { + // Track if we're currently "pressed" (to handle repeat events) + let is_pressed = Arc::new(AtomicBool::new(false)); + + // Create a channel for events from the callback + let (event_tx, event_rx) = std_mpsc::channel::(); + + // Clone values for the callback closure + let is_pressed_clone = is_pressed.clone(); + let stop_flag_clone = stop_flag.clone(); + + // Create the event tap callback + let callback = move |_proxy: core_graphics::event::CGEventTapProxy, + event_type: CGEventType, + event: &CGEvent| + -> Option { + // Check stop flag + if stop_flag_clone.load(Ordering::SeqCst) { + CFRunLoop::get_current().stop(); + return Some(event.clone()); + } + + let key_code = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE) as u16; + + // Get current modifier flags from the event + let current_flags = event.get_flags(); + + match event_type { + CGEventType::KeyDown => { + // Check cancel key first + if let Some(cancel) = cancel_key { + if key_code == cancel as u16 { + let _ = event_tx.send(HotkeyEvent::Cancel); + return Some(event.clone()); + } + } + + // Check target key with modifiers + if key_code == target_key as u16 { + let modifiers_match = check_modifiers(current_flags, modifier_flags); + + if modifiers_match && !is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(true, Ordering::SeqCst); + tracing::debug!("Hotkey pressed (macOS)"); + let _ = event_tx.send(HotkeyEvent::Pressed { model_override: None }); + } + } + } + CGEventType::KeyUp => { + if key_code == target_key as u16 && is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(false, Ordering::SeqCst); + tracing::debug!("Hotkey released (macOS)"); + let _ = event_tx.send(HotkeyEvent::Released); + } + } + CGEventType::FlagsChanged => { + // Special handling for FN key - detect via flag, not key code + if target_key == VirtualKeyCode::KEY_FN { + let fn_pressed = current_flags.contains(CGEventFlags::CGEventFlagSecondaryFn); + let was_pressed = is_pressed_clone.load(Ordering::SeqCst); + tracing::debug!("FN flag check: fn_pressed={}, was_pressed={}", fn_pressed, was_pressed); + if fn_pressed && !was_pressed { + is_pressed_clone.store(true, Ordering::SeqCst); + tracing::debug!("FN key pressed (macOS)"); + let _ = event_tx.send(HotkeyEvent::Pressed { model_override: None }); + } else if !fn_pressed && is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(false, Ordering::SeqCst); + tracing::debug!("FN key released (macOS)"); + let _ = event_tx.send(HotkeyEvent::Released); + } + } else if key_code == target_key as u16 { + // Handle other modifier key changes + let is_modifier_pressed = check_modifier_pressed(key_code, current_flags); + + if is_modifier_pressed && !is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(true, Ordering::SeqCst); + tracing::debug!("Modifier hotkey pressed (macOS)"); + let _ = event_tx.send(HotkeyEvent::Pressed { model_override: None }); + } else if !is_modifier_pressed && is_pressed_clone.load(Ordering::SeqCst) { + is_pressed_clone.store(false, Ordering::SeqCst); + tracing::debug!("Modifier hotkey released (macOS)"); + let _ = event_tx.send(HotkeyEvent::Released); + } + } + } + _ => {} + } + + // Return the event unchanged (don't consume it) + Some(event.clone()) + }; + + // Create the event tap + let event_tap = CGEventTap::new( + CGEventTapLocation::Session, + CGEventTapPlacement::HeadInsertEventTap, + CGEventTapOptions::ListenOnly, + vec![ + CGEventType::KeyDown, + CGEventType::KeyUp, + CGEventType::FlagsChanged, + ], + callback, + ) + .map_err(|_| { + HotkeyError::DeviceAccess( + "Failed to create event tap. Ensure Accessibility permissions are granted.".to_string(), + ) + })?; + + // Enable the event tap + event_tap.enable(); + + // Create a run loop source from the event tap + let run_loop_source = event_tap + .mach_port + .create_runloop_source(0) + .map_err(|_| HotkeyError::DeviceAccess("Failed to create run loop source".to_string()))?; + + // Get the current run loop and add the source + let run_loop = CFRunLoop::get_current(); + run_loop.add_source(&run_loop_source, unsafe { kCFRunLoopCommonModes }); + + if let Some(cancel) = cancel_key { + tracing::info!( + "Listening for {:?} (with modifiers: {:?}) and cancel key {:?} on macOS", + target_key, + modifier_flags, + cancel + ); + } else { + tracing::info!( + "Listening for {:?} (with modifiers: {:?}) on macOS", + target_key, + modifier_flags + ); + } + + // Spawn a thread to forward events from std channel to tokio channel + // and check stop flag periodically + let tx_clone = tx.clone(); + let stop_flag_thread = stop_flag.clone(); + std::thread::spawn(move || { + loop { + if stop_flag_thread.load(Ordering::SeqCst) { + break; + } + + match event_rx.recv_timeout(std::time::Duration::from_millis(100)) { + Ok(event) => { + if tx_clone.blocking_send(event).is_err() { + break; + } + } + Err(std_mpsc::RecvTimeoutError::Timeout) => { + // Continue checking stop flag + } + Err(std_mpsc::RecvTimeoutError::Disconnected) => { + break; + } + } + } + }); + + // Run the event loop (blocks until stopped) + // Run with a timeout to periodically check stop flag + while !stop_flag.load(Ordering::SeqCst) { + CFRunLoop::run_in_mode( + unsafe { kCFRunLoopDefaultMode }, + std::time::Duration::from_millis(100), + true, + ); + } + + tracing::debug!("macOS hotkey listener stopping"); + Ok(()) +} + +/// Check if required modifier flags are satisfied +fn check_modifiers(current: CGEventFlags, required: CGEventFlags) -> bool { + if required.is_empty() { + return true; + } + current.contains(required) +} + +/// Check if a modifier key is currently pressed based on flags +fn check_modifier_pressed(key_code: u16, flags: CGEventFlags) -> bool { + match key_code { + 0x38 | 0x3C => flags.contains(CGEventFlags::CGEventFlagShift), + 0x3B | 0x3E => flags.contains(CGEventFlags::CGEventFlagControl), + 0x3A | 0x3D => flags.contains(CGEventFlags::CGEventFlagAlternate), + 0x37 | 0x36 => flags.contains(CGEventFlags::CGEventFlagCommand), + 0x39 => flags.contains(CGEventFlags::CGEventFlagAlphaShift), + 0x3F => flags.contains(CGEventFlags::CGEventFlagSecondaryFn), + _ => false, + } +} + +/// Parse a key name string to macOS virtual key code +fn parse_key_name(name: &str) -> Result { + // Normalize: uppercase and replace - or space with _ + let normalized: String = name + .chars() + .map(|c| match c { + '-' | ' ' => '_', + c => c.to_ascii_uppercase(), + }) + .collect(); + + // Remove KEY_ prefix if present + let key_name = normalized.strip_prefix("KEY_").unwrap_or(&normalized); + + // Map common key names to macOS virtual key codes + let key = match key_name { + // Lock keys (good hotkey candidates) + "SCROLLLOCK" => { + return Err(HotkeyError::UnknownKey( + "SCROLLLOCK is not available on macOS. Try F13-F20, FN, or a modifier key like RIGHTOPTION".to_string() + )); + } + "PAUSE" => { + return Err(HotkeyError::UnknownKey( + "PAUSE is not available on macOS. Try F13-F20, FN, or a modifier key like RIGHTOPTION".to_string() + )); + } + "CAPSLOCK" => VirtualKeyCode::KEY_CAPSLOCK, + "NUMLOCK" => { + return Err(HotkeyError::UnknownKey( + "NUMLOCK is not available on macOS. Try F13-F20, FN, or CAPSLOCK".to_string(), + )); + } + "INSERT" | "HELP" => VirtualKeyCode::KEY_HELP, + + // Modifier keys + "LEFTALT" | "LALT" | "OPTION" | "LEFTOPTION" => VirtualKeyCode::KEY_OPTION, + "RIGHTALT" | "RALT" | "RIGHTOPTION" | "ALTGR" => VirtualKeyCode::KEY_RIGHTOPTION, + "LEFTCTRL" | "LCTRL" | "CONTROL" | "LEFTCONTROL" => VirtualKeyCode::KEY_CONTROL, + "RIGHTCTRL" | "RCTRL" | "RIGHTCONTROL" => VirtualKeyCode::KEY_RIGHTCONTROL, + "LEFTSHIFT" | "LSHIFT" | "SHIFT" => VirtualKeyCode::KEY_SHIFT, + "RIGHTSHIFT" | "RSHIFT" => VirtualKeyCode::KEY_RIGHTSHIFT, + "LEFTMETA" | "LMETA" | "SUPER" | "COMMAND" | "LEFTCOMMAND" | "CMD" => { + VirtualKeyCode::KEY_COMMAND + } + "RIGHTMETA" | "RMETA" | "RIGHTCOMMAND" | "RCMD" => VirtualKeyCode::KEY_RIGHTCOMMAND, + "FN" | "FUNCTION" | "GLOBE" => VirtualKeyCode::KEY_FN, + + // Function keys (F13-F20 are good hotkey choices on macOS) + "F1" => VirtualKeyCode::KEY_F1, + "F2" => VirtualKeyCode::KEY_F2, + "F3" => VirtualKeyCode::KEY_F3, + "F4" => VirtualKeyCode::KEY_F4, + "F5" => VirtualKeyCode::KEY_F5, + "F6" => VirtualKeyCode::KEY_F6, + "F7" => VirtualKeyCode::KEY_F7, + "F8" => VirtualKeyCode::KEY_F8, + "F9" => VirtualKeyCode::KEY_F9, + "F10" => VirtualKeyCode::KEY_F10, + "F11" => VirtualKeyCode::KEY_F11, + "F12" => VirtualKeyCode::KEY_F12, + "F13" => VirtualKeyCode::KEY_F13, + "F14" => VirtualKeyCode::KEY_F14, + "F15" => VirtualKeyCode::KEY_F15, + "F16" => VirtualKeyCode::KEY_F16, + "F17" => VirtualKeyCode::KEY_F17, + "F18" => VirtualKeyCode::KEY_F18, + "F19" => VirtualKeyCode::KEY_F19, + "F20" => VirtualKeyCode::KEY_F20, + + // Navigation keys + "HOME" => VirtualKeyCode::KEY_HOME, + "END" => VirtualKeyCode::KEY_END, + "PAGEUP" => VirtualKeyCode::KEY_PAGEUP, + "PAGEDOWN" => VirtualKeyCode::KEY_PAGEDOWN, + "DELETE" | "FORWARDDELETE" => VirtualKeyCode::KEY_FORWARDDELETE, + "BACKSPACE" => VirtualKeyCode::KEY_DELETE, + + // Common keys + "SPACE" => VirtualKeyCode::KEY_SPACE, + "ENTER" | "RETURN" => VirtualKeyCode::KEY_RETURN, + "TAB" => VirtualKeyCode::KEY_TAB, + "ESC" | "ESCAPE" => VirtualKeyCode::KEY_ESCAPE, + "GRAVE" | "BACKTICK" => VirtualKeyCode::KEY_GRAVE, + + // Media keys + "MUTE" => VirtualKeyCode::KEY_MUTE, + "VOLUMEDOWN" => VirtualKeyCode::KEY_VOLUMEDOWN, + "VOLUMEUP" => VirtualKeyCode::KEY_VOLUMEUP, + + // If not found, return error with macOS-specific suggestions + _ => { + return Err(HotkeyError::UnknownKey(format!( + "{}. On macOS, try: F13-F20, FN, RIGHTOPTION, or CAPSLOCK. \ + Note: SCROLLLOCK and PAUSE are not available on macOS keyboards.", + name + ))); + } + }; + + Ok(key) +} + +/// Parse a modifier name to CGEventFlags +fn parse_modifier_name(name: &str) -> Result { + let normalized: String = name + .chars() + .map(|c| match c { + '-' | ' ' => '_', + c => c.to_ascii_uppercase(), + }) + .collect(); + + let key_name = normalized.strip_prefix("KEY_").unwrap_or(&normalized); + + match key_name { + "LEFTSHIFT" | "LSHIFT" | "RIGHTSHIFT" | "RSHIFT" | "SHIFT" => { + Ok(CGEventFlags::CGEventFlagShift) + } + "LEFTCTRL" | "LCTRL" | "RIGHTCTRL" | "RCTRL" | "CONTROL" | "CTRL" => { + Ok(CGEventFlags::CGEventFlagControl) + } + "LEFTALT" | "LALT" | "RIGHTALT" | "RALT" | "ALT" | "OPTION" | "LEFTOPTION" + | "RIGHTOPTION" => Ok(CGEventFlags::CGEventFlagAlternate), + "LEFTMETA" | "LMETA" | "RIGHTMETA" | "RMETA" | "SUPER" | "COMMAND" | "CMD" => { + Ok(CGEventFlags::CGEventFlagCommand) + } + "FN" | "FUNCTION" => Ok(CGEventFlags::CGEventFlagSecondaryFn), + _ => Err(HotkeyError::UnknownKey(format!( + "Unknown modifier: {}. On macOS, use: SHIFT, CONTROL, OPTION (ALT), COMMAND, or FN", + name + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_key_name() { + assert_eq!(parse_key_name("F13").unwrap(), VirtualKeyCode::KEY_F13); + assert_eq!(parse_key_name("f13").unwrap(), VirtualKeyCode::KEY_F13); + assert_eq!(parse_key_name("KEY_F13").unwrap(), VirtualKeyCode::KEY_F13); + assert_eq!( + parse_key_name("RIGHTOPTION").unwrap(), + VirtualKeyCode::KEY_RIGHTOPTION + ); + assert_eq!( + parse_key_name("RIGHTALT").unwrap(), + VirtualKeyCode::KEY_RIGHTOPTION + ); + assert_eq!( + parse_key_name("CAPSLOCK").unwrap(), + VirtualKeyCode::KEY_CAPSLOCK + ); + } + + #[test] + fn test_parse_key_name_scrolllock_error() { + let result = parse_key_name("SCROLLLOCK"); + assert!(result.is_err()); + let err = result.unwrap_err(); + match err { + HotkeyError::UnknownKey(msg) => { + assert!(msg.contains("not available on macOS")); + } + _ => panic!("Expected UnknownKey error"), + } + } + + #[test] + fn test_parse_modifier_name() { + assert_eq!( + parse_modifier_name("LEFTCTRL").unwrap(), + CGEventFlags::CGEventFlagControl + ); + assert_eq!( + parse_modifier_name("CONTROL").unwrap(), + CGEventFlags::CGEventFlagControl + ); + assert_eq!( + parse_modifier_name("OPTION").unwrap(), + CGEventFlags::CGEventFlagAlternate + ); + assert_eq!( + parse_modifier_name("COMMAND").unwrap(), + CGEventFlags::CGEventFlagCommand + ); + assert_eq!( + parse_modifier_name("SHIFT").unwrap(), + CGEventFlags::CGEventFlagShift + ); + } + + #[test] + fn test_parse_modifier_name_error() { + assert!(parse_modifier_name("INVALID_MOD").is_err()); + } + + #[test] + fn test_check_modifiers_empty() { + let current = CGEventFlags::CGEventFlagShift; + let required = CGEventFlags::empty(); + assert!(check_modifiers(current, required)); + } + + #[test] + fn test_check_modifiers_match() { + let current = CGEventFlags::CGEventFlagShift | CGEventFlags::CGEventFlagControl; + let required = CGEventFlags::CGEventFlagShift; + assert!(check_modifiers(current, required)); + } + + #[test] + fn test_check_modifiers_no_match() { + let current = CGEventFlags::CGEventFlagShift; + let required = CGEventFlags::CGEventFlagControl; + assert!(!check_modifiers(current, required)); + } +} diff --git a/src/hotkey/mod.rs b/src/hotkey/mod.rs index 0420c40..7650f52 100644 --- a/src/hotkey/mod.rs +++ b/src/hotkey/mod.rs @@ -1,13 +1,19 @@ //! Hotkey detection module //! -//! Provides kernel-level key event detection using evdev. -//! This approach works on all Wayland compositors because it -//! operates at the Linux input subsystem level. +//! Provides cross-platform hotkey detection: +//! - Linux: Uses kernel-level evdev interface for key event detection +//! - macOS: Uses CGEventTap for global key event capture, or IOHIDManager for FN/Globe key //! -//! Requires the user to be in the 'input' group. +//! On Linux, the user must be in the 'input' group. +//! On macOS, Accessibility permissions must be granted in System Settings. +//! The FN/Globe key (default on macOS) requires an Apple keyboard. +#[cfg(target_os = "linux")] pub mod evdev_listener; +#[cfg(target_os = "macos")] +pub mod macos; + use crate::config::HotkeyConfig; use crate::error::HotkeyError; use tokio::sync::mpsc; @@ -37,7 +43,8 @@ pub trait HotkeyListener: Send + Sync { async fn stop(&mut self) -> Result<(), HotkeyError>; } -/// Factory function to create the appropriate hotkey listener +/// Factory function to create the appropriate hotkey listener for the current platform +#[cfg(target_os = "linux")] pub fn create_listener( config: &HotkeyConfig, secondary_model: Option, @@ -46,3 +53,51 @@ pub fn create_listener( listener.set_secondary_model(secondary_model); Ok(Box::new(listener)) } + +/// Factory function to create the appropriate hotkey listener for the current platform +#[cfg(target_os = "macos")] +pub fn create_listener( + config: &HotkeyConfig, + _secondary_model: Option, +) -> Result, HotkeyError> { + Ok(Box::new(macos::MacOSListener::new(config)?)) +} + +/// Factory function for unsupported platforms +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +pub fn create_listener( + _config: &HotkeyConfig, + _secondary_model: Option, +) -> Result, HotkeyError> { + Err(HotkeyError::DeviceAccess( + "Hotkey capture is only supported on Linux and macOS".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hotkey_event_equality() { + assert_eq!(HotkeyEvent::Pressed, HotkeyEvent::Pressed); + assert_eq!(HotkeyEvent::Released, HotkeyEvent::Released); + assert_eq!(HotkeyEvent::Cancel, HotkeyEvent::Cancel); + assert_ne!(HotkeyEvent::Pressed, HotkeyEvent::Released); + assert_ne!(HotkeyEvent::Pressed, HotkeyEvent::Cancel); + } + + #[test] + fn test_hotkey_event_debug() { + let pressed = HotkeyEvent::Pressed; + let debug_str = format!("{:?}", pressed); + assert!(debug_str.contains("Pressed")); + } + + #[test] + fn test_hotkey_event_clone() { + let event = HotkeyEvent::Pressed; + let cloned = event; + assert_eq!(event, cloned); + } +} diff --git a/src/main.rs b/src/main.rs index 8bc595c..2be14ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -184,6 +184,7 @@ async fn main() -> anyhow::Result<()> { Some(SetupAction::Check) => { setup::run_checks(&config).await?; } + #[cfg(target_os = "linux")] Some(SetupAction::Systemd { uninstall, status }) => { if status { setup::systemd::status().await?; @@ -193,6 +194,12 @@ async fn main() -> anyhow::Result<()> { setup::systemd::install().await?; } } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Systemd { .. }) => { + eprintln!("Error: systemd setup is only available on Linux."); + std::process::exit(1); + } + #[cfg(target_os = "linux")] Some(SetupAction::Waybar { json, css, @@ -211,6 +218,7 @@ async fn main() -> anyhow::Result<()> { setup::waybar::print_config(); } } + #[cfg(target_os = "linux")] Some(SetupAction::Dms { install, uninstall, @@ -226,6 +234,16 @@ async fn main() -> anyhow::Result<()> { setup::dms::print_config(); } } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Dms { .. }) => { + eprintln!("Error: DMS setup is only available on Linux."); + std::process::exit(1); + } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Waybar { .. }) => { + eprintln!("Error: Waybar setup is only available on Linux."); + std::process::exit(1); + } Some(SetupAction::Model { list, set, restart }) => { if list { setup::model::list_installed(); @@ -235,6 +253,7 @@ async fn main() -> anyhow::Result<()> { setup::model::interactive_select().await?; } } + #[cfg(target_os = "linux")] Some(SetupAction::Gpu { enable, disable, @@ -251,6 +270,7 @@ async fn main() -> anyhow::Result<()> { setup::gpu::show_status(); } } + #[cfg(target_os = "linux")] Some(SetupAction::Parakeet { enable, disable, status }) => { if status { setup::parakeet::show_status(); @@ -263,9 +283,26 @@ async fn main() -> anyhow::Result<()> { setup::parakeet::show_status(); } } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Parakeet { .. }) => { + eprintln!("Error: Parakeet backend management is only available on Linux."); + std::process::exit(1); + } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Gpu { .. }) => { + eprintln!("Error: GPU backend management is only available on Linux."); + eprintln!("On macOS, use --features gpu-metal when building."); + std::process::exit(1); + } + #[cfg(target_os = "linux")] Some(SetupAction::Compositor { compositor_type }) => { setup::compositor::run(&compositor_type).await?; } + #[cfg(not(target_os = "linux"))] + Some(SetupAction::Compositor { .. }) => { + eprintln!("Error: Compositor setup is only available on Linux."); + std::process::exit(1); + } None => { // Default: run setup (non-blocking) setup::run_setup(&config, download, model.as_deref(), quiet, no_post_install) @@ -296,6 +333,7 @@ async fn main() -> anyhow::Result<()> { } /// Send a record command to the running daemon via Unix signals or file triggers +#[cfg(target_os = "linux")] fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow::Result<()> { use nix::sys::signal::{kill, Signal}; use nix::unistd::Pid; @@ -426,6 +464,110 @@ fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow: Ok(()) } +/// Send a record command to the running daemon via Unix signals or file triggers (macOS version) +#[cfg(target_os = "macos")] +fn send_record_command(config: &config::Config, action: RecordAction) -> anyhow::Result<()> { + use voxtype::OutputModeOverride; + + // Read PID from the pid file + let pid_file = config::Config::runtime_dir().join("pid"); + + if !pid_file.exists() { + eprintln!("Error: Voxtype daemon is not running."); + eprintln!("Start it with: voxtype daemon"); + std::process::exit(1); + } + + let pid_str = std::fs::read_to_string(&pid_file) + .map_err(|e| anyhow::anyhow!("Failed to read PID file: {}", e))?; + + let pid: i32 = pid_str + .trim() + .parse() + .map_err(|e| anyhow::anyhow!("Invalid PID in file: {}", e))?; + + // Check if the process is actually running using libc::kill with signal 0 + let process_exists = unsafe { libc::kill(pid, 0) } == 0; + if !process_exists { + // Process doesn't exist, clean up stale PID file + let _ = std::fs::remove_file(&pid_file); + eprintln!("Error: Voxtype daemon is not running (stale PID file removed)."); + eprintln!("Start it with: voxtype daemon"); + std::process::exit(1); + } + + // Handle cancel separately (uses file trigger instead of signal) + if matches!(action, RecordAction::Cancel) { + let cancel_file = config::Config::runtime_dir().join("cancel"); + std::fs::write(&cancel_file, "cancel") + .map_err(|e| anyhow::anyhow!("Failed to write cancel file: {}", e))?; + return Ok(()); + } + + // Write output mode override file if specified + // For file mode, format is "file" or "file:/path/to/file" + if let Some(mode_override) = action.output_mode_override() { + let override_file = config::Config::runtime_dir().join("output_mode_override"); + let mode_str = match mode_override { + OutputModeOverride::Type => "type".to_string(), + OutputModeOverride::Clipboard => "clipboard".to_string(), + OutputModeOverride::Paste => "paste".to_string(), + OutputModeOverride::File => { + // Check if explicit path was provided with --file=path + match action.file_path() { + Some(path) if !path.is_empty() => format!("file:{}", path), + _ => "file".to_string(), + } + } + }; + std::fs::write(&override_file, mode_str) + .map_err(|e| anyhow::anyhow!("Failed to write output mode override: {}", e))?; + } + + // For toggle, we need to read current state to decide which signal to send + let signal = match &action { + RecordAction::Start { .. } => libc::SIGUSR1, + RecordAction::Stop { .. } => libc::SIGUSR2, + RecordAction::Toggle { .. } => { + // Read current state to determine action + let state_file = match config.resolve_state_file() { + Some(path) => path, + None => { + eprintln!("Error: Cannot toggle recording without state_file configured."); + eprintln!(); + eprintln!("Add to your config.toml:"); + eprintln!(" state_file = \"auto\""); + eprintln!(); + eprintln!("Or use explicit start/stop commands:"); + eprintln!(" voxtype record start"); + eprintln!(" voxtype record stop"); + std::process::exit(1); + } + }; + + let current_state = + std::fs::read_to_string(&state_file).unwrap_or_else(|_| "idle".to_string()); + + if current_state.trim() == "recording" { + libc::SIGUSR2 // Stop + } else { + libc::SIGUSR1 // Start + } + } + RecordAction::Cancel => unreachable!(), // Handled above + }; + + let result = unsafe { libc::kill(pid, signal) }; + if result != 0 { + return Err(anyhow::anyhow!( + "Failed to send signal to daemon: {}", + std::io::Error::last_os_error() + )); + } + + Ok(()) +} + /// Transcribe an audio file fn transcribe_file(config: &config::Config, path: &PathBuf) -> anyhow::Result<()> { use hound::WavReader; @@ -523,6 +665,7 @@ struct ExtendedStatusInfo { } impl ExtendedStatusInfo { + #[cfg(target_os = "linux")] fn from_config(config: &config::Config) -> Self { let backend = setup::gpu::detect_current_backend() .map(|b| match b { @@ -540,9 +683,25 @@ impl ExtendedStatusInfo { backend, } } + + #[cfg(target_os = "macos")] + fn from_config(config: &config::Config) -> Self { + // On macOS, determine backend from build features + #[cfg(feature = "gpu-metal")] + let backend = "GPU (Metal)".to_string(); + #[cfg(not(feature = "gpu-metal"))] + let backend = "CPU".to_string(); + + Self { + model: config.whisper.model.clone(), + device: config.audio.device.clone(), + backend, + } + } } -/// Check if the daemon is actually running by verifying the PID file +/// Check if the daemon is actually running by verifying the PID file (Linux version) +#[cfg(target_os = "linux")] fn is_daemon_running() -> bool { let pid_path = config::Config::runtime_dir().join("pid"); @@ -561,6 +720,26 @@ fn is_daemon_running() -> bool { std::path::Path::new(&format!("/proc/{}", pid)).exists() } +/// Check if the daemon is actually running by verifying the PID file (macOS version) +#[cfg(target_os = "macos")] +fn is_daemon_running() -> bool { + let pid_path = config::Config::runtime_dir().join("pid"); + + // Read PID from file + let pid_str = match std::fs::read_to_string(&pid_path) { + Ok(s) => s, + Err(_) => return false, // No PID file = not running + }; + + let pid: i32 = match pid_str.trim().parse() { + Ok(p) => p, + Err(_) => return false, // Invalid PID = not running + }; + + // Check if process exists using kill with signal 0 + unsafe { libc::kill(pid, 0) == 0 } +} + /// Run the status command - show current daemon state async fn run_status( config: &config::Config, @@ -616,7 +795,7 @@ async fn run_status( return Ok(()); } - // Follow mode: watch for changes using inotify + // Follow mode: watch for changes using notify crate use notify::{Config as NotifyConfig, RecommendedWatcher, RecursiveMode, Watcher}; use std::sync::mpsc::channel; use std::time::Duration; @@ -863,9 +1042,19 @@ async fn show_config(config: &config::Config) -> anyhow::Result<()> { } } - // Show output chain status - let output_status = setup::detect_output_chain().await; - setup::print_output_chain_status(&output_status); + // Show output chain status (Linux only) + #[cfg(target_os = "linux")] + { + let output_status = setup::detect_output_chain().await; + setup::print_output_chain_status(&output_status); + } + + #[cfg(target_os = "macos")] + { + println!("\nOutput Chain:"); + println!(" Platform: macOS"); + println!(" Note: Text output via external tools not yet implemented on macOS"); + } println!("\n---"); println!( diff --git a/src/output/cgevent.rs b/src/output/cgevent.rs new file mode 100644 index 0000000..f9ee745 --- /dev/null +++ b/src/output/cgevent.rs @@ -0,0 +1,550 @@ +//! CGEvent-based text output for macOS +//! +//! Uses the macOS CGEvent API to simulate keyboard input. This is the native +//! method for text injection on macOS. +//! +//! Requires: +//! - macOS 10.15 or later +//! - Accessibility permissions (System Preferences > Security & Privacy > Accessibility) +//! +//! The implementation supports: +//! - Standard US keyboard layout character-to-keycode mapping +//! - Unicode character support via CGEventKeyboardSetUnicodeString +//! - Configurable typing delays +//! - Auto-submit (Enter key) after output + +use super::TextOutput; +use crate::error::OutputError; +use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, CGKeyCode}; +use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; +use std::time::Duration; + +/// CGEvent-based text output for macOS +pub struct CGEventOutput { + /// Delay between keypresses in milliseconds + type_delay_ms: u32, + /// Delay before typing starts in milliseconds + pre_type_delay_ms: u32, + /// Whether to show a desktop notification + notify: bool, + /// Whether to send Enter key after output + auto_submit: bool, +} + +impl CGEventOutput { + /// Create a new CGEvent output + pub fn new( + type_delay_ms: u32, + pre_type_delay_ms: u32, + notify: bool, + auto_submit: bool, + ) -> Self { + Self { + type_delay_ms, + pre_type_delay_ms, + notify, + auto_submit, + } + } + + /// Check if we have Accessibility permissions + /// + /// On macOS, simulating keyboard events requires the app to be granted + /// Accessibility permissions in System Preferences. + fn check_accessibility_permission() -> bool { + // Link against ApplicationServices framework for AXIsProcessTrusted + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn AXIsProcessTrusted() -> bool; + } + unsafe { AXIsProcessTrusted() } + } + + /// Prompt the user to grant Accessibility permissions + /// + /// Logs instructions for granting permissions. + fn prompt_accessibility_permission() { + tracing::warn!( + "Accessibility permission required. Please grant permission in:\n\ + System Preferences > Security & Privacy > Privacy > Accessibility\n\ + Then restart the application." + ); + } + + /// Send a desktop notification using osascript + async fn send_notification(&self, text: &str) { + use std::process::Stdio; + use tokio::process::Command; + + // Truncate preview for notification + let preview: String = text.chars().take(100).collect(); + let preview = if text.chars().count() > 100 { + format!("{}...", preview) + } else { + preview + }; + + // Escape quotes for AppleScript + let escaped = preview.replace('\\', "\\\\").replace('"', "\\\""); + + let script = format!( + "display notification \"{}\" with title \"Voxtype\" subtitle \"Transcribed\"", + escaped + ); + + let _ = Command::new("osascript") + .args(["-e", &script]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .await; + } + + /// Type a single character using CGEvent + /// + /// For ASCII characters, uses keycode mapping with proper modifiers. + /// For Unicode characters, uses CGEventKeyboardSetUnicodeString. + fn type_character(source: &CGEventSource, ch: char) -> Result<(), OutputError> { + // Try to map to a keycode first (faster and more reliable for ASCII) + if let Some((keycode, shift_needed)) = char_to_keycode(ch) { + Self::press_key(source, keycode, shift_needed)?; + } else { + // Fall back to Unicode string injection for non-ASCII characters + Self::type_unicode_char(source, ch)?; + } + Ok(()) + } + + /// Press a key with optional shift modifier + fn press_key( + source: &CGEventSource, + keycode: CGKeyCode, + shift_needed: bool, + ) -> Result<(), OutputError> { + // Create key down event + let key_down = CGEvent::new_keyboard_event(source.clone(), keycode, true) + .map_err(|_| OutputError::InjectionFailed("Failed to create key down event".into()))?; + + // Create key up event + let key_up = CGEvent::new_keyboard_event(source.clone(), keycode, false) + .map_err(|_| OutputError::InjectionFailed("Failed to create key up event".into()))?; + + // Set flags: shift if needed, otherwise explicitly clear all modifiers + // This prevents interference from Caps Lock or stuck modifier keys + if shift_needed { + key_down.set_flags(CGEventFlags::CGEventFlagShift); + key_up.set_flags(CGEventFlags::CGEventFlagShift); + } else { + key_down.set_flags(CGEventFlags::CGEventFlagNull); + key_up.set_flags(CGEventFlags::CGEventFlagNull); + } + + // Post the events + key_down.post(CGEventTapLocation::HID); + key_up.post(CGEventTapLocation::HID); + + Ok(()) + } + + /// Type a Unicode character using CGEventKeyboardSetUnicodeString + fn type_unicode_char(source: &CGEventSource, ch: char) -> Result<(), OutputError> { + // Create a keyboard event (keycode 0 is placeholder, we override with Unicode) + let event = CGEvent::new_keyboard_event(source.clone(), 0, true) + .map_err(|_| OutputError::InjectionFailed("Failed to create Unicode event".into()))?; + + // Convert char to UTF-16 + let mut utf16_buf = [0u16; 2]; + let utf16 = ch.encode_utf16(&mut utf16_buf); + + // Set the Unicode string on the event + event.set_string_from_utf16_unchecked(utf16); + + // Post key down + event.post(CGEventTapLocation::HID); + + // Create and post key up + let event_up = CGEvent::new_keyboard_event(source.clone(), 0, false).map_err(|_| { + OutputError::InjectionFailed("Failed to create Unicode up event".into()) + })?; + event_up.set_string_from_utf16_unchecked(utf16); + event_up.post(CGEventTapLocation::HID); + + Ok(()) + } + + /// Press the Enter/Return key + fn press_enter(source: &CGEventSource) -> Result<(), OutputError> { + Self::press_key(source, KEYCODE_RETURN, false) + } + + /// Type text in a blocking manner (for use in spawn_blocking) + /// + /// This function handles all CGEvent operations synchronously, + /// including inter-keystroke delays using std::thread::sleep. + fn type_text_blocking( + text: &str, + type_delay_ms: u32, + auto_submit: bool, + ) -> Result<(), OutputError> { + // Create event source + let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState) + .map_err(|_| OutputError::InjectionFailed("Failed to create CGEventSource".into()))?; + + // Type each character + let delay = Duration::from_millis(type_delay_ms as u64); + for ch in text.chars() { + Self::type_character(&source, ch)?; + + // Add delay between keystrokes if configured + if type_delay_ms > 0 { + std::thread::sleep(delay); + } + } + + // Send Enter key if configured + if auto_submit { + // Small delay before Enter to ensure all text is processed + std::thread::sleep(Duration::from_millis(50)); + Self::press_enter(&source)?; + } + + Ok(()) + } +} + +#[async_trait::async_trait] +impl TextOutput for CGEventOutput { + async fn output(&self, text: &str) -> Result<(), OutputError> { + if text.is_empty() { + return Ok(()); + } + + // Check accessibility permissions first (can be done on main thread) + if !Self::check_accessibility_permission() { + Self::prompt_accessibility_permission(); + return Err(OutputError::InjectionFailed( + "Accessibility permission denied. Grant permission in System Preferences > \ + Security & Privacy > Privacy > Accessibility, then restart the application." + .into(), + )); + } + + // Pre-typing delay if configured + if self.pre_type_delay_ms > 0 { + tracing::debug!( + "cgevent: sleeping {}ms before typing", + self.pre_type_delay_ms + ); + tokio::time::sleep(Duration::from_millis(self.pre_type_delay_ms as u64)).await; + } + + tracing::debug!( + "cgevent: typing text: \"{}\"", + text.chars().take(20).collect::() + ); + + // CGEventSource is not Send, so we need to do all CGEvent work in a blocking task + let text_owned = text.to_string(); + let type_delay_ms = self.type_delay_ms; + let auto_submit = self.auto_submit; + + let result = tokio::task::spawn_blocking(move || { + Self::type_text_blocking(&text_owned, type_delay_ms, auto_submit) + }) + .await + .map_err(|e| OutputError::InjectionFailed(format!("Task join error: {}", e)))??; + + tracing::info!("Text typed via CGEvent ({} chars)", text.len()); + + // Send notification if enabled + if self.notify { + self.send_notification(text).await; + } + + Ok(result) + } + + async fn is_available(&self) -> bool { + // CGEvent is always available on macOS, but we check for accessibility permissions + // We return true here so the output method can provide a helpful error message + // if permissions are denied + true + } + + fn name(&self) -> &'static str { + "cgevent" + } +} + +// macOS virtual key codes for US keyboard layout +// Reference: /System/Library/Frameworks/Carbon.framework/Versions/A/Frameworks/HIToolbox.framework/Headers/Events.h + +const KEYCODE_A: CGKeyCode = 0x00; +const KEYCODE_S: CGKeyCode = 0x01; +const KEYCODE_D: CGKeyCode = 0x02; +const KEYCODE_F: CGKeyCode = 0x03; +const KEYCODE_H: CGKeyCode = 0x04; +const KEYCODE_G: CGKeyCode = 0x05; +const KEYCODE_Z: CGKeyCode = 0x06; +const KEYCODE_X: CGKeyCode = 0x07; +const KEYCODE_C: CGKeyCode = 0x08; +const KEYCODE_V: CGKeyCode = 0x09; +const KEYCODE_B: CGKeyCode = 0x0B; +const KEYCODE_Q: CGKeyCode = 0x0C; +const KEYCODE_W: CGKeyCode = 0x0D; +const KEYCODE_E: CGKeyCode = 0x0E; +const KEYCODE_R: CGKeyCode = 0x0F; +const KEYCODE_Y: CGKeyCode = 0x10; +const KEYCODE_T: CGKeyCode = 0x11; +const KEYCODE_1: CGKeyCode = 0x12; +const KEYCODE_2: CGKeyCode = 0x13; +const KEYCODE_3: CGKeyCode = 0x14; +const KEYCODE_4: CGKeyCode = 0x15; +const KEYCODE_6: CGKeyCode = 0x16; +const KEYCODE_5: CGKeyCode = 0x17; +const KEYCODE_EQUAL: CGKeyCode = 0x18; +const KEYCODE_9: CGKeyCode = 0x19; +const KEYCODE_7: CGKeyCode = 0x1A; +const KEYCODE_MINUS: CGKeyCode = 0x1B; +const KEYCODE_8: CGKeyCode = 0x1C; +const KEYCODE_0: CGKeyCode = 0x1D; +const KEYCODE_RIGHT_BRACKET: CGKeyCode = 0x1E; +const KEYCODE_O: CGKeyCode = 0x1F; +const KEYCODE_U: CGKeyCode = 0x20; +const KEYCODE_LEFT_BRACKET: CGKeyCode = 0x21; +const KEYCODE_I: CGKeyCode = 0x22; +const KEYCODE_P: CGKeyCode = 0x23; +const KEYCODE_RETURN: CGKeyCode = 0x24; +const KEYCODE_L: CGKeyCode = 0x25; +const KEYCODE_J: CGKeyCode = 0x26; +const KEYCODE_QUOTE: CGKeyCode = 0x27; +const KEYCODE_K: CGKeyCode = 0x28; +const KEYCODE_SEMICOLON: CGKeyCode = 0x29; +const KEYCODE_BACKSLASH: CGKeyCode = 0x2A; +const KEYCODE_COMMA: CGKeyCode = 0x2B; +const KEYCODE_SLASH: CGKeyCode = 0x2C; +const KEYCODE_N: CGKeyCode = 0x2D; +const KEYCODE_M: CGKeyCode = 0x2E; +const KEYCODE_PERIOD: CGKeyCode = 0x2F; +const KEYCODE_TAB: CGKeyCode = 0x30; +const KEYCODE_SPACE: CGKeyCode = 0x31; +const KEYCODE_GRAVE: CGKeyCode = 0x32; + +/// Map a character to a macOS virtual keycode and whether shift is needed +/// +/// Returns Some((keycode, shift_needed)) for ASCII characters that can be +/// typed with the US keyboard layout, None for characters that need Unicode input. +pub fn char_to_keycode(ch: char) -> Option<(CGKeyCode, bool)> { + match ch { + // Lowercase letters (no shift) + 'a' => Some((KEYCODE_A, false)), + 'b' => Some((KEYCODE_B, false)), + 'c' => Some((KEYCODE_C, false)), + 'd' => Some((KEYCODE_D, false)), + 'e' => Some((KEYCODE_E, false)), + 'f' => Some((KEYCODE_F, false)), + 'g' => Some((KEYCODE_G, false)), + 'h' => Some((KEYCODE_H, false)), + 'i' => Some((KEYCODE_I, false)), + 'j' => Some((KEYCODE_J, false)), + 'k' => Some((KEYCODE_K, false)), + 'l' => Some((KEYCODE_L, false)), + 'm' => Some((KEYCODE_M, false)), + 'n' => Some((KEYCODE_N, false)), + 'o' => Some((KEYCODE_O, false)), + 'p' => Some((KEYCODE_P, false)), + 'q' => Some((KEYCODE_Q, false)), + 'r' => Some((KEYCODE_R, false)), + 's' => Some((KEYCODE_S, false)), + 't' => Some((KEYCODE_T, false)), + 'u' => Some((KEYCODE_U, false)), + 'v' => Some((KEYCODE_V, false)), + 'w' => Some((KEYCODE_W, false)), + 'x' => Some((KEYCODE_X, false)), + 'y' => Some((KEYCODE_Y, false)), + 'z' => Some((KEYCODE_Z, false)), + + // Uppercase letters (shift) + 'A' => Some((KEYCODE_A, true)), + 'B' => Some((KEYCODE_B, true)), + 'C' => Some((KEYCODE_C, true)), + 'D' => Some((KEYCODE_D, true)), + 'E' => Some((KEYCODE_E, true)), + 'F' => Some((KEYCODE_F, true)), + 'G' => Some((KEYCODE_G, true)), + 'H' => Some((KEYCODE_H, true)), + 'I' => Some((KEYCODE_I, true)), + 'J' => Some((KEYCODE_J, true)), + 'K' => Some((KEYCODE_K, true)), + 'L' => Some((KEYCODE_L, true)), + 'M' => Some((KEYCODE_M, true)), + 'N' => Some((KEYCODE_N, true)), + 'O' => Some((KEYCODE_O, true)), + 'P' => Some((KEYCODE_P, true)), + 'Q' => Some((KEYCODE_Q, true)), + 'R' => Some((KEYCODE_R, true)), + 'S' => Some((KEYCODE_S, true)), + 'T' => Some((KEYCODE_T, true)), + 'U' => Some((KEYCODE_U, true)), + 'V' => Some((KEYCODE_V, true)), + 'W' => Some((KEYCODE_W, true)), + 'X' => Some((KEYCODE_X, true)), + 'Y' => Some((KEYCODE_Y, true)), + 'Z' => Some((KEYCODE_Z, true)), + + // Numbers (no shift) + '0' => Some((KEYCODE_0, false)), + '1' => Some((KEYCODE_1, false)), + '2' => Some((KEYCODE_2, false)), + '3' => Some((KEYCODE_3, false)), + '4' => Some((KEYCODE_4, false)), + '5' => Some((KEYCODE_5, false)), + '6' => Some((KEYCODE_6, false)), + '7' => Some((KEYCODE_7, false)), + '8' => Some((KEYCODE_8, false)), + '9' => Some((KEYCODE_9, false)), + + // Shifted number row symbols + '!' => Some((KEYCODE_1, true)), + '@' => Some((KEYCODE_2, true)), + '#' => Some((KEYCODE_3, true)), + '$' => Some((KEYCODE_4, true)), + '%' => Some((KEYCODE_5, true)), + '^' => Some((KEYCODE_6, true)), + '&' => Some((KEYCODE_7, true)), + '*' => Some((KEYCODE_8, true)), + '(' => Some((KEYCODE_9, true)), + ')' => Some((KEYCODE_0, true)), + + // Punctuation (no shift) + '-' => Some((KEYCODE_MINUS, false)), + '=' => Some((KEYCODE_EQUAL, false)), + '[' => Some((KEYCODE_LEFT_BRACKET, false)), + ']' => Some((KEYCODE_RIGHT_BRACKET, false)), + '\\' => Some((KEYCODE_BACKSLASH, false)), + ';' => Some((KEYCODE_SEMICOLON, false)), + '\'' => Some((KEYCODE_QUOTE, false)), + ',' => Some((KEYCODE_COMMA, false)), + '.' => Some((KEYCODE_PERIOD, false)), + '/' => Some((KEYCODE_SLASH, false)), + '`' => Some((KEYCODE_GRAVE, false)), + + // Punctuation (shift) + '_' => Some((KEYCODE_MINUS, true)), + '+' => Some((KEYCODE_EQUAL, true)), + '{' => Some((KEYCODE_LEFT_BRACKET, true)), + '}' => Some((KEYCODE_RIGHT_BRACKET, true)), + '|' => Some((KEYCODE_BACKSLASH, true)), + ':' => Some((KEYCODE_SEMICOLON, true)), + '"' => Some((KEYCODE_QUOTE, true)), + '<' => Some((KEYCODE_COMMA, true)), + '>' => Some((KEYCODE_PERIOD, true)), + '?' => Some((KEYCODE_SLASH, true)), + '~' => Some((KEYCODE_GRAVE, true)), + + // Whitespace + ' ' => Some((KEYCODE_SPACE, false)), + '\t' => Some((KEYCODE_TAB, false)), + '\n' => Some((KEYCODE_RETURN, false)), + + // All other characters need Unicode input + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new() { + let output = CGEventOutput::new(10, 0, true, false); + assert_eq!(output.type_delay_ms, 10); + assert_eq!(output.pre_type_delay_ms, 0); + assert!(output.notify); + assert!(!output.auto_submit); + } + + #[test] + fn test_new_with_auto_submit() { + let output = CGEventOutput::new(0, 0, false, true); + assert_eq!(output.type_delay_ms, 0); + assert!(!output.notify); + assert!(output.auto_submit); + } + + #[test] + fn test_new_with_pre_type_delay() { + let output = CGEventOutput::new(0, 200, false, false); + assert_eq!(output.type_delay_ms, 0); + assert_eq!(output.pre_type_delay_ms, 200); + } + + #[test] + fn test_char_to_keycode_lowercase() { + assert_eq!(char_to_keycode('a'), Some((KEYCODE_A, false))); + assert_eq!(char_to_keycode('z'), Some((KEYCODE_Z, false))); + assert_eq!(char_to_keycode('m'), Some((KEYCODE_M, false))); + } + + #[test] + fn test_char_to_keycode_uppercase() { + assert_eq!(char_to_keycode('A'), Some((KEYCODE_A, true))); + assert_eq!(char_to_keycode('Z'), Some((KEYCODE_Z, true))); + assert_eq!(char_to_keycode('M'), Some((KEYCODE_M, true))); + } + + #[test] + fn test_char_to_keycode_numbers() { + assert_eq!(char_to_keycode('0'), Some((KEYCODE_0, false))); + assert_eq!(char_to_keycode('1'), Some((KEYCODE_1, false))); + assert_eq!(char_to_keycode('9'), Some((KEYCODE_9, false))); + } + + #[test] + fn test_char_to_keycode_shifted_numbers() { + assert_eq!(char_to_keycode('!'), Some((KEYCODE_1, true))); + assert_eq!(char_to_keycode('@'), Some((KEYCODE_2, true))); + assert_eq!(char_to_keycode('#'), Some((KEYCODE_3, true))); + assert_eq!(char_to_keycode('$'), Some((KEYCODE_4, true))); + assert_eq!(char_to_keycode('%'), Some((KEYCODE_5, true))); + assert_eq!(char_to_keycode('^'), Some((KEYCODE_6, true))); + assert_eq!(char_to_keycode('&'), Some((KEYCODE_7, true))); + assert_eq!(char_to_keycode('*'), Some((KEYCODE_8, true))); + assert_eq!(char_to_keycode('('), Some((KEYCODE_9, true))); + assert_eq!(char_to_keycode(')'), Some((KEYCODE_0, true))); + } + + #[test] + fn test_char_to_keycode_punctuation() { + assert_eq!(char_to_keycode('.'), Some((KEYCODE_PERIOD, false))); + assert_eq!(char_to_keycode(','), Some((KEYCODE_COMMA, false))); + assert_eq!(char_to_keycode(';'), Some((KEYCODE_SEMICOLON, false))); + assert_eq!(char_to_keycode(':'), Some((KEYCODE_SEMICOLON, true))); + assert_eq!(char_to_keycode('\''), Some((KEYCODE_QUOTE, false))); + assert_eq!(char_to_keycode('"'), Some((KEYCODE_QUOTE, true))); + } + + #[test] + fn test_char_to_keycode_whitespace() { + assert_eq!(char_to_keycode(' '), Some((KEYCODE_SPACE, false))); + assert_eq!(char_to_keycode('\t'), Some((KEYCODE_TAB, false))); + assert_eq!(char_to_keycode('\n'), Some((KEYCODE_RETURN, false))); + } + + #[test] + fn test_char_to_keycode_unicode() { + // Unicode characters should return None (need Unicode input) + assert_eq!(char_to_keycode('\u{00E9}'), None); // e with acute accent + assert_eq!(char_to_keycode('\u{00F1}'), None); // n with tilde + assert_eq!(char_to_keycode('\u{4E2D}'), None); // Chinese character + } + + #[test] + fn test_char_to_keycode_brackets() { + assert_eq!(char_to_keycode('['), Some((KEYCODE_LEFT_BRACKET, false))); + assert_eq!(char_to_keycode(']'), Some((KEYCODE_RIGHT_BRACKET, false))); + assert_eq!(char_to_keycode('{'), Some((KEYCODE_LEFT_BRACKET, true))); + assert_eq!(char_to_keycode('}'), Some((KEYCODE_RIGHT_BRACKET, true))); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index e1a4402..f815119 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -2,14 +2,18 @@ //! //! Provides text output via keyboard simulation or clipboard. //! -//! Fallback chain for `mode = "type"`: +//! Fallback chain for `mode = "type"` on Linux: //! 1. wtype - Wayland-native, best Unicode/CJK support, no daemon needed //! 2. dotool - Works on X11/Wayland/TTY, supports keyboard layouts, no daemon needed //! 3. ydotool - Works on X11/Wayland/TTY, requires daemon //! 4. clipboard (wl-copy) - Wayland clipboard fallback //! 5. xclip - X11 clipboard fallback //! -//! Paste mode (clipboard + Ctrl+V) helps with system with non US keyboard layouts. +//! On macOS: +//! 1. cgevent - Native CGEvent API for keyboard simulation +//! 2. clipboard - Fallback (requires pbcopy/pbpaste) +//! +//! Paste mode (clipboard + Ctrl+V) helps with systems with non US keyboard layouts. pub mod clipboard; pub mod dotool; @@ -19,6 +23,9 @@ pub mod wtype; pub mod xclip; pub mod ydotool; +#[cfg(target_os = "macos")] +pub mod cgevent; + use crate::config::{OutputConfig, OutputDriver}; use crate::error::OutputError; use std::borrow::Cow; @@ -201,45 +208,66 @@ pub fn create_output_chain_with_override( match config.mode { crate::config::OutputMode::Type => { - // Determine driver order: CLI override > config > default - let driver_order: &[OutputDriver] = driver_override - .or(config.driver_order.as_deref()) - .unwrap_or(DEFAULT_DRIVER_ORDER); - - if let Some(custom_order) = driver_override.or(config.driver_order.as_deref()) { - tracing::info!( - "Using custom driver order: {}", - custom_order - .iter() - .map(|d| d.to_string()) - .collect::>() - .join(" -> ") - ); - } + // Platform-specific output methods + #[cfg(target_os = "macos")] + { + // macOS: CGEvent is the primary (and only) method + chain.push(Box::new(cgevent::CGEventOutput::new( + config.type_delay_ms, + pre_type_delay_ms, + config.notification.on_transcription, + config.auto_submit, + ))); - // Build chain based on driver order - for (i, driver) in driver_order.iter().enumerate() { - // Skip clipboard if it's in the middle and fallback_to_clipboard is false - // (clipboard should only be added if explicitly in the order OR fallback is enabled and it's last) - let is_last = i == driver_order.len() - 1; - if *driver == OutputDriver::Clipboard && !is_last && !config.fallback_to_clipboard { - continue; + // Clipboard fallback on macOS + if config.fallback_to_clipboard { + chain.push(Box::new(clipboard::ClipboardOutput::new(false))); } - - chain.push(create_driver_output( - *driver, - config, - pre_type_delay_ms, - i == 0, - )); } - // If fallback_to_clipboard is true but clipboard wasn't in the custom order, add it - if config.fallback_to_clipboard - && config.driver_order.is_some() - && !driver_order.contains(&OutputDriver::Clipboard) + #[cfg(not(target_os = "macos"))] { - chain.push(Box::new(clipboard::ClipboardOutput::new(false))); + // Determine driver order: CLI override > config > default + let driver_order: &[OutputDriver] = driver_override + .or(config.driver_order.as_deref()) + .unwrap_or(DEFAULT_DRIVER_ORDER); + + if let Some(custom_order) = driver_override.or(config.driver_order.as_deref()) { + tracing::info!( + "Using custom driver order: {}", + custom_order + .iter() + .map(|d| d.to_string()) + .collect::>() + .join(" -> ") + ); + } + + // Build chain based on driver order + for (i, driver) in driver_order.iter().enumerate() { + // Skip clipboard if it's in the middle and fallback_to_clipboard is false + // (clipboard should only be added if explicitly in the order OR fallback is enabled and it's last) + let is_last = i == driver_order.len() - 1; + if *driver == OutputDriver::Clipboard && !is_last && !config.fallback_to_clipboard + { + continue; + } + + chain.push(create_driver_output( + *driver, + config, + pre_type_delay_ms, + i == 0, + )); + } + + // If fallback_to_clipboard is true but clipboard wasn't in the custom order, add it + if config.fallback_to_clipboard + && config.driver_order.is_some() + && !driver_order.contains(&OutputDriver::Clipboard) + { + chain.push(Box::new(clipboard::ClipboardOutput::new(false))); + } } } crate::config::OutputMode::Clipboard => { diff --git a/src/output/post_process.rs b/src/output/post_process.rs index 195d57e..6dbd853 100644 --- a/src/output/post_process.rs +++ b/src/output/post_process.rs @@ -198,8 +198,8 @@ mod tests { #[tokio::test] async fn test_empty_output_fallback() { - // echo -n outputs nothing, which should trigger fallback - let config = make_config("echo -n ''", 5000); + // printf with empty string outputs nothing, which should trigger fallback + let config = make_config("printf ''", 5000); let processor = PostProcessor::new(&config); let result = processor.process("original text").await; assert_eq!(result, "original text"); // Falls back to original diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 4c21c04..0f5d945 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -1,20 +1,27 @@ //! Setup module for voxtype installation and configuration //! //! Provides subcommands for: -//! - systemd service installation -//! - Waybar configuration generation +//! - systemd service installation (Linux only) +//! - Waybar configuration generation (Linux only) +//! - DMS configuration (Linux only) //! - Interactive model selection //! - Output chain detection -//! - GPU backend management -//! - Parakeet backend management -//! - Compositor integration (modifier key fix) +//! - GPU backend management (Linux only) +//! - Parakeet backend management (Linux only) +//! - Compositor integration (modifier key fix, Linux only) +#[cfg(target_os = "linux")] pub mod compositor; +#[cfg(target_os = "linux")] pub mod dms; +#[cfg(target_os = "linux")] pub mod gpu; pub mod model; +#[cfg(target_os = "linux")] pub mod parakeet; +#[cfg(target_os = "linux")] pub mod systemd; +#[cfg(target_os = "linux")] pub mod waybar; use crate::config::Config; @@ -61,7 +68,8 @@ pub struct OutputChainStatus { pub primary_method: Option, } -/// Check if user is in a specific group +/// Check if user is in a specific group (Unix-specific) +#[cfg(unix)] pub fn user_in_group(group: &str) -> bool { std::process::Command::new("groups") .output() @@ -649,37 +657,50 @@ pub async fn run_checks(config: &Config) -> anyhow::Result<()> { } } - // Check input group - println!("\nInput:"); - if user_in_group("input") { - print_success("User is in 'input' group (evdev hotkeys available)"); - } else { - print_warning("User is not in 'input' group (evdev hotkeys unavailable)"); - println!(" Required only for evdev hotkey mode, not compositor keybindings"); - println!(" To enable: sudo usermod -aG input $USER && logout"); + // Check input group (Linux-specific evdev support) + #[cfg(target_os = "linux")] + { + println!("\nInput:"); + if user_in_group("input") { + print_success("User is in 'input' group (evdev hotkeys available)"); + } else { + print_warning("User is not in 'input' group (evdev hotkeys unavailable)"); + println!(" Required only for evdev hotkey mode, not compositor keybindings"); + println!(" To enable: sudo usermod -aG input $USER && logout"); + } } - // Check output chain - let output_status = detect_output_chain().await; - print_output_chain_status(&output_status); + // Check output chain (Linux tools) + #[cfg(target_os = "linux")] + { + let output_status = detect_output_chain().await; + print_output_chain_status(&output_status); - if output_status.primary_method.is_none() { - print_failure("No text output method available"); - if output_status.display_server == DisplayServer::Wayland { - println!(" Install wtype: sudo pacman -S wtype"); - } else { - println!(" Install ydotool: sudo pacman -S ydotool"); - } - all_ok = false; - } else if output_status.primary_method.as_deref() == Some("clipboard") { - print_warning("Only clipboard mode available - typing won't work"); - if output_status.display_server == DisplayServer::Wayland { - println!(" Install wtype: sudo pacman -S wtype"); - } else { - println!(" Install ydotool: sudo pacman -S ydotool"); + if output_status.primary_method.is_none() { + print_failure("No text output method available"); + if output_status.display_server == DisplayServer::Wayland { + println!(" Install wtype: sudo pacman -S wtype"); + } else { + println!(" Install ydotool: sudo pacman -S ydotool"); + } + all_ok = false; + } else if output_status.primary_method.as_deref() == Some("clipboard") { + print_warning("Only clipboard mode available - typing won't work"); + if output_status.display_server == DisplayServer::Wayland { + println!(" Install wtype: sudo pacman -S wtype"); + } else { + println!(" Install ydotool: sudo pacman -S ydotool"); + } } } + #[cfg(target_os = "macos")] + { + println!("\nPlatform:"); + print_info("Running on macOS - text output via external tools not yet implemented"); + print_info("Use 'voxtype record' commands with external keybindings"); + } + // Check whisper model println!("\nWhisper Model:"); let model_name = &config.whisper.model; diff --git a/src/transcribe/whisper.rs b/src/transcribe/whisper.rs index abad99d..715a723 100644 --- a/src/transcribe/whisper.rs +++ b/src/transcribe/whisper.rs @@ -188,6 +188,10 @@ impl Transcriber for WhisperTranscriber { tracing::debug!("Using initial prompt: {:?}", prompt); } + // Prevent hallucination/looping by not conditioning on previous text + // This is especially important for short clips where Whisper can repeat itself + params.set_no_context(true); + // For short recordings, use single segment mode if duration_secs < 30.0 { params.set_single_segment(true); @@ -300,14 +304,20 @@ fn resolve_model_path(model: &str) -> Result { } /// Calculate audio_ctx parameter for short clips (≤22.5s). -/// Formula: duration_seconds * 50 + 64 +/// Formula: duration_seconds * 50 + 64, rounded up to next multiple of 8 /// /// This optimization reduces transcription time for short recordings by /// telling Whisper to use a smaller context window proportional to the /// actual audio length, rather than the full 30-second batch window. +/// +/// The result is aligned to 8 to satisfy Metal backend alignment requirements +/// (GGML requires nb01 % 8 == 0 for Metal operations). fn calculate_audio_ctx(duration_secs: f32) -> Option { if duration_secs <= 22.5 { - Some((duration_secs * 50.0) as i32 + 64) + let raw_ctx = (duration_secs * 50.0) as i32 + 64; + // Round up to next multiple of 8 for Metal backend alignment + let aligned_ctx = (raw_ctx + 7) / 8 * 8; + Some(aligned_ctx) } else { None } @@ -354,17 +364,23 @@ mod tests { #[test] fn test_calculate_audio_ctx_short_clips() { - // Very short clip: 1s -> 1 * 50 + 64 = 114 - assert_eq!(calculate_audio_ctx(1.0), Some(114)); + // Very short clip: 1s -> (1 * 50 + 64 = 114) rounded up to 120 + assert_eq!(calculate_audio_ctx(1.0), Some(120)); - // 5 second clip: 5 * 50 + 64 = 314 - assert_eq!(calculate_audio_ctx(5.0), Some(314)); + // 5 second clip: (5 * 50 + 64 = 314) rounded up to 320 + assert_eq!(calculate_audio_ctx(5.0), Some(320)); - // 10 second clip: 10 * 50 + 64 = 564 - assert_eq!(calculate_audio_ctx(10.0), Some(564)); + // 10 second clip: (10 * 50 + 64 = 564) rounded up to 568 + assert_eq!(calculate_audio_ctx(10.0), Some(568)); - // At threshold: 22.5 * 50 + 64 = 1189 - assert_eq!(calculate_audio_ctx(22.5), Some(1189)); + // At threshold: (22.5 * 50 + 64 = 1189) rounded up to 1192 + assert_eq!(calculate_audio_ctx(22.5), Some(1192)); + + // Verify all results are aligned to 8 (for Metal backend) + for duration in [1.0, 2.0, 3.0, 4.0, 5.0, 10.0, 15.0, 20.0, 22.5] { + let ctx = calculate_audio_ctx(duration).unwrap(); + assert_eq!(ctx % 8, 0, "audio_ctx {} not aligned to 8 for {}s", ctx, duration); + } } #[test] @@ -386,14 +402,14 @@ mod tests { // (the full 30-second context window). // // This test verifies the optimization logic by demonstrating: - // 1. When enabled: short clips get optimized audio_ctx (e.g., 114 for 1s) + // 1. When enabled: short clips get optimized audio_ctx (e.g., 120 for 1s) // 2. When disabled: Whisper's default 1500 is used (not set explicitly) const WHISPER_DEFAULT_AUDIO_CTX: i32 = 1500; - // With optimization enabled, 1s clip would use audio_ctx=114 + // With optimization enabled, 1s clip would use audio_ctx=120 (aligned to 8) let optimized_ctx = calculate_audio_ctx(1.0); - assert_eq!(optimized_ctx, Some(114)); + assert_eq!(optimized_ctx, Some(120)); assert!(optimized_ctx.unwrap() < WHISPER_DEFAULT_AUDIO_CTX); // With optimization disabled, we don't call calculate_audio_ctx,