diff --git a/Cargo.lock b/Cargo.lock index 5a6c710c7..e9e751abe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -31,6 +37,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -139,6 +154,29 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -278,6 +316,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -302,6 +349,31 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + [[package]] name = "cc" version = "1.2.67" @@ -314,6 +386,16 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon 0.12.16", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -343,8 +425,10 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "num-traits", "serde", + "windows-link", ] [[package]] @@ -375,7 +459,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -437,6 +521,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "data-encoding" version = "2.11.1" @@ -467,6 +575,37 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -478,6 +617,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + [[package]] name = "dunce" version = "1.0.5" @@ -543,12 +688,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" @@ -695,6 +870,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" @@ -736,6 +969,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" @@ -766,6 +1141,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" @@ -902,6 +1283,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" @@ -1174,18 +1579,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" @@ -1240,6 +1717,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" @@ -1252,6 +1738,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" @@ -1263,6 +1769,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" @@ -1340,52 +1867,204 @@ dependencies = [ ] [[package]] -name = "num-integer" -version = "0.1.46" +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "block2", + "libc", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "num-traits", + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] -name = "num-iter" -version = "0.1.46" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" -dependencies = [ - "num-integer", - "num-traits", -] +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "num-rational" -version = "0.4.2" +name = "objc2-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "num-bigint", - "num-integer", - "num-traits", + "bitflags", + "block2", + "libc", + "objc2", + "objc2-core-foundation", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "objc2-io-surface" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "autocfg", + "bitflags", + "objc2", + "objc2-core-foundation", ] [[package]] -name = "num_cpus" -version = "1.17.0" +name = "objc2-quartz-core" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "hermit-abi", - "libc", + "bitflags", + "objc2", + "objc2-foundation", ] [[package]] @@ -1490,12 +2169,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" @@ -1557,6 +2267,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" @@ -1605,6 +2328,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" @@ -1729,7 +2496,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]] @@ -1760,7 +2527,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", @@ -1919,6 +2686,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" @@ -2263,6 +3040,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" @@ -2309,6 +3095,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" @@ -2384,7 +3176,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", @@ -2448,6 +3240,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" @@ -2463,7 +3270,7 @@ dependencies = [ "switchyard-translation", "tempfile", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "wiremock", ] @@ -2534,7 +3341,7 @@ dependencies = [ "switchyard-protocol", "thiserror 2.0.18", "tokio", - "toml", + "toml 1.1.3+spec-1.1.0", "tracing", ] @@ -2616,6 +3423,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" @@ -2658,6 +3475,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" @@ -2814,6 +3650,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" @@ -2822,11 +3685,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]] @@ -2838,13 +3719,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]] @@ -3009,6 +3914,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" @@ -3105,6 +4031,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" @@ -3248,6 +4180,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -3257,12 +4205,71 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3345,6 +4352,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" @@ -3386,6 +4408,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" @@ -3495,6 +4527,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 a194b8023..5e64fbd9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/protocol", "crates/switchyard-nemo-relay-plugin", "crates/switchyard-runner", + "crates/switchyard-menubar", "crates/switchyard-server", "crates/switchyard-skill-distillation", "crates/switchyard-soak", diff --git a/Makefile b/Makefile index a0dd77668..7136e3a26 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ help: @echo "install-linux Install the systemd user service and Codex profile" @echo "install-linux-dry-run Preview installation without changes" @echo "uninstall-linux Remove the service and Codex profile" - @echo "install-macos Install the background server" + @echo "install-macos Install the background server and menu bar app" @echo "install-macos-dry-run Preview installation without changes" @echo "uninstall-macos Remove the LaunchAgents and Codex profile" @@ -24,7 +24,7 @@ install-linux-dry-run: uninstall-linux: @scripts/linux/uninstall.sh -## Install the Switchyard background server for this user. +## Install the Switchyard background server and menu bar app for this user. install-macos: @scripts/macos/install.sh @@ -32,6 +32,6 @@ install-macos: install-macos-dry-run: @scripts/macos/install.sh --dry-run -## Remove the LaunchAgent, the sy Codex profile, and the codex alias. +## 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..9095aad95 --- /dev/null +++ b/crates/switchyard-menubar/README.md @@ -0,0 +1,101 @@ +# 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, and writes a `sy` Codex profile. Use the CLI profile with +`codex -p sy`. + +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. + +Codex's "Approve for me" reviews count too. The server config that the +installer writes sends them to `codex-auto-review` on the ChatGPT backend. With +a ChatGPT login, Codex sends these reviews to `codex-auto-review` even without +Switchyard, so the installer prices that model at the `baseline_model` rates. +Each review then adds the same amount to the actual cost and to the baseline, +so reviews do not change the dollar amount saved. They do lower the percentage +a little, because the baseline grows. If you change `baseline_model`, update +this price to match. + +The menu bar needs a price for the model ID that the routing log records for +reviews, which is the reviewer target's `id`. Without that price, the menu bar +hides the Saved row for every period that includes a review. The installer does +not overwrite an existing `composite.toml` or `menubar.toml`, so an existing +install needs the route and the price added by hand. + +Codex's "Approve for me" reviews need a price too. Use the reviewer target's +`id`, which is the model ID recorded in the routing log. The default reviewer +route uses `codex-auto-review`. With a ChatGPT login, these reviews also happen +without Switchyard. Add that ID to the price table at the `baseline_model` +rates, including the cached input rate. Each review then adds the same cost to +actual and baseline. Dollars saved stay the same; the percentage drops as the +baseline grows. Update this price when you change `baseline_model`. + +If a reviewer uses another model ID, add a price for that ID. Any period with +an unpriced review hides its savings and shows the missing-price hint. Existing +`menubar.toml` files need these prices added by hand. + +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`. The installer writes this +standalone profile and keeps your existing `~/.codex/config.toml` unchanged. +Reinstalling backs up the profile before replacing it if its contents change. + +The profile sets `model`, `model_provider`, and the Switchyard provider details. +It leaves `approval_policy` and `sandbox_mode` to your existing Codex settings. + +Codex.app cannot use the profile. The installer leaves its config unchanged. +For older installs that replaced `config.toml`, `make uninstall-macos` restores +`config.toml.direct` and keeps a copy of the active config before replacing it. diff --git a/crates/switchyard-menubar/src/app.rs b/crates/switchyard-menubar/src/app.rs new file mode 100644 index 000000000..158411f40 --- /dev/null +++ b/crates/switchyard-menubar/src/app.rs @@ -0,0 +1,57 @@ +// 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}; + +/// Reads new log entries, probes the server, and builds the menu body. +pub fn refresh(config: &Config, log: &mut rollup::Reader) -> Vec { + let usage = log + .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() + }, + &mut rollup::Reader::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..8b29b4b6a --- /dev/null +++ b/crates/switchyard-menubar/src/config.rs @@ -0,0 +1,169 @@ +// 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 mut config: Self = match std::fs::read_to_string(path) { + Ok(text) => toml::from_str(&text) + .map_err(|error| format!("parse {}: {error}", path.display()))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Self::default(), + Err(error) => return Err(format!("read {}: {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")) + } +} + +pub fn validate_toml_file(path: &Path) -> Result<(), String> { + let text = std::fs::read_to_string(path) + .map_err(|error| format!("read {}: {error}", path.display()))?; + toml::from_str::(&text) + .map_err(|error| format!("parse {}: {error}", path.display()))?; + Ok(()) +} + +/// 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()); + let empty = dir.path().join("empty.toml"); + std::fs::write(&empty, "").expect("write settings"); + let loaded = Config::load(&empty).expect("load empty settings"); + assert_eq!(config.routing_log, loaded.routing_log); + assert_eq!(config.config_file, loaded.config_file); + } + + #[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 validates_arbitrary_toml_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("codex.toml"); + std::fs::write(&path, "[model_providers.\"sy\"]\nname = \"Switchyard\"\n") + .expect("write config"); + + assert!(validate_toml_file(&path).is_ok()); + + std::fs::write(&path, "[model_providers.\"sy\"]\nname = ").expect("write config"); + assert!(validate_toml_file(&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..9105ca75a --- /dev/null +++ b/crates/switchyard-menubar/src/health.rs @@ -0,0 +1,111 @@ +// 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 { + let authority = authority(server_url); + 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))?; + 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")) +} + +fn authority(server_url: &str) -> String { + let rest = server_url.rsplit("://").next().unwrap_or(server_url); + let mut authority = rest.split('/').next().unwrap_or(rest).to_string(); + let host_end = authority.rfind(']').map_or(0, |index| index + 1); + if !authority[host_end..].contains(':') { + authority.push_str(":4123"); + } + + authority +} + +#[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 defaults_the_port_after_an_ipv6_literal() { + assert_eq!(authority("http://[::1]/v1"), "[::1]:4123"); + assert_eq!(authority("http://[::1]:9000/v1"), "[::1]:9000"); + assert_eq!(authority("http://localhost"), "localhost:4123"); + } + + #[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..fd42bc616 --- /dev/null +++ b/crates/switchyard-menubar/src/main.rs @@ -0,0 +1,94 @@ +// 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 | --validate-toml] [FILE] + +Shows Switchyard usage and estimated savings in the macOS menu bar. + + FILE Settings file [default: ~/.switchyard/menubar.toml] + --print Print the current summary and exit, instead of running + --validate-toml Parse FILE as TOML and exit"; + +fn main() -> ExitCode { + let mut settings = None; + let mut print_only = false; + let mut validate_toml = false; + for arg in std::env::args().skip(1) { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return ExitCode::SUCCESS; + } + "--print" => print_only = true, + "--validate-toml" => validate_toml = true, + _ if arg.starts_with('-') => { + eprintln!("switchyard-menubar: unknown option {arg}\n\n{USAGE}"); + return ExitCode::FAILURE; + } + _ => settings = Some(PathBuf::from(arg)), + } + } + + if validate_toml { + let Some(path) = settings else { + eprintln!("switchyard-menubar: --validate-toml requires a file path"); + return ExitCode::FAILURE; + }; + if let Err(error) = config::validate_toml_file(&path) { + eprintln!("switchyard-menubar: {error}"); + return ExitCode::FAILURE; + } + return ExitCode::SUCCESS; + } + + let settings_path = settings.unwrap_or_else(Config::default_path); + let config = match Config::load(&settings_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, &mut rollup::Reader::default()) { + 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, settings_path) { + 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..c05a34780 --- /dev/null +++ b/crates/switchyard-menubar/src/rollup.rs @@ -0,0 +1,428 @@ +// 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, Metadata}; +use std::io::{BufRead, BufReader, Seek, SeekFrom}; +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()); + } + + fn merge(&mut self, other: &Self) { + for (model, tokens) in &other.routed { + self.routed.entry(model.clone()).or_default().add(tokens); + } + for (model, tokens) in &other.classifier { + self.classifier + .entry(model.clone()) + .or_default() + .add(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, +} + +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, + // Completion tokens already include reasoning tokens. + output: self.completion_tokens, + } + } +} + +/// Keeps daily totals and reads only newly completed log lines. +#[derive(Default)] +pub struct Reader { + metadata: Option, + offset: u64, + days: BTreeMap, +} + +impl Reader { + /// A missing log has no usage. Replacement or truncation starts fresh. + pub fn read(&mut self, path: &Path, today: NaiveDate) -> std::io::Result { + let mut file = match File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + *self = Self::default(); + return Ok(Usage::default()); + } + Err(error) => return Err(error), + }; + let metadata = file.metadata()?; + if metadata.len() < self.offset + || !self + .metadata + .as_ref() + .is_some_and(|previous| is_same_file(previous, &metadata)) + { + self.offset = 0; + self.days.clear(); + } + self.metadata = Some(metadata); + file.seek(SeekFrom::Start(self.offset))?; + + let week_start = today - chrono::Duration::days(6); + self.days.retain(|day, _| *day >= week_start); + let mut reader = BufReader::new(file); + let mut line = Vec::new(); + loop { + line.clear(); + let bytes = reader.read_until(b'\n', &mut line)?; + // Leave an unfinished line at the offset so the next refresh retries it. + if line.last() != Some(&b'\n') { + break; + } + self.offset += bytes as u64; + let Ok(record) = serde_json::from_slice::(&line) else { + continue; + }; + let Ok(ts) = DateTime::parse_from_rfc3339(&record.ts) else { + continue; + }; + let day = ts.with_timezone(&Local).date_naive(); + if day >= week_start { + self.days.entry(day).or_default().add(&record); + } + } + let mut usage = Usage::default(); + for (day, totals) in &self.days { + if *day == today { + usage.today = totals.clone(); + } + if *day <= today { + usage.week.merge(totals); + } + } + Ok(usage) + } +} + +fn is_same_file(left: &Metadata, right: &Metadata) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + left.dev() == right.dev() && left.ino() == right.ino() + } + #[cfg(not(unix))] + { + left.created() + .ok() + .is_some_and(|created| right.created().ok() == Some(created)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(path: &Path, today: NaiveDate) -> std::io::Result { + Reader::default().read(path, today) + } + + 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, 50); + assert_eq!(tokens.total(), 1_050); + } + + #[test] + fn counts_completion_tokens_once_and_prices_them() { + let record: Record = serde_json::from_str( + r#"{"ts":"2026-09-28T10:00:00.000Z","model":"m","prompt_tokens":12,"cached_tokens":0,"completion_tokens":5,"reasoning_tokens":3}"#, + ) + .expect("parse record"); + + let tokens = record.tokens(); + + assert_eq!(tokens.input, 12); + assert_eq!(tokens.output, 5); + assert_eq!(tokens.total(), 17); + let mut totals = Totals::default(); + totals.add(&record); + let prices = crate::pricing::PriceTable::from([( + "m".to_string(), + crate::pricing::ModelPrice { + input_per_mtok: 1.0, + cached_input_per_mtok: None, + output_per_mtok: 2.0, + }, + )]); + let savings = crate::pricing::estimate(&totals, &prices, "m").expect("priced"); + assert!((savings.actual - 0.000022).abs() < 1e-12); + } + + #[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 reads_appends_once_and_retries_an_unfinished_line() { + use std::io::Write; + + let first = line("2026-09-28T10:00:00.000", "luna", "", 100, 10); + let second = line("2026-09-28T10:00:01.000", "terra", "classifier", 30, 1); + let (_dir, path) = log(&format!("{first}\n")); + let today = date("2026-09-28"); + let mut reader = Reader::default(); + assert_eq!(reader.read(&path, today).expect("read").today.tokens(), 110); + assert_eq!( + reader.read(&path, today).expect("reread").today.tokens(), + 110 + ); + + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open"); + let split = second.len() / 2; + file.write_all(&second.as_bytes()[..split]) + .expect("partial append"); + assert_eq!( + reader + .read(&path, today) + .expect("partial read") + .today + .tokens(), + 110 + ); + writeln!(file, "{}", &second[split..]).expect("finish append"); + let usage = reader.read(&path, today).expect("read append"); + assert_eq!(usage.today.tokens(), 141); + assert_eq!(usage.today.requests(), 1); + assert_eq!(usage.today.classifier["terra"].requests, 1); + } + + #[test] + fn drops_old_days_without_rereading_the_log() { + let (_dir, path) = log(&format!( + "{}\n{}\n", + line("2026-09-22T10:00:00.000", "luna", "", 100, 10), + line("2026-09-28T10:00:00.000", "luna", "", 100, 10), + )); + let mut reader = Reader::default(); + assert_eq!( + reader + .read(&path, date("2026-09-28")) + .expect("read") + .week + .requests(), + 2 + ); + let usage = reader.read(&path, date("2026-09-29")).expect("next day"); + assert_eq!(usage.today.requests(), 0); + assert_eq!(usage.week.requests(), 1); + } + + #[test] + fn resets_totals_when_the_log_is_replaced_or_truncated() { + let (_dir, path) = log(&format!( + "{}\n{}\n", + line("2026-09-28T10:00:00.000", "luna", "", 100, 10), + line("2026-09-28T10:00:01.000", "luna", "", 100, 10), + )); + let mut reader = Reader::default(); + let today = date("2026-09-28"); + assert_eq!(reader.read(&path, today).expect("read").today.requests(), 2); + let replacement = format!("{}\n", line("2026-09-28T11:00:00.000", "sol", "", 200, 20)); + std::fs::write(&path, &replacement).expect("truncate"); + let usage = reader.read(&path, today).expect("read truncated"); + assert_eq!(usage.today.requests(), 1); + assert_eq!(usage.today.tokens(), 220); + + std::fs::rename(&path, path.with_extension("old")).expect("rotate"); + std::fs::write(&path, replacement.repeat(3)).expect("replace"); + let usage = reader.read(&path, today).expect("read replacement"); + assert_eq!(usage.today.requests(), 3); + assert_eq!(usage.today.tokens(), 660); + } + + #[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..9c18cb6e2 --- /dev/null +++ b/crates/switchyard-menubar/src/summary.rs @@ -0,0 +1,285 @@ +// 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 estimate(&usage.week, &config.prices, &config.baseline_model).is_none() { + 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)); + + week.classifier + .insert("sol".to_string(), counted(100, 0, 1)); + for prices in [ + PriceTable::new(), + PriceTable::from([("sol".to_string(), priced()["sol"])]), + PriceTable::from([("luna".to_string(), priced()["luna"])]), + ] { + let rows = build( + ServerStatus::Running, + &usage_of(week.clone(), week.clone()), + &config(prices), + ); + 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..7a39c87da --- /dev/null +++ b/crates/switchyard-menubar/src/tray.rs @@ -0,0 +1,192 @@ +// 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::path::PathBuf; +use std::process::Command; +use std::thread; +use std::time::{Duration, Instant}; + +use objc2::rc::autoreleasepool; +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, settings_path: PathBuf) -> 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 mut log = crate::rollup::Reader::default(); + 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, &mut log))?)) + .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; + let mut actions = Vec::new(); + loop { + let should_quit = autoreleasepool(|_| -> Result { + pump_events(&ns_app); + + let mut redraw = false; + while let Ok(event) = MenuEvent::receiver().try_recv() { + let action = match event.id.as_ref() { + QUIT => return Ok(true), + RESTART => { + let label = config.launchd_label.clone(); + thread::spawn(move || restart_server(&label)) + } + OPEN_CONFIG | OPEN_SETTINGS => { + let path = if event.id.as_ref() == OPEN_CONFIG { + config.config_file.clone() + } else { + settings_path.clone() + }; + thread::spawn(move || open(&path)) + } + _ => continue, + }; + actions.push(action); + } + for index in (0..actions.len()).rev() { + if actions[index].is_finished() { + report( + actions + .swap_remove(index) + .join() + .unwrap_or_else(|_| Err("menu action thread panicked".to_string())), + ); + redraw = true; + } + } + + if redraw || Instant::now() >= next { + tray.set_menu(Some(Box::new(menu(&refresh(&config, &mut log))?))); + next = Instant::now() + interval; + } + Ok(false) + })?; + if should_quit { + return Ok(()); + } + } +} + +/// Restarts the server's LaunchAgent. +fn restart_server(label: &str) -> Result<(), String> { + let uid = command("id", &["-u"])?; + let target = format!("gui/{}/{}", uid.trim(), label); + command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) +} + +/// Opens a config file in the user's editor. +fn open(path: &std::path::Path) -> Result<(), String> { + command("open", &["-t", &path.display().to_string()]).map(|_| ()) +} + +/// Runs a command, returning its stdout or a message naming what failed. +fn command(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("run {program}: {error}"))?; + if !output.status.success() { + return Err(format!( + "{program} {}: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +fn report(result: Result<(), String>) { + if let Err(error) = result { + eprintln!("switchyard-menubar: {error}"); + } +} + +/// Builds the whole menu: the summary rows, then the fixed actions. +fn menu(rows: &[Row]) -> Result { + let menu = Menu::new(); + let append = |item: &dyn IsMenuItem| { + menu.append(item) + .map_err(|error| format!("build menu: {error}")) + }; + + for row in rows { + match row { + Row::Separator => append(&PredefinedMenuItem::separator())?, + // Rows are labels, not commands, so they are disabled. + Row::Label(text) => append(&MenuItem::new(text, false, None))?, + } + } + append(&PredefinedMenuItem::separator())?; + for (id, text) in [ + (RESTART, "Restart server"), + (OPEN_CONFIG, "Open server config…"), + (OPEN_SETTINGS, "Open menu bar settings…"), + (QUIT, "Quit"), + ] { + append(&MenuItem::with_id(id, text, true, None))?; + } + Ok(menu) +} + +/// Drains pending AppKit events, blocking briefly when there are none. +fn pump_events(ns_app: &NSApplication) { + let mut expiration = Some(NSDate::dateWithTimeIntervalSinceNow(EVENT_POLL)); + while let Some(event) = unsafe { + ns_app.nextEventMatchingMask_untilDate_inMode_dequeue( + NSEventMask::Any, + expiration.as_deref(), + NSDefaultRunLoopMode, + true, + ) + } { + ns_app.sendEvent(&event); + // Only the first wait blocks; the rest drain what is already queued. + expiration = Some(NSDate::distantPast()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_which_command_failed() { + let error = command("switchyard-does-not-exist", &[]).expect_err("missing program"); + + assert!(error.contains("switchyard-does-not-exist")); + } +} diff --git a/scripts/macos/common.sh b/scripts/macos/common.sh index b63b4fcb8..9978601f4 100644 --- a/scripts/macos/common.sh +++ b/scripts/macos/common.sh @@ -1,10 +1,46 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Paths shared by install.sh and uninstall.sh. +# Paths, markers, and helpers shared by install.sh and uninstall.sh. +# The markers must match between the two, which is why they live here. # shellcheck source=scripts/common.sh source "$(dirname "${BASH_SOURCE[0]}")/../common.sh" SERVER_LABEL="com.nvidia.switchyard.server" +MENUBAR_LABEL="com.nvidia.switchyard.menubar" LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +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.app ignores profiles entirely, so it gets a whole config.toml with the +# routing inlined. Keep the generated file beside the original for reference. +CODEX_SWITCHYARD_CONFIG="$CODEX_DIR/config.sy.toml" +CODEX_DIRECT_CONFIG="$CODEX_DIR/config.toml.direct" +PROFILE_START="# >>> switchyard sy profile >>>" +PROFILE_END="# <<< switchyard sy profile <<<" + +codex_config_uses_switchyard() { + local path="$1" + [[ -f "$path" ]] || return 1 + awk ' + function is_string(value, wanted, quote, prefix, suffix) { + sub(/^[[:space:]]*/, "", value) + quote = substr(value, 1, 1) + if (quote != "\"" && quote != sprintf("%c", 39)) return 0 + prefix = quote wanted quote + if (substr(value, 1, length(prefix)) != prefix) return 0 + suffix = substr(value, length(prefix) + 1) + return suffix ~ /^[[:space:]]*(#.*)?$/ + } + BEGIN { top_level = 1 } + /^[[:space:]]*#/ { next } + /^[[:space:]]*\[/ { top_level = 0; next } + top_level && /^[[:space:]]*model_provider[[:space:]]*=/ { + value = $0 + sub(/^[^=]*=[[:space:]]*/, "", value) + if (is_string(value, "sy")) found = 1 + } + END { exit !found } + ' "$path" +} diff --git a/scripts/macos/install.sh b/scripts/macos/install.sh index 3cb6cc1a2..43c97c9c0 100755 --- a/scripts/macos/install.sh +++ b/scripts/macos/install.sh @@ -2,10 +2,11 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Installs the Switchyard background server as a per-user LaunchAgent and sets -# up a `sy` Codex profile. +# Installs the Switchyard background server and its menu bar companion as +# per-user LaunchAgents and sets up a `sy` Codex profile. # -# Keeps existing composite.toml and backs up sy.config.toml before replacing it. +# Keeps existing server and menu bar settings and backs up sy.config.toml +# before replacing it. # Run with --dry-run to print what would happen. set -euo pipefail @@ -30,6 +31,10 @@ xml_escape_text() { -e 's/>/\>/g' } +toml_escape_basic_string() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + if [[ "$(uname -s)" != "Darwin" ]]; then say "This installer is for macOS only." >&2 exit 1 @@ -37,17 +42,60 @@ fi step "Building release binaries" run cargo build --release --manifest-path "$REPO_ROOT/Cargo.toml" \ - -p switchyard-server + -p switchyard-server -p switchyard-menubar step "Installing binaries into $SY_HOME/bin" run mkdir -p "$SY_HOME/bin" "$SY_HOME/logs" -run install -m 755 "$REPO_ROOT/target/release/switchyard-server" \ - "$SY_HOME/bin/switchyard-server" +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" # Keep macOS and Linux on the same routing defaults. write_once "$SY_HOME/composite.toml" < "$REPO_ROOT/scripts/config/composite.toml" +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. +TOML_SY_HOME="$(toml_escape_basic_string "$SY_HOME")" +write_once "$SY_HOME/menubar.toml" < EOF +# SuccessfulExit false means the Quit menu item stays quit, while a crash is +# still restarted. Aqua-only, since there is no menu bar without a login session. +write_always "$LAUNCH_AGENTS/$MENUBAR_LABEL.plist" < + + + + Label + $MENUBAR_LABEL + ProgramArguments + + $XML_SY_HOME/bin/switchyard-menubar + $XML_SY_HOME/menubar.toml + + RunAtLoad + + KeepAlive + + SuccessfulExit + + + LimitLoadToSessionType + Aqua + StandardOutPath + $XML_SY_HOME/logs/menubar.log + StandardErrorPath + $XML_SY_HOME/logs/menubar.err.log + + +EOF + step "Loading LaunchAgents" -if (( DRY_RUN )); then - say " would reload gui/$UID/$SERVER_LABEL" -else - launchctl bootout "gui/$UID/$SERVER_LABEL" 2>/dev/null || true +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/$SERVER_LABEL" >/dev/null 2>&1 || break + launchctl print "gui/$UID/$label" >/dev/null 2>&1 || break sleep 0.1 done - launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$SERVER_LABEL.plist" - say " loaded $SERVER_LABEL" -fi + launchctl bootstrap "gui/$UID" "$LAUNCH_AGENTS/$label.plist" + say " loaded $label" +done step "Adding the sy Codex profile" # This profile only changes which router answers. Approval and sandbox @@ -120,4 +201,5 @@ sed "s/@SY_PORT@/$SY_PORT/g" "$REPO_ROOT/scripts/config/codex.sy.toml" | step "Done" say "Server: http://127.0.0.1:$SY_PORT (logs in $SY_HOME/logs)" say "Use it with: codex -p sy (requires Codex CLI 0.134.0 or newer)" -say "Codex profile: $CODEX_PROFILE_CONFIG" +say "Settings: $SY_HOME/menubar.toml" +say "Look for the Switchyard glyph in the menu bar." diff --git a/scripts/macos/uninstall.sh b/scripts/macos/uninstall.sh index f431daef4..66f670e17 100755 --- a/scripts/macos/uninstall.sh +++ b/scripts/macos/uninstall.sh @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Removes what install.sh added: the LaunchAgent, the `sy` Codex profile, +# 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. # @@ -23,16 +23,42 @@ esac source "$SCRIPT_DIR/common.sh" step "Unloading LaunchAgents" -if (( DRY_RUN )); then - say " would unload gui/$UID/$SERVER_LABEL and delete its plist" -else - launchctl bootout "gui/$UID/$SERVER_LABEL" 2>/dev/null || true - rm -f "$LAUNCH_AGENTS/$SERVER_LABEL.plist" - say " unloaded $SERVER_LABEL" -fi +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" +if [[ -f "$CODEX_DIRECT_CONFIG" ]]; then + if (( DRY_RUN )); then + say " would preserve $CODEX_CONFIG before restoring $CODEX_DIRECT_CONFIG" + else + backup="" + if [[ -f "$CODEX_CONFIG" ]]; then + backup="$(mktemp "$CODEX_CONFIG.switchyard-current.XXXXXX")" + cp "$CODEX_CONFIG" "$backup" + fi + cp "$CODEX_DIRECT_CONFIG" "$CODEX_CONFIG" + rm -f "$CODEX_DIRECT_CONFIG" + say " restored the original config.toml${backup:+; preserved the current file at $backup}" + fi +elif codex_config_uses_switchyard "$CODEX_CONFIG"; then + say " config.toml is routed but $CODEX_DIRECT_CONFIG is missing; edit it by hand" +else + remove_file "$CODEX_DIRECT_CONFIG" +fi step "Removing the codex alias" for rc in "$HOME/.zshrc" "$HOME/.bashrc"; do @@ -43,4 +69,5 @@ done step "Done" say "Left in place, delete them if you want:" say " $SY_HOME (binaries, config, routing log, logs)" +say " $CODEX_CONFIG.switchyard-current.* (configs preserved during restore)" say " $CODEX_PROFILE_CONFIG.switchyard-backup.* (profile backups)" diff --git a/tests/test_macos_install.py b/tests/test_macos_install.py index 94136260c..6a2ce3a79 100644 --- a/tests/test_macos_install.py +++ b/tests/test_macos_install.py @@ -4,6 +4,11 @@ import os import shutil import subprocess + +try: + import tomllib +except ModuleNotFoundError: # Python 3.10 + import tomli as tomllib import xml.etree.ElementTree as ET from pathlib import Path @@ -74,21 +79,30 @@ def test_install_escapes_switchyard_path_in_launch_agent(setup): assert values["StandardOutPath"].text == str(switchyard_home / "logs" / "server.log") assert values["StandardErrorPath"].text == str(switchyard_home / "logs" / "server.err.log") + menubar = home / "Library" / "LaunchAgents" / "com.nvidia.switchyard.menubar.plist" + menubar_root = ET.parse(menubar).getroot() + menubar_entries = list(menubar_root.find("dict")) + menubar_values = { + menubar_entries[index].text: menubar_entries[index + 1] + for index in range(0, len(menubar_entries), 2) + } + menubar_arguments = [element.text for element in menubar_values["ProgramArguments"]] + assert menubar_arguments == [ + str(switchyard_home / "bin" / "switchyard-menubar"), + str(switchyard_home / "menubar.toml"), + ] + def test_missing_codex_config_creates_only_standalone_profile(setup): - _, _, switchyard_home, env = setup - env["SY_PORT"] = "5123" + _, home, _, _ = setup result = run(setup, "install.sh") assert result.returncode == 0, result.stderr - codex = Path(env["CODEX_HOME"]) - assert sorted(path.name for path in codex.iterdir()) == ["sy.config.toml"] + codex = home / ".codex" expected = read_config(REPO / "scripts" / "config" / "codex.sy.toml").replace( - "@SY_PORT@", "5123" + "@SY_PORT@", "4123" ) assert read_config(codex / "sy.config.toml") == expected - assert read_config(switchyard_home / "composite.toml") == read_config( - REPO / "scripts" / "config" / "composite.toml" - ) + assert {path.name for path in codex.iterdir()} == {"sy.config.toml"} def test_install_prints_profile_usage_without_editing_shell_files(setup): @@ -106,62 +120,112 @@ def test_install_prints_profile_usage_without_editing_shell_files(setup): assert bashrc.read_text() == "bash settings\n" -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) +def test_menu_bar_settings_escape_sy_home_as_toml_strings(setup): + _, home, _, env = setup + switchyard_home = home / 'Switchyard "quoted" \\ folder' + env["SY_HOME"] = str(switchyard_home) + + result = run(setup, "install.sh") + + assert result.returncode == 0, result.stderr + settings = tomllib.loads((switchyard_home / "menubar.toml").read_text()) + assert settings["routing_log"] == str(switchyard_home / "routing.jsonl") + assert settings["config_file"] == str(switchyard_home / "composite.toml") + + +def test_reinstall_backs_up_profile_and_keeps_user_settings(setup): + _, _, switchyard_home, env = setup + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + profile = Path(env["CODEX_HOME"]) / "sy.config.toml" + original = profile.read_text() + server_config = switchyard_home / "composite.toml" + menu_settings = switchyard_home / "menubar.toml" + server_config.write_text("user server settings\n") + menu_settings.write_text("user menu settings\n") + env["SY_PORT"] = "5000" + + result = run(setup, "install.sh") + + assert result.returncode == 0, result.stderr + assert "127.0.0.1:5000/v1" in profile.read_text() + backups = list(profile.parent.glob("sy.config.toml.switchyard-backup.*")) + assert len(backups) == 1 + assert backups[0].read_text() == original + assert server_config.read_text() == "user server settings\n" + assert menu_settings.read_text() == "user menu settings\n" + + @pytest.mark.parametrize( - "original", + "provider_header", [ - 'model_provider = "sy"\n[model_providers."sy"]\nname = "Old"\n', - 'developer_instructions = """\nmodel = "example"\n' - "# >>> switchyard sy profile >>>\n[model_providers.sy]\n" - '# <<< switchyard sy profile <<<\n"""\n' - '# >>> switchyard sy profile >>>\n[profiles.sy]\nmodel_provider = "sy"\n' - "# <<< switchyard sy profile <<<\n", - "invalid TOML that must be left alone\n", + "[model_providers.\"sy\"]", + "[model_providers . 'sy']", + '[ "model_providers" . "sy" ]', ], ) -def test_scripts_leave_main_config_and_legacy_files_untouched(setup, script, original): - _, _, _, env = setup +def test_existing_codex_config_is_preserved_and_shared_templates_are_used( + setup, provider_header +): + _, home, switchyard_home, env = setup codex = Path(env["CODEX_HOME"]) codex.mkdir() - files = { - "config.toml": original, - "config.toml.direct": 'model = "direct"\n', - "config.sy.toml": 'model = "previous routed config"\n', - } - for name, content in files.items(): - (codex / name).write_text(content) - (codex / "sy.config.toml").write_text('model = "old profile"\n') + config = codex / "config.toml" + original = f'''theme = "dark" - result = run(setup, script) +{provider_header} # old provider +name = "Old" +base_url = "http://old" +[other] +model_provider = "sy" +''' + config.write_text(original) + result = run(setup, "install.sh") assert result.returncode == 0, result.stderr - for name, content in files.items(): - assert (codex / name).read_text() == content - assert not list(codex.glob("config.toml.switchyard-*")) - if script == "uninstall.sh": - assert not (codex / "sy.config.toml").exists() - else: - assert (codex / "sy.config.toml").read_text() == read_config( - REPO / "scripts" / "config" / "codex.sy.toml" - ).replace("@SY_PORT@", env.get("SY_PORT", "4123")) - backups = list(codex.glob("sy.config.toml.switchyard-backup.*")) - assert len(backups) == 1 - assert backups[0].read_text() == 'model = "old profile"\n' + assert read_config(config) == original + profile = read_config(codex / "sy.config.toml") + expected = read_config(REPO / "scripts" / "config" / "codex.sy.toml").replace( + "@SY_PORT@", "4123" + ) + assert profile == expected + assert tomllib.loads(profile)["model_providers"]["sy"]["name"] == "Switchyard" + assert not (codex / "config.sy.toml").exists() + assert read_config(switchyard_home / "composite.toml") == read_config( + REPO / "scripts" / "config" / "composite.toml" + ) + assert not (codex / "config.toml.direct").exists() -@pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"]) -def test_scripts_dry_run_does_not_create_files(setup, script): - scripts, home, _, env = setup - result = subprocess.run( - ["bash", str(scripts / script), "--dry-run"], - env=env, - capture_output=True, - text=True, - timeout=10, - ) +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_quoted_top_level_provider_keeps_existing_snapshot(setup, quote): + _, home, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + (codex / "config.toml").write_text(f"model_provider = {quote}sy{quote} # routed\n") + snapshot = codex / "config.toml.direct" + snapshot.write_text("original direct config\n") + result = run(setup, "install.sh") + assert result.returncode == 0, result.stderr + assert snapshot.read_text() == "original direct config\n" + assert (codex / "config.toml").read_text() == f"model_provider = {quote}sy{quote} # routed\n" + assert not (codex / "config.sy.toml").exists() + assert (codex / "sy.config.toml").is_file() + + +def test_uninstall_preserves_routed_config_before_restoring_snapshot(setup): + _, home, _, env = setup + codex = Path(env["CODEX_HOME"]) + codex.mkdir() + current = "model_provider = 'sy' # routed\nuser_setting = \"keep me\"\n" + (codex / "config.toml").write_text(current) + (codex / "config.toml.direct").write_text("model = \"original\"\n") + result = run(setup, "uninstall.sh") assert result.returncode == 0, result.stderr - assert "would" in result.stdout - assert list(home.iterdir()) == [] + assert (codex / "config.toml").read_text() == 'model = "original"\n' + backups = list(codex.glob("config.toml.switchyard-current.*")) + assert len(backups) == 1 + assert backups[0].read_text() == current @pytest.mark.parametrize("script", ["install.sh", "uninstall.sh"])