diff --git a/Cargo.lock b/Cargo.lock index b1cbce1be..cb816b4ac 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 2.0.119", ] +[[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" @@ -512,7 +657,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]] @@ -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" @@ -1335,33 +1862,185 @@ dependencies = [ ] [[package]] -name = "num-rational" -version = "0.4.2" +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 = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", @@ -1764,7 +2531,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -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" @@ -1998,7 +2775,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2055,7 +2832,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -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", ] @@ -2517,6 +3324,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" @@ -2559,6 +3376,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" @@ -2575,7 +3411,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2715,6 +3551,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" @@ -2723,11 +3586,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]] @@ -2739,13 +3620,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]] @@ -2878,6 +3783,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" @@ -2974,6 +3900,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" @@ -3117,13 +4049,70 @@ 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.52.0", + "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]] @@ -3132,6 +4121,24 @@ 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" @@ -3214,6 +4221,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" @@ -3255,6 +4277,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" @@ -3364,6 +4396,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 4151bc415..e732ecaed 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 new file mode 100644 index 000000000..9a09db0f5 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +.PHONY: install-macos install-macos-dry-run uninstall-macos + +## 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)"