From 7843ff6ae37d76a3494b7763cd892ca97153f036 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Mon, 28 Sep 2026 13:49:56 -0400 Subject: [PATCH 1/8] feat(macos): create macos switchyard deamon Signed-off-by: Greg Clark chore: cleanup Signed-off-by: Greg Clark chore: cleanup Signed-off-by: Greg Clark --- Cargo.lock | 1110 +++++++++++++++++++++- Cargo.toml | 1 + Makefile | 14 +- crates/switchyard-menubar/Cargo.toml | 29 + crates/switchyard-menubar/README.md | 87 ++ crates/switchyard-menubar/src/app.rs | 53 ++ crates/switchyard-menubar/src/config.rs | 146 +++ crates/switchyard-menubar/src/health.rs | 101 ++ crates/switchyard-menubar/src/icon.rs | 68 ++ crates/switchyard-menubar/src/main.rs | 78 ++ crates/switchyard-menubar/src/pricing.rs | 186 ++++ crates/switchyard-menubar/src/rollup.rs | 259 +++++ crates/switchyard-menubar/src/summary.rs | 278 ++++++ crates/switchyard-menubar/src/tray.rs | 162 ++++ scripts/macos/common.sh | 50 + scripts/macos/install.sh | 370 ++++++++ scripts/macos/uninstall.sh | 78 ++ 17 files changed, 3033 insertions(+), 37 deletions(-) create mode 100644 crates/switchyard-menubar/Cargo.toml create mode 100644 crates/switchyard-menubar/README.md create mode 100644 crates/switchyard-menubar/src/app.rs create mode 100644 crates/switchyard-menubar/src/config.rs create mode 100644 crates/switchyard-menubar/src/health.rs create mode 100644 crates/switchyard-menubar/src/icon.rs create mode 100644 crates/switchyard-menubar/src/main.rs create mode 100644 crates/switchyard-menubar/src/pricing.rs create mode 100644 crates/switchyard-menubar/src/rollup.rs create mode 100644 crates/switchyard-menubar/src/summary.rs create mode 100644 crates/switchyard-menubar/src/tray.rs create mode 100644 scripts/macos/common.sh create mode 100755 scripts/macos/install.sh create mode 100755 scripts/macos/uninstall.sh diff --git a/Cargo.lock b/Cargo.lock index aeeeaf37b..f4a4ebcab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -31,6 +37,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -139,6 +154,29 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -278,6 +316,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -302,6 +349,31 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "cc" version = "1.2.67" @@ -314,6 +386,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -343,8 +425,10 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "windows-link", ] [[package]] @@ -375,7 +459,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -437,6 +521,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "data-encoding" version = "2.11.1" @@ -467,6 +575,37 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -478,6 +617,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -532,12 +677,42 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -684,6 +859,64 @@ dependencies = [ "slab", ] +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -725,6 +958,148 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "h2" version = "0.4.15" @@ -755,6 +1130,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" @@ -878,6 +1259,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1150,18 +1555,90 @@ dependencies = [ "serde_json", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags", + "serde", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "libxdo" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" +dependencies = [ + "libxdo-sys", +] + +[[package]] +name = "libxdo-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" +dependencies = [ + "libc", + "x11", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1216,6 +1693,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "micromap" version = "0.3.0" @@ -1228,6 +1714,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.2" @@ -1239,6 +1745,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "muda" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "libxdo", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "nemo-relay-plugin" version = "0.8.4" @@ -1316,52 +1843,204 @@ dependencies = [ ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "num-traits", + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] -name = "num-iter" -version = "0.1.46" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "num-rational" -version = "0.4.2" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "num-bigint", - "num-integer", - "num-traits", + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2-io-surface" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "autocfg", + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] -name = "num_cpus" -version = "1.17.0" +name = "objc2-quartz-core" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "hermit-abi", - "libc", + "bitflags", + "objc2", + "objc2-foundation", ] [[package]] @@ -1466,12 +2145,43 @@ dependencies = [ "tokio", ] +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -1513,6 +2223,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1561,6 +2284,50 @@ dependencies = [ "yansi", ] +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1662,7 +2429,7 @@ version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ - "target-lexicon", + "target-lexicon 0.13.5", ] [[package]] @@ -1693,7 +2460,7 @@ version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "pyo3-build-config", "quote", @@ -1852,6 +2619,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.26" @@ -2196,6 +2973,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2242,6 +3028,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "simd_cesu8" version = "1.2.0" @@ -2307,7 +3099,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -2370,6 +3162,21 @@ dependencies = [ "wiremock", ] +[[package]] +name = "switchyard-menubar" +version = "0.3.0" +dependencies = [ + "chrono", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "serde", + "serde_json", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "tray-icon", +] + [[package]] name = "switchyard-nemo-relay-plugin" version = "0.3.0" @@ -2385,7 +3192,7 @@ dependencies = [ "switchyard-translation", "tempfile", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "wiremock", ] @@ -2434,7 +3241,7 @@ dependencies = [ "switchyard-protocol", "thiserror 2.0.18", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "tracing", ] @@ -2516,6 +3323,16 @@ dependencies = [ "tracing", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.119" @@ -2558,6 +3375,25 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "target-lexicon" version = "0.13.5" @@ -2714,6 +3550,33 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -2722,11 +3585,29 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", ] [[package]] @@ -2738,13 +3619,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -2877,6 +3782,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tray-icon" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -2973,6 +3899,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -3116,6 +4048,22 @@ dependencies = [ "rustls-pki-types", ] +[[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-util" version = "0.1.11" @@ -3125,12 +4073,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3213,6 +4220,21 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + [[package]] name = "winnow" version = "1.0.4" @@ -3254,6 +4276,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "yansi" version = "1.0.1" @@ -3363,6 +4395,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index a3c8c9d02..26cea2f35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ members = [ "crates/protocol", "crates/switchyard-nemo-relay-plugin", "crates/switchyard-runner", + "crates/switchyard-menubar", "crates/switchyard-server", "crates/switchyard-skill-distillation", "crates/switchyard-soak", diff --git a/Makefile b/Makefile index eadc87cff..d4bb80724 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 .DEFAULT_GOAL := help -.PHONY: help install-linux install-linux-dry-run uninstall-linux +.PHONY: help install-linux install-linux-dry-run uninstall-linux install-macos install-macos-dry-run uninstall-macos help: @echo "install-linux Install the systemd user service and Codex profile" @@ -20,3 +20,15 @@ install-linux-dry-run: ## Remove the systemd user service, the sy Codex profile, and the codex alias. uninstall-linux: @scripts/linux/uninstall.sh + +## Install the Switchyard background server and menu bar app for this user. +install-macos: + @scripts/macos/install.sh + +## Print what install-macos would do, without changing anything. +install-macos-dry-run: + @scripts/macos/install.sh --dry-run + +## Remove the LaunchAgents, the sy Codex profile, and the codex alias. +uninstall-macos: + @scripts/macos/uninstall.sh diff --git a/crates/switchyard-menubar/Cargo.toml b/crates/switchyard-menubar/Cargo.toml new file mode 100644 index 000000000..d12551f0d --- /dev/null +++ b/crates/switchyard-menubar/Cargo.toml @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "switchyard-menubar" +version.workspace = true +description = "macOS menu bar companion for a locally running Switchyard server" +authors.workspace = true +edition.workspace = true +homepage = "https://github.com/NVIDIA-NeMo/Switchyard" +license.workspace = true +repository.workspace = true +rust-version.workspace = true +publish = false + +[dependencies] +chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } +serde.workspace = true +serde_json.workspace = true +toml = "0.9" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = "0.3" +objc2-foundation = "0.3" +tray-icon = "0.25" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/switchyard-menubar/README.md b/crates/switchyard-menubar/README.md new file mode 100644 index 000000000..2472746ed --- /dev/null +++ b/crates/switchyard-menubar/README.md @@ -0,0 +1,87 @@ +# switchyard-menubar + +A macOS menu bar companion for a Switchyard server running in the background. + +The server does the routing. This process only reads what the server already +wrote, so quitting it never affects traffic. + +Click the glyph to see whether the server is answering, today's and this +week's requests and tokens, estimated savings, and which models served the +week. It can also restart the server and open either config file. + +## Install + +From the repository root: + +```sh +make install-macos # or install-macos-dry-run to see the steps first +make uninstall-macos +``` + +That builds `switchyard-server` and `switchyard-menubar` into +`~/.switchyard/bin`, writes a server config and menu bar settings, loads two +LaunchAgents, writes a `sy` Codex profile, and aliases `codex` to use it. + +Your server config and menu bar settings are never overwritten once they +exist, so edits survive a reinstall. Logs are in `~/.switchyard/logs/`. + +## Settings + +`~/.switchyard/menubar.toml`. Any key may be omitted. + +```toml +server_url = "http://127.0.0.1:4123" +routing_log = "~/.switchyard/routing.jsonl" +baseline_model = "gpt-5.6-sol" +refresh_seconds = 30 + +[prices."gpt-5.6-sol"] +input_per_mtok = 1.25 +cached_input_per_mtok = 0.125 +output_per_mtok = 10.0 +``` + +## How savings are computed + +The server writes one JSONL record per call, with the model that answered and +its token counts. + +- **Actual** is every call priced at the model that served it, including + Switchyard's own classifier calls. +- **Baseline** is the caller-facing calls only, priced as if each had used + `baseline_model`. Classifier calls have no baseline counterpart, because + without Switchyard they would not happen. + +Savings are the difference, so routing overhead counts against the figure and +a bad day shows a negative number. Dollar figures stay hidden until every +model seen has a price, so a partial table cannot mislead. + +These are list-price estimates. On a ChatGPT login there is no per-token bill, +so read them as "what this traffic would have cost at API rates". + +## Codex profiles + +`codex --profile sy` reads `~/.codex/sy.config.toml`. A `[profiles.sy]` table +inside `config.toml` is legacy config the CLI now refuses to start with, so the +installer removes one if an older version left it there. + +The profile sets only `model` and `model_provider`. It deliberately leaves +`approval_policy` and `sandbox_mode` alone, because routing should not quietly +change how Codex asks before it acts. + +**Codex.app cannot use the profile.** It still writes profiles in the legacy +format its own bundled CLI rejects. So it gets a whole config instead: the +installer builds `~/.codex/config.toml.sy`, which is your `config.toml` with +the routing inlined at the top level, the one format both accept. Swap it in +and restart the app: + +```sh +cp ~/.codex/config.toml.sy ~/.codex/config.toml # route +cp ~/.codex/config.toml.direct ~/.codex/config.toml # back to normal +``` + +`config.toml.direct` is a snapshot of your unrouted config, never taken while +`config.toml` is already routed, and restored by `make uninstall-macos`. + +Swapping routes everything the app does. Unlike the CLI alias, there is no +unrouted escape hatch while it is in place. diff --git a/crates/switchyard-menubar/src/app.rs b/crates/switchyard-menubar/src/app.rs new file mode 100644 index 000000000..1c96407ac --- /dev/null +++ b/crates/switchyard-menubar/src/app.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Ties the pieces together: read the log, probe the server, build the rows. + +use chrono::Local; + +use crate::config::Config; +use crate::health::probe; +use crate::rollup; +use crate::summary::{Row, build}; + +/// Rereads the log, probes the server, and builds the menu body. +pub fn refresh(config: &Config) -> Vec { + let usage = + rollup::read(&config.routing_log, Local::now().date_naive()).unwrap_or_else(|error| { + eprintln!( + "switchyard-menubar: read {}: {error}", + config.routing_log.display() + ); + rollup::Usage::default() + }); + build(probe(&config.server_url), &usage, config) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_configured_log() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("routing.jsonl"); + let ts = Local::now().to_rfc3339(); + std::fs::write( + &path, + format!( + r#"{{"ts":"{ts}","model":"luna","tier":"","prompt_tokens":1000,"cached_tokens":0,"completion_tokens":100,"reasoning_tokens":0}}"# + ) + "\n", + ) + .expect("write log"); + + let rows = refresh(&Config { + routing_log: path, + ..Config::default() + }); + + assert!( + rows.contains(&Row::Label("Today — 1 request · 1100 tokens".to_string())), + "unexpected rows: {rows:?}" + ); + } +} diff --git a/crates/switchyard-menubar/src/config.rs b/crates/switchyard-menubar/src/config.rs new file mode 100644 index 000000000..6e10dc653 --- /dev/null +++ b/crates/switchyard-menubar/src/config.rs @@ -0,0 +1,146 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Menu bar settings, read from `~/.switchyard/menubar.toml`. + +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::pricing::PriceTable; + +/// Everything the menu bar needs to find the server and price its traffic. +/// Missing keys fall back to [`Config::default`]. +#[derive(Clone, Debug, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + /// Base URL the server listens on, used for the health check. + pub server_url: String, + /// JSONL file the server appends routing records to. + pub routing_log: PathBuf, + /// Server TOML, opened by the "Open server config" menu item. + pub config_file: PathBuf, + /// LaunchAgent label used to restart the server. + pub launchd_label: String, + /// How often the menu contents are recomputed. + pub refresh_seconds: u64, + /// Model the traffic is assumed to have used without Switchyard. + pub baseline_model: String, + /// Per-model rates. Dollar figures are hidden while a seen model is absent. + pub prices: PriceTable, +} + +impl Default for Config { + fn default() -> Self { + Self { + server_url: "http://127.0.0.1:4123".to_string(), + routing_log: PathBuf::from("~/.switchyard/routing.jsonl"), + config_file: PathBuf::from("~/.switchyard/composite.toml"), + launchd_label: "com.nvidia.switchyard.server".to_string(), + refresh_seconds: 30, + baseline_model: "gpt-5.6-sol".to_string(), + prices: PriceTable::new(), + } + } +} + +impl Config { + /// Parses settings, falling back to defaults when the file is absent. + pub fn load(path: &Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(Self::default()); + } + Err(error) => return Err(format!("read {}: {error}", path.display())), + }; + let mut config: Self = + toml::from_str(&text).map_err(|error| format!("parse {}: {error}", path.display()))?; + config.routing_log = expand_home(&config.routing_log); + config.config_file = expand_home(&config.config_file); + Ok(config) + } + + /// Default settings path, `~/.switchyard/menubar.toml`. + pub fn default_path() -> PathBuf { + expand_home(Path::new("~/.switchyard/menubar.toml")) + } +} + +/// Rewrites a leading `~`. Paths are written by hand in the settings file, +/// where `~` is the natural way to spell a home path. +pub fn expand_home(path: &Path) -> PathBuf { + let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from); + expand_under(path, &home) +} + +fn expand_under(path: &Path, home: &Path) -> PathBuf { + match path.strip_prefix("~") { + Ok(rest) => home.join(rest), + Err(_) => path.to_path_buf(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_settings_and_prices() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("menubar.toml"); + std::fs::write( + &path, + r#" +server_url = "http://127.0.0.1:9000" +baseline_model = "sol" + +[prices.luna] +input_per_mtok = 0.25 +cached_input_per_mtok = 0.025 +output_per_mtok = 2.0 +"#, + ) + .expect("write settings"); + + let config = Config::load(&path).expect("load"); + + assert_eq!(config.server_url, "http://127.0.0.1:9000"); + assert_eq!(config.baseline_model, "sol"); + assert_eq!(config.prices["luna"].cached_input_per_mtok, Some(0.025)); + assert_eq!(config.refresh_seconds, 30, "unset keys keep their default"); + } + + #[test] + fn a_missing_file_yields_defaults() { + let dir = tempfile::tempdir().expect("tempdir"); + + let config = Config::load(&dir.path().join("absent.toml")).expect("load"); + + assert_eq!(config.server_url, "http://127.0.0.1:4123"); + assert!(config.prices.is_empty()); + } + + #[test] + fn rejects_unparsable_settings() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("menubar.toml"); + std::fs::write(&path, "server_url = ").expect("write settings"); + + assert!(Config::load(&path).is_err()); + } + + #[test] + fn expands_a_leading_tilde() { + let home = Path::new("/Users/example"); + + assert_eq!( + expand_under(Path::new("~/.switchyard/routing.jsonl"), home), + PathBuf::from("/Users/example/.switchyard/routing.jsonl") + ); + assert_eq!( + expand_under(Path::new("/tmp/routing.jsonl"), home), + PathBuf::from("/tmp/routing.jsonl") + ); + } +} diff --git a/crates/switchyard-menubar/src/health.rs b/crates/switchyard-menubar/src/health.rs new file mode 100644 index 000000000..1f13aee0b --- /dev/null +++ b/crates/switchyard-menubar/src/health.rs @@ -0,0 +1,101 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Liveness probe against the local server's `/health` endpoint. +//! +//! The request is written by hand over TCP. The server is always on loopback +//! over plain HTTP, so a full HTTP client would buy nothing here. + +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpStream, ToSocketAddrs}; +use std::time::Duration; + +/// How long the probe waits before calling the server unreachable. Short, +/// because it runs on the thread that draws the menu. +const TIMEOUT: Duration = Duration::from_millis(500); + +/// Whether the server answered its health check. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ServerStatus { + Running, + Stopped, +} + +/// Probes `GET /health`, treating any failure as a stopped server. +pub fn probe(server_url: &str) -> ServerStatus { + match request(server_url) { + Ok(true) => ServerStatus::Running, + _ => ServerStatus::Stopped, + } +} + +fn request(server_url: &str) -> std::io::Result { + // Strip the scheme and any path, then default the port. + let rest = server_url.rsplit("://").next().unwrap_or(server_url); + let mut authority = rest.split('/').next().unwrap_or(rest).to_string(); + if !authority.contains(':') { + authority.push_str(":4123"); + } + + let address = authority + .to_socket_addrs()? + .next() + .ok_or_else(|| std::io::Error::other(format!("no address for {authority}")))?; + let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)?; + stream.set_read_timeout(Some(TIMEOUT))?; + stream.set_write_timeout(Some(TIMEOUT))?; + // HTTP/1.0 needs no Host header and closes the connection for us. + stream.write_all(b"GET /health HTTP/1.0\r\n\r\n")?; + + let mut status_line = String::new(); + BufReader::new(stream).read_line(&mut status_line)?; + Ok(status_line.contains(" 200")) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::TcpListener; + + /// Serves one canned response, then closes. + fn serve(response: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let _ = stream.write_all(response.as_bytes()); + } + }); + format!("http://127.0.0.1:{port}") + } + + #[test] + fn reports_running_only_on_a_200() { + let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); + let down = serve("HTTP/1.1 503 Service Unavailable\r\n\r\n"); + + assert_eq!(probe(&ok), ServerStatus::Running); + assert_eq!(probe(&down), ServerStatus::Stopped); + } + + #[test] + fn reports_stopped_when_nothing_is_listening() { + // Binding then dropping yields a port with no listener. + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let port = listener.local_addr().expect("addr").port(); + drop(listener); + + assert_eq!( + probe(&format!("http://127.0.0.1:{port}")), + ServerStatus::Stopped + ); + } + + #[test] + fn accepts_a_url_with_a_path_or_no_port() { + let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); + + assert_eq!(probe(&format!("{ok}/v1")), ServerStatus::Running); + assert_eq!(probe("http://localhost"), ServerStatus::Stopped); + } +} diff --git a/crates/switchyard-menubar/src/icon.rs b/crates/switchyard-menubar/src/icon.rs new file mode 100644 index 000000000..5139e4ad0 --- /dev/null +++ b/crates/switchyard-menubar/src/icon.rs @@ -0,0 +1,68 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The menu bar glyph: a track that splits in two, drawn in code. +//! +//! macOS scales the image to 18 points tall, so it is drawn at 2x for retina +//! displays. It is a template image, so only the alpha channel matters and the +//! system recolors it for light and dark menu bars. + +/// Side length of the generated image, in pixels. +pub const SIZE: u32 = 36; + +/// Half the stroke width, in pixels. +const HALF_STROKE: f64 = 1.7; + +const SEGMENTS: [((f64, f64), (f64, f64)); 3] = [ + ((5.0, 18.0), (17.0, 18.0)), + ((17.0, 18.0), (30.0, 7.0)), + ((17.0, 18.0), (30.0, 29.0)), +]; + +/// Renders the glyph as RGBA pixels, row-major from the top left. +pub fn glyph() -> Vec { + let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); + for y in 0..SIZE { + for x in 0..SIZE { + let point = (f64::from(x) + 0.5, f64::from(y) + 0.5); + let distance = SEGMENTS + .iter() + .map(|(start, end)| distance_to_segment(point, *start, *end)) + .fold(f64::INFINITY, f64::min); + // One pixel of falloff past the stroke edge softens the diagonals. + let coverage = (HALF_STROKE + 0.5 - distance).clamp(0.0, 1.0); + pixels.extend_from_slice(&[0, 0, 0, (coverage * 255.0).round() as u8]); + } + } + pixels +} + +fn distance_to_segment(point: (f64, f64), start: (f64, f64), end: (f64, f64)) -> f64 { + let (dx, dy) = (end.0 - start.0, end.1 - start.1); + let length_squared = dx * dx + dy * dy; + let along = ((point.0 - start.0) * dx + (point.1 - start.1) * dy) / length_squared; + let along = along.clamp(0.0, 1.0); + let (cx, cy) = (start.0 + dx * along, start.1 + dy * along); + ((point.0 - cx).powi(2) + (point.1 - cy).powi(2)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn alpha(pixels: &[u8], x: u32, y: u32) -> u8 { + pixels[((y * SIZE + x) * 4 + 3) as usize] + } + + #[test] + fn draws_a_trunk_that_splits_in_two() { + let pixels = glyph(); + + assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); + assert_eq!(alpha(&pixels, 10, 18), 255, "the trunk is solid"); + assert!(alpha(&pixels, 29, 7) > 0, "upper branch reaches its end"); + assert!(alpha(&pixels, 29, 29) > 0, "lower branch reaches its end"); + assert_eq!(alpha(&pixels, 29, 18), 0, "nothing runs straight through"); + assert_eq!(alpha(&pixels, 0, 0), 0, "corners stay transparent"); + } +} diff --git a/crates/switchyard-menubar/src/main.rs b/crates/switchyard-menubar/src/main.rs new file mode 100644 index 000000000..7a6c91364 --- /dev/null +++ b/crates/switchyard-menubar/src/main.rs @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Menu bar companion for a locally running Switchyard server. +//! +//! The server owns routing; this process only reads what the server already +//! wrote. It shows today's and this week's traffic, and what that traffic +//! would have cost had every call gone to the capable model instead. + +mod app; +mod config; +mod health; +mod pricing; +mod rollup; +mod summary; +// The status item and the glyph it draws are the only platform-specific code. +#[cfg(target_os = "macos")] +mod icon; +#[cfg(target_os = "macos")] +mod tray; + +use std::path::PathBuf; +use std::process::ExitCode; + +use config::Config; + +const USAGE: &str = "\ +Usage: switchyard-menubar [--print] [SETTINGS_FILE] + +Shows Switchyard usage and estimated savings in the macOS menu bar. + + SETTINGS_FILE Settings TOML [default: ~/.switchyard/menubar.toml] + --print Print the current summary and exit, instead of running"; + +fn main() -> ExitCode { + let mut settings = None; + let mut print_only = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "--print" => print_only = true, + _ if arg.starts_with('-') => { + eprintln!("switchyard-menubar: unknown option {arg}\n\n{USAGE}"); + return ExitCode::FAILURE; + } + _ => settings = Some(PathBuf::from(arg)), + } + } + + let config = match Config::load(&settings.unwrap_or_else(Config::default_path)) { + Ok(config) => config, + Err(error) => { + eprintln!("switchyard-menubar: {error}"); + return ExitCode::FAILURE; + } + }; + // Off macOS there is no menu bar to attach to, so printing is the whole + // program. That keeps the rollup and pricing logic buildable everywhere. + if print_only || !cfg!(target_os = "macos") { + for row in app::refresh(&config) { + match row { + summary::Row::Separator => println!(), + summary::Row::Label(text) => println!("{text}"), + } + } + return ExitCode::SUCCESS; + } + + #[cfg(target_os = "macos")] + if let Err(error) = tray::run(config) { + eprintln!("switchyard-menubar: {error}"); + return ExitCode::FAILURE; + } + ExitCode::SUCCESS +} diff --git a/crates/switchyard-menubar/src/pricing.rs b/crates/switchyard-menubar/src/pricing.rs new file mode 100644 index 000000000..e551bdd71 --- /dev/null +++ b/crates/switchyard-menubar/src/pricing.rs @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Counterfactual cost: what routed traffic cost against what it would have +//! cost had every call gone to one capable model. + +use std::collections::BTreeMap; + +use serde::Deserialize; + +use crate::rollup::{ModelTokens, Totals}; + +const PER_MILLION: f64 = 1_000_000.0; + +/// Per-million-token rates for one model. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +pub struct ModelPrice { + pub input_per_mtok: f64, + /// Rate for cache reads. Falls back to the full input rate. + #[serde(default)] + pub cached_input_per_mtok: Option, + /// Rate for generated tokens, reasoning included. + pub output_per_mtok: f64, +} + +impl ModelPrice { + fn cost(&self, tokens: &ModelTokens) -> f64 { + let cached_rate = self.cached_input_per_mtok.unwrap_or(self.input_per_mtok); + (tokens.input as f64 * self.input_per_mtok + + tokens.cached_input as f64 * cached_rate + + tokens.output as f64 * self.output_per_mtok) + / PER_MILLION + } +} + +/// Rates keyed by the model id the server records in the routing log. +pub type PriceTable = BTreeMap; + +/// What the traffic cost, and what it would have cost without routing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Savings { + /// Cost of the calls that actually ran, classifier overhead included. + pub actual: f64, + /// Cost of the same caller-facing calls had they all used the baseline model. + pub baseline: f64, +} + +impl Savings { + /// Dollars kept. Negative when routing overhead outweighed the cheaper tier. + pub fn saved(&self) -> f64 { + self.baseline - self.actual + } + + /// Fraction of the baseline bill avoided, or `None` with nothing to compare. + pub fn percent(&self) -> Option { + (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) + } +} + +/// Prices a period against the baseline model. +/// +/// Returns `None` when any model seen has no entry in the table, since a +/// partial bill would understate cost and overstate savings. +pub fn estimate(totals: &Totals, prices: &PriceTable, baseline_model: &str) -> Option { + let baseline_price = prices.get(baseline_model)?; + + let mut actual = 0.0; + let mut baseline = 0.0; + for (model, tokens) in &totals.routed { + actual += prices.get(model)?.cost(tokens); + baseline += baseline_price.cost(tokens); + } + // Classifier calls are Switchyard's own cost. They have no baseline + // counterpart, so they only ever reduce the savings figure. + for (model, tokens) in &totals.classifier { + actual += prices.get(model)?.cost(tokens); + } + + Some(Savings { actual, baseline }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn price(input: f64, output: f64) -> ModelPrice { + ModelPrice { + input_per_mtok: input, + cached_input_per_mtok: None, + output_per_mtok: output, + } + } + + fn table() -> PriceTable { + PriceTable::from([ + ("sol".to_string(), price(1.25, 10.0)), + ("luna".to_string(), price(0.25, 2.0)), + ("terra".to_string(), price(0.05, 0.4)), + ]) + } + + fn tokens(input: u64, output: u64) -> ModelTokens { + ModelTokens { + requests: 1, + input, + cached_input: 0, + output, + } + } + + #[test] + fn prices_the_cheaper_tier_against_the_capable_baseline() { + let mut totals = Totals::default(); + totals + .routed + .insert("luna".to_string(), tokens(1_000_000, 100_000)); + + let savings = estimate(&totals, &table(), "sol").expect("priced"); + + assert!((savings.actual - 0.45).abs() < 1e-9); + assert!((savings.baseline - 2.25).abs() < 1e-9); + assert!((savings.percent().expect("percent") - 80.0).abs() < 1e-9); + } + + #[test] + fn classifier_overhead_counts_against_savings() { + let mut totals = Totals::default(); + totals + .routed + .insert("luna".to_string(), tokens(1_000_000, 100_000)); + let before = estimate(&totals, &table(), "sol").expect("priced"); + + totals + .classifier + .insert("terra".to_string(), tokens(1_000_000, 10_000)); + let after = estimate(&totals, &table(), "sol").expect("priced"); + + assert_eq!(after.baseline, before.baseline); + assert!(after.saved() < before.saved()); + } + + #[test] + fn cache_reads_use_the_cached_rate() { + let prices = PriceTable::from([( + "sol".to_string(), + ModelPrice { + input_per_mtok: 1.0, + cached_input_per_mtok: Some(0.1), + output_per_mtok: 0.0, + }, + )]); + let mut totals = Totals::default(); + totals.routed.insert( + "sol".to_string(), + ModelTokens { + requests: 1, + input: 1_000_000, + cached_input: 1_000_000, + output: 0, + }, + ); + + let savings = estimate(&totals, &prices, "sol").expect("priced"); + + assert!((savings.actual - 1.1).abs() < 1e-9); + } + + #[test] + fn an_unpriced_model_suppresses_the_estimate() { + let mut totals = Totals::default(); + totals + .routed + .insert("unknown".to_string(), tokens(1_000, 100)); + + assert!(estimate(&totals, &table(), "sol").is_none()); + assert!(estimate(&Totals::default(), &table(), "absent").is_none()); + } + + #[test] + fn an_empty_period_has_no_percentage() { + let savings = estimate(&Totals::default(), &table(), "sol").expect("priced"); + + assert_eq!(savings.saved(), 0.0); + assert!(savings.percent().is_none()); + } +} diff --git a/crates/switchyard-menubar/src/rollup.rs b/crates/switchyard-menubar/src/rollup.rs new file mode 100644 index 000000000..8e3ad22b7 --- /dev/null +++ b/crates/switchyard-menubar/src/rollup.rs @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Token totals for today and the past week, read from the server's routing log. + +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; + +use chrono::{DateTime, Local, NaiveDate}; +use serde::Deserialize; + +/// Tier value the server writes for classifier and judge calls. +const CLASSIFIER_TIER: &str = "classifier"; + +/// Billable token counts for one model. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ModelTokens { + pub requests: u64, + /// Input tokens billed at the full rate. Cache writes bill at that rate + /// too, so they are counted here rather than tracked separately. + pub input: u64, + /// Input tokens served from the provider cache. + pub cached_input: u64, + /// Generated tokens, including reasoning. + pub output: u64, +} + +impl ModelTokens { + /// Every token the provider counted, cached reads included. + pub fn total(&self) -> u64 { + self.input + self.cached_input + self.output + } + + fn add(&mut self, other: &Self) { + self.requests += other.requests; + self.input += other.input; + self.cached_input += other.cached_input; + self.output += other.output; + } +} + +/// Tokens for a period, split by who asked for them. +/// +/// The split matters for savings: without Switchyard the `routed` calls would +/// still have happened, but the `classifier` calls would not exist at all. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Totals { + /// Calls that served the caller, keyed by the model that answered. + pub routed: BTreeMap, + /// Switchyard's own classifier and judge calls, keyed by model. + pub classifier: BTreeMap, +} + +impl Totals { + fn add(&mut self, record: &Record) { + let bucket = if record.tier == CLASSIFIER_TIER { + &mut self.classifier + } else { + &mut self.routed + }; + bucket + .entry(record.model.clone()) + .or_default() + .add(&record.tokens()); + } + + /// Calls made on the caller's behalf. + pub fn requests(&self) -> u64 { + self.routed.values().map(|tokens| tokens.requests).sum() + } + + /// Every token spent, Switchyard's own routing overhead included. + pub fn tokens(&self) -> u64 { + self.routed + .values() + .chain(self.classifier.values()) + .map(ModelTokens::total) + .sum() + } + + /// True when nothing was recorded. + pub fn is_empty(&self) -> bool { + self.routed.is_empty() && self.classifier.is_empty() + } +} + +/// What the menu shows. +#[derive(Clone, Debug, Default)] +pub struct Usage { + pub today: Totals, + pub week: Totals, +} + +/// Fields the rollup needs from one routing log line. +#[derive(Deserialize)] +struct Record { + ts: String, + model: String, + #[serde(default)] + tier: String, + #[serde(default)] + prompt_tokens: u64, + #[serde(default)] + cached_tokens: u64, + #[serde(default)] + completion_tokens: u64, + #[serde(default)] + reasoning_tokens: u64, +} + +impl Record { + fn tokens(&self) -> ModelTokens { + ModelTokens { + requests: 1, + // `prompt_tokens` is the whole input, cache reads included. + input: self.prompt_tokens.saturating_sub(self.cached_tokens), + cached_input: self.cached_tokens, + output: self.completion_tokens + self.reasoning_tokens, + } + } +} + +/// Sums the log into today's and the trailing week's totals. +/// +/// A missing log is not an error: the server creates it on its first request. +/// Unparsable lines are skipped, which also covers the last line while the +/// server is still writing it. +pub fn read(path: &Path, today: NaiveDate) -> std::io::Result { + let file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Usage::default()), + Err(error) => return Err(error), + }; + + let week_start = today - chrono::Duration::days(6); + let mut usage = Usage::default(); + for line in BufReader::new(file).lines() { + let line = line?; + let Ok(record) = serde_json::from_str::(&line) else { + continue; + }; + let Ok(ts) = DateTime::parse_from_rfc3339(&record.ts) else { + continue; + }; + let day = ts.with_timezone(&Local).date_naive(); + if day == today { + usage.today.add(&record); + } + if day >= week_start && day <= today { + usage.week.add(&record); + } + } + Ok(usage) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn date(text: &str) -> NaiveDate { + text.parse().expect("valid date") + } + + /// Builds a log line stamped at a local time, mirroring how the server + /// writes timestamps for events that happened in the local day. + fn line(local_ts: &str, model: &str, tier: &str, prompt: u64, completion: u64) -> String { + let offset = Local::now().offset().to_string(); + format!( + r#"{{"ts":"{local_ts}{offset}","model":"{model}","tier":"{tier}","prompt_tokens":{prompt},"cached_tokens":0,"completion_tokens":{completion},"reasoning_tokens":0}}"# + ) + } + + fn log(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("routing.jsonl"); + std::fs::write(&path, contents).expect("write log"); + (dir, path) + } + + #[test] + fn splits_routed_and_classifier_tokens() { + let (_dir, path) = log(&format!( + "{}\n{}\n", + line("2026-09-28T10:00:00.000", "luna", "", 1_000, 200), + line("2026-09-28T10:00:01.000", "terra", "classifier", 300, 10), + )); + + let usage = read(&path, date("2026-09-28")).expect("read"); + + assert_eq!( + usage.today.requests(), + 1, + "classifier calls are not requests" + ); + assert_eq!(usage.today.routed["luna"].input, 1_000); + assert_eq!(usage.today.classifier["terra"].input, 300); + assert_eq!(usage.today.tokens(), 1_000 + 200 + 300 + 10); + } + + #[test] + fn separates_cache_reads_from_full_price_input() { + let record: Record = serde_json::from_str( + r#"{"ts":"2026-09-28T10:00:00.000Z","model":"m","prompt_tokens":1000,"cached_tokens":600,"completion_tokens":50,"reasoning_tokens":25}"#, + ) + .expect("parse record"); + + let tokens = record.tokens(); + + assert_eq!(tokens.input, 400); + assert_eq!(tokens.cached_input, 600); + assert_eq!(tokens.output, 75, "reasoning tokens are billed as output"); + assert_eq!(tokens.total(), 1_075); + } + + #[test] + fn the_week_covers_seven_days() { + let mut contents = String::new(); + for day in 18..=28 { + contents.push_str(&line( + &format!("2026-09-{day:02}T10:00:00.000"), + "luna", + "", + 100, + 10, + )); + contents.push('\n'); + } + let (_dir, path) = log(&contents); + + let usage = read(&path, date("2026-09-28")).expect("read"); + + assert_eq!(usage.today.requests(), 1); + assert_eq!(usage.week.requests(), 7); + } + + #[test] + fn skips_lines_it_cannot_parse() { + // The trailing line has no newline yet, as when the server is mid-write. + let (_dir, path) = log(&format!( + "not json\n{}\n{{\"ts\":\"2026-09-2", + line("2026-09-28T10:00:00.000", "luna", "", 100, 10), + )); + + let usage = read(&path, date("2026-09-28")).expect("read"); + + assert_eq!(usage.today.requests(), 1); + } + + #[test] + fn a_missing_log_is_not_an_error() { + let dir = tempfile::tempdir().expect("tempdir"); + + let usage = read(&dir.path().join("absent.jsonl"), date("2026-09-28")).expect("read"); + + assert!(usage.today.is_empty()); + } +} diff --git a/crates/switchyard-menubar/src/summary.rs b/crates/switchyard-menubar/src/summary.rs new file mode 100644 index 000000000..ca5896122 --- /dev/null +++ b/crates/switchyard-menubar/src/summary.rs @@ -0,0 +1,278 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Turns totals and prices into the rows the menu shows. + +use crate::config::Config; +use crate::health::ServerStatus; +use crate::pricing::{Savings, estimate}; +use crate::rollup::{Totals, Usage}; + +/// Most model rows shown, so the menu stays short. +const MAX_MODEL_ROWS: usize = 5; + +/// One line of the menu. Rows are informational; the actions are fixed. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Row { + Separator, + Label(String), +} + +fn label(text: impl Into) -> Row { + Row::Label(text.into()) +} + +/// Builds the menu body for the current day and trailing week. +pub fn build(status: ServerStatus, usage: &Usage, config: &Config) -> Vec { + let state = match status { + ServerStatus::Running => "running", + ServerStatus::Stopped => "not responding", + }; + let mut rows = vec![ + label(format!("Server: {state} · {}", config.server_url)), + Row::Separator, + ]; + + if usage.week.is_empty() { + rows.push(label("No requests recorded yet")); + return rows; + } + + rows.extend(period("Today", &usage.today, config)); + rows.push(Row::Separator); + rows.extend(period("This week", &usage.week, config)); + + let models = model_shares(&usage.week); + if !models.is_empty() { + rows.push(Row::Separator); + rows.push(label("This week by model")); + rows.extend(models); + } + + if config.prices.is_empty() { + rows.push(Row::Separator); + rows.push(label("Add prices to menubar.toml to see savings")); + } + rows +} + +fn period(name: &str, totals: &Totals, config: &Config) -> Vec { + let requests = totals.requests(); + let mut rows = vec![label(format!( + "{name} — {requests} request{} · {} tokens", + if requests == 1 { "" } else { "s" }, + tokens(totals.tokens()) + ))]; + if let Some(savings) = estimate(totals, &config.prices, &config.baseline_model) { + rows.push(label(format!(" Saved {}", saved(&savings)))); + } + rows +} + +fn saved(savings: &Savings) -> String { + let amount = money(savings.saved()); + match savings.percent() { + Some(percent) => format!("{amount} ({percent:.0}% of {})", money(savings.baseline)), + None => amount, + } +} + +fn model_shares(week: &Totals) -> Vec { + let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); + if total == 0 { + return Vec::new(); + } + let mut models: Vec<_> = week + .routed + .iter() + .map(|(model, tokens)| (model, tokens.total())) + .collect(); + // Largest share first; model id breaks ties so the order is stable. + models.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + models + .into_iter() + .take(MAX_MODEL_ROWS) + .map(|(model, count)| { + label(format!( + " {model} — {:.0}%", + count as f64 / total as f64 * 100.0 + )) + }) + .collect() +} + +/// Formats a token count compactly, since exact totals are not useful here. +fn tokens(value: u64) -> String { + match value { + 0..=9_999 => value.to_string(), + 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), + 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), + _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), + } +} + +/// Formats dollars, keeping the sign readable when routing cost more than it saved. +fn money(value: f64) -> String { + if value < 0.0 { + format!("-${:.2}", -value) + } else { + format!("${value:.2}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pricing::{ModelPrice, PriceTable}; + use crate::rollup::ModelTokens; + + fn counted(input: u64, output: u64, requests: u64) -> ModelTokens { + ModelTokens { + requests, + input, + cached_input: 0, + output, + } + } + + fn config(prices: PriceTable) -> Config { + Config { + baseline_model: "sol".to_string(), + prices, + ..Config::default() + } + } + + fn priced() -> PriceTable { + PriceTable::from([ + ( + "sol".to_string(), + ModelPrice { + input_per_mtok: 1.25, + cached_input_per_mtok: None, + output_per_mtok: 10.0, + }, + ), + ( + "luna".to_string(), + ModelPrice { + input_per_mtok: 0.25, + cached_input_per_mtok: None, + output_per_mtok: 2.0, + }, + ), + ]) + } + + fn labels(rows: &[Row]) -> Vec { + rows.iter() + .filter_map(|row| match row { + Row::Label(text) => Some(text.clone()), + Row::Separator => None, + }) + .collect() + } + + fn usage_of(today: Totals, week: Totals) -> Usage { + Usage { today, week } + } + + #[test] + fn shows_requests_tokens_and_savings_for_both_periods() { + let mut today = Totals::default(); + today + .routed + .insert("luna".to_string(), counted(1_000_000, 100_000, 3)); + let mut week = Totals::default(); + week.routed + .insert("luna".to_string(), counted(4_000_000, 400_000, 12)); + + let rows = build( + ServerStatus::Running, + &usage_of(today, week), + &config(priced()), + ); + let labels = labels(&rows); + + assert_eq!(labels[0], "Server: running · http://127.0.0.1:4123"); + assert_eq!(labels[1], "Today — 3 requests · 1.1M tokens"); + assert_eq!(labels[2], " Saved $1.80 (80% of $2.25)"); + assert_eq!(labels[3], "This week — 12 requests · 4.4M tokens"); + assert_eq!(labels[4], " Saved $7.20 (80% of $9.00)"); + } + + #[test] + fn hides_savings_and_explains_why_when_prices_are_missing() { + let mut week = Totals::default(); + week.routed + .insert("luna".to_string(), counted(1_000, 100, 1)); + + let rows = build( + ServerStatus::Running, + &usage_of(week.clone(), week), + &config(PriceTable::new()), + ); + let labels = labels(&rows); + + assert!(labels.iter().all(|label| !label.contains("Saved"))); + assert!(labels.iter().any(|label| label.contains("menubar.toml"))); + } + + #[test] + fn ranks_models_by_token_share() { + let mut week = Totals::default(); + week.routed + .insert("luna".to_string(), counted(700_000, 0, 7)); + week.routed + .insert("sol".to_string(), counted(300_000, 0, 3)); + + let rows = build( + ServerStatus::Running, + &usage_of(Totals::default(), week), + &config(priced()), + ); + let models: Vec<_> = labels(&rows) + .into_iter() + .filter(|label| label.starts_with(" ") && label.ends_with('%')) + .collect(); + + assert_eq!(models, vec![" luna — 70%", " sol — 30%"]); + } + + #[test] + fn reports_a_stopped_server_with_no_traffic() { + let rows = build(ServerStatus::Stopped, &Usage::default(), &config(priced())); + let labels = labels(&rows); + + assert!(labels[0].starts_with("Server: not responding")); + assert_eq!(labels[1], "No requests recorded yet"); + } + + #[test] + fn shows_a_loss_when_routing_overhead_exceeds_the_saving() { + let mut today = Totals::default(); + today.routed.insert("sol".to_string(), counted(1_000, 0, 1)); + today + .classifier + .insert("sol".to_string(), counted(1_000_000, 0, 1)); + + let rows = build( + ServerStatus::Running, + &usage_of(today.clone(), today), + &config(priced()), + ); + + assert!(labels(&rows)[2].starts_with(" Saved -$1.25")); + } + + #[test] + fn formats_tokens_and_money_for_reading() { + assert_eq!(tokens(412), "412"); + assert_eq!(tokens(12_345), "12K"); + assert_eq!(tokens(1_400_000), "1.4M"); + assert_eq!(tokens(9_700_000_000), "9.7B"); + + assert_eq!(money(2.181), "$2.18"); + assert_eq!(money(-0.125), "-$0.12"); + } +} diff --git a/crates/switchyard-menubar/src/tray.rs b/crates/switchyard-menubar/src/tray.rs new file mode 100644 index 000000000..6f3181c8e --- /dev/null +++ b/crates/switchyard-menubar/src/tray.rs @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The macOS status item: event loop, menu, and the menu's actions. +//! +//! Everything platform-specific lives here, so the rest of the crate builds +//! and is tested on any target. + +use std::process::Command; +use std::time::{Duration, Instant}; + +use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEventMask}; +use objc2_foundation::{MainThreadMarker, NSDate, NSDefaultRunLoopMode}; +use tray_icon::menu::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem}; +use tray_icon::{Icon, TrayIconBuilder}; + +use crate::app::refresh; +use crate::config::Config; +use crate::icon; +use crate::summary::Row; + +const RESTART: &str = "restart-server"; +const OPEN_CONFIG: &str = "open-config"; +const OPEN_SETTINGS: &str = "open-settings"; +const QUIT: &str = "quit"; + +/// How long the loop blocks waiting for a UI event before checking the clock. +const EVENT_POLL: f64 = 0.1; + +/// Runs the status item until the user quits. +pub fn run(config: Config) -> Result<(), String> { + let mtm = MainThreadMarker::new().ok_or("the menu bar must run on the main thread")?; + let ns_app = NSApplication::sharedApplication(mtm); + // Accessory keeps the process out of the Dock and the app switcher. + ns_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); + + let glyph = Icon::from_rgba(icon::glyph(), icon::SIZE, icon::SIZE) + .map_err(|error| format!("build icon: {error}"))?; + let tray = TrayIconBuilder::new() + .with_icon(glyph) + .with_icon_as_template(true) + .with_tooltip("Switchyard") + .with_menu(Box::new(menu(&refresh(&config))?)) + .build() + .map_err(|error| format!("create status item: {error}"))?; + + ns_app.finishLaunching(); + + let interval = Duration::from_secs(config.refresh_seconds.max(5)); + let mut next = Instant::now() + interval; + loop { + pump_events(&ns_app); + + let mut redraw = false; + while let Ok(event) = MenuEvent::receiver().try_recv() { + match event.id.as_ref() { + QUIT => return Ok(()), + RESTART => report(restart_server(&config)), + OPEN_CONFIG => report(open(&config.config_file)), + OPEN_SETTINGS => report(open(&Config::default_path())), + // Informational rows are disabled, so nothing else fires. + _ => continue, + } + redraw = true; + } + + if redraw || Instant::now() >= next { + tray.set_menu(Some(Box::new(menu(&refresh(&config))?))); + next = Instant::now() + interval; + } + } +} + +/// Restarts the server's LaunchAgent. +fn restart_server(config: &Config) -> Result<(), String> { + let uid = command("id", &["-u"])?; + let target = format!("gui/{}/{}", uid.trim(), config.launchd_label); + command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) +} + +/// Opens a config file in the user's editor. +fn open(path: &std::path::Path) -> Result<(), String> { + command("open", &["-t", &path.display().to_string()]).map(|_| ()) +} + +/// Runs a command, returning its stdout or a message naming what failed. +fn command(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("run {program}: {error}"))?; + if !output.status.success() { + return Err(format!( + "{program} {}: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn report(result: Result<(), String>) { + if let Err(error) = result { + eprintln!("switchyard-menubar: {error}"); + } +} + +/// Builds the whole menu: the summary rows, then the fixed actions. +fn menu(rows: &[Row]) -> Result { + let menu = Menu::new(); + let append = |item: &dyn IsMenuItem| { + menu.append(item) + .map_err(|error| format!("build menu: {error}")) + }; + + for row in rows { + match row { + Row::Separator => append(&PredefinedMenuItem::separator())?, + // Rows are labels, not commands, so they are disabled. + Row::Label(text) => append(&MenuItem::new(text, false, None))?, + } + } + append(&PredefinedMenuItem::separator())?; + for (id, text) in [ + (RESTART, "Restart server"), + (OPEN_CONFIG, "Open server config…"), + (OPEN_SETTINGS, "Open menu bar settings…"), + (QUIT, "Quit"), + ] { + append(&MenuItem::with_id(id, text, true, None))?; + } + Ok(menu) +} + +/// Drains pending AppKit events, blocking briefly when there are none. +fn pump_events(ns_app: &NSApplication) { + let mut expiration = Some(NSDate::dateWithTimeIntervalSinceNow(EVENT_POLL)); + while let Some(event) = unsafe { + ns_app.nextEventMatchingMask_untilDate_inMode_dequeue( + NSEventMask::Any, + expiration.as_deref(), + NSDefaultRunLoopMode, + true, + ) + } { + ns_app.sendEvent(&event); + // Only the first wait blocks; the rest drain what is already queued. + expiration = Some(NSDate::distantPast()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_which_command_failed() { + let error = command("switchyard-does-not-exist", &[]).expect_err("missing program"); + + assert!(error.contains("switchyard-does-not-exist")); + } +} diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh new file mode 100644 index 000000000..3d5e265bd --- /dev/null +++ b/scripts/macos/common.sh @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Paths, markers, and helpers shared by install.sh and uninstall.sh. +# The markers must match between the two, which is why they live here. + +SY_HOME="${SY_HOME:-$HOME/.switchyard}" +SY_PORT="${SY_PORT:-4123}" +SERVER_LABEL="com.nvidia.switchyard.server" +MENUBAR_LABEL="com.nvidia.switchyard.menubar" +LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" +CODEX_CONFIG="$CODEX_DIR/config.toml" +# Codex reads `--profile sy` from its own file next to config.toml. A +# [profiles.sy] table in config.toml is rejected outright as legacy config. +CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" +# Codex.app ignores profiles entirely, so it gets a whole config.toml with the +# routing inlined, plus a snapshot of the unrouted one to swap back to. +CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.toml.sy" +CODEX_DIRECT_CONFIG="$CODEX_DIR/config.toml.direct" +ALIAS_START="# >>> switchyard codex alias >>>" +ALIAS_END="# <<< switchyard codex alias <<<" +PROFILE_START="# >>> switchyard sy profile >>>" +PROFILE_END="# <<< switchyard sy profile <<<" + +say() { printf '%s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } + +# Deletes the marked block, inclusive, leaving the rest of the file alone. +strip_block() { + local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" + if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then + return 1 + fi + if (( DRY_RUN )); then + say " would remove $label from $path" + return 0 + fi + local temp + temp="$(mktemp)" + awk -v start="$start" -v end="$end" ' + index($0, start) { skipping = 1 } + !skipping { print } + index($0, end) { skipping = 0 } + ' "$path" > "$temp" + cat "$temp" > "$path" + rm -f "$temp" + say " removed $label from $path" + return 0 +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh new file mode 100755 index 000000000..873f853bb --- /dev/null +++ b/scripts/macos/install.sh @@ -0,0 +1,370 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Installs the Switchyard background server and its menu bar companion as +# per-user LaunchAgents, sets up a `sy` Codex profile, and aliases `codex` to +# use it. +# +# Every step is idempotent and never overwrites a file you have edited. +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/macos/common.sh +source "$SCRIPT_DIR/common.sh" + +# Runs a command, or prints it when dry running. +run() { + if (( DRY_RUN )); then + say " would run: $*" + else + "$@" + fi +} + +# Writes stdin to a file, leaving an existing file untouched. +write_once() { + local path="$1" + if [[ -f "$path" ]]; then + say " keeping existing $path" + cat >/dev/null + return + fi + if (( DRY_RUN )); then + say " would create $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " created $path" + fi +} + +# Keeps a timestamped copy of a file this script is about to replace. +back_up() { + local path="$1" + [[ -f "$path" ]] || return 0 + if (( DRY_RUN )); then + say " would back up $path" + else + cp "$path" "$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" + say " backed up $path" + fi +} + +# Builds a whole Codex config that routes through Switchyard, derived from the +# config you use now. Codex.app cannot use the sy profile, so it gets a second +# config.toml to swap in instead. See the crate README for why. +# +# Regenerating from an already-swapped config.toml yields the same file, since +# the keys this adds are stripped before they are written back. +write_codex_switchyard_config() { + local source="$1" target="$2" + if [[ ! -f "$source" ]]; then + say " no $source to derive from, skipping" + return + fi + if (( DRY_RUN )); then + say " would write $target" + return + fi + + local body + body="$(mktemp)" + # Drop what this file replaces: the top-level model selection, any + # model_provider, and an earlier copy of the provider table. + awk ' + BEGIN { preamble = 1; skip_table = 0 } + /^[[:space:]]*\[/ { + preamble = 0 + skip_table = ($0 ~ /^[[:space:]]*\[model_providers\.sy\][[:space:]]*\]?[[:space:]]*$/) + } + preamble && /^[[:space:]]*(model|model_provider)[[:space:]]*=/ { next } + !skip_table { print } + ' "$source" > "$body" + + { + echo "# Generated by the Switchyard installer. Do not edit; edit config.toml" + echo "# and reinstall, and this file is rebuilt from it." + echo "#" + echo "# This is config.toml with Switchyard routing inlined, for Codex.app," + echo "# which cannot use the sy profile. Swap it in and out with:" + echo "# cp ~/.codex/config.toml.sy ~/.codex/config.toml # route" + echo "# cp ~/.codex/config.toml.direct ~/.codex/config.toml # stop routing" + echo "model = \"switchyard\"" + echo "model_provider = \"sy\"" + cat "$body" + echo + echo "[model_providers.sy]" + echo "name = \"Switchyard\"" + echo "base_url = \"http://127.0.0.1:$SY_PORT/v1\"" + echo "wire_api = \"responses\"" + echo "requires_openai_auth = true" + } > "$target" + rm -f "$body" + say " wrote $target" +} + +# Writes stdin to a file, replacing it. Used only for files this script owns. +write_file() { + local path="$1" + if (( DRY_RUN )); then + say " would write $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " wrote $path" + fi +} + +if [[ "$(uname -s)" != "Darwin" ]]; then + say "This installer is for macOS only." >&2 + exit 1 +fi + +step "Building release binaries" +run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ + -p switchyard-server -p switchyard-menubar + +step "Installing binaries into $SY_HOME/bin" +run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" +for binary in switchyard-server switchyard-menubar; do + run install -m 755 "$REPO_ROOT/target/release/$binary" "$SY_HOME/bin/$binary" +done + +step "Writing server config" +# The composite router from examples/run_codex.sh: Terra classifies each user +# turn and sets the tier, Stage drives the tool loop underneath it. +write_once "$SY_HOME/composite.toml" <<'EOF' +schema_version = 1 + +[llm_clients.chatgpt_backend] +format = "openai_responses" +base_url = "https://chatgpt.com/backend-api/codex" +forward_auth = true + +[targets.capable] +id = "gpt-5.6-sol" +llm_client = "chatgpt_backend" + +[targets.efficient] +id = "gpt-5.6-luna" +llm_client = "chatgpt_backend" + +# chatgpt.com/backend-api/codex is Codex CLI's own private endpoint, not the +# public OpenAI Responses API. It 400s unless store=false and stream=true are +# set explicitly, and it rejects max_output_tokens outright, so the +# classifier's own token cap has to be dropped before the request goes out. +[targets.terra] +id = "gpt-5.6-terra" +llm_client = "chatgpt_backend" +extra_body = { store = false, stream = true } +omit_body_fields = ["max_output_tokens"] + +[routes.switchyard] +id = "switchyard" +type = "composite" + +[routes.switchyard.classifier] +target = "terra" +base_threshold = 0.5 +classify_trigger = "user_turn" + +[routes.switchyard.stage] +capable_target = "capable" +efficient_target = "efficient" +confidence_threshold = 0.5 +EOF + +step "Writing menu bar settings" +# Rates are list prices per million tokens and are only used to estimate +# savings. Edit them to match what you actually pay. +write_once "$SY_HOME/menubar.toml" < + + + + Label + $SERVER_LABEL + ProgramArguments + + $SY_HOME/bin/switchyard-server + --config + $SY_HOME/composite.toml + --host + 127.0.0.1 + --port + $SY_PORT + --routing-log-file + $SY_HOME/routing.jsonl + + RunAtLoad + + KeepAlive + + ProcessType + Background + StandardOutPath + $SY_HOME/logs/server.log + StandardErrorPath + $SY_HOME/logs/server.err.log + EnvironmentVariables + + RUST_LOG + info + + + +EOF + +# SuccessfulExit false means the Quit menu item stays quit, while a crash is +# still restarted. Aqua-only, since there is no menu bar without a login session. +write_file "$LAUNCH_AGENTS/$MENUBAR_LABEL.plist" < + + + + Label + $MENUBAR_LABEL + ProgramArguments + + $SY_HOME/bin/switchyard-menubar + $SY_HOME/menubar.toml + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + LimitLoadToSessionType + Aqua + StandardOutPath + $SY_HOME/logs/menubar.log + StandardErrorPath + $SY_HOME/logs/menubar.err.log + + +EOF + +step "Loading LaunchAgents" +for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do + if (( DRY_RUN )); then + say " would reload gui/$UID/$label" + continue + fi + launchctl bootout "gui/$UID/$label" 2>/dev/null || true + # bootout returns before the job leaves the domain, and bootstrapping a + # service that is still shutting down fails with an I/O error. + for _ in $(seq 50); do + launchctl print "gui/$UID/$label" >/dev/null 2>&1 || break + sleep 0.1 + done + launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$label.plist" + say " loaded $label" +done + +step "Adding the sy Codex profile" +# `codex --profile sy` reads ~/.codex/sy.config.toml. A [profiles.sy] table in +# config.toml is legacy config that Codex now refuses to start with, so an +# earlier install of this script has to be cleaned up first. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || true + +# This profile only changes which router answers. Approval and sandbox +# settings are deliberately left out, so the profile cannot loosen how Codex +# asks before it acts. Set those yourself if you want them. +back_up "$CODEX_PROFILE_CONFIG" +write_file "$CODEX_PROFILE_CONFIG" <> "$rc" + say " added the codex alias to $rc" + fi +done + +step "Done" +say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" +say "Settings: $SY_HOME/menubar.toml" +say "Look for the Switchyard glyph in the menu bar." +say "Open a new shell, or run: alias codex=\"codex --profile sy\"" +say "" +say "To route Codex.app too, swap its config:" +say " cp $CODEX_SWITCHYARD_CONFIG $CODEX_CONFIG" +say " cp $CODEX_DIRECT_CONFIG $CODEX_CONFIG # to stop routing" +say "Restart Codex.app after swapping." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh new file mode 100755 index 000000000..e36efdf61 --- /dev/null +++ b/scripts/macos/uninstall.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Removes what install.sh added: the two LaunchAgents, the `sy` Codex profile, +# and the codex alias. Your config, routing log, and binaries stay put; the +# paths are printed so you can delete them yourself. +# +# Run with --dry-run to print what would happen. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +DRY_RUN=0 +[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=1 + +# shellcheck source=scripts/macos/common.sh +source "$SCRIPT_DIR/common.sh" + +# Deletes a file this script owns. +remove_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + say " nothing to remove at $path" + elif (( DRY_RUN )); then + say " would delete $path" + else + rm -f "$path" + say " deleted $path" + fi +} + +step "Unloading LaunchAgents" +for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do + if (( DRY_RUN )); then + say " would unload gui/$UID/$label and delete its plist" + else + launchctl bootout "gui/$UID/$label" 2>/dev/null || true + rm -f "$LAUNCH_AGENTS/$label.plist" + say " unloaded $label" + fi +done + +step "Removing the sy Codex profile" +remove_file "$CODEX_PROFILE_CONFIG" +remove_file "$CODEX_SWITCHYARD_CONFIG" +# Older installs of this script put the profile in config.toml instead. +strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profile" || + say " no legacy profile in $CODEX_CONFIG" + +step "Unrouting Codex.app" +# Leaving a routed config.toml behind would point Codex.app at a server that +# is no longer running, so put the snapshot back before the agents go away. +if [[ -f "$CODEX_CONFIG" ]] && grep -qE '^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"sy"' "$CODEX_CONFIG"; then + if [[ ! -f "$CODEX_DIRECT_CONFIG" ]]; then + say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" + elif (( DRY_RUN )); then + say " would restore $CODEX_DIRECT_CONFIG over config.toml" + else + cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" + rm -f "$CODEX_DIRECT_CONFIG" + say " restored the unrouted config.toml" + fi +else + remove_file "$CODEX_DIRECT_CONFIG" +fi + +step "Removing the codex alias" +for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do + strip_block "$rc" "$ALIAS_START" "$ALIAS_END" "the codex alias" || + say " no alias in $rc" +done + +step "Done" +say "Left in place, delete them if you want:" +say " $SY_HOME (binaries, config, routing log, logs)" +say " $CODEX_CONFIG.switchyard-backup.* (backups taken at install time)" From bdd2cfe4e11136b039aba1cffb802169ecb6fd8d Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 16:47:56 -0400 Subject: [PATCH 2/8] chore: add macos to make help Signed-off-by: Greg Clark --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index d4bb80724..7136e3a26 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,9 @@ help: @echo "install-linux Install the systemd user service and Codex profile" @echo "install-linux-dry-run Preview installation without changes" @echo "uninstall-linux Remove the service and Codex profile" + @echo "install-macos Install the background server and menu bar app" + @echo "install-macos-dry-run Preview installation without changes" + @echo "uninstall-macos Remove the LaunchAgents and Codex profile" ## Install the Switchyard background server as a systemd user service. install-linux: From 368b903e52ff6fe62f369e3e8ac10f08e3d32fac Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 17:03:31 -0400 Subject: [PATCH 3/8] refactor(macos): separate menu bar from daemon install Signed-off-by: Greg Clark --- Cargo.lock | 1100 +--------------------- Cargo.toml | 1 - Makefile | 6 +- crates/switchyard-menubar/Cargo.toml | 29 - crates/switchyard-menubar/README.md | 87 -- crates/switchyard-menubar/src/app.rs | 53 -- crates/switchyard-menubar/src/config.rs | 146 --- crates/switchyard-menubar/src/health.rs | 101 -- crates/switchyard-menubar/src/icon.rs | 68 -- crates/switchyard-menubar/src/main.rs | 78 -- crates/switchyard-menubar/src/pricing.rs | 186 ---- crates/switchyard-menubar/src/rollup.rs | 259 ----- crates/switchyard-menubar/src/summary.rs | 278 ------ crates/switchyard-menubar/src/tray.rs | 162 ---- scripts/macos/common.sh | 1 - scripts/macos/install.sh | 94 +- scripts/macos/uninstall.sh | 18 +- 17 files changed, 55 insertions(+), 2612 deletions(-) delete mode 100644 crates/switchyard-menubar/Cargo.toml delete mode 100644 crates/switchyard-menubar/README.md delete mode 100644 crates/switchyard-menubar/src/app.rs delete mode 100644 crates/switchyard-menubar/src/config.rs delete mode 100644 crates/switchyard-menubar/src/health.rs delete mode 100644 crates/switchyard-menubar/src/icon.rs delete mode 100644 crates/switchyard-menubar/src/main.rs delete mode 100644 crates/switchyard-menubar/src/pricing.rs delete mode 100644 crates/switchyard-menubar/src/rollup.rs delete mode 100644 crates/switchyard-menubar/src/summary.rs delete mode 100644 crates/switchyard-menubar/src/tray.rs diff --git a/Cargo.lock b/Cargo.lock index f4a4ebcab..4e167baa1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "ahash" version = "0.8.12" @@ -37,15 +31,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - [[package]] name = "anstream" version = "1.0.0" @@ -154,29 +139,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -316,15 +278,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "borrow-or-share" version = "0.2.4" @@ -349,31 +302,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - [[package]] name = "cc" version = "1.2.67" @@ -386,16 +314,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon 0.12.16", -] - [[package]] name = "cfg-if" version = "1.0.4" @@ -425,10 +343,8 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "iana-time-zone", "num-traits", "serde", - "windows-link", ] [[package]] @@ -459,7 +375,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.119", @@ -521,30 +437,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc32fast" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" - [[package]] name = "data-encoding" version = "2.11.1" @@ -575,37 +467,6 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" -[[package]] -name = "dirs" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -617,12 +478,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - [[package]] name = "dunce" version = "1.0.5" @@ -657,7 +512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -677,42 +532,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fdeflate" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" -dependencies = [ - "crc32fast", - "miniz_oxide 0.9.1", - "zlib-rs", -] - [[package]] name = "fluent-uri" version = "0.4.1" @@ -859,64 +684,6 @@ dependencies = [ "slab", ] -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -958,148 +725,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "h2" version = "0.4.15" @@ -1130,12 +755,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -1259,30 +878,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1555,90 +1150,18 @@ dependencies = [ "serde_json", ] -[[package]] -name = "keyboard-types" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" -dependencies = [ - "bitflags", - "serde", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading", - "once_cell", -] - [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[package]] -name = "libredox" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" -dependencies = [ - "libc", -] - -[[package]] -name = "libxdo" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db" -dependencies = [ - "libxdo-sys", -] - -[[package]] -name = "libxdo-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212" -dependencies = [ - "libc", - "x11", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -1693,15 +1216,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - [[package]] name = "micromap" version = "0.3.0" @@ -1714,26 +1228,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "miniz_oxide" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.2" @@ -1745,27 +1239,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "muda" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "libxdo", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png", - "thiserror 2.0.18", - "windows-sys 0.61.2", -] - [[package]] name = "nemo-relay-plugin" version = "0.8.4" @@ -1862,185 +1335,33 @@ dependencies = [ ] [[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-text", - "objc2-core-video", - "objc2-foundation", - "objc2-quartz-core", -] - -[[package]] -name = "objc2-cloud-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-data" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" -dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-text" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-core-video" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", + "num-bigint", + "num-integer", + "num-traits", ] [[package]] -name = "objc2-io-surface" -version = "0.3.2" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", + "autocfg", ] [[package]] -name = "objc2-quartz-core" -version = "0.3.2" +name = "num_cpus" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "bitflags", - "objc2", - "objc2-foundation", + "hermit-abi", + "libc", ] [[package]] @@ -2145,43 +1466,12 @@ dependencies = [ "tokio", ] -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - [[package]] name = "outref" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -2223,19 +1513,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -2284,50 +1561,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2429,7 +1662,7 @@ version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ - "target-lexicon 0.13.5", + "target-lexicon", ] [[package]] @@ -2460,7 +1693,7 @@ version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "pyo3-build-config", "quote", @@ -2531,7 +1764,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2619,16 +1852,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" -dependencies = [ - "libredox", - "thiserror 2.0.18", -] - [[package]] name = "ref-cast" version = "1.0.26" @@ -2775,7 +1998,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2832,7 +2055,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2973,15 +2196,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_spanned" version = "1.1.1" @@ -3028,12 +2242,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - [[package]] name = "simd_cesu8" version = "1.2.0" @@ -3099,7 +2307,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.119", @@ -3162,21 +2370,6 @@ dependencies = [ "wiremock", ] -[[package]] -name = "switchyard-menubar" -version = "0.3.0" -dependencies = [ - "chrono", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "serde", - "serde_json", - "tempfile", - "toml 0.9.12+spec-1.1.0", - "tray-icon", -] - [[package]] name = "switchyard-nemo-relay-plugin" version = "0.3.0" @@ -3192,7 +2385,7 @@ dependencies = [ "switchyard-translation", "tempfile", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml", "wiremock", ] @@ -3241,7 +2434,7 @@ dependencies = [ "switchyard-protocol", "thiserror 2.0.18", "tokio", - "toml 1.1.3+spec-1.1.0", + "toml", "tracing", ] @@ -3323,16 +2516,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -3375,25 +2558,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.2", - "version-compare", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - [[package]] name = "target-lexicon" version = "0.13.5" @@ -3410,7 +2574,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3550,33 +2714,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - [[package]] name = "toml" version = "1.1.3+spec-1.1.0" @@ -3585,29 +2722,11 @@ checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.4", -] - -[[package]] -name = "toml_datetime" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", + "winnow", ] [[package]] @@ -3619,37 +2738,13 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] @@ -3782,27 +2877,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "tray-icon" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png", - "thiserror 2.0.18", - "windows-sys 0.61.2", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -3899,12 +2973,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - [[package]] name = "version_check" version = "0.9.5" @@ -4048,70 +3116,13 @@ dependencies = [ "rustls-pki-types", ] -[[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-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "windows-sys 0.52.0", ] [[package]] @@ -4120,24 +3131,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -4220,21 +3213,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" @@ -4276,16 +3254,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - [[package]] name = "yansi" version = "1.0.1" @@ -4395,12 +3363,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "zlib-rs" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" - [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 26cea2f35..a3c8c9d02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,6 @@ members = [ "crates/protocol", "crates/switchyard-nemo-relay-plugin", "crates/switchyard-runner", - "crates/switchyard-menubar", "crates/switchyard-server", "crates/switchyard-skill-distillation", "crates/switchyard-soak", diff --git a/Makefile b/Makefile index 7136e3a26..a0dd77668 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ help: @echo "install-linux Install the systemd user service and Codex profile" @echo "install-linux-dry-run Preview installation without changes" @echo "uninstall-linux Remove the service and Codex profile" - @echo "install-macos Install the background server and menu bar app" + @echo "install-macos Install the background server" @echo "install-macos-dry-run Preview installation without changes" @echo "uninstall-macos Remove the LaunchAgents and Codex profile" @@ -24,7 +24,7 @@ install-linux-dry-run: uninstall-linux: @scripts/linux/uninstall.sh -## Install the Switchyard background server and menu bar app for this user. +## Install the Switchyard background server for this user. install-macos: @scripts/macos/install.sh @@ -32,6 +32,6 @@ install-macos: install-macos-dry-run: @scripts/macos/install.sh --dry-run -## Remove the LaunchAgents, the sy Codex profile, and the codex alias. +## Remove the LaunchAgent, the sy Codex profile, and the codex alias. uninstall-macos: @scripts/macos/uninstall.sh diff --git a/crates/switchyard-menubar/Cargo.toml b/crates/switchyard-menubar/Cargo.toml deleted file mode 100644 index d12551f0d..000000000 --- a/crates/switchyard-menubar/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[package] -name = "switchyard-menubar" -version.workspace = true -description = "macOS menu bar companion for a locally running Switchyard server" -authors.workspace = true -edition.workspace = true -homepage = "https://github.com/NVIDIA-NeMo/Switchyard" -license.workspace = true -repository.workspace = true -rust-version.workspace = true -publish = false - -[dependencies] -chrono = { version = "0.4", default-features = false, features = ["std", "clock"] } -serde.workspace = true -serde_json.workspace = true -toml = "0.9" - -[target.'cfg(target_os = "macos")'.dependencies] -objc2 = "0.6" -objc2-app-kit = "0.3" -objc2-foundation = "0.3" -tray-icon = "0.25" - -[dev-dependencies] -tempfile = "3" diff --git a/crates/switchyard-menubar/README.md b/crates/switchyard-menubar/README.md deleted file mode 100644 index 2472746ed..000000000 --- a/crates/switchyard-menubar/README.md +++ /dev/null @@ -1,87 +0,0 @@ -# switchyard-menubar - -A macOS menu bar companion for a Switchyard server running in the background. - -The server does the routing. This process only reads what the server already -wrote, so quitting it never affects traffic. - -Click the glyph to see whether the server is answering, today's and this -week's requests and tokens, estimated savings, and which models served the -week. It can also restart the server and open either config file. - -## Install - -From the repository root: - -```sh -make install-macos # or install-macos-dry-run to see the steps first -make uninstall-macos -``` - -That builds `switchyard-server` and `switchyard-menubar` into -`~/.switchyard/bin`, writes a server config and menu bar settings, loads two -LaunchAgents, writes a `sy` Codex profile, and aliases `codex` to use it. - -Your server config and menu bar settings are never overwritten once they -exist, so edits survive a reinstall. Logs are in `~/.switchyard/logs/`. - -## Settings - -`~/.switchyard/menubar.toml`. Any key may be omitted. - -```toml -server_url = "http://127.0.0.1:4123" -routing_log = "~/.switchyard/routing.jsonl" -baseline_model = "gpt-5.6-sol" -refresh_seconds = 30 - -[prices."gpt-5.6-sol"] -input_per_mtok = 1.25 -cached_input_per_mtok = 0.125 -output_per_mtok = 10.0 -``` - -## How savings are computed - -The server writes one JSONL record per call, with the model that answered and -its token counts. - -- **Actual** is every call priced at the model that served it, including - Switchyard's own classifier calls. -- **Baseline** is the caller-facing calls only, priced as if each had used - `baseline_model`. Classifier calls have no baseline counterpart, because - without Switchyard they would not happen. - -Savings are the difference, so routing overhead counts against the figure and -a bad day shows a negative number. Dollar figures stay hidden until every -model seen has a price, so a partial table cannot mislead. - -These are list-price estimates. On a ChatGPT login there is no per-token bill, -so read them as "what this traffic would have cost at API rates". - -## Codex profiles - -`codex --profile sy` reads `~/.codex/sy.config.toml`. A `[profiles.sy]` table -inside `config.toml` is legacy config the CLI now refuses to start with, so the -installer removes one if an older version left it there. - -The profile sets only `model` and `model_provider`. It deliberately leaves -`approval_policy` and `sandbox_mode` alone, because routing should not quietly -change how Codex asks before it acts. - -**Codex.app cannot use the profile.** It still writes profiles in the legacy -format its own bundled CLI rejects. So it gets a whole config instead: the -installer builds `~/.codex/config.toml.sy`, which is your `config.toml` with -the routing inlined at the top level, the one format both accept. Swap it in -and restart the app: - -```sh -cp ~/.codex/config.toml.sy ~/.codex/config.toml # route -cp ~/.codex/config.toml.direct ~/.codex/config.toml # back to normal -``` - -`config.toml.direct` is a snapshot of your unrouted config, never taken while -`config.toml` is already routed, and restored by `make uninstall-macos`. - -Swapping routes everything the app does. Unlike the CLI alias, there is no -unrouted escape hatch while it is in place. diff --git a/crates/switchyard-menubar/src/app.rs b/crates/switchyard-menubar/src/app.rs deleted file mode 100644 index 1c96407ac..000000000 --- a/crates/switchyard-menubar/src/app.rs +++ /dev/null @@ -1,53 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Ties the pieces together: read the log, probe the server, build the rows. - -use chrono::Local; - -use crate::config::Config; -use crate::health::probe; -use crate::rollup; -use crate::summary::{Row, build}; - -/// Rereads the log, probes the server, and builds the menu body. -pub fn refresh(config: &Config) -> Vec { - let usage = - rollup::read(&config.routing_log, Local::now().date_naive()).unwrap_or_else(|error| { - eprintln!( - "switchyard-menubar: read {}: {error}", - config.routing_log.display() - ); - rollup::Usage::default() - }); - build(probe(&config.server_url), &usage, config) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_the_configured_log() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("routing.jsonl"); - let ts = Local::now().to_rfc3339(); - std::fs::write( - &path, - format!( - r#"{{"ts":"{ts}","model":"luna","tier":"","prompt_tokens":1000,"cached_tokens":0,"completion_tokens":100,"reasoning_tokens":0}}"# - ) + "\n", - ) - .expect("write log"); - - let rows = refresh(&Config { - routing_log: path, - ..Config::default() - }); - - assert!( - rows.contains(&Row::Label("Today — 1 request · 1100 tokens".to_string())), - "unexpected rows: {rows:?}" - ); - } -} diff --git a/crates/switchyard-menubar/src/config.rs b/crates/switchyard-menubar/src/config.rs deleted file mode 100644 index 6e10dc653..000000000 --- a/crates/switchyard-menubar/src/config.rs +++ /dev/null @@ -1,146 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Menu bar settings, read from `~/.switchyard/menubar.toml`. - -use std::path::{Path, PathBuf}; - -use serde::Deserialize; - -use crate::pricing::PriceTable; - -/// Everything the menu bar needs to find the server and price its traffic. -/// Missing keys fall back to [`Config::default`]. -#[derive(Clone, Debug, Deserialize)] -#[serde(default, deny_unknown_fields)] -pub struct Config { - /// Base URL the server listens on, used for the health check. - pub server_url: String, - /// JSONL file the server appends routing records to. - pub routing_log: PathBuf, - /// Server TOML, opened by the "Open server config" menu item. - pub config_file: PathBuf, - /// LaunchAgent label used to restart the server. - pub launchd_label: String, - /// How often the menu contents are recomputed. - pub refresh_seconds: u64, - /// Model the traffic is assumed to have used without Switchyard. - pub baseline_model: String, - /// Per-model rates. Dollar figures are hidden while a seen model is absent. - pub prices: PriceTable, -} - -impl Default for Config { - fn default() -> Self { - Self { - server_url: "http://127.0.0.1:4123".to_string(), - routing_log: PathBuf::from("~/.switchyard/routing.jsonl"), - config_file: PathBuf::from("~/.switchyard/composite.toml"), - launchd_label: "com.nvidia.switchyard.server".to_string(), - refresh_seconds: 30, - baseline_model: "gpt-5.6-sol".to_string(), - prices: PriceTable::new(), - } - } -} - -impl Config { - /// Parses settings, falling back to defaults when the file is absent. - pub fn load(path: &Path) -> Result { - let text = match std::fs::read_to_string(path) { - Ok(text) => text, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - return Ok(Self::default()); - } - Err(error) => return Err(format!("read {}: {error}", path.display())), - }; - let mut config: Self = - toml::from_str(&text).map_err(|error| format!("parse {}: {error}", path.display()))?; - config.routing_log = expand_home(&config.routing_log); - config.config_file = expand_home(&config.config_file); - Ok(config) - } - - /// Default settings path, `~/.switchyard/menubar.toml`. - pub fn default_path() -> PathBuf { - expand_home(Path::new("~/.switchyard/menubar.toml")) - } -} - -/// Rewrites a leading `~`. Paths are written by hand in the settings file, -/// where `~` is the natural way to spell a home path. -pub fn expand_home(path: &Path) -> PathBuf { - let home = std::env::var_os("HOME").map_or_else(|| PathBuf::from("."), PathBuf::from); - expand_under(path, &home) -} - -fn expand_under(path: &Path, home: &Path) -> PathBuf { - match path.strip_prefix("~") { - Ok(rest) => home.join(rest), - Err(_) => path.to_path_buf(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reads_settings_and_prices() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("menubar.toml"); - std::fs::write( - &path, - r#" -server_url = "http://127.0.0.1:9000" -baseline_model = "sol" - -[prices.luna] -input_per_mtok = 0.25 -cached_input_per_mtok = 0.025 -output_per_mtok = 2.0 -"#, - ) - .expect("write settings"); - - let config = Config::load(&path).expect("load"); - - assert_eq!(config.server_url, "http://127.0.0.1:9000"); - assert_eq!(config.baseline_model, "sol"); - assert_eq!(config.prices["luna"].cached_input_per_mtok, Some(0.025)); - assert_eq!(config.refresh_seconds, 30, "unset keys keep their default"); - } - - #[test] - fn a_missing_file_yields_defaults() { - let dir = tempfile::tempdir().expect("tempdir"); - - let config = Config::load(&dir.path().join("absent.toml")).expect("load"); - - assert_eq!(config.server_url, "http://127.0.0.1:4123"); - assert!(config.prices.is_empty()); - } - - #[test] - fn rejects_unparsable_settings() { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("menubar.toml"); - std::fs::write(&path, "server_url = ").expect("write settings"); - - assert!(Config::load(&path).is_err()); - } - - #[test] - fn expands_a_leading_tilde() { - let home = Path::new("/Users/example"); - - assert_eq!( - expand_under(Path::new("~/.switchyard/routing.jsonl"), home), - PathBuf::from("/Users/example/.switchyard/routing.jsonl") - ); - assert_eq!( - expand_under(Path::new("/tmp/routing.jsonl"), home), - PathBuf::from("/tmp/routing.jsonl") - ); - } -} diff --git a/crates/switchyard-menubar/src/health.rs b/crates/switchyard-menubar/src/health.rs deleted file mode 100644 index 1f13aee0b..000000000 --- a/crates/switchyard-menubar/src/health.rs +++ /dev/null @@ -1,101 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Liveness probe against the local server's `/health` endpoint. -//! -//! The request is written by hand over TCP. The server is always on loopback -//! over plain HTTP, so a full HTTP client would buy nothing here. - -use std::io::{BufRead, BufReader, Write}; -use std::net::{TcpStream, ToSocketAddrs}; -use std::time::Duration; - -/// How long the probe waits before calling the server unreachable. Short, -/// because it runs on the thread that draws the menu. -const TIMEOUT: Duration = Duration::from_millis(500); - -/// Whether the server answered its health check. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum ServerStatus { - Running, - Stopped, -} - -/// Probes `GET /health`, treating any failure as a stopped server. -pub fn probe(server_url: &str) -> ServerStatus { - match request(server_url) { - Ok(true) => ServerStatus::Running, - _ => ServerStatus::Stopped, - } -} - -fn request(server_url: &str) -> std::io::Result { - // Strip the scheme and any path, then default the port. - let rest = server_url.rsplit("://").next().unwrap_or(server_url); - let mut authority = rest.split('/').next().unwrap_or(rest).to_string(); - if !authority.contains(':') { - authority.push_str(":4123"); - } - - let address = authority - .to_socket_addrs()? - .next() - .ok_or_else(|| std::io::Error::other(format!("no address for {authority}")))?; - let mut stream = TcpStream::connect_timeout(&address, TIMEOUT)?; - stream.set_read_timeout(Some(TIMEOUT))?; - stream.set_write_timeout(Some(TIMEOUT))?; - // HTTP/1.0 needs no Host header and closes the connection for us. - stream.write_all(b"GET /health HTTP/1.0\r\n\r\n")?; - - let mut status_line = String::new(); - BufReader::new(stream).read_line(&mut status_line)?; - Ok(status_line.contains(" 200")) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::net::TcpListener; - - /// Serves one canned response, then closes. - fn serve(response: &'static str) -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - let _ = stream.write_all(response.as_bytes()); - } - }); - format!("http://127.0.0.1:{port}") - } - - #[test] - fn reports_running_only_on_a_200() { - let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); - let down = serve("HTTP/1.1 503 Service Unavailable\r\n\r\n"); - - assert_eq!(probe(&ok), ServerStatus::Running); - assert_eq!(probe(&down), ServerStatus::Stopped); - } - - #[test] - fn reports_stopped_when_nothing_is_listening() { - // Binding then dropping yields a port with no listener. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - drop(listener); - - assert_eq!( - probe(&format!("http://127.0.0.1:{port}")), - ServerStatus::Stopped - ); - } - - #[test] - fn accepts_a_url_with_a_path_or_no_port() { - let ok = serve("HTTP/1.1 200 OK\r\n\r\n"); - - assert_eq!(probe(&format!("{ok}/v1")), ServerStatus::Running); - assert_eq!(probe("http://localhost"), ServerStatus::Stopped); - } -} diff --git a/crates/switchyard-menubar/src/icon.rs b/crates/switchyard-menubar/src/icon.rs deleted file mode 100644 index 5139e4ad0..000000000 --- a/crates/switchyard-menubar/src/icon.rs +++ /dev/null @@ -1,68 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! The menu bar glyph: a track that splits in two, drawn in code. -//! -//! macOS scales the image to 18 points tall, so it is drawn at 2x for retina -//! displays. It is a template image, so only the alpha channel matters and the -//! system recolors it for light and dark menu bars. - -/// Side length of the generated image, in pixels. -pub const SIZE: u32 = 36; - -/// Half the stroke width, in pixels. -const HALF_STROKE: f64 = 1.7; - -const SEGMENTS: [((f64, f64), (f64, f64)); 3] = [ - ((5.0, 18.0), (17.0, 18.0)), - ((17.0, 18.0), (30.0, 7.0)), - ((17.0, 18.0), (30.0, 29.0)), -]; - -/// Renders the glyph as RGBA pixels, row-major from the top left. -pub fn glyph() -> Vec { - let mut pixels = Vec::with_capacity((SIZE * SIZE * 4) as usize); - for y in 0..SIZE { - for x in 0..SIZE { - let point = (f64::from(x) + 0.5, f64::from(y) + 0.5); - let distance = SEGMENTS - .iter() - .map(|(start, end)| distance_to_segment(point, *start, *end)) - .fold(f64::INFINITY, f64::min); - // One pixel of falloff past the stroke edge softens the diagonals. - let coverage = (HALF_STROKE + 0.5 - distance).clamp(0.0, 1.0); - pixels.extend_from_slice(&[0, 0, 0, (coverage * 255.0).round() as u8]); - } - } - pixels -} - -fn distance_to_segment(point: (f64, f64), start: (f64, f64), end: (f64, f64)) -> f64 { - let (dx, dy) = (end.0 - start.0, end.1 - start.1); - let length_squared = dx * dx + dy * dy; - let along = ((point.0 - start.0) * dx + (point.1 - start.1) * dy) / length_squared; - let along = along.clamp(0.0, 1.0); - let (cx, cy) = (start.0 + dx * along, start.1 + dy * along); - ((point.0 - cx).powi(2) + (point.1 - cy).powi(2)).sqrt() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn alpha(pixels: &[u8], x: u32, y: u32) -> u8 { - pixels[((y * SIZE + x) * 4 + 3) as usize] - } - - #[test] - fn draws_a_trunk_that_splits_in_two() { - let pixels = glyph(); - - assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize); - assert_eq!(alpha(&pixels, 10, 18), 255, "the trunk is solid"); - assert!(alpha(&pixels, 29, 7) > 0, "upper branch reaches its end"); - assert!(alpha(&pixels, 29, 29) > 0, "lower branch reaches its end"); - assert_eq!(alpha(&pixels, 29, 18), 0, "nothing runs straight through"); - assert_eq!(alpha(&pixels, 0, 0), 0, "corners stay transparent"); - } -} diff --git a/crates/switchyard-menubar/src/main.rs b/crates/switchyard-menubar/src/main.rs deleted file mode 100644 index 7a6c91364..000000000 --- a/crates/switchyard-menubar/src/main.rs +++ /dev/null @@ -1,78 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Menu bar companion for a locally running Switchyard server. -//! -//! The server owns routing; this process only reads what the server already -//! wrote. It shows today's and this week's traffic, and what that traffic -//! would have cost had every call gone to the capable model instead. - -mod app; -mod config; -mod health; -mod pricing; -mod rollup; -mod summary; -// The status item and the glyph it draws are the only platform-specific code. -#[cfg(target_os = "macos")] -mod icon; -#[cfg(target_os = "macos")] -mod tray; - -use std::path::PathBuf; -use std::process::ExitCode; - -use config::Config; - -const USAGE: &str = "\ -Usage: switchyard-menubar [--print] [SETTINGS_FILE] - -Shows Switchyard usage and estimated savings in the macOS menu bar. - - SETTINGS_FILE Settings TOML [default: ~/.switchyard/menubar.toml] - --print Print the current summary and exit, instead of running"; - -fn main() -> ExitCode { - let mut settings = None; - let mut print_only = false; - for arg in std::env::args().skip(1) { - match arg.as_str() { - "-h" | "--help" => { - println!("{USAGE}"); - return ExitCode::SUCCESS; - } - "--print" => print_only = true, - _ if arg.starts_with('-') => { - eprintln!("switchyard-menubar: unknown option {arg}\n\n{USAGE}"); - return ExitCode::FAILURE; - } - _ => settings = Some(PathBuf::from(arg)), - } - } - - let config = match Config::load(&settings.unwrap_or_else(Config::default_path)) { - Ok(config) => config, - Err(error) => { - eprintln!("switchyard-menubar: {error}"); - return ExitCode::FAILURE; - } - }; - // Off macOS there is no menu bar to attach to, so printing is the whole - // program. That keeps the rollup and pricing logic buildable everywhere. - if print_only || !cfg!(target_os = "macos") { - for row in app::refresh(&config) { - match row { - summary::Row::Separator => println!(), - summary::Row::Label(text) => println!("{text}"), - } - } - return ExitCode::SUCCESS; - } - - #[cfg(target_os = "macos")] - if let Err(error) = tray::run(config) { - eprintln!("switchyard-menubar: {error}"); - return ExitCode::FAILURE; - } - ExitCode::SUCCESS -} diff --git a/crates/switchyard-menubar/src/pricing.rs b/crates/switchyard-menubar/src/pricing.rs deleted file mode 100644 index e551bdd71..000000000 --- a/crates/switchyard-menubar/src/pricing.rs +++ /dev/null @@ -1,186 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Counterfactual cost: what routed traffic cost against what it would have -//! cost had every call gone to one capable model. - -use std::collections::BTreeMap; - -use serde::Deserialize; - -use crate::rollup::{ModelTokens, Totals}; - -const PER_MILLION: f64 = 1_000_000.0; - -/// Per-million-token rates for one model. -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] -pub struct ModelPrice { - pub input_per_mtok: f64, - /// Rate for cache reads. Falls back to the full input rate. - #[serde(default)] - pub cached_input_per_mtok: Option, - /// Rate for generated tokens, reasoning included. - pub output_per_mtok: f64, -} - -impl ModelPrice { - fn cost(&self, tokens: &ModelTokens) -> f64 { - let cached_rate = self.cached_input_per_mtok.unwrap_or(self.input_per_mtok); - (tokens.input as f64 * self.input_per_mtok - + tokens.cached_input as f64 * cached_rate - + tokens.output as f64 * self.output_per_mtok) - / PER_MILLION - } -} - -/// Rates keyed by the model id the server records in the routing log. -pub type PriceTable = BTreeMap; - -/// What the traffic cost, and what it would have cost without routing. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Savings { - /// Cost of the calls that actually ran, classifier overhead included. - pub actual: f64, - /// Cost of the same caller-facing calls had they all used the baseline model. - pub baseline: f64, -} - -impl Savings { - /// Dollars kept. Negative when routing overhead outweighed the cheaper tier. - pub fn saved(&self) -> f64 { - self.baseline - self.actual - } - - /// Fraction of the baseline bill avoided, or `None` with nothing to compare. - pub fn percent(&self) -> Option { - (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) - } -} - -/// Prices a period against the baseline model. -/// -/// Returns `None` when any model seen has no entry in the table, since a -/// partial bill would understate cost and overstate savings. -pub fn estimate(totals: &Totals, prices: &PriceTable, baseline_model: &str) -> Option { - let baseline_price = prices.get(baseline_model)?; - - let mut actual = 0.0; - let mut baseline = 0.0; - for (model, tokens) in &totals.routed { - actual += prices.get(model)?.cost(tokens); - baseline += baseline_price.cost(tokens); - } - // Classifier calls are Switchyard's own cost. They have no baseline - // counterpart, so they only ever reduce the savings figure. - for (model, tokens) in &totals.classifier { - actual += prices.get(model)?.cost(tokens); - } - - Some(Savings { actual, baseline }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn price(input: f64, output: f64) -> ModelPrice { - ModelPrice { - input_per_mtok: input, - cached_input_per_mtok: None, - output_per_mtok: output, - } - } - - fn table() -> PriceTable { - PriceTable::from([ - ("sol".to_string(), price(1.25, 10.0)), - ("luna".to_string(), price(0.25, 2.0)), - ("terra".to_string(), price(0.05, 0.4)), - ]) - } - - fn tokens(input: u64, output: u64) -> ModelTokens { - ModelTokens { - requests: 1, - input, - cached_input: 0, - output, - } - } - - #[test] - fn prices_the_cheaper_tier_against_the_capable_baseline() { - let mut totals = Totals::default(); - totals - .routed - .insert("luna".to_string(), tokens(1_000_000, 100_000)); - - let savings = estimate(&totals, &table(), "sol").expect("priced"); - - assert!((savings.actual - 0.45).abs() < 1e-9); - assert!((savings.baseline - 2.25).abs() < 1e-9); - assert!((savings.percent().expect("percent") - 80.0).abs() < 1e-9); - } - - #[test] - fn classifier_overhead_counts_against_savings() { - let mut totals = Totals::default(); - totals - .routed - .insert("luna".to_string(), tokens(1_000_000, 100_000)); - let before = estimate(&totals, &table(), "sol").expect("priced"); - - totals - .classifier - .insert("terra".to_string(), tokens(1_000_000, 10_000)); - let after = estimate(&totals, &table(), "sol").expect("priced"); - - assert_eq!(after.baseline, before.baseline); - assert!(after.saved() < before.saved()); - } - - #[test] - fn cache_reads_use_the_cached_rate() { - let prices = PriceTable::from([( - "sol".to_string(), - ModelPrice { - input_per_mtok: 1.0, - cached_input_per_mtok: Some(0.1), - output_per_mtok: 0.0, - }, - )]); - let mut totals = Totals::default(); - totals.routed.insert( - "sol".to_string(), - ModelTokens { - requests: 1, - input: 1_000_000, - cached_input: 1_000_000, - output: 0, - }, - ); - - let savings = estimate(&totals, &prices, "sol").expect("priced"); - - assert!((savings.actual - 1.1).abs() < 1e-9); - } - - #[test] - fn an_unpriced_model_suppresses_the_estimate() { - let mut totals = Totals::default(); - totals - .routed - .insert("unknown".to_string(), tokens(1_000, 100)); - - assert!(estimate(&totals, &table(), "sol").is_none()); - assert!(estimate(&Totals::default(), &table(), "absent").is_none()); - } - - #[test] - fn an_empty_period_has_no_percentage() { - let savings = estimate(&Totals::default(), &table(), "sol").expect("priced"); - - assert_eq!(savings.saved(), 0.0); - assert!(savings.percent().is_none()); - } -} diff --git a/crates/switchyard-menubar/src/rollup.rs b/crates/switchyard-menubar/src/rollup.rs deleted file mode 100644 index 8e3ad22b7..000000000 --- a/crates/switchyard-menubar/src/rollup.rs +++ /dev/null @@ -1,259 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Token totals for today and the past week, read from the server's routing log. - -use std::collections::BTreeMap; -use std::fs::File; -use std::io::{BufRead, BufReader}; -use std::path::Path; - -use chrono::{DateTime, Local, NaiveDate}; -use serde::Deserialize; - -/// Tier value the server writes for classifier and judge calls. -const CLASSIFIER_TIER: &str = "classifier"; - -/// Billable token counts for one model. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct ModelTokens { - pub requests: u64, - /// Input tokens billed at the full rate. Cache writes bill at that rate - /// too, so they are counted here rather than tracked separately. - pub input: u64, - /// Input tokens served from the provider cache. - pub cached_input: u64, - /// Generated tokens, including reasoning. - pub output: u64, -} - -impl ModelTokens { - /// Every token the provider counted, cached reads included. - pub fn total(&self) -> u64 { - self.input + self.cached_input + self.output - } - - fn add(&mut self, other: &Self) { - self.requests += other.requests; - self.input += other.input; - self.cached_input += other.cached_input; - self.output += other.output; - } -} - -/// Tokens for a period, split by who asked for them. -/// -/// The split matters for savings: without Switchyard the `routed` calls would -/// still have happened, but the `classifier` calls would not exist at all. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Totals { - /// Calls that served the caller, keyed by the model that answered. - pub routed: BTreeMap, - /// Switchyard's own classifier and judge calls, keyed by model. - pub classifier: BTreeMap, -} - -impl Totals { - fn add(&mut self, record: &Record) { - let bucket = if record.tier == CLASSIFIER_TIER { - &mut self.classifier - } else { - &mut self.routed - }; - bucket - .entry(record.model.clone()) - .or_default() - .add(&record.tokens()); - } - - /// Calls made on the caller's behalf. - pub fn requests(&self) -> u64 { - self.routed.values().map(|tokens| tokens.requests).sum() - } - - /// Every token spent, Switchyard's own routing overhead included. - pub fn tokens(&self) -> u64 { - self.routed - .values() - .chain(self.classifier.values()) - .map(ModelTokens::total) - .sum() - } - - /// True when nothing was recorded. - pub fn is_empty(&self) -> bool { - self.routed.is_empty() && self.classifier.is_empty() - } -} - -/// What the menu shows. -#[derive(Clone, Debug, Default)] -pub struct Usage { - pub today: Totals, - pub week: Totals, -} - -/// Fields the rollup needs from one routing log line. -#[derive(Deserialize)] -struct Record { - ts: String, - model: String, - #[serde(default)] - tier: String, - #[serde(default)] - prompt_tokens: u64, - #[serde(default)] - cached_tokens: u64, - #[serde(default)] - completion_tokens: u64, - #[serde(default)] - reasoning_tokens: u64, -} - -impl Record { - fn tokens(&self) -> ModelTokens { - ModelTokens { - requests: 1, - // `prompt_tokens` is the whole input, cache reads included. - input: self.prompt_tokens.saturating_sub(self.cached_tokens), - cached_input: self.cached_tokens, - output: self.completion_tokens + self.reasoning_tokens, - } - } -} - -/// Sums the log into today's and the trailing week's totals. -/// -/// A missing log is not an error: the server creates it on its first request. -/// Unparsable lines are skipped, which also covers the last line while the -/// server is still writing it. -pub fn read(path: &Path, today: NaiveDate) -> std::io::Result { - let file = match File::open(path) { - Ok(file) => file, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Usage::default()), - Err(error) => return Err(error), - }; - - let week_start = today - chrono::Duration::days(6); - let mut usage = Usage::default(); - for line in BufReader::new(file).lines() { - let line = line?; - let Ok(record) = serde_json::from_str::(&line) else { - continue; - }; - let Ok(ts) = DateTime::parse_from_rfc3339(&record.ts) else { - continue; - }; - let day = ts.with_timezone(&Local).date_naive(); - if day == today { - usage.today.add(&record); - } - if day >= week_start && day <= today { - usage.week.add(&record); - } - } - Ok(usage) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn date(text: &str) -> NaiveDate { - text.parse().expect("valid date") - } - - /// Builds a log line stamped at a local time, mirroring how the server - /// writes timestamps for events that happened in the local day. - fn line(local_ts: &str, model: &str, tier: &str, prompt: u64, completion: u64) -> String { - let offset = Local::now().offset().to_string(); - format!( - r#"{{"ts":"{local_ts}{offset}","model":"{model}","tier":"{tier}","prompt_tokens":{prompt},"cached_tokens":0,"completion_tokens":{completion},"reasoning_tokens":0}}"# - ) - } - - fn log(contents: &str) -> (tempfile::TempDir, std::path::PathBuf) { - let dir = tempfile::tempdir().expect("tempdir"); - let path = dir.path().join("routing.jsonl"); - std::fs::write(&path, contents).expect("write log"); - (dir, path) - } - - #[test] - fn splits_routed_and_classifier_tokens() { - let (_dir, path) = log(&format!( - "{}\n{}\n", - line("2026-09-28T10:00:00.000", "luna", "", 1_000, 200), - line("2026-09-28T10:00:01.000", "terra", "classifier", 300, 10), - )); - - let usage = read(&path, date("2026-09-28")).expect("read"); - - assert_eq!( - usage.today.requests(), - 1, - "classifier calls are not requests" - ); - assert_eq!(usage.today.routed["luna"].input, 1_000); - assert_eq!(usage.today.classifier["terra"].input, 300); - assert_eq!(usage.today.tokens(), 1_000 + 200 + 300 + 10); - } - - #[test] - fn separates_cache_reads_from_full_price_input() { - let record: Record = serde_json::from_str( - r#"{"ts":"2026-09-28T10:00:00.000Z","model":"m","prompt_tokens":1000,"cached_tokens":600,"completion_tokens":50,"reasoning_tokens":25}"#, - ) - .expect("parse record"); - - let tokens = record.tokens(); - - assert_eq!(tokens.input, 400); - assert_eq!(tokens.cached_input, 600); - assert_eq!(tokens.output, 75, "reasoning tokens are billed as output"); - assert_eq!(tokens.total(), 1_075); - } - - #[test] - fn the_week_covers_seven_days() { - let mut contents = String::new(); - for day in 18..=28 { - contents.push_str(&line( - &format!("2026-09-{day:02}T10:00:00.000"), - "luna", - "", - 100, - 10, - )); - contents.push('\n'); - } - let (_dir, path) = log(&contents); - - let usage = read(&path, date("2026-09-28")).expect("read"); - - assert_eq!(usage.today.requests(), 1); - assert_eq!(usage.week.requests(), 7); - } - - #[test] - fn skips_lines_it_cannot_parse() { - // The trailing line has no newline yet, as when the server is mid-write. - let (_dir, path) = log(&format!( - "not json\n{}\n{{\"ts\":\"2026-09-2", - line("2026-09-28T10:00:00.000", "luna", "", 100, 10), - )); - - let usage = read(&path, date("2026-09-28")).expect("read"); - - assert_eq!(usage.today.requests(), 1); - } - - #[test] - fn a_missing_log_is_not_an_error() { - let dir = tempfile::tempdir().expect("tempdir"); - - let usage = read(&dir.path().join("absent.jsonl"), date("2026-09-28")).expect("read"); - - assert!(usage.today.is_empty()); - } -} diff --git a/crates/switchyard-menubar/src/summary.rs b/crates/switchyard-menubar/src/summary.rs deleted file mode 100644 index ca5896122..000000000 --- a/crates/switchyard-menubar/src/summary.rs +++ /dev/null @@ -1,278 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Turns totals and prices into the rows the menu shows. - -use crate::config::Config; -use crate::health::ServerStatus; -use crate::pricing::{Savings, estimate}; -use crate::rollup::{Totals, Usage}; - -/// Most model rows shown, so the menu stays short. -const MAX_MODEL_ROWS: usize = 5; - -/// One line of the menu. Rows are informational; the actions are fixed. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Row { - Separator, - Label(String), -} - -fn label(text: impl Into) -> Row { - Row::Label(text.into()) -} - -/// Builds the menu body for the current day and trailing week. -pub fn build(status: ServerStatus, usage: &Usage, config: &Config) -> Vec { - let state = match status { - ServerStatus::Running => "running", - ServerStatus::Stopped => "not responding", - }; - let mut rows = vec![ - label(format!("Server: {state} · {}", config.server_url)), - Row::Separator, - ]; - - if usage.week.is_empty() { - rows.push(label("No requests recorded yet")); - return rows; - } - - rows.extend(period("Today", &usage.today, config)); - rows.push(Row::Separator); - rows.extend(period("This week", &usage.week, config)); - - let models = model_shares(&usage.week); - if !models.is_empty() { - rows.push(Row::Separator); - rows.push(label("This week by model")); - rows.extend(models); - } - - if config.prices.is_empty() { - rows.push(Row::Separator); - rows.push(label("Add prices to menubar.toml to see savings")); - } - rows -} - -fn period(name: &str, totals: &Totals, config: &Config) -> Vec { - let requests = totals.requests(); - let mut rows = vec![label(format!( - "{name} — {requests} request{} · {} tokens", - if requests == 1 { "" } else { "s" }, - tokens(totals.tokens()) - ))]; - if let Some(savings) = estimate(totals, &config.prices, &config.baseline_model) { - rows.push(label(format!(" Saved {}", saved(&savings)))); - } - rows -} - -fn saved(savings: &Savings) -> String { - let amount = money(savings.saved()); - match savings.percent() { - Some(percent) => format!("{amount} ({percent:.0}% of {})", money(savings.baseline)), - None => amount, - } -} - -fn model_shares(week: &Totals) -> Vec { - let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); - if total == 0 { - return Vec::new(); - } - let mut models: Vec<_> = week - .routed - .iter() - .map(|(model, tokens)| (model, tokens.total())) - .collect(); - // Largest share first; model id breaks ties so the order is stable. - models.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); - models - .into_iter() - .take(MAX_MODEL_ROWS) - .map(|(model, count)| { - label(format!( - " {model} — {:.0}%", - count as f64 / total as f64 * 100.0 - )) - }) - .collect() -} - -/// Formats a token count compactly, since exact totals are not useful here. -fn tokens(value: u64) -> String { - match value { - 0..=9_999 => value.to_string(), - 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), - 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), - _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), - } -} - -/// Formats dollars, keeping the sign readable when routing cost more than it saved. -fn money(value: f64) -> String { - if value < 0.0 { - format!("-${:.2}", -value) - } else { - format!("${value:.2}") - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pricing::{ModelPrice, PriceTable}; - use crate::rollup::ModelTokens; - - fn counted(input: u64, output: u64, requests: u64) -> ModelTokens { - ModelTokens { - requests, - input, - cached_input: 0, - output, - } - } - - fn config(prices: PriceTable) -> Config { - Config { - baseline_model: "sol".to_string(), - prices, - ..Config::default() - } - } - - fn priced() -> PriceTable { - PriceTable::from([ - ( - "sol".to_string(), - ModelPrice { - input_per_mtok: 1.25, - cached_input_per_mtok: None, - output_per_mtok: 10.0, - }, - ), - ( - "luna".to_string(), - ModelPrice { - input_per_mtok: 0.25, - cached_input_per_mtok: None, - output_per_mtok: 2.0, - }, - ), - ]) - } - - fn labels(rows: &[Row]) -> Vec { - rows.iter() - .filter_map(|row| match row { - Row::Label(text) => Some(text.clone()), - Row::Separator => None, - }) - .collect() - } - - fn usage_of(today: Totals, week: Totals) -> Usage { - Usage { today, week } - } - - #[test] - fn shows_requests_tokens_and_savings_for_both_periods() { - let mut today = Totals::default(); - today - .routed - .insert("luna".to_string(), counted(1_000_000, 100_000, 3)); - let mut week = Totals::default(); - week.routed - .insert("luna".to_string(), counted(4_000_000, 400_000, 12)); - - let rows = build( - ServerStatus::Running, - &usage_of(today, week), - &config(priced()), - ); - let labels = labels(&rows); - - assert_eq!(labels[0], "Server: running · http://127.0.0.1:4123"); - assert_eq!(labels[1], "Today — 3 requests · 1.1M tokens"); - assert_eq!(labels[2], " Saved $1.80 (80% of $2.25)"); - assert_eq!(labels[3], "This week — 12 requests · 4.4M tokens"); - assert_eq!(labels[4], " Saved $7.20 (80% of $9.00)"); - } - - #[test] - fn hides_savings_and_explains_why_when_prices_are_missing() { - let mut week = Totals::default(); - week.routed - .insert("luna".to_string(), counted(1_000, 100, 1)); - - let rows = build( - ServerStatus::Running, - &usage_of(week.clone(), week), - &config(PriceTable::new()), - ); - let labels = labels(&rows); - - assert!(labels.iter().all(|label| !label.contains("Saved"))); - assert!(labels.iter().any(|label| label.contains("menubar.toml"))); - } - - #[test] - fn ranks_models_by_token_share() { - let mut week = Totals::default(); - week.routed - .insert("luna".to_string(), counted(700_000, 0, 7)); - week.routed - .insert("sol".to_string(), counted(300_000, 0, 3)); - - let rows = build( - ServerStatus::Running, - &usage_of(Totals::default(), week), - &config(priced()), - ); - let models: Vec<_> = labels(&rows) - .into_iter() - .filter(|label| label.starts_with(" ") && label.ends_with('%')) - .collect(); - - assert_eq!(models, vec![" luna — 70%", " sol — 30%"]); - } - - #[test] - fn reports_a_stopped_server_with_no_traffic() { - let rows = build(ServerStatus::Stopped, &Usage::default(), &config(priced())); - let labels = labels(&rows); - - assert!(labels[0].starts_with("Server: not responding")); - assert_eq!(labels[1], "No requests recorded yet"); - } - - #[test] - fn shows_a_loss_when_routing_overhead_exceeds_the_saving() { - let mut today = Totals::default(); - today.routed.insert("sol".to_string(), counted(1_000, 0, 1)); - today - .classifier - .insert("sol".to_string(), counted(1_000_000, 0, 1)); - - let rows = build( - ServerStatus::Running, - &usage_of(today.clone(), today), - &config(priced()), - ); - - assert!(labels(&rows)[2].starts_with(" Saved -$1.25")); - } - - #[test] - fn formats_tokens_and_money_for_reading() { - assert_eq!(tokens(412), "412"); - assert_eq!(tokens(12_345), "12K"); - assert_eq!(tokens(1_400_000), "1.4M"); - assert_eq!(tokens(9_700_000_000), "9.7B"); - - assert_eq!(money(2.181), "$2.18"); - assert_eq!(money(-0.125), "-$0.12"); - } -} diff --git a/crates/switchyard-menubar/src/tray.rs b/crates/switchyard-menubar/src/tray.rs deleted file mode 100644 index 6f3181c8e..000000000 --- a/crates/switchyard-menubar/src/tray.rs +++ /dev/null @@ -1,162 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! The macOS status item: event loop, menu, and the menu's actions. -//! -//! Everything platform-specific lives here, so the rest of the crate builds -//! and is tested on any target. - -use std::process::Command; -use std::time::{Duration, Instant}; - -use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEventMask}; -use objc2_foundation::{MainThreadMarker, NSDate, NSDefaultRunLoopMode}; -use tray_icon::menu::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem}; -use tray_icon::{Icon, TrayIconBuilder}; - -use crate::app::refresh; -use crate::config::Config; -use crate::icon; -use crate::summary::Row; - -const RESTART: &str = "restart-server"; -const OPEN_CONFIG: &str = "open-config"; -const OPEN_SETTINGS: &str = "open-settings"; -const QUIT: &str = "quit"; - -/// How long the loop blocks waiting for a UI event before checking the clock. -const EVENT_POLL: f64 = 0.1; - -/// Runs the status item until the user quits. -pub fn run(config: Config) -> Result<(), String> { - let mtm = MainThreadMarker::new().ok_or("the menu bar must run on the main thread")?; - let ns_app = NSApplication::sharedApplication(mtm); - // Accessory keeps the process out of the Dock and the app switcher. - ns_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory); - - let glyph = Icon::from_rgba(icon::glyph(), icon::SIZE, icon::SIZE) - .map_err(|error| format!("build icon: {error}"))?; - let tray = TrayIconBuilder::new() - .with_icon(glyph) - .with_icon_as_template(true) - .with_tooltip("Switchyard") - .with_menu(Box::new(menu(&refresh(&config))?)) - .build() - .map_err(|error| format!("create status item: {error}"))?; - - ns_app.finishLaunching(); - - let interval = Duration::from_secs(config.refresh_seconds.max(5)); - let mut next = Instant::now() + interval; - loop { - pump_events(&ns_app); - - let mut redraw = false; - while let Ok(event) = MenuEvent::receiver().try_recv() { - match event.id.as_ref() { - QUIT => return Ok(()), - RESTART => report(restart_server(&config)), - OPEN_CONFIG => report(open(&config.config_file)), - OPEN_SETTINGS => report(open(&Config::default_path())), - // Informational rows are disabled, so nothing else fires. - _ => continue, - } - redraw = true; - } - - if redraw || Instant::now() >= next { - tray.set_menu(Some(Box::new(menu(&refresh(&config))?))); - next = Instant::now() + interval; - } - } -} - -/// Restarts the server's LaunchAgent. -fn restart_server(config: &Config) -> Result<(), String> { - let uid = command("id", &["-u"])?; - let target = format!("gui/{}/{}", uid.trim(), config.launchd_label); - command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) -} - -/// Opens a config file in the user's editor. -fn open(path: &std::path::Path) -> Result<(), String> { - command("open", &["-t", &path.display().to_string()]).map(|_| ()) -} - -/// Runs a command, returning its stdout or a message naming what failed. -fn command(program: &str, args: &[&str]) -> Result { - let output = Command::new(program) - .args(args) - .output() - .map_err(|error| format!("run {program}: {error}"))?; - if !output.status.success() { - return Err(format!( - "{program} {}: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr).trim() - )); - } - Ok(String::from_utf8_lossy(&output.stdout).into_owned()) -} - -fn report(result: Result<(), String>) { - if let Err(error) = result { - eprintln!("switchyard-menubar: {error}"); - } -} - -/// Builds the whole menu: the summary rows, then the fixed actions. -fn menu(rows: &[Row]) -> Result { - let menu = Menu::new(); - let append = |item: &dyn IsMenuItem| { - menu.append(item) - .map_err(|error| format!("build menu: {error}")) - }; - - for row in rows { - match row { - Row::Separator => append(&PredefinedMenuItem::separator())?, - // Rows are labels, not commands, so they are disabled. - Row::Label(text) => append(&MenuItem::new(text, false, None))?, - } - } - append(&PredefinedMenuItem::separator())?; - for (id, text) in [ - (RESTART, "Restart server"), - (OPEN_CONFIG, "Open server config…"), - (OPEN_SETTINGS, "Open menu bar settings…"), - (QUIT, "Quit"), - ] { - append(&MenuItem::with_id(id, text, true, None))?; - } - Ok(menu) -} - -/// Drains pending AppKit events, blocking briefly when there are none. -fn pump_events(ns_app: &NSApplication) { - let mut expiration = Some(NSDate::dateWithTimeIntervalSinceNow(EVENT_POLL)); - while let Some(event) = unsafe { - ns_app.nextEventMatchingMask_untilDate_inMode_dequeue( - NSEventMask::Any, - expiration.as_deref(), - NSDefaultRunLoopMode, - true, - ) - } { - ns_app.sendEvent(&event); - // Only the first wait blocks; the rest drain what is already queued. - expiration = Some(NSDate::distantPast()); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reports_which_command_failed() { - let error = command("switchyard-does-not-exist", &[]).expect_err("missing program"); - - assert!(error.contains("switchyard-does-not-exist")); - } -} diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index 3d5e265bd..2456b01ea 100644 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -7,7 +7,6 @@ SY_HOME="${SY_HOME:-$HOME/.switchyard}" SY_PORT="${SY_PORT:-4123}" SERVER_LABEL="com.nvidia.switchyard.server" -MENUBAR_LABEL="com.nvidia.switchyard.menubar" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" CODEX_CONFIG="$CODEX_DIR/config.toml" diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 873f853bb..863d4f0d2 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -2,9 +2,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Installs the Switchyard background server and its menu bar companion as -# per-user LaunchAgents, sets up a `sy` Codex profile, and aliases `codex` to -# use it. +# Installs the Switchyard background server as a per-user LaunchAgent, sets up +# a `sy` Codex profile, and aliases `codex` to use it. # # Every step is idempotent and never overwrites a file you have edited. # Run with --dry-run to print what would happen. @@ -132,13 +131,12 @@ fi step "Building release binaries" run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ - -p switchyard-server -p switchyard-menubar + -p switchyard-server step "Installing binaries into $SY_HOME/bin" run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" -for binary in switchyard-server switchyard-menubar; do - run install -m 755 "$REPO_ROOT/target/release/$binary" "$SY_HOME/bin/$binary" -done +run install -m 755 "$REPO_ROOT/target/release/switchyard-server" \ + "$SY_HOME/bin/switchyard-server" step "Writing server config" # The composite router from examples/run_codex.sh: Terra classifies each user @@ -184,37 +182,6 @@ efficient_target = "efficient" confidence_threshold = 0.5 EOF -step "Writing menu bar settings" -# Rates are list prices per million tokens and are only used to estimate -# savings. Edit them to match what you actually pay. -write_once "$SY_HOME/menubar.toml" < EOF -# SuccessfulExit false means the Quit menu item stays quit, while a crash is -# still restarted. Aqua-only, since there is no menu bar without a login session. -write_file "$LAUNCH_AGENTS/$MENUBAR_LABEL.plist" < - - - - Label - $MENUBAR_LABEL - ProgramArguments - - $SY_HOME/bin/switchyard-menubar - $SY_HOME/menubar.toml - - RunAtLoad - - KeepAlive - - SuccessfulExit - - - LimitLoadToSessionType - Aqua - StandardOutPath - $SY_HOME/logs/menubar.log - StandardErrorPath - $SY_HOME/logs/menubar.err.log - - -EOF - step "Loading LaunchAgents" -for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do - if (( DRY_RUN )); then - say " would reload gui/$UID/$label" - continue - fi - launchctl bootout "gui/$UID/$label" 2>/dev/null || true +if (( DRY_RUN )); then + say " would reload gui/$UID/$SERVER_LABEL" +else + launchctl bootout "gui/$UID/$SERVER_LABEL" 2>/dev/null || true # bootout returns before the job leaves the domain, and bootstrapping a # service that is still shutting down fails with an I/O error. for _ in $(seq 50); do - launchctl print "gui/$UID/$label" >/dev/null 2>&1 || break + launchctl print "gui/$UID/$SERVER_LABEL" >/dev/null 2>&1 || break sleep 0.1 done - launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$label.plist" - say " loaded $label" -done + launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$SERVER_LABEL.plist" + say " loaded $SERVER_LABEL" +fi step "Adding the sy Codex profile" # `codex --profile sy` reads ~/.codex/sy.config.toml. A [profiles.sy] table in @@ -360,8 +294,6 @@ done step "Done" say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" -say "Settings: $SY_HOME/menubar.toml" -say "Look for the Switchyard glyph in the menu bar." say "Open a new shell, or run: alias codex=\"codex --profile sy\"" say "" say "To route Codex.app too, swap its config:" diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index e36efdf61..9e4d4bbeb 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Removes what install.sh added: the two LaunchAgents, the `sy` Codex profile, +# Removes what install.sh added: the LaunchAgent, the `sy` Codex profile, # and the codex alias. Your config, routing log, and binaries stay put; the # paths are printed so you can delete them yourself. # @@ -32,15 +32,13 @@ remove_file() { } step "Unloading LaunchAgents" -for label in "$SERVER_LABEL" "$MENUBAR_LABEL"; do - if (( DRY_RUN )); then - say " would unload gui/$UID/$label and delete its plist" - else - launchctl bootout "gui/$UID/$label" 2>/dev/null || true - rm -f "$LAUNCH_AGENTS/$label.plist" - say " unloaded $label" - fi -done +if (( DRY_RUN )); then + say " would unload gui/$UID/$SERVER_LABEL and delete its plist" +else + launchctl bootout "gui/$UID/$SERVER_LABEL" 2>/dev/null || true + rm -f "$LAUNCH_AGENTS/$SERVER_LABEL.plist" + say " unloaded $SERVER_LABEL" +fi step "Removing the sy Codex profile" remove_file "$CODEX_PROFILE_CONFIG" From 042211d13ebb1c5935a18b19f70501076f0b419e Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 18:04:06 -0400 Subject: [PATCH 4/8] chore: cleanup Signed-off-by: Greg Clark --- Cargo.lock | 12 ++-- scripts/macos/common.sh | 25 +++++++ scripts/macos/install.sh | 100 +++++++++++--------------- scripts/macos/uninstall.sh | 8 ++- tests/test_macos_install.py | 139 ++++++++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 68 deletions(-) create mode 100644 tests/test_macos_install.py diff --git a/Cargo.lock b/Cargo.lock index 4e167baa1..aeeeaf37b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -512,7 +512,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1764,7 +1764,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1998,7 +1998,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2055,7 +2055,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2574,7 +2574,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3122,7 +3122,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index 2456b01ea..1a04771a9 100644 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -47,3 +47,28 @@ strip_block() { say " removed $label from $path" return 0 } + +codex_config_uses_switchyard() { + local path="$1" + [[ -f "$path" ]] || return 1 + awk ' + function is_string(value, wanted, quote, prefix, suffix) { + sub(/^[[:space:]]*/, "", value) + quote = substr(value, 1, 1) + if (quote != "\"" && quote != sprintf("%c", 39)) return 0 + prefix = quote wanted quote + if (substr(value, 1, length(prefix)) != prefix) return 0 + suffix = substr(value, length(prefix) + 1) + return suffix ~ /^[[:space:]]*(#.*)?$/ + } + BEGIN { top_level = 1 } + /^[[:space:]]*#/ { next } + /^[[:space:]]*\[/ { top_level = 0; next } + top_level && /^[[:space:]]*model_provider[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*/, "", value) + if (is_string(value, "sy")) found = 1 + } + END { exit !found } + ' "$path" +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 863d4f0d2..bf97adf85 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -67,8 +67,7 @@ back_up() { write_codex_switchyard_config() { local source="$1" target="$2" if [[ ! -f "$source" ]]; then - say " no $source to derive from, skipping" - return + say " no $source; using an empty baseline" fi if (( DRY_RUN )); then say " would write $target" @@ -79,15 +78,21 @@ write_codex_switchyard_config() { body="$(mktemp)" # Drop what this file replaces: the top-level model selection, any # model_provider, and an earlier copy of the provider table. - awk ' - BEGIN { preamble = 1; skip_table = 0 } - /^[[:space:]]*\[/ { - preamble = 0 - skip_table = ($0 ~ /^[[:space:]]*\[model_providers\.sy\][[:space:]]*\]?[[:space:]]*$/) - } - preamble && /^[[:space:]]*(model|model_provider)[[:space:]]*=/ { next } - !skip_table { print } - ' "$source" > "$body" + if [[ -f "$source" ]]; then + awk ' + BEGIN { preamble = 1; skip_table = 0 } + /^[[:space:]]*\[/ { + preamble = 0 + header = $0 + sub(/[[:space:]]*#.*/, "", header) + skip_table = (header ~ /^[[:space:]]*\[model_providers\.sy\][[:space:]]*$/) + } + preamble && /^[[:space:]]*(model|model_provider)[[:space:]]*=/ { next } + !skip_table { print } + ' "$source" > "$body" + else + : > "$body" + fi { echo "# Generated by the Switchyard installer. Do not edit; edit config.toml" @@ -124,6 +129,14 @@ write_file() { fi } +xml_escape_text() { + local value="$1" + value="${value//&/&}" + value="${value///>}" + printf '%s' "$value" +} + if [[ "$(uname -s)" != "Darwin" ]]; then say "This installer is for macOS only." >&2 exit 1 @@ -139,48 +152,8 @@ run install -m 755 "$REPO_ROOT/target/release/switchyard-server" \ "$SY_HOME/bin/switchyard-server" step "Writing server config" -# The composite router from examples/run_codex.sh: Terra classifies each user -# turn and sets the tier, Stage drives the tool loop underneath it. -write_once "$SY_HOME/composite.toml" <<'EOF' -schema_version = 1 - -[llm_clients.chatgpt_backend] -format = "openai_responses" -base_url = "https://chatgpt.com/backend-api/codex" -forward_auth = true - -[targets.capable] -id = "gpt-5.6-sol" -llm_client = "chatgpt_backend" - -[targets.efficient] -id = "gpt-5.6-luna" -llm_client = "chatgpt_backend" - -# chatgpt.com/backend-api/codex is Codex CLI's own private endpoint, not the -# public OpenAI Responses API. It 400s unless store=false and stream=true are -# set explicitly, and it rejects max_output_tokens outright, so the -# classifier's own token cap has to be dropped before the request goes out. -[targets.terra] -id = "gpt-5.6-terra" -llm_client = "chatgpt_backend" -extra_body = { store = false, stream = true } -omit_body_fields = ["max_output_tokens"] - -[routes.switchyard] -id = "switchyard" -type = "composite" - -[routes.switchyard.classifier] -target = "terra" -base_threshold = 0.5 -classify_trigger = "user_turn" - -[routes.switchyard.stage] -capable_target = "capable" -efficient_target = "efficient" -confidence_threshold = 0.5 -EOF +# Keep macOS and Linux on the same routing defaults. +write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" step "Validating the server config" if (( DRY_RUN )); then @@ -190,6 +163,7 @@ else fi step "Writing LaunchAgents" +XML_SY_HOME="$(xml_escape_text "$SY_HOME")" write_file "$LAUNCH_AGENTS/$SERVER_LABEL.plist" < @@ -199,15 +173,15 @@ write_file "$LAUNCH_AGENTS/$SERVER_LABEL.plist" <$SERVER_LABEL ProgramArguments - $SY_HOME/bin/switchyard-server + $XML_SY_HOME/bin/switchyard-server --config - $SY_HOME/composite.toml + $XML_SY_HOME/composite.toml --host 127.0.0.1 --port $SY_PORT --routing-log-file - $SY_HOME/routing.jsonl + $XML_SY_HOME/routing.jsonl RunAtLoad @@ -216,9 +190,9 @@ write_file "$LAUNCH_AGENTS/$SERVER_LABEL.plist" <ProcessType Background StandardOutPath - $SY_HOME/logs/server.log + $XML_SY_HOME/logs/server.log StandardErrorPath - $SY_HOME/logs/server.err.log + $XML_SY_HOME/logs/server.err.log EnvironmentVariables RUST_LOG @@ -268,7 +242,7 @@ step "Building a swap-in config for Codex.app" # Snapshot the unrouted config so swapping back is a copy, not an edit. Taken # only when config.toml is not already the routed one, so a reinstall while # swapped in cannot overwrite the snapshot with a routed copy. -if [[ -f "$CODEX_CONFIG" ]] && grep -qE '^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"sy"' "$CODEX_CONFIG"; then +if codex_config_uses_switchyard "$CODEX_CONFIG"; then say " config.toml is already routed, keeping $CODEX_DIRECT_CONFIG as it is" elif [[ -f "$CODEX_CONFIG" ]]; then if (( DRY_RUN )); then @@ -277,6 +251,14 @@ elif [[ -f "$CODEX_CONFIG" ]]; then cp "$CODEX_CONFIG" "$CODEX_DIRECT_CONFIG" say " snapshotted config.toml to $CODEX_DIRECT_CONFIG" fi +elif [[ ! -f "$CODEX_DIRECT_CONFIG" ]]; then + if (( DRY_RUN )); then + say " would create an empty direct-config baseline at $CODEX_DIRECT_CONFIG" + else + mkdir -p "$(dirname "$CODEX_DIRECT_CONFIG")" + : > "$CODEX_DIRECT_CONFIG" + say " created an empty direct-config baseline at $CODEX_DIRECT_CONFIG" + fi fi write_codex_switchyard_config "$CODEX_CONFIG" "$CODEX_SWITCHYARD_CONFIG" diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index 9e4d4bbeb..f0be29082 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -50,15 +50,17 @@ strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profi step "Unrouting Codex.app" # Leaving a routed config.toml behind would point Codex.app at a server that # is no longer running, so put the snapshot back before the agents go away. -if [[ -f "$CODEX_CONFIG" ]] && grep -qE '^[[:space:]]*model_provider[[:space:]]*=[[:space:]]*"sy"' "$CODEX_CONFIG"; then +if codex_config_uses_switchyard "$CODEX_CONFIG"; then if [[ ! -f "$CODEX_DIRECT_CONFIG" ]]; then say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" elif (( DRY_RUN )); then - say " would restore $CODEX_DIRECT_CONFIG over config.toml" + say " would preserve $CODEX_CONFIG before restoring $CODEX_DIRECT_CONFIG" else + backup="$(mktemp "$CODEX_CONFIG.switchyard-current.XXXXXX")" + cp "$CODEX_CONFIG" "$backup" cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" rm -f "$CODEX_DIRECT_CONFIG" - say " restored the unrouted config.toml" + say " restored the unrouted config.toml; preserved the current file at $backup" fi else remove_file "$CODEX_DIRECT_CONFIG" diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py new file mode 100644 index 000000000..995c22c27 --- /dev/null +++ b/tests/test_macos_install.py @@ -0,0 +1,139 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import shutil +import subprocess +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] + + +@pytest.fixture +def setup(tmp_path): + scripts = tmp_path / "repo" / "scripts" / "macos" + shutil.copytree(REPO / "scripts" / "macos", scripts) + shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") + home = tmp_path / "home" + home.mkdir() + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + switchyard_home = home / "Switchyard & Routing " + env = { + **os.environ, + "HOME": str(home), + "SY_HOME": str(switchyard_home), + "CODEX_HOME": str(home / ".codex"), + "TMPDIR": str(tmp_path), + "PATH": f"{bin_dir}:/usr/bin:/bin", + } + stubs = { + "cargo": "exit 0\n", + "uname": "echo Darwin\n", + "install": 'printf "#!/bin/sh\\nexit 0\\n" > "$4"\nchmod +x "$4"\n', + "launchctl": '[[ "$1" != print ]]\n', + } + for name, body in stubs.items(): + stub = bin_dir / name + stub.write_text("#!/bin/bash\nset -eu\n" + body) + stub.chmod(0o755) + return scripts, home, switchyard_home, env + + +def run(setup, script): + scripts, _, _, env = setup + return subprocess.run( + ["bash", str(scripts / script)], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + +def read_config(path): + return path.read_text() + + +def test_install_escapes_switchyard_path_in_launch_agent(setup): + _, home, switchyard_home, _ = setup + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + plist = home / "Library" / "LaunchAgents" / "com.nvidia.switchyard.server.plist" + root = ET.parse(plist).getroot() + entries = list(root.find("dict")) + values = {entries[index].text: entries[index + 1] for index in range(0, len(entries), 2)} + program_arguments = [element.text for element in values["ProgramArguments"]] + assert program_arguments[0] == str(switchyard_home / "bin" / "switchyard-server") + assert program_arguments[2] == str(switchyard_home / "composite.toml") + assert program_arguments[8] == str(switchyard_home / "routing.jsonl") + assert values["StandardOutPath"].text == str(switchyard_home / "logs" / "server.log") + assert values["StandardErrorPath"].text == str(switchyard_home / "logs" / "server.err.log") + + +def test_missing_codex_config_creates_routed_and_empty_direct_baselines(setup): + _, home, _, _ = setup + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + codex = home / ".codex" + assert 'model_provider = "sy"' in read_config(codex / "config.toml.sy") + assert (codex / "config.toml.direct").read_text() == "" + + +def test_provider_table_with_comment_is_replaced_and_shared_template_is_used(setup): + _, home, switchyard_home, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + config = codex / "config.toml" + original = '''theme = "dark" + +[model_providers.sy] # old provider +name = "Old" +base_url = "http://old" + +[other] +model_provider = "sy" +''' + config.write_text(original) + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + generated = read_config(codex / "config.toml.sy") + assert generated.count("[model_providers.sy]") == 1 + assert 'name = "Old"' not in generated + assert '[other]\nmodel_provider = "sy"' in generated + assert read_config(switchyard_home / "composite.toml") == read_config( + REPO / "scripts" / "config" / "composite.toml" + ) + assert read_config(codex / "config.toml.direct") == original + + +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_quoted_top_level_provider_keeps_existing_snapshot(setup, quote): + _, home, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + (codex / "config.toml").write_text(f"model_provider = {quote}sy{quote} # routed\n") + snapshot = codex / "config.toml.direct" + snapshot.write_text("original direct config\n") + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + assert snapshot.read_text() == "original direct config\n" + assert (codex / "config.toml.sy").is_file() + + +def test_uninstall_preserves_routed_config_before_restoring_snapshot(setup): + _, home, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + current = "model_provider = 'sy' # routed\nuser_setting = \"keep me\"\n" + (codex / "config.toml").write_text(current) + (codex / "config.toml.direct").write_text("model = \"original\"\n") + result = run(setup, "uninstall.sh") + assert result.returncode == 0, result.stderr + assert (codex / "config.toml").read_text() == 'model = "original"\n' + backups = list(codex.glob("config.toml.switchyard-current.*")) + assert len(backups) == 1 + assert backups[0].read_text() == current From 35b6d9a0d41678328f6593f69f665880f107f02b Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 20:21:29 -0400 Subject: [PATCH 5/8] chore: fix xlm Signed-off-by: Greg Clark --- scripts/macos/install.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index bf97adf85..39b8bfe0b 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -130,11 +130,10 @@ write_file() { } xml_escape_text() { - local value="$1" - value="${value//&/&}" - value="${value///>}" - printf '%s' "$value" + printf '%s' "$1" | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' } if [[ "$(uname -s)" != "Darwin" ]]; then From 9d632cebfca85eb5b069711dcf7c3a684e59be50 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 20:38:46 -0400 Subject: [PATCH 6/8] chore: cleanup Signed-off-by: Greg Clark --- scripts/common.sh | 117 ++++++++++++++++++++++++++++++++++ scripts/linux/common.sh | 43 +------------ scripts/linux/install.sh | 69 -------------------- scripts/linux/uninstall.sh | 13 ---- scripts/macos/common.sh | 39 ++---------- scripts/macos/install.sh | 122 ++++++++++-------------------------- scripts/macos/uninstall.sh | 35 ++++------- tests/test_linux_install.py | 1 + tests/test_macos_install.py | 11 ++-- 9 files changed, 177 insertions(+), 273 deletions(-) create mode 100644 scripts/common.sh diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100644 index 000000000..cd27254f1 --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,117 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +SY_HOME="${SY_HOME:-$HOME/.switchyard}" +SY_PORT="${SY_PORT:-4123}" +CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" +# Both installers use Codex's standalone profile file for the CLI. +CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" +ALIAS_START="# >>> switchyard codex alias >>>" +ALIAS_END="# <<< switchyard codex alias <<<" + +say() { printf '%s\n' "$*"; } +step() { printf '\n==> %s\n' "$*"; } + +strip_block() { + local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" + if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then + return 1 + fi + if ! grep -qF "$end" "$path"; then + say " $path has no end marker for $label; not editing it" >&2 + return 1 + fi + if (( DRY_RUN )); then + say " would remove $label from $path" + return 0 + fi + local temp + temp="$(mktemp)" + awk -v start="$start" -v end="$end" ' + index($0, start) { skipping = 1 } + !skipping { print } + index($0, end) { skipping = 0 } + END { if (skipping) exit 1 } + ' "$path" > "$temp" + cat "$temp" > "$path" + rm -f "$temp" + say " removed $label from $path" + return 0 +} + +run() { + if (( DRY_RUN )); then + say " would run: $*" + else + "$@" + fi +} + +write_once() { + local path="$1" + if [[ -f "$path" ]]; then + say " keeping existing $path" + cat >/dev/null + return + fi + if (( DRY_RUN )); then + say " would create $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " created $path" + fi +} + +write_with_backup() { + local path="$1" + if (( DRY_RUN )); then + [[ -f "$path" ]] && say " would back up $path" + say " would write $path" + cat >/dev/null + return + fi + mkdir -p "$(dirname "$path")" + local incoming + incoming="$(mktemp)" + cat > "$incoming" + if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then + say " $path is already up to date" + rm -f "$incoming" + return + fi + if [[ -f "$path" ]]; then + local backup + backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" + cp "$path" "$backup" + say " backed up $path to $backup" + fi + cat "$incoming" > "$path" + rm -f "$incoming" + say " wrote $path" +} + +write_always() { + local path="$1" + if (( DRY_RUN )); then + say " would write $path" + cat >/dev/null + else + mkdir -p "$(dirname "$path")" + cat > "$path" + say " wrote $path" + fi +} + +remove_file() { + local path="$1" + if [[ ! -e "$path" ]]; then + say " nothing to remove at $path" + elif (( DRY_RUN )); then + say " would delete $path" + else + rm -f "$path" + say " deleted $path" + fi +} diff --git a/scripts/linux/common.sh b/scripts/linux/common.sh index 25b16dd83..3a9b7782e 100644 --- a/scripts/linux/common.sh +++ b/scripts/linux/common.sh @@ -1,47 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Paths, markers, and helpers shared by install.sh and uninstall.sh. -# The markers must match between the two, which is why they live here. +# shellcheck source=scripts/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" -SY_HOME="${SY_HOME:-$HOME/.switchyard}" -SY_PORT="${SY_PORT:-4123}" SERVICE_NAME="switchyard.service" SYSTEMD_USER_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user" -CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" -# Since Codex 0.134.0, `--profile sy` reads sy.config.toml and fails if -# config.toml still has [profiles.sy]. -CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" -ALIAS_START="# >>> switchyard codex alias >>>" -ALIAS_END="# <<< switchyard codex alias <<<" - -say() { printf '%s\n' "$*"; } -step() { printf '\n==> %s\n' "$*"; } - -# Deletes the marked block, inclusive, leaving the rest of the file alone. -strip_block() { - local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" - if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then - return 1 - fi - if ! grep -qF "$end" "$path"; then - say " $path has no end marker for $label; not editing it" >&2 - return 1 - fi - if (( DRY_RUN )); then - say " would remove $label from $path" - return 0 - fi - local temp - temp="$(mktemp)" - awk -v start="$start" -v end="$end" ' - index($0, start) { skipping = 1 } - !skipping { print } - index($0, end) { skipping = 0 } - END { if (skipping) exit 1 } - ' "$path" > "$temp" - cat "$temp" > "$path" - rm -f "$temp" - say " removed $label from $path" - return 0 -} diff --git a/scripts/linux/install.sh b/scripts/linux/install.sh index 247bf8c6f..c296ebcfc 100755 --- a/scripts/linux/install.sh +++ b/scripts/linux/install.sh @@ -34,75 +34,6 @@ if [[ ! "$SY_PORT" =~ ^[0-9]+$ ]]; then exit 1 fi -# Runs a command, or prints it when dry running. -run() { - if (( DRY_RUN )); then - say " would run: $*" - else - "$@" - fi -} - -# Writes stdin to a file, leaving an existing file untouched. -write_once() { - local path="$1" - if [[ -f "$path" ]]; then - say " keeping existing $path" - cat >/dev/null - return - fi - if (( DRY_RUN )); then - say " would create $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " created $path" - fi -} - -# Writes stdin to a file, keeping any existing version as a timestamped backup. -write_with_backup() { - local path="$1" - if (( DRY_RUN )); then - [[ -f "$path" ]] && say " would back up $path" - say " would write $path" - cat >/dev/null - return - fi - mkdir -p "$(dirname "$path")" - local incoming - incoming="$(mktemp)" - cat > "$incoming" - if [[ -f "$path" ]] && cmp -s "$incoming" "$path"; then - say " $path is already up to date" - rm -f "$incoming" - return - fi - if [[ -f "$path" ]]; then - local backup - backup="$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" - cp "$path" "$backup" - say " backed up $path to $backup" - fi - cat "$incoming" > "$path" - rm -f "$incoming" - say " wrote $path" -} - -# Writes stdin to a file, replacing it. Used only for files this script owns. -write_always() { - local path="$1" - if (( DRY_RUN )); then - say " would write $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " wrote $path" - fi -} - if [[ "$(uname -s)" != "Linux" ]]; then say "This installer is for Linux only." >&2 exit 1 diff --git a/scripts/linux/uninstall.sh b/scripts/linux/uninstall.sh index fe019ce27..f968a030d 100755 --- a/scripts/linux/uninstall.sh +++ b/scripts/linux/uninstall.sh @@ -22,19 +22,6 @@ esac # shellcheck source=scripts/linux/common.sh source "$SCRIPT_DIR/common.sh" -# Deletes a file this script owns. -remove_file() { - local path="$1" - if [[ ! -e "$path" ]]; then - say " nothing to remove at $path" - elif (( DRY_RUN )); then - say " would delete $path" - else - rm -f "$path" - say " deleted $path" - fi -} - step "Removing the sy Codex profile" remove_file "$CODEX_PROFILE_CONFIG" diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index 1a04771a9..70f2f5653 100644 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -4,50 +4,21 @@ # Paths, markers, and helpers shared by install.sh and uninstall.sh. # The markers must match between the two, which is why they live here. -SY_HOME="${SY_HOME:-$HOME/.switchyard}" -SY_PORT="${SY_PORT:-4123}" +# shellcheck source=scripts/common.sh +source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" + SERVER_LABEL="com.nvidia.switchyard.server" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" -CODEX_DIR="${CODEX_HOME:-$HOME/.codex}" CODEX_CONFIG="$CODEX_DIR/config.toml" # Codex reads `--profile sy` from its own file next to config.toml. A # [profiles.sy] table in config.toml is rejected outright as legacy config. -CODEX_PROFILE_CONFIG="$CODEX_DIR/sy.config.toml" # Codex.app ignores profiles entirely, so it gets a whole config.toml with the -# routing inlined, plus a snapshot of the unrouted one to swap back to. -CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.toml.sy" +# routing inlined. Keep the generated file beside the original for reference. +CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.sy.toml" CODEX_DIRECT_CONFIG="$CODEX_DIR/config.toml.direct" -ALIAS_START="# >>> switchyard codex alias >>>" -ALIAS_END="# <<< switchyard codex alias <<<" PROFILE_START="# >>> switchyard sy profile >>>" PROFILE_END="# <<< switchyard sy profile <<<" -say() { printf '%s\n' "$*"; } -step() { printf '\n==> %s\n' "$*"; } - -# Deletes the marked block, inclusive, leaving the rest of the file alone. -strip_block() { - local path="$1" start="$2" end="$3" label="${4:-the switchyard block}" - if [[ ! -f "$path" ]] || ! grep -qF "$start" "$path"; then - return 1 - fi - if (( DRY_RUN )); then - say " would remove $label from $path" - return 0 - fi - local temp - temp="$(mktemp)" - awk -v start="$start" -v end="$end" ' - index($0, start) { skipping = 1 } - !skipping { print } - index($0, end) { skipping = 0 } - ' "$path" > "$temp" - cat "$temp" > "$path" - rm -f "$temp" - say " removed $label from $path" - return 0 -} - codex_config_uses_switchyard() { local path="$1" [[ -f "$path" ]] || return 1 diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 39b8bfe0b..e860c79b8 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -5,7 +5,8 @@ # Installs the Switchyard background server as a per-user LaunchAgent, sets up # a `sy` Codex profile, and aliases `codex` to use it. # -# Every step is idempotent and never overwrites a file you have edited. +# Reinstalling keeps the original Codex config backup and regenerates the +# routed config from it. # Run with --dry-run to print what would happen. set -euo pipefail @@ -19,51 +20,8 @@ DRY_RUN=0 # shellcheck source=scripts/macos/common.sh source "$SCRIPT_DIR/common.sh" -# Runs a command, or prints it when dry running. -run() { - if (( DRY_RUN )); then - say " would run: $*" - else - "$@" - fi -} - -# Writes stdin to a file, leaving an existing file untouched. -write_once() { - local path="$1" - if [[ -f "$path" ]]; then - say " keeping existing $path" - cat >/dev/null - return - fi - if (( DRY_RUN )); then - say " would create $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " created $path" - fi -} - -# Keeps a timestamped copy of a file this script is about to replace. -back_up() { - local path="$1" - [[ -f "$path" ]] || return 0 - if (( DRY_RUN )); then - say " would back up $path" - else - cp "$path" "$path.switchyard-backup.$(date +%Y%m%d%H%M%S)" - say " backed up $path" - fi -} - # Builds a whole Codex config that routes through Switchyard, derived from the -# config you use now. Codex.app cannot use the sy profile, so it gets a second -# config.toml to swap in instead. See the crate README for why. -# -# Regenerating from an already-swapped config.toml yields the same file, since -# the keys this adds are stripped before they are written back. +# original config.toml. Codex.app cannot use the sy profile. write_codex_switchyard_config() { local source="$1" target="$2" if [[ ! -f "$source" ]]; then @@ -95,13 +53,7 @@ write_codex_switchyard_config() { fi { - echo "# Generated by the Switchyard installer. Do not edit; edit config.toml" - echo "# and reinstall, and this file is rebuilt from it." - echo "#" - echo "# This is config.toml with Switchyard routing inlined, for Codex.app," - echo "# which cannot use the sy profile. Swap it in and out with:" - echo "# cp ~/.codex/config.toml.sy ~/.codex/config.toml # route" - echo "# cp ~/.codex/config.toml.direct ~/.codex/config.toml # stop routing" + echo "# Generated by the Switchyard installer from the existing Codex config." echo "model = \"switchyard\"" echo "model_provider = \"sy\"" cat "$body" @@ -116,19 +68,6 @@ write_codex_switchyard_config() { say " wrote $target" } -# Writes stdin to a file, replacing it. Used only for files this script owns. -write_file() { - local path="$1" - if (( DRY_RUN )); then - say " would write $path" - cat >/dev/null - else - mkdir -p "$(dirname "$path")" - cat > "$path" - say " wrote $path" - fi -} - xml_escape_text() { printf '%s' "$1" | sed \ -e 's/&/\&/g' \ @@ -163,7 +102,7 @@ fi step "Writing LaunchAgents" XML_SY_HOME="$(xml_escape_text "$SY_HOME")" -write_file "$LAUNCH_AGENTS/$SERVER_LABEL.plist" < @@ -225,41 +164,43 @@ strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profi # This profile only changes which router answers. Approval and sandbox # settings are deliberately left out, so the profile cannot loosen how Codex # asks before it acts. Set those yourself if you want them. -back_up "$CODEX_PROFILE_CONFIG" -write_file "$CODEX_PROFILE_CONFIG" < "$CODEX_DIRECT_CONFIG" say " created an empty direct-config baseline at $CODEX_DIRECT_CONFIG" + CONFIG_SOURCE="$CODEX_DIRECT_CONFIG" fi fi -write_codex_switchyard_config "$CODEX_CONFIG" "$CODEX_SWITCHYARD_CONFIG" +write_codex_switchyard_config "$CONFIG_SOURCE" "$CODEX_SWITCHYARD_CONFIG" +if (( DRY_RUN )); then + say " would replace $CODEX_CONFIG with $CODEX_SWITCHYARD_CONFIG" +else + cp "$CODEX_SWITCHYARD_CONFIG" "$CODEX_CONFIG" + say " replaced $CODEX_CONFIG with the Switchyard config" +fi step "Aliasing codex" for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do @@ -277,7 +218,10 @@ step "Done" say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" say "Open a new shell, or run: alias codex=\"codex --profile sy\"" say "" -say "To route Codex.app too, swap its config:" -say " cp $CODEX_SWITCHYARD_CONFIG $CODEX_CONFIG" -say " cp $CODEX_DIRECT_CONFIG $CODEX_CONFIG # to stop routing" -say "Restart Codex.app after swapping." +if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then + say "Codex.app is routed. Its original config is backed up at:" + say " $CODEX_DIRECT_CONFIG" +else + say "Codex.app is routed, but no original config backup was available." +fi +say "Restart Codex.app to load the updated config." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index f0be29082..dda11e2e9 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -18,19 +18,6 @@ DRY_RUN=0 # shellcheck source=scripts/macos/common.sh source "$SCRIPT_DIR/common.sh" -# Deletes a file this script owns. -remove_file() { - local path="$1" - if [[ ! -e "$path" ]]; then - say " nothing to remove at $path" - elif (( DRY_RUN )); then - say " would delete $path" - else - rm -f "$path" - say " deleted $path" - fi -} - step "Unloading LaunchAgents" if (( DRY_RUN )); then say " would unload gui/$UID/$SERVER_LABEL and delete its plist" @@ -48,20 +35,21 @@ strip_block "$CODEX_CONFIG" "$PROFILE_START" "$PROFILE_END" "the legacy sy profi say " no legacy profile in $CODEX_CONFIG" step "Unrouting Codex.app" -# Leaving a routed config.toml behind would point Codex.app at a server that -# is no longer running, so put the snapshot back before the agents go away. -if codex_config_uses_switchyard "$CODEX_CONFIG"; then - if [[ ! -f "$CODEX_DIRECT_CONFIG" ]]; then - say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" - elif (( DRY_RUN )); then +if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then + if (( DRY_RUN )); then say " would preserve $CODEX_CONFIG before restoring $CODEX_DIRECT_CONFIG" else - backup="$(mktemp "$CODEX_CONFIG.switchyard-current.XXXXXX")" - cp "$CODEX_CONFIG" "$backup" + backup="" + if [[ -f "$CODEX_CONFIG" ]]; then + backup="$(mktemp "$CODEX_CONFIG.switchyard-current.XXXXXX")" + cp "$CODEX_CONFIG" "$backup" + fi cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" rm -f "$CODEX_DIRECT_CONFIG" - say " restored the unrouted config.toml; preserved the current file at $backup" + say " restored the original config.toml${backup:+; preserved the current file at $backup}" fi +elif codex_config_uses_switchyard "$CODEX_CONFIG"; then + say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" else remove_file "$CODEX_DIRECT_CONFIG" fi @@ -75,4 +63,5 @@ done step "Done" say "Left in place, delete them if you want:" say " $SY_HOME (binaries, config, routing log, logs)" -say " $CODEX_CONFIG.switchyard-backup.* (backups taken at install time)" +say " $CODEX_CONFIG.switchyard-current.* (configs preserved during restore)" +say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (profile backups)" diff --git a/tests/test_linux_install.py b/tests/test_linux_install.py index 33b029beb..7d070c8e3 100644 --- a/tests/test_linux_install.py +++ b/tests/test_linux_install.py @@ -18,6 +18,7 @@ def setup(tmp_path): scripts = tmp_path / "repo" / "scripts" / "linux" shutil.copytree(REPO / "scripts" / "linux", scripts) + shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") home = tmp_path / "home" home.mkdir() diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py index 995c22c27..376b35f78 100644 --- a/tests/test_macos_install.py +++ b/tests/test_macos_install.py @@ -16,6 +16,7 @@ def setup(tmp_path): scripts = tmp_path / "repo" / "scripts" / "macos" shutil.copytree(REPO / "scripts" / "macos", scripts) + shutil.copy(REPO / "scripts" / "common.sh", scripts.parent / "common.sh") shutil.copytree(REPO / "scripts" / "config", scripts.parent / "config") home = tmp_path / "home" home.mkdir() @@ -74,12 +75,13 @@ def test_install_escapes_switchyard_path_in_launch_agent(setup): assert values["StandardErrorPath"].text == str(switchyard_home / "logs" / "server.err.log") -def test_missing_codex_config_creates_routed_and_empty_direct_baselines(setup): +def test_missing_codex_config_creates_routed_config_and_empty_backup(setup): _, home, _, _ = setup result = run(setup, "install.sh") assert result.returncode == 0, result.stderr codex = home / ".codex" - assert 'model_provider = "sy"' in read_config(codex / "config.toml.sy") + assert 'model_provider = "sy"' in read_config(codex / "config.sy.toml") + assert read_config(codex / "config.toml") == read_config(codex / "config.sy.toml") assert (codex / "config.toml.direct").read_text() == "" @@ -100,10 +102,11 @@ def test_provider_table_with_comment_is_replaced_and_shared_template_is_used(set config.write_text(original) result = run(setup, "install.sh") assert result.returncode == 0, result.stderr - generated = read_config(codex / "config.toml.sy") + generated = read_config(codex / "config.sy.toml") assert generated.count("[model_providers.sy]") == 1 assert 'name = "Old"' not in generated assert '[other]\nmodel_provider = "sy"' in generated + assert read_config(config) == generated assert read_config(switchyard_home / "composite.toml") == read_config( REPO / "scripts" / "config" / "composite.toml" ) @@ -121,7 +124,7 @@ def test_quoted_top_level_provider_keeps_existing_snapshot(setup, quote): result = run(setup, "install.sh") assert result.returncode == 0, result.stderr assert snapshot.read_text() == "original direct config\n" - assert (codex / "config.toml.sy").is_file() + assert (codex / "config.sy.toml").is_file() def test_uninstall_preserves_routed_config_before_restoring_snapshot(setup): From 23a8b77c6a952b5dc0612ed630d133dce5853edd Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 20:51:13 -0400 Subject: [PATCH 7/8] chore: remove cmd alias Signed-off-by: Greg Clark --- scripts/macos/install.sh | 18 +++--------------- tests/test_macos_install.py | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index e860c79b8..aaef4388e 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -2,8 +2,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Installs the Switchyard background server as a per-user LaunchAgent, sets up -# a `sy` Codex profile, and aliases `codex` to use it. +# Installs the Switchyard background server as a per-user LaunchAgent and sets +# up a `sy` Codex profile. # # Reinstalling keeps the original Codex config backup and regenerates the # routed config from it. @@ -202,21 +202,9 @@ else say " replaced $CODEX_CONFIG with the Switchyard config" fi -step "Aliasing codex" -for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do - if [[ -f "$rc" ]] && grep -qF "$ALIAS_START" "$rc"; then - say " alias already in $rc" - elif (( DRY_RUN )); then - say " would add the codex alias to $rc" - else - printf '\n%s\nalias codex="codex --profile sy"\n%s\n' "$ALIAS_START" "$ALIAS_END" >> "$rc" - say " added the codex alias to $rc" - fi -done - step "Done" say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" -say "Open a new shell, or run: alias codex=\"codex --profile sy\"" +say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" say "" if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then say "Codex.app is routed. Its original config is backed up at:" diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py index 376b35f78..b1aec2725 100644 --- a/tests/test_macos_install.py +++ b/tests/test_macos_install.py @@ -85,6 +85,21 @@ def test_missing_codex_config_creates_routed_config_and_empty_backup(setup): assert (codex / "config.toml.direct").read_text() == "" +def test_install_prints_profile_usage_without_editing_shell_files(setup): + _, home, _, _ = setup + zshrc = home / ".zshrc" + bashrc = home / ".bashrc" + zshrc.write_text("zsh settings\n") + bashrc.write_text("bash settings\n") + + result = run(setup, "install.sh") + + assert result.returncode == 0, result.stderr + assert "Use it with: codex -p sy" in result.stdout + assert zshrc.read_text() == "zsh settings\n" + assert bashrc.read_text() == "bash settings\n" + + def test_provider_table_with_comment_is_replaced_and_shared_template_is_used(setup): _, home, switchyard_home, env = setup codex = Path(env["CODEX_HOME"]) From 569aaf6135450adf502498be3c1b88eddfed73b8 Mon Sep 17 00:00:00 2001 From: Greg Clark Date: Thu, 1 Oct 2026 21:47:31 -0400 Subject: [PATCH 8/8] chore: review feedback Signed-off-by: Greg Clark --- scripts/macos/common.sh | 21 +-------------------- scripts/macos/install.sh | 7 +------ tests/test_macos_install.py | 22 ++++++++++++++++++---- 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index 70f2f5653..24c224451 100644 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -22,24 +22,5 @@ PROFILE_END="# <<< switchyard sy profile <<<" codex_config_uses_switchyard() { local path="$1" [[ -f "$path" ]] || return 1 - awk ' - function is_string(value, wanted, quote, prefix, suffix) { - sub(/^[[:space:]]*/, "", value) - quote = substr(value, 1, 1) - if (quote != "\"" && quote != sprintf("%c", 39)) return 0 - prefix = quote wanted quote - if (substr(value, 1, length(prefix)) != prefix) return 0 - suffix = substr(value, length(prefix) + 1) - return suffix ~ /^[[:space:]]*(#.*)?$/ - } - BEGIN { top_level = 1 } - /^[[:space:]]*#/ { next } - /^[[:space:]]*\[/ { top_level = 0; next } - top_level && /^[[:space:]]*model_provider[[:space:]]*=/ { - value = $0 - sub(/^[^=]*=[[:space:]]*/, "", value) - if (is_string(value, "sy")) found = 1 - } - END { exit !found } - ' "$path" + grep -Fq '[model_providers.sy]' "$path" } diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index aaef4388e..eb2a3bbf3 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -195,12 +195,7 @@ else fi fi write_codex_switchyard_config "$CONFIG_SOURCE" "$CODEX_SWITCHYARD_CONFIG" -if (( DRY_RUN )); then - say " would replace $CODEX_CONFIG with $CODEX_SWITCHYARD_CONFIG" -else - cp "$CODEX_SWITCHYARD_CONFIG" "$CODEX_CONFIG" - say " replaced $CODEX_CONFIG with the Switchyard config" -fi +write_with_backup "$CODEX_CONFIG" < "$CODEX_SWITCHYARD_CONFIG" step "Done" say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py index b1aec2725..0be77d381 100644 --- a/tests/test_macos_install.py +++ b/tests/test_macos_install.py @@ -115,6 +115,7 @@ def test_provider_table_with_comment_is_replaced_and_shared_template_is_used(set model_provider = "sy" ''' config.write_text(original) + (codex / "config.toml.direct").write_text(original) result = run(setup, "install.sh") assert result.returncode == 0, result.stderr generated = read_config(codex / "config.sy.toml") @@ -126,14 +127,16 @@ def test_provider_table_with_comment_is_replaced_and_shared_template_is_used(set REPO / "scripts" / "config" / "composite.toml" ) assert read_config(codex / "config.toml.direct") == original + backups = list(codex.glob("config.toml.switchyard-backup.*")) + assert len(backups) == 1 + assert backups[0].read_text() == original -@pytest.mark.parametrize("quote", ['"', "'"]) -def test_quoted_top_level_provider_keeps_existing_snapshot(setup, quote): +def test_provider_table_marker_keeps_existing_snapshot(setup): _, home, _, env = setup codex = Path(env["CODEX_HOME"]) codex.mkdir() - (codex / "config.toml").write_text(f"model_provider = {quote}sy{quote} # routed\n") + (codex / "config.toml").write_text('[model_providers.sy]\nname = "Switchyard"\n') snapshot = codex / "config.toml.direct" snapshot.write_text("original direct config\n") result = run(setup, "install.sh") @@ -142,11 +145,22 @@ def test_quoted_top_level_provider_keeps_existing_snapshot(setup, quote): assert (codex / "config.sy.toml").is_file() +def test_provider_setting_without_table_gets_snapshotted(setup): + _, _, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + original = 'model_provider = "sy"\n' + (codex / "config.toml").write_text(original) + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + assert (codex / "config.toml.direct").read_text() == original + + def test_uninstall_preserves_routed_config_before_restoring_snapshot(setup): _, home, _, env = setup codex = Path(env["CODEX_HOME"]) codex.mkdir() - current = "model_provider = 'sy' # routed\nuser_setting = \"keep me\"\n" + current = '[model_providers.sy]\nname = "Switchyard"\nuser_setting = "keep me"\n' (codex / "config.toml").write_text(current) (codex / "config.toml.direct").write_text("model = \"original\"\n") result = run(setup, "uninstall.sh")