hydra

Terminal replacement for Loopback — virtual audio devices and routing on macOS, from a ratatui TUI.
Log | Files | Refs | README | LICENSE

commit 95cb7f1878f3ea08e22d39f18fb2989ca4ecc1e4
Author: Matthew Gantenbein <ganten1998@gmail.com>
Date:   Sun, 31 May 2026 10:50:26 -0500

P0: workspace skeleton — daemon/TUI handshake over Unix socket

Four-crate workspace (hydra-ipc / hydra-core / hydrad / hydra) replacing
Loopback, structured like valentine/ivory-rust. NDJSON wire protocol with
zero macOS deps keeps the ratatui TUI off CoreAudio. Daemon binds the control
socket and answers Ping/GetState; TUI renders the Navi-themed shell with live
connection status. Theme is a swappable TOML (Navi default).

7/7 tests green; builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
A.gitignore | 3+++
ACargo.lock | 901+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
ACargo.toml | 14++++++++++++++
AREADME.md | 41+++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-core/Cargo.toml | 15+++++++++++++++
Acrates/hydra-core/src/lib.rs | 10++++++++++
Acrates/hydra-core/src/model.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra-ipc/Cargo.toml | 10++++++++++
Acrates/hydra-ipc/src/lib.rs | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra/Cargo.toml | 19+++++++++++++++++++
Acrates/hydra/src/app.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra/src/client.rs | 28++++++++++++++++++++++++++++
Acrates/hydra/src/main.rs | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra/src/theme.rs | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydra/src/ui.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydrad/Cargo.toml | 14++++++++++++++
Acrates/hydrad/src/main.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Acrates/hydrad/src/server.rs | 31+++++++++++++++++++++++++++++++
Athemes/navi.toml | 17+++++++++++++++++
19 files changed, 1757 insertions(+), 0 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -0,0 +1,3 @@ +/target +**/*.rs.bk +.DS_Store diff --git a/Cargo.lock b/Cargo.lock @@ -0,0 +1,901 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hydra" +version = "0.1.0" +dependencies = [ + "crossterm", + "dirs", + "hydra-ipc", + "ratatui", + "serde", + "serde_json", + "toml", +] + +[[package]] +name = "hydra-core" +version = "0.1.0" +dependencies = [ + "dirs", + "hydra-ipc", + "serde", + "serde_json", +] + +[[package]] +name = "hydra-ipc" +version = "0.1.0" +dependencies = [ + "dirs", + "serde", + "serde_json", +] + +[[package]] +name = "hydrad" +version = "0.1.0" +dependencies = [ + "hydra-core", + "hydra-ipc", + "serde_json", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = ["crates/hydra-ipc", "crates/hydra-core", "crates/hydrad", "crates/hydra"] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "GPL-3.0-or-later" +authors = ["Ganten"] + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +dirs = "5" diff --git a/README.md b/README.md @@ -0,0 +1,41 @@ +# Hydra + +A terminal-native, functional replacement for [Loopback](https://rogueamoeba.com/loopback/) — +create virtual audio devices, capture per-app audio, combine inputs, monitor, and map +channels, all from a `ratatui` TUI. Navy/cyan/amber [Navi](https://github.com/ganten7/navi-palette) +theme, swappable. + +> macOS only. Per-app capture needs **macOS 14.4+** (Core Audio process taps); the virtual +> driver is a fork of [BlackHole](https://github.com/ExistentialAudio/BlackHole) (GPL-3.0). + +## Architecture + +| Piece | Crate / dir | Role | +|-------|-------------|------| +| Daemon | `crates/hydrad` | Owns **all** CoreAudio state; persists routing; the only process that links the engine. | +| Engine | `crates/hydra-core` | Routing model, config, CoreAudio FFI (taps / aggregates / IOProcs). | +| Wire protocol | `crates/hydra-ipc` | Shared NDJSON types over a Unix socket. **No macOS deps** — keeps the TUI off CoreAudio. | +| TUI / query | `crates/hydra` | `ratatui` client + (P5) `hydra query --sketchybar`. | +| Driver | `driver/` *(P2)* | Forked BlackHole publishing dynamic devices from a JSON manifest. | + +The daemon runs as a per-user **LaunchAgent** (GUI session — required for tap TCC consent). +Clients talk to it over `~/Library/Application Support/hydra/hydrad.sock`. + +## Status + +**Phase 0 — skeleton.** Workspace builds; daemon answers `Ping`/`GetState`; TUI renders the +Navi-themed shell and shows live connection status. CoreAudio engine + driver land in P1–P3. + +## Run it + +```sh +cargo run -p hydrad # terminal 1: start the daemon +cargo run -p hydra # terminal 2: the TUI (press q to quit, r to refresh) +cargo test # unit tests (model + wire-format round-trips + theme parsing) +``` + +Swap the theme: copy `themes/navi.toml` to `~/Library/Application Support/hydra/theme.toml`. + +## License + +GPL-3.0-or-later (the BlackHole-derived driver makes this obligatory for the whole work). diff --git a/crates/hydra-core/Cargo.toml b/crates/hydra-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "hydra-core" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +hydra-ipc = { path = "../hydra-ipc" } +serde.workspace = true +serde_json.workspace = true +dirs.workspace = true + +# CoreAudio FFI (process taps, aggregate devices, IOProcs) lands in P1. The engine +# modules will be gated behind #[cfg(target_os = "macos")] so the pure model/config +# layer stays portable and unit-testable. diff --git a/crates/hydra-core/src/lib.rs b/crates/hydra-core/src/lib.rs @@ -0,0 +1,10 @@ +//! Hydra's routing core: the domain model, config persistence, and (from P1 onward) +//! the CoreAudio engine. Only the daemon (`hydrad`) links this crate; the TUI talks to +//! the daemon over [`hydra_ipc`] and never touches CoreAudio. + +pub mod model; + +pub use model::RoutingState; + +/// Daemon/engine version, surfaced to clients in snapshots. +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/crates/hydra-core/src/model.rs b/crates/hydra-core/src/model.rs @@ -0,0 +1,80 @@ +//! The routing domain model. This is the authoritative in-memory state the daemon owns; +//! [`RoutingState::snapshot`] projects it into the wire types in [`hydra_ipc`]. +//! +//! P0 keeps this intentionally thin — sources, channel maps, monitors, and the live +//! `MixSnapshot` arrive with the CoreAudio engine in P1–P3. + +use hydra_ipc::{DeviceSummary, RouteSummary, StateSnapshot}; + +/// A virtual audio device Hydra publishes via the forked HAL driver. +#[derive(Debug, Clone)] +pub struct VirtualDevice { + pub uid: String, + pub name: String, + pub channels: u32, +} + +/// A routing definition: sources mixed onto a target virtual device's channels. +#[derive(Debug, Clone)] +pub struct Route { + pub id: String, + pub target_device_uid: String, + /// Whether the route's CoreAudio objects are currently live. + pub active: bool, +} + +/// The daemon's complete routing state. +#[derive(Debug, Default)] +pub struct RoutingState { + pub devices: Vec<VirtualDevice>, + pub routes: Vec<Route>, +} + +impl RoutingState { + /// Project into a wire-safe snapshot for clients. + pub fn snapshot(&self) -> StateSnapshot { + StateSnapshot { + daemon_version: crate::VERSION.to_string(), + devices: self + .devices + .iter() + .map(|d| DeviceSummary { uid: d.uid.clone(), name: d.name.clone(), channels: d.channels }) + .collect(), + routes: self + .routes + .iter() + .map(|r| RouteSummary { + id: r.id.clone(), + target: r.target_device_uid.clone(), + source_count: 0, + active: r.active, + }) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_snapshot_carries_version() { + let snap = RoutingState::default().snapshot(); + assert_eq!(snap.daemon_version, crate::VERSION); + assert!(snap.devices.is_empty()); + assert!(snap.routes.is_empty()); + } + + #[test] + fn snapshot_projects_devices_and_routes() { + let state = RoutingState { + devices: vec![VirtualDevice { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }], + routes: vec![Route { id: "r1".into(), target_device_uid: "hydra:main".into(), active: true }], + }; + let snap = state.snapshot(); + assert_eq!(snap.devices[0].channels, 16); + assert_eq!(snap.routes[0].target, "hydra:main"); + assert!(snap.routes[0].active); + } +} diff --git a/crates/hydra-ipc/Cargo.toml b/crates/hydra-ipc/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "hydra-ipc" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +dirs.workspace = true diff --git a/crates/hydra-ipc/src/lib.rs b/crates/hydra-ipc/src/lib.rs @@ -0,0 +1,152 @@ +//! Shared wire protocol for Hydra: the only crate both the daemon (`hydrad`) and the +//! TUI/query binary (`hydra`) depend on. Deliberately has **zero** macOS / CoreAudio +//! dependencies so the TUI can never accidentally link the audio engine. +//! +//! Transport is newline-delimited JSON (NDJSON) over a Unix-domain socket: one serde +//! value per line, hand-debuggable with `nc`. + +use serde::{Deserialize, Serialize}; +use std::io::{self, BufRead, Write}; +use std::path::PathBuf; + +/// Bumped when the wire format changes incompatibly. +pub const PROTOCOL_VERSION: u32 = 1; + +/// `~/Library/Application Support/hydra` on macOS. The daemon keeps its socket, config, +/// and theme here. Falls back to `/tmp/hydra` if the data dir can't be resolved. +pub fn runtime_dir() -> PathBuf { + let mut p = dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")); + p.push("hydra"); + p +} + +/// Path to the daemon's control socket. +pub fn socket_path() -> PathBuf { + let mut p = runtime_dir(); + p.push("hydrad.sock"); + p +} + +// ── Request / response ──────────────────────────────────────────────────────── + +/// A request from a client (TUI or `hydra query`) to the daemon. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Command { + /// Liveness check. + Ping, + /// Fetch the current routing snapshot. + GetState, + /// Opt this connection into the server-push event stream (state deltas, meters). + Subscribe, + /// Ask the daemon to exit. + Shutdown, +} + +/// The daemon's reply to a [`Command`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Response { + Pong { version: u32 }, + State(StateSnapshot), + Ok, + Error(String), +} + +/// Server-initiated push messages (only sent after [`Command::Subscribe`]). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Event { + StateChanged(StateSnapshot), +} + +// ── Snapshot types ──────────────────────────────────────────────────────────── + +/// A read-only view of the daemon's routing state, safe to render or print. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct StateSnapshot { + pub daemon_version: String, + pub devices: Vec<DeviceSummary>, + pub routes: Vec<RouteSummary>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceSummary { + pub uid: String, + pub name: String, + pub channels: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RouteSummary { + pub id: String, + pub target: String, + pub source_count: usize, + pub active: bool, +} + +// ── NDJSON framing ──────────────────────────────────────────────────────────── + +fn invalid_data<E: std::fmt::Display>(e: E) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, e.to_string()) +} + +/// Serialize `msg` as a single JSON line and flush it to `w`. +pub fn write_msg<W: Write, T: Serialize>(w: &mut W, msg: &T) -> io::Result<()> { + let mut line = serde_json::to_vec(msg).map_err(invalid_data)?; + line.push(b'\n'); + w.write_all(&line)?; + w.flush() +} + +/// Read one JSON line from `r`. Returns `Ok(None)` on clean EOF. +pub fn read_msg<R: BufRead, T: for<'de> Deserialize<'de>>(r: &mut R) -> io::Result<Option<T>> { + let mut line = String::new(); + if r.read_line(&mut line)? == 0 { + return Ok(None); + } + let trimmed = line.trim_end(); + if trimmed.is_empty() { + return Ok(None); + } + serde_json::from_str(trimmed).map(Some).map_err(invalid_data) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::BufReader; + + #[test] + fn command_round_trips() { + let mut buf = Vec::new(); + write_msg(&mut buf, &Command::Ping).unwrap(); + let mut r = BufReader::new(&buf[..]); + let got: Option<Command> = read_msg(&mut r).unwrap(); + assert!(matches!(got, Some(Command::Ping))); + } + + #[test] + fn snapshot_round_trips() { + let snap = StateSnapshot { + daemon_version: "0.1.0".into(), + devices: vec![DeviceSummary { uid: "hydra:main".into(), name: "Main".into(), channels: 16 }], + routes: vec![RouteSummary { id: "r1".into(), target: "hydra:main".into(), source_count: 2, active: true }], + }; + let mut buf = Vec::new(); + write_msg(&mut buf, &Response::State(snap)).unwrap(); + let mut r = BufReader::new(&buf[..]); + let got: Option<Response> = read_msg(&mut r).unwrap(); + match got { + Some(Response::State(s)) => { + assert_eq!(s.devices.len(), 1); + assert_eq!(s.routes[0].source_count, 2); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn eof_returns_none() { + let mut r = BufReader::new(&b""[..]); + let got: Option<Command> = read_msg(&mut r).unwrap(); + assert!(got.is_none()); + } +} diff --git a/crates/hydra/Cargo.toml b/crates/hydra/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "hydra" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "hydra" +path = "src/main.rs" + +[dependencies] +# The TUI depends on hydra-ipc ONLY — never hydra-core — so it can't link CoreAudio. +hydra-ipc = { path = "../hydra-ipc" } +ratatui = "0.29" +crossterm = "0.28" +serde.workspace = true +serde_json.workspace = true +dirs.workspace = true +toml = "0.8" diff --git a/crates/hydra/src/app.rs b/crates/hydra/src/app.rs @@ -0,0 +1,66 @@ +//! TUI application state and the actions that mutate it. Rendering lives in `ui.rs`. + +use hydra_ipc::{Command, Response, StateSnapshot}; + +use crate::client::Client; + +/// Whether we're currently talking to the daemon. +#[derive(Debug, Clone)] +pub enum Connection { + Connected { protocol: u32 }, + Disconnected { reason: String }, +} + +pub struct App { + pub connection: Connection, + pub snapshot: StateSnapshot, + pub status: String, + pub should_quit: bool, +} + +impl App { + pub fn new() -> Self { + let mut app = App { + connection: Connection::Disconnected { reason: "not yet connected".into() }, + snapshot: StateSnapshot::default(), + status: String::new(), + should_quit: false, + }; + app.refresh(); + app + } + + /// (Re)connect to the daemon, ping it, and pull the latest snapshot. + pub fn refresh(&mut self) { + match Client::connect() { + Ok(client) => match client.request(Command::Ping) { + Ok(Response::Pong { version }) => { + self.connection = Connection::Connected { protocol: version }; + self.pull_state(&client); + self.status = "connected".into(); + } + Ok(other) => { + self.connection = Connection::Disconnected { reason: format!("unexpected reply: {other:?}") }; + } + Err(e) => { + self.connection = Connection::Disconnected { reason: e.to_string() }; + } + }, + Err(e) => { + self.connection = Connection::Disconnected { reason: format!("daemon not reachable ({e})") }; + } + } + } + + fn pull_state(&mut self, client: &Client) { + match client.request(Command::GetState) { + Ok(Response::State(snap)) => self.snapshot = snap, + Ok(other) => self.status = format!("unexpected state reply: {other:?}"), + Err(e) => self.status = format!("GetState failed: {e}"), + } + } + + pub fn quit(&mut self) { + self.should_quit = true; + } +} diff --git a/crates/hydra/src/client.rs b/crates/hydra/src/client.rs @@ -0,0 +1,28 @@ +//! Thin blocking client to the `hydrad` control socket. P0 does simple one-shot +//! request/response calls; the persistent subscribe/event stream arrives in P4. + +use std::io::{BufReader, BufWriter}; +use std::os::unix::net::UnixStream; + +use hydra_ipc::{read_msg, write_msg, Command, Response}; + +pub struct Client { + stream: UnixStream, +} + +impl Client { + /// Connect to the daemon's socket, or error if it isn't running. + pub fn connect() -> std::io::Result<Self> { + let stream = UnixStream::connect(hydra_ipc::socket_path())?; + Ok(Self { stream }) + } + + /// Send one command and read exactly one response. + pub fn request(&self, cmd: Command) -> std::io::Result<Response> { + let mut writer = BufWriter::new(self.stream.try_clone()?); + let mut reader = BufReader::new(self.stream.try_clone()?); + write_msg(&mut writer, &cmd)?; + read_msg::<_, Response>(&mut reader)? + .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "daemon closed connection")) + } +} diff --git a/crates/hydra/src/main.rs b/crates/hydra/src/main.rs @@ -0,0 +1,66 @@ +//! `hydra` — the terminal UI (and, from P5, the `query` subcommand for SketchyBar). +//! P0: connect to `hydrad`, show connection status + (empty) routing state, Navi-themed. + +mod app; +mod client; +mod theme; +mod ui; + +use std::error::Error; +use std::io::{self, Stdout}; +use std::time::Duration; + +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +use app::App; +use theme::Theme; + +type Tui = Terminal<CrosstermBackend<Stdout>>; + +fn main() -> Result<(), Box<dyn Error>> { + let mut terminal = setup_terminal()?; + let theme = Theme::load(); + let result = run(&mut terminal, theme); + restore_terminal(&mut terminal)?; + result +} + +fn run(terminal: &mut Tui, theme: Theme) -> Result<(), Box<dyn Error>> { + let mut app = App::new(); + while !app.should_quit { + terminal.draw(|f| ui::draw(f, &app, &theme))?; + + // Poll so the UI stays responsive; no busy loop. + if event::poll(Duration::from_millis(250))? { + if let Event::Key(key) = event::read()? { + if key.kind == KeyEventKind::Press { + match key.code { + KeyCode::Char('q') | KeyCode::Esc => app.quit(), + KeyCode::Char('r') => app.refresh(), + _ => {} + } + } + } + } + } + Ok(()) +} + +fn setup_terminal() -> Result<Tui, Box<dyn Error>> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + Ok(Terminal::new(CrosstermBackend::new(stdout))?) +} + +fn restore_terminal(terminal: &mut Tui) -> Result<(), Box<dyn Error>> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/crates/hydra/src/theme.rs b/crates/hydra/src/theme.rs @@ -0,0 +1,140 @@ +//! Swappable color theme. Defaults to the Navi palette but loads +//! `~/Library/Application Support/hydra/theme.toml` (or a `--theme <path>`) if present, +//! so the palette is configurable from day one and never hardcoded into widgets. + +use ratatui::style::Color; +use serde::Deserialize; +use std::path::{Path, PathBuf}; + +/// Resolved colors used across the UI. Semantic roles, not raw palette names. +#[derive(Debug, Clone)] +pub struct Theme { + pub bg: Color, + pub bg_elevated: Color, + pub fg: Color, + pub fg_dim: Color, + /// Lone amber anchor — used sparingly for the primary accent. + pub accent: Color, + /// Ghost-light cyan — titles, cursor, active highlights. + pub ghost: Color, + pub border: Color, + pub success: Color, + pub warning: Color, + pub danger: Color, +} + +impl Default for Theme { + /// The Navi palette (semantic subset). See ~/Dropbox/Projects/Libs/navi-palette. + fn default() -> Self { + Theme { + bg: rgb(0x10, 0x1e, 0x2e), // Deep Night Blue (main-bg) + bg_elevated: rgb(0x14, 0x2c, 0x4a), // Space Cadet (region-bg) + fg: rgb(0xe0, 0xf0, 0xff), // Alice Blue + fg_dim: rgb(0x78, 0x98, 0xb8), // Cadet Grey + accent: rgb(0xf0, 0x90, 0x30), // Amber (warm anchor) + ghost: rgb(0x40, 0xe8, 0xff), // Electric Cyan (ghost light) + border: rgb(0x26, 0x50, 0x80), // Payne's Grey + success: rgb(0x00, 0xe8, 0x98), // Caribbean Green + warning: rgb(0xf0, 0x90, 0x30), // Amber + danger: rgb(0xff, 0x60, 0x60), // Bittersweet + } + } +} + +impl Theme { + /// Load from the standard config location, falling back to the Navi default. + pub fn load() -> Self { + Self::default_path() + .filter(|p| p.exists()) + .and_then(|p| Self::from_file(&p).ok()) + .unwrap_or_default() + } + + /// `~/Library/Application Support/hydra/theme.toml`. + pub fn default_path() -> Option<PathBuf> { + dirs::data_dir().map(|d| d.join("hydra").join("theme.toml")) + } + + /// Parse a theme file, using Navi defaults for any unspecified key. + pub fn from_file(path: &Path) -> Result<Self, Box<dyn std::error::Error>> { + let raw = std::fs::read_to_string(path)?; + let file: ThemeFile = toml::from_str(&raw)?; + let mut t = Theme::default(); + let p = file.palette; + apply(&mut t.bg, p.bg); + apply(&mut t.bg_elevated, p.bg_elevated); + apply(&mut t.fg, p.fg); + apply(&mut t.fg_dim, p.fg_dim); + apply(&mut t.accent, p.accent); + apply(&mut t.ghost, p.ghost); + apply(&mut t.border, p.border); + apply(&mut t.success, p.success); + apply(&mut t.warning, p.warning); + apply(&mut t.danger, p.danger); + Ok(t) + } +} + +#[derive(Debug, Deserialize)] +struct ThemeFile { + #[allow(dead_code)] + name: Option<String>, + #[serde(default)] + palette: Palette, +} + +#[derive(Debug, Default, Deserialize)] +struct Palette { + bg: Option<String>, + bg_elevated: Option<String>, + fg: Option<String>, + fg_dim: Option<String>, + accent: Option<String>, + ghost: Option<String>, + border: Option<String>, + success: Option<String>, + warning: Option<String>, + danger: Option<String>, +} + +const fn rgb(r: u8, g: u8, b: u8) -> Color { + Color::Rgb(r, g, b) +} + +/// Parse `#rrggbb` (with or without the leading `#`). +fn parse_hex(s: &str) -> Option<Color> { + let s = s.trim().trim_start_matches('#'); + if s.len() != 6 { + return None; + } + let r = u8::from_str_radix(&s[0..2], 16).ok()?; + let g = u8::from_str_radix(&s[2..4], 16).ok()?; + let b = u8::from_str_radix(&s[4..6], 16).ok()?; + Some(Color::Rgb(r, g, b)) +} + +/// Overwrite `slot` only if `value` is present and parses. +fn apply(slot: &mut Color, value: Option<String>) { + if let Some(c) = value.as_deref().and_then(parse_hex) { + *slot = c; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_hex_with_and_without_hash() { + assert_eq!(parse_hex("#40e8ff"), Some(Color::Rgb(0x40, 0xe8, 0xff))); + assert_eq!(parse_hex("101e2e"), Some(Color::Rgb(0x10, 0x1e, 0x2e))); + assert_eq!(parse_hex("nope"), None); + } + + #[test] + fn default_is_navi() { + let t = Theme::default(); + assert_eq!(t.ghost, Color::Rgb(0x40, 0xe8, 0xff)); + assert_eq!(t.accent, Color::Rgb(0xf0, 0x90, 0x30)); + } +} diff --git a/crates/hydra/src/ui.rs b/crates/hydra/src/ui.rs @@ -0,0 +1,105 @@ +//! Ratatui rendering for the Hydra TUI. Pure view: reads [`App`] + [`Theme`], draws. + +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::app::{App, Connection}; +use crate::theme::Theme; + +pub fn draw(f: &mut Frame, app: &App, theme: &Theme) { + let base = Style::default().bg(theme.bg).fg(theme.fg); + f.render_widget(Block::default().style(base), f.area()); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(4), // header + Constraint::Min(1), // body + Constraint::Length(1), // footer + ]) + .split(f.area()); + + draw_header(f, chunks[0], app, theme); + draw_body(f, chunks[1], app, theme); + draw_footer(f, chunks[2], theme); +} + +fn draw_header(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { + let (state_label, state_color) = match &app.connection { + Connection::Connected { protocol } => { + (format!("● daemon connected (protocol v{protocol})"), theme.success) + } + Connection::Disconnected { reason } => (format!("○ disconnected — {reason}"), theme.danger), + }; + + let title = Line::from(vec![ + Span::styled("✦ Hydra", Style::default().fg(theme.ghost).add_modifier(Modifier::BOLD)), + Span::styled(" · audio routing", Style::default().fg(theme.fg_dim)), + ]); + let status = Line::from(Span::styled(state_label, Style::default().fg(state_color))); + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .style(Style::default().bg(theme.bg)); + f.render_widget(Paragraph::new(vec![title, status]).block(block), area); +} + +fn draw_body(f: &mut Frame, area: Rect, app: &App, theme: &Theme) { + let block = Block::default() + .title(Span::styled(" routes ", Style::default().fg(theme.accent))) + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border)) + .style(Style::default().bg(theme.bg)); + + let body: Vec<Line> = if app.snapshot.routes.is_empty() { + vec![ + Line::from(""), + Line::from(Span::styled(" No routes yet.", Style::default().fg(theme.fg_dim))), + Line::from(Span::styled( + " Per-app capture + virtual devices arrive in the next phases.", + Style::default().fg(theme.fg_dim), + )), + ] + } else { + app.snapshot + .routes + .iter() + .map(|r| { + let dot = if r.active { theme.success } else { theme.fg_dim }; + Line::from(vec![ + Span::styled(" ● ", Style::default().fg(dot)), + Span::styled(format!("{} ", r.target), Style::default().fg(theme.fg)), + Span::styled( + format!("◀ {} sources", r.source_count), + Style::default().fg(theme.fg_dim), + ), + ]) + }) + .collect() + }; + + f.render_widget(Paragraph::new(body).block(block), area); +} + +fn draw_footer(f: &mut Frame, area: Rect, theme: &Theme) { + let hint = Line::from(vec![ + key("r", theme), + Span::styled(" refresh ", Style::default().fg(theme.fg_dim)), + key("q", theme), + Span::styled(" quit", Style::default().fg(theme.fg_dim)), + ]); + f.render_widget(Paragraph::new(hint).style(Style::default().bg(theme.bg)), area); +} + +fn key<'a>(k: &'a str, theme: &Theme) -> Span<'a> { + Span::styled( + format!(" {k} "), + Style::default().fg(theme.bg).bg(theme.ghost).add_modifier(Modifier::BOLD), + ) +} diff --git a/crates/hydrad/Cargo.toml b/crates/hydrad/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "hydrad" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "hydrad" +path = "src/main.rs" + +[dependencies] +hydra-core = { path = "../hydra-core" } +hydra-ipc = { path = "../hydra-ipc" } +serde_json.workspace = true diff --git a/crates/hydrad/src/main.rs b/crates/hydrad/src/main.rs @@ -0,0 +1,45 @@ +//! `hydrad` — the Hydra daemon. It owns all CoreAudio state (from P1) and is the only +//! process that touches the audio engine. Clients connect over a Unix-domain socket. +//! +//! P0 scope: bind the socket, answer `Ping`/`GetState`, hold an (empty) routing state. + +mod server; + +use std::error::Error; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::UnixListener; +use std::sync::{Arc, Mutex}; + +use hydra_core::RoutingState; + +fn main() -> Result<(), Box<dyn Error>> { + let sock = hydra_ipc::socket_path(); + if let Some(dir) = sock.parent() { + fs::create_dir_all(dir)?; + } + // A stale socket from a previous run would make bind() fail with EADDRINUSE. + let _ = fs::remove_file(&sock); + + let listener = UnixListener::bind(&sock)?; + fs::set_permissions(&sock, fs::Permissions::from_mode(0o600))?; + eprintln!("hydrad {} listening on {}", hydra_core::VERSION, sock.display()); + + let state = Arc::new(Mutex::new(RoutingState::default())); + + for conn in listener.incoming() { + match conn { + Ok(stream) => { + let state = Arc::clone(&state); + std::thread::spawn(move || { + if let Err(e) = server::handle(stream, state) { + eprintln!("connection error: {e}"); + } + }); + } + Err(e) => eprintln!("accept error: {e}"), + } + } + + Ok(()) +} diff --git a/crates/hydrad/src/server.rs b/crates/hydrad/src/server.rs @@ -0,0 +1,31 @@ +//! Per-connection request handling. Each accepted socket runs on its own thread and +//! processes a stream of NDJSON [`Command`]s, replying with a [`Response`] to each. + +use std::error::Error; +use std::io::{BufReader, BufWriter}; +use std::os::unix::net::UnixStream; +use std::sync::{Arc, Mutex}; + +use hydra_core::RoutingState; +use hydra_ipc::{read_msg, write_msg, Command, Response, PROTOCOL_VERSION}; + +pub fn handle(stream: UnixStream, state: Arc<Mutex<RoutingState>>) -> Result<(), Box<dyn Error>> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut writer = BufWriter::new(stream); + + while let Some(cmd) = read_msg::<_, Command>(&mut reader)? { + let resp = match cmd { + Command::Ping => Response::Pong { version: PROTOCOL_VERSION }, + Command::GetState => Response::State(state.lock().unwrap().snapshot()), + // Server-push subscription lands in P4; acknowledge for now. + Command::Subscribe => Response::Ok, + Command::Shutdown => { + write_msg(&mut writer, &Response::Ok)?; + std::process::exit(0); + } + }; + write_msg(&mut writer, &resp)?; + } + + Ok(()) +} diff --git a/themes/navi.toml b/themes/navi.toml @@ -0,0 +1,17 @@ +# Hydra theme — Navi palette (default). +# Copy to ~/Library/Application Support/hydra/theme.toml and edit to swap palettes. +# Any omitted key falls back to the built-in Navi default. + +name = "Navi" + +[palette] +bg = "#101e2e" # Deep Night Blue +bg_elevated = "#142c4a" # Space Cadet +fg = "#e0f0ff" # Alice Blue +fg_dim = "#7898b8" # Cadet Grey +accent = "#f09030" # Amber — lone warm anchor +ghost = "#40e8ff" # Electric Cyan — ghost light +border = "#265080" # Payne's Grey +success = "#00e898" # Caribbean Green +warning = "#f09030" # Amber +danger = "#ff6060" # Bittersweet