diff --git a/.cargo/config.toml b/.cargo/config.toml index 94a019606a..edc8493b54 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,3 +10,6 @@ rustflags = ["-C", "target-cpu=x86-64-v3", "-C", "force-frame-pointers=yes"] # Force frame pointers to make performance and memory profiling much easier [target.aarch64-unknown-linux-gnu] rustflags = ["-C", "target-cpu=neoverse-n1", "-C", "force-frame-pointers=yes"] + +[alias] +desktop-icons = "run --package switchyard-desktop --example desktop-icons --" diff --git a/Cargo.lock b/Cargo.lock index 1be38b1131..0718aa7216 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.8.12" @@ -292,6 +303,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bit-set" version = "0.8.0" @@ -307,6 +324,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -316,6 +339,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -343,6 +384,24 @@ version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -355,7 +414,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -374,6 +433,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.67" @@ -415,7 +483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.3.0", "rand_core 0.10.1", ] @@ -431,6 +499,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.6.2" @@ -521,6 +599,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "cpufeatures" version = "0.3.0" @@ -554,6 +641,16 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "ctor" version = "1.0.13" @@ -566,6 +663,36 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "openssl", + "sha2", + "zeroize", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -584,12 +711,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "dirs" version = "7.0.0" @@ -617,7 +761,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", ] @@ -762,6 +906,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -943,6 +1102,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1022,7 +1191,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -1174,6 +1343,24 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.2" @@ -1281,7 +1468,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1425,6 +1612,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1435,6 +1635,16 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1600,7 +1810,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "serde", ] @@ -1640,6 +1850,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -1794,6 +2014,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "mslnk" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86c97310150b7f496a93f31690da7822b99d95ff68ca9d30fb09d3ad54375c76" +dependencies = [ + "bitflags 1.3.2", + "byteorder", + "log", +] + [[package]] name = "muda" version = "0.20.0" @@ -1821,7 +2062,7 @@ version = "3.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e2cf85c02d9b057898c485b824c3c416b65193689e2b7f517efc94b86ed24c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "ctor", "futures", "libc", @@ -1894,7 +2135,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc2c1819818ca76215d1a6955ae0269ebafb6904bf198f26f0dc443ae1c155ac" dependencies = [ - "bitflags", + "bitflags 2.13.1", "chrono", "serde", "serde_json", @@ -1956,6 +2197,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -2020,7 +2267,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -2041,7 +2288,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2052,7 +2299,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2063,7 +2310,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", ] @@ -2074,7 +2321,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags", + "bitflags 2.13.1", "dispatch2", "objc2", "objc2-core-foundation", @@ -2097,7 +2344,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2109,7 +2356,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", "objc2-core-graphics", @@ -2128,7 +2375,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.13.1", "block2", "libc", "objc2", @@ -2141,7 +2388,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-core-foundation", ] @@ -2152,7 +2399,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.13.1", "objc2", "objc2-foundation", ] @@ -2169,12 +2416,59 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.32.0" @@ -2357,13 +2651,26 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plist" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" +dependencies = [ + "base64 0.23.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + [[package]] name = "png" version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -2385,6 +2692,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2552,6 +2865,12 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "pyo3" version = "0.28.3" @@ -2634,6 +2953,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.11" @@ -2773,7 +3101,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2858,7 +3186,7 @@ version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -2928,7 +3256,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3052,7 +3380,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -3069,6 +3397,17 @@ dependencies = [ "libc", ] +[[package]] +name = "self-replace" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7" +dependencies = [ + "fastrand", + "tempfile", + "windows-sys 0.52.0", +] + [[package]] name = "semver" version = "1.0.28" @@ -3160,6 +3499,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3290,6 +3640,26 @@ dependencies = [ "tokio", ] +[[package]] +name = "switchyard-desktop" +version = "0.3.0" +dependencies = [ + "dbus-secret-service", + "image", + "libc", + "mslnk", + "plist", + "security-framework", + "self-replace", + "serde", + "serde_json", + "switchyard-runner", + "tempfile", + "toml 0.9.12+spec-1.1.0", + "toml_edit 0.25.13+spec-1.1.0", + "windows-sys 0.61.2", +] + [[package]] name = "switchyard-libsy" version = "0.3.0" @@ -3533,7 +3903,7 @@ name = "switchyard-translation" version = "0.3.0" dependencies = [ "async-stream", - "base64", + "base64 0.22.1", "futures", "pretty_assertions", "serde", @@ -3682,6 +4052,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3863,6 +4263,19 @@ dependencies = [ "winnow 0.5.40", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -3886,7 +4299,7 @@ checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -3932,7 +4345,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -4081,6 +4494,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -4157,6 +4576,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4498,6 +4923,9 @@ name = "winnow" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] [[package]] name = "wiremock" @@ -4506,7 +4934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -4619,6 +5047,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 2431f33eb0..e13c41b0d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/protocol", "crates/switchyard-nemo-relay-plugin", "crates/switchyard-runner", + "crates/switchyard-desktop", "crates/switchyard-menubar", "crates/switchyard-server", "crates/switchyard-skill-distillation", diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml new file mode 100644 index 0000000000..cc6c5af223 --- /dev/null +++ b/crates/switchyard-desktop/Cargo.toml @@ -0,0 +1,41 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "switchyard-desktop" +version.workspace = true +description = "Installer and server key helper for a local 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] +switchyard-runner.workspace = true +serde.workspace = true +serde_json.workspace = true +tempfile = "3" +toml = "0.9" +toml_edit = "0.25" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +# Vendored libdbus builds without the libdbus-1 development package on Ubuntu or Arch. +[target.'cfg(target_os = "linux")'.dependencies] +dbus-secret-service = { version = "4.1", features = ["crypto-rust", "vendored"] } + +[target.'cfg(windows)'.dependencies] +mslnk = "0.1.8" +self-replace = "1.5" +windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Globalization", "Win32_System_Threading", "Win32_System_Diagnostics_ToolHelp", "Win32_Security_Credentials", "Win32_Security_Authorization", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Console"] } + +[dev-dependencies] +image = { version = "0.25", default-features = false, features = ["png"] } +plist = "1" diff --git a/crates/switchyard-desktop/examples/desktop-icons/export.rs b/crates/switchyard-desktop/examples/desktop-icons/export.rs new file mode 100644 index 0000000000..a497fde3a5 --- /dev/null +++ b/crates/switchyard-desktop/examples/desktop-icons/export.rs @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module exports app and tray icons from their PNG sources on any platform. + +use image::{ImageEncoder, RgbaImage, codecs::png::PngEncoder, imageops::FilterType}; +use std::{fs, path::Path}; + +type Result = std::result::Result>; + +fn read_source(path: &Path, size: u32) -> Result { + let image = image::open(path)?.into_rgba8(); + if image.dimensions() != (size, size) { + return Err(format!("{} must be {size} × {size} pixels", path.display()).into()); + } + Ok(image) +} + +fn png(image: &RgbaImage) -> Result> { + let mut bytes = Vec::new(); + PngEncoder::new(&mut bytes).write_image( + image.as_raw(), + image.width(), + image.height(), + image::ExtendedColorType::Rgba8, + )?; + Ok(bytes) +} + +fn representations(source: &RgbaImage) -> Result)>> { + [16, 32, 64, 128, 256, 512, 1024] + .into_iter() + .map(|size| { + let resized = image::imageops::resize(source, size, size, FilterType::Lanczos3); + Ok((size, png(&resized)?)) + }) + .collect() +} + +fn icns(images: &[(u32, Vec)]) -> Vec { + let mut bytes = b"icns\0\0\0\0".to_vec(); + // Retina entries name the logical size separately from their pixel size. + for (index, tag) in [ + (0, b"icp4"), + (1, b"icp5"), + (1, b"ic11"), + (2, b"ic12"), + (3, b"ic07"), + (4, b"ic08"), + (4, b"ic13"), + (5, b"ic09"), + (5, b"ic14"), + (6, b"ic10"), + ] { + let png = &images[index].1; + bytes.extend(tag); + bytes.extend(((png.len() + 8) as u32).to_be_bytes()); + bytes.extend(png); + } + let length = (bytes.len() as u32).to_be_bytes(); + bytes[4..8].copy_from_slice(&length); + bytes +} + +fn ico(images: &[(u32, Vec)]) -> Vec { + let images = &images[..5]; + let mut bytes = vec![0, 0, 1, 0, images.len() as u8, 0]; + let mut offset = 6 + 16 * images.len(); + for (size, png) in images { + bytes.extend([*size as u8, *size as u8, 0, 0]); + bytes.extend(1_u16.to_le_bytes()); + bytes.extend(32_u16.to_le_bytes()); + bytes.extend((png.len() as u32).to_le_bytes()); + bytes.extend((offset as u32).to_le_bytes()); + offset += png.len(); + } + for (_, png) in images { + bytes.extend(png); + } + bytes +} + +/// This function checks or writes the macOS and Windows icon files and the tray's decoded RGBA pixels. +/// A check preserves all files and reports the command needed to rebuild a stale export. +pub fn export(directory: &Path, check: bool) -> Result<()> { + let source = read_source(&directory.join("icon.png"), 1024)?; + let tray_source = read_source(&directory.join("tray-source.png"), 1024)?; + let tray = image::imageops::resize(&tray_source, 22, 22, FilterType::Lanczos3); + let images = representations(&source)?; + for (name, expected) in [ + ("icon.icns", icns(&images)), + ("icon.ico", ico(&images)), + ("tray.png", png(&tray)?), + ("tray.rgba", tray.into_raw()), + ] { + let path = directory.join(name); + if check { + if fs::read(&path).ok().as_deref() != Some(expected.as_slice()) { + return Err(format!("{} is stale; run cargo desktop-icons", path.display()).into()); + } + } else { + fs::write(path, expected)?; + } + } + Ok(()) +} diff --git a/crates/switchyard-desktop/examples/desktop-icons/main.rs b/crates/switchyard-desktop/examples/desktop-icons/main.rs new file mode 100644 index 0000000000..47c7d04429 --- /dev/null +++ b/crates/switchyard-desktop/examples/desktop-icons/main.rs @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod export; + +fn main() -> Result<(), Box> { + let check = match std::env::args().skip(1).collect::>().as_slice() { + [] => false, + [arg] if arg == "--check" => true, + _ => return Err("usage: cargo desktop-icons [--check]".into()), + }; + let directory = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("icons"); + export::export(&directory, check)?; + println!( + "Desktop icons {}.", + if check { + "match the app and tray PNG sources" + } else { + "exported for macOS and Windows" + } + ); + Ok(()) +} diff --git a/crates/switchyard-desktop/icons/README.md b/crates/switchyard-desktop/icons/README.md new file mode 100644 index 0000000000..3fb1bff466 --- /dev/null +++ b/crates/switchyard-desktop/icons/README.md @@ -0,0 +1,51 @@ +# Desktop app icon + +The app icon shows a raised titanium railway turnout with pale-blue supports on +a navy beveled tile. It represents Switchyard routing requests between models. + +`icon.png` is the 1024 × 1024 RGBA source with a transparent tile exterior. +`icon.icns` contains macOS sizes from 16 to 1024 pixels. `icon.ico` contains +Windows sizes of 16, 32, 64, 128, and 256 pixels. + +`tray-source.png` contains the railway turnout on a transparent background, +enlarged to fill the tray icon without the navy tile or its frame. The desktop +tray uses a 22 × 22 pixel color copy of this separate source on macOS and +Windows. `tray.png` is the generated preview, and `tray.rgba` contains the same +decoded pixels. macOS displays the icon's original colors. + +## Generation + +An image-generation edit extracted `tray-source.png` from the app artwork. The +edit removed the navy tile and frame, kept the titanium turnout and pale-blue +supports, and enlarged the mark on a transparent canvas. The source was then +resized to 1024 × 1024 pixels before export. + +Gemini 3 Pro Image generated the selected artwork through Inference Hub with +`gcp/google/gemini-3-pro-image`, `n: 1`, and `size: "1024x1024"`. +The selected image used this exact prompt: + +```text +Create a refined Switchyard macOS app icon based on this visual description of the selected reference: raised silver titanium paired railway rails, ONE right-hand turnout with one straight continuing main route and one gradually curved branch, dark navy beveled rounded-square tile, restrained cool-blue accents. Generate one new polished 1024x1024 icon refinement. The reference has finely brushed silver rail top faces, charcoal metal rail webs, broad pale-blue base plates around the switch, and a small cool-blue inset near the central frog, all mounted on a textured midnight tile with a thick rounded beveled frame. Improve its clipping and mixed perspective. Reduce and recenter the ENTIRE rail motif so EVERY part, including end faces, foundations and shadows, is safely inside the tile. Leave at least 140 pixels of empty navy tile between EVERY rail end and the nearest tile edge; no rail reaches a tile rim or image boundary. Rail motif bounding box approximately x220..804 and y210..804, tile occupies x70..954 and y70..954. ALL rail endpoints clearly visible and finished, not cropped. Use ONE coherent near-front orthographic camera for the whole object: tile surface, rail top faces, metal side faces, gauge spacing and shadows all follow the same projection. Straight main rail pair stays straight and parallel. Branch rail pair separates gradually beginning tangent, with matching constant gauge along the curve and continuous plausible switch/frog junction. No extra branches, crossings or disconnected rail fragments. Maintain realistic physical extrusion, titanium bevels, softly grounded contact shadows, and large readable shapes. NO text, logos, arrows, trains, scenery, decorative stars or circuit patterns. Simple professional app icon, plain white exterior, no checkerboard. Change only the subtle material, bevel or lighting treatment specified below while keeping the reference turnout concept and corrected generous margins. Variant: bright polished titanium upper bevels with satin silver top faces and graphite side faces; cool pale blue support inlay, frontal orthographic camera, restrained upper-left soft key light. Moderate visible metal thickness and very clean machining. Rail motif small enough for generous interior margins. +``` + +## Export + +1. Preserve the generated image before export. +2. Isolate the navy tile from its white exterior. Fill enclosed mask regions so + the rails and supports stay opaque, then soften the outer mask by 0.35 pixels. +3. Save the result as a 1024 × 1024 RGBA PNG, keeping the artwork inside the tile + unchanged. +4. Keep `tray-source.png` as a separate 1024 × 1024 RGBA image. Its transparent + background includes the gaps between rails and supports. Check the 22 × 22 + pixel preview to make sure the turnout endpoints remain visible. +5. Run `cargo desktop-icons` from the repository. The Rust command exports + `icon.icns` and `icon.ico` from `icon.png`, and the tray PNG and pixel file from + `tray-source.png`. It uses Lanczos filtering when resizing both sources. +6. Inspect both PNGs on light, dark, and vivid backgrounds. Compare the app icon + at 16, 32, 64, and 128 pixels and the tray icon at 22 × 22 pixels. + +Run `cargo desktop-icons --check` to verify exports without changing files. +`cargo test` runs the same check on every platform, so CI fails when an export +is stale. After changing either PNG source, regenerate its exports before you +commit. Keep both sources as +1024 × 1024 RGBA images. diff --git a/crates/switchyard-desktop/icons/icon.icns b/crates/switchyard-desktop/icons/icon.icns new file mode 100644 index 0000000000..aa3d06723a Binary files /dev/null and b/crates/switchyard-desktop/icons/icon.icns differ diff --git a/crates/switchyard-desktop/icons/icon.ico b/crates/switchyard-desktop/icons/icon.ico new file mode 100644 index 0000000000..3519cb9194 Binary files /dev/null and b/crates/switchyard-desktop/icons/icon.ico differ diff --git a/crates/switchyard-desktop/icons/icon.png b/crates/switchyard-desktop/icons/icon.png new file mode 100644 index 0000000000..78b2c7f1ee Binary files /dev/null and b/crates/switchyard-desktop/icons/icon.png differ diff --git a/crates/switchyard-desktop/icons/tray-source.png b/crates/switchyard-desktop/icons/tray-source.png new file mode 100644 index 0000000000..baff99c68f Binary files /dev/null and b/crates/switchyard-desktop/icons/tray-source.png differ diff --git a/crates/switchyard-desktop/icons/tray.png b/crates/switchyard-desktop/icons/tray.png new file mode 100644 index 0000000000..09cb0346c7 Binary files /dev/null and b/crates/switchyard-desktop/icons/tray.png differ diff --git a/crates/switchyard-desktop/icons/tray.rgba b/crates/switchyard-desktop/icons/tray.rgba new file mode 100644 index 0000000000..a0668bd102 Binary files /dev/null and b/crates/switchyard-desktop/icons/tray.rgba differ diff --git a/crates/switchyard-desktop/prices.toml b/crates/switchyard-desktop/prices.toml new file mode 100644 index 0000000000..744cac30ca --- /dev/null +++ b/crates/switchyard-desktop/prices.toml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The baseline model prices caller-facing calls as if routing had not changed models. +# Savings subtract actual calls, including classifier calls, from that estimate. +# The default route sends each call to gpt-6-sol or gpt-6-luna, so the baseline is gpt-6-sol. +baseline_model = "gpt-6-sol" + +# OpenAI standard API prices per million tokens, for prompts up to 272K input tokens. +[prices."gpt-6-sol"] +input_per_mtok = 2.0 +cached_input_per_mtok = 0.2 +output_per_mtok = 10.0 + +[prices."gpt-6-luna"] +input_per_mtok = 0.1 +cached_input_per_mtok = 0.01 +output_per_mtok = 0.5 + +[prices."gpt-6.1-sol"] +input_per_mtok = 2.0 +cached_input_per_mtok = 0.1 +output_per_mtok = 10.0 + +[prices."gpt-5.6-sol"] +input_per_mtok = 1.25 +cached_input_per_mtok = 0.125 +output_per_mtok = 10.0 + +[prices."gpt-5.6-luna"] +input_per_mtok = 0.25 +cached_input_per_mtok = 0.025 +output_per_mtok = 2.0 + +[prices."gpt-5.6-terra"] +input_per_mtok = 0.05 +cached_input_per_mtok = 0.005 +output_per_mtok = 0.4 + +# With a ChatGPT login, Codex sends "Approve for me" reviews to +# codex-auto-review even without Switchyard, so this price copies the +# baseline_model rates. Reviews then add the same amount to the actual cost and +# the baseline. The dollars saved stay the same, but the percentage drops a +# little. Update this price if you change baseline_model. +[prices."codex-auto-review"] +input_per_mtok = 2.0 +cached_input_per_mtok = 0.2 +output_per_mtok = 10.0 diff --git a/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs new file mode 100644 index 0000000000..b5f469f0a5 --- /dev/null +++ b/crates/switchyard-desktop/src/bin/switchyard-desktop-install.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg_attr(windows, windows_subsystem = "windows")] + +use std::{path::Path, process::ExitCode}; +#[cfg(windows)] +use switchyard_desktop::install::windows; +use switchyard_desktop::install::{Settings, credentials, execute}; + +// A backslash right after the opening quote drops the first newline. Every other line break in +// these literals is part of the text, so the lines start at column 0. +const HELP: &str = "\ +Usage: cargo desktop [--dry-run] + +Build and install the local server and macOS or Windows desktop app, or Linux terminal app. +Uninstall uses the recorded installation and preserves settings, history, and recovery copies. + +Defaults: SY_HOME=~/.switchyard, SY_PORT=4123, SY_PROFILE=sy, +SY_MODEL=composite-gpt-6-sol-gpt-6-luna, CODEX_HOME=~/.codex. +Example: SY_PROFILE=team SY_MODEL=my-route cargo desktop install + +Preview the installation or removal plan with --dry-run. Installation requires +a Rust toolchain and LaunchAgents (macOS) or a systemd user session (Linux). +Windows requires MSVC build tools, WebView2, and a Task Scheduler user session. +The Cargo command waits for completion. On Windows, running the installed installer +starts a temporary process and returns; read SY_HOME/logs/update.log for progress and errors. +Use uninstall --settings FILE to select another recorded installation. +Preview its removal with: cargo desktop uninstall --settings FILE --dry-run"; + +const KEY_HELP: &str = "\ +Usage: switchyard-desktop-install key + +The installed server reads each api_key_env variable from the system credential store, not from your shell. +set reads the key from standard input. import reads the variable once from your login shell. +Restart the server to use a changed key."; + +const INVALID_VALIDATE: &str = "Invalid validate command arguments."; + +/// This function runs the command that the arguments name. Each accepted shape is one arm, and +/// any other shape is an error. +fn run() -> Result<(), String> { + let args: Vec = std::env::args().skip(1).collect(); + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + match args.as_slice() { + ["validate", "--server", server, "--config", config] => validate(server, config), + ["validate", ..] => Err(INVALID_VALIDATE.into()), + ["key", "status", "--config", config] => key_status(Path::new(config)), + ["key", "set", name] => key_set(name), + ["key", "import", name] => key_import(name), + ["key", "remove", name] => key_remove(name), + ["key", ..] => Err(KEY_HELP.into()), + #[cfg(unix)] + ["serve", "--settings", file] => serve(Path::new(file)), + #[cfg(windows)] + [action @ ("serve" | "desktop"), "--settings", file] => { + windows::launch_managed(&Settings::load(Path::new(file))?, *action == "desktop") + } + #[cfg(windows)] + [ + "apply", + "--handoff-record", + file, + "--action", + action @ ("install" | "update" | "uninstall"), + "--wait", + pid, + ] => apply(Path::new(file), action, pid).map_err(|e| e.to_string()), + ["--help" | "-h"] | ["install" | "uninstall", "--help" | "-h"] => { + println!("{HELP}"); + Ok(()) + } + [action @ ("install" | "uninstall")] => perform(action, false, None), + [action @ ("install" | "uninstall"), "--dry-run"] => perform(action, true, None), + [action @ ("update" | "uninstall"), "--settings", file] => { + perform(action, false, Some(Path::new(file))) + } + ["uninstall", "--settings", file, "--dry-run"] + | ["uninstall", "--dry-run", "--settings", file] => { + perform("uninstall", true, Some(Path::new(file))) + } + _ => Err(HELP.into()), + } +} + +/// This function runs the server's config check. Both paths must be absolute and free of ASCII +/// control characters. +fn validate(server: &str, config: &str) -> Result<(), String> { + for path in [server, config] { + if !Path::new(path).is_absolute() || path.chars().any(|c| c.is_ascii_control()) { + return Err(INVALID_VALIDATE.into()); + } + } + credentials::validate(Path::new(server), Path::new(config)) +} + +/// This function runs `action` with the settings in `file`. Without a file, uninstall uses the +/// recorded installation and the other actions read the environment. +fn perform(action: &str, dry_run: bool, file: Option<&Path>) -> Result<(), String> { + let settings = match file { + Some(file) => Settings::load(file), + None if action == "uninstall" => Settings::for_uninstall(), + None => Settings::from_env(), + }?; + #[cfg(windows)] + if !dry_run && handoff(&settings, action).map_err(|e| e.to_string())? { + return Ok(()); + } + execute(&settings, action, dry_run) +} + +/// This function prints, as JSON, whether each variable that `config` declares has a saved key. +fn key_status(config: &Path) -> Result<(), String> { + let status = serde_json::to_string(&credentials::status(config)?).map_err(|e| e.to_string())?; + println!("{status}"); + Ok(()) +} + +fn key_set(name: &str) -> Result<(), String> { + credentials::set(name, &read_key(name)?)?; + println!("Saved the key for {name}. Restart the server to use it."); + Ok(()) +} + +fn key_import(name: &str) -> Result<(), String> { + credentials::import(name)?; + println!("Saved {name} from your login shell. Restart the server to use it."); + Ok(()) +} + +fn key_remove(name: &str) -> Result<(), String> { + credentials::remove(name)?; + println!("Removed the saved key for {name}."); + Ok(()) +} + +/// This function replaces this process with the server and gives it the saved keys. Exec keeps +/// the service manager's process ID. +#[cfg(unix)] +fn serve(file: &Path) -> Result<(), String> { + use std::os::unix::process::CommandExt; + let s = Settings::load(file)?; + let config = s.sy_home.join("composite.toml"); + let mut server = std::process::Command::new(s.binary("switchyard-server")); + #[cfg(target_os = "linux")] + credentials::wait_for_keyring()?; + credentials::configure(&mut server, &config)?; + let error = server + .arg("--config") + .arg(config) + .args([ + "--host", + "127.0.0.1", + "--port", + &s.port.to_string(), + "--routing-log-file", + ]) + .arg(s.sy_home.join("routing.jsonl")) + .exec(); + Err(format!("Could not start the server: {error}")) +} + +/// This function reads a key from standard input. At a terminal it prompts and turns off echo, +/// so the key never appears on screen or in the command line. +fn read_key(name: &str) -> Result { + use std::io::Read; + let mut stdin = std::io::stdin().lock(); + let mut key = String::new(); + #[cfg(unix)] + if std::io::IsTerminal::is_terminal(&stdin) { + use std::io::BufRead; + eprint!("Key for {name}: "); + // SAFETY: termios is plain data; tcgetattr fills it before it is read. + let mut saved: libc::termios = unsafe { std::mem::zeroed() }; + if unsafe { libc::tcgetattr(libc::STDIN_FILENO, &mut saved) } != 0 { + return Err("Could not read the terminal settings.".into()); + } + let quiet = libc::termios { + c_lflag: saved.c_lflag & !libc::ECHO, + ..saved + }; + unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &quiet) }; + let read = stdin.read_line(&mut key); + unsafe { libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &saved) }; + eprintln!(); + return read.map(|_| key).map_err(|e| e.to_string()); + } + let _ = name; + stdin + .read_to_string(&mut key) + .map(|_| key) + .map_err(|e| e.to_string()) +} + +fn main() -> ExitCode { + // The Windows subsystem starts without a console. Commands that a person runs attach to the + // caller's console so their output appears there. + #[cfg(windows)] + if !matches!( + std::env::args_os() + .nth(1) + .as_deref() + .and_then(std::ffi::OsStr::to_str), + Some("serve" | "desktop" | "apply" | "update" | "validate") + ) { + unsafe { windows_sys::Win32::System::Console::AttachConsole(u32::MAX) }; + } + let result = run(); + // A temporary updater copy deletes itself when it finishes. + #[cfg(windows)] + let result = if std::env::current_exe().is_ok_and(|exe| { + exe.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("switchyard-update-")) + }) { + self_replace::self_delete() + .map_err(|e| e.to_string()) + .and(result) + } else { + result + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop-install: {error}"); + ExitCode::FAILURE + } + } +} + +/// Handoff is the record that the installed helper passes to its temporary copy. +#[cfg(windows)] +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct Handoff { + settings: T, +} + +/// When this process runs the installed helper, this function hands `action` to a temporary +/// copy and returns true, because the installed executable cannot replace itself while Windows +/// holds it open. Any other copy returns false without doing anything. +#[cfg(windows)] +fn handoff(settings: &Settings, action: &str) -> Result> { + use std::io::Write; + use std::process::{Command, Stdio}; + let installed = settings.binary("switchyard-desktop-install"); + if !installed.try_exists()? + || std::fs::canonicalize(std::env::current_exe()?)? != std::fs::canonicalize(installed)? + { + return Ok(false); + } + windows::record_handoff(settings)?; + let helper = windows::temporary_updater(&std::env::current_exe()?)?; + let mut record = tempfile::Builder::new() + .prefix("switchyard-handoff-") + .suffix(".toml") + .tempfile()?; + record.write_all(toml::to_string(&Handoff { settings })?.as_bytes())?; + let record = record.into_temp_path(); + let log_path = settings.sy_home.join("logs/update.log"); + std::fs::create_dir_all(settings.sy_home.join("logs"))?; + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path)?; + let mut child = Command::new(&helper); + windows::hide_console(&mut child); + child + .arg("apply") + .arg("--handoff-record") + .arg(&record) + .arg("--action") + .arg(action) + .arg("--wait") + .arg(std::process::id().to_string()) + .stdin(Stdio::null()) + .stderr(log.try_clone()?) + .stdout(log) + .spawn()?; + helper.keep()?; + record.keep()?; + println!( + "Started {action}. Progress and errors: {}", + log_path.display() + ); + Ok(true) +} + +/// This function checks the handoff record at `path`, waits for the installed helper with +/// process ID `pid` to exit, and runs `action`. It deletes the record when it finishes. +#[cfg(windows)] +fn apply(path: &Path, action: &str, pid: &str) -> Result<(), Box> { + const INVALID: &str = "Invalid handoff record."; + let metadata = std::fs::symlink_metadata(path)?; + let (Some(parent), Some(name)) = ( + path.parent(), + path.file_name().and_then(std::ffi::OsStr::to_str), + ) else { + return Err(INVALID.into()); + }; + if !metadata.file_type().is_file() + || !name.starts_with("switchyard-handoff-") + || !name.ends_with(".toml") + || std::fs::canonicalize(parent)? != std::fs::canonicalize(std::env::temp_dir())? + { + return Err(INVALID.into()); + } + let handoff: Handoff = toml::from_str(&std::fs::read_to_string(path)?)?; + handoff.settings.validate()?; + let pid = pid + .parse::() + .ok() + .filter(|&pid| pid != 0) + .ok_or("Invalid parent process ID.")?; + let _record = tempfile::TempPath::try_from_path(path)?; + windows::wait_parent(pid)?; + Ok(execute(&handoff.settings, action, false)?) +} diff --git a/crates/switchyard-desktop/src/install.rs b/crates/switchyard-desktop/src/install.rs new file mode 100644 index 0000000000..500cf319b3 --- /dev/null +++ b/crates/switchyard-desktop/src/install.rs @@ -0,0 +1,1401 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The source installer owns app bundles, service definitions, and installation metadata. + +use serde::{Deserialize, Serialize}; +use std::{ + fs, + io::{Read, Write}, + net::{SocketAddr, TcpStream}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + thread, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +pub mod credentials; +#[cfg(windows)] +pub mod windows; + +pub fn executable_name(name: &str) -> String { + format!("{name}{}", std::env::consts::EXE_SUFFIX) +} + +const SERVER: &str = "com.nvidia.switchyard.server"; +const DESKTOP: &str = "com.nvidia.switchyard.desktop"; +// Migration removes this LaunchAgent so the old app cannot restart alongside the desktop app. +const LEGACY_DESKTOP: &str = "com.nvidia.switchyard.menubar"; +const UNIT: &str = "switchyard.service"; +const BUNDLED_CONFIG: &str = include_str!("../../../scripts/config/composite.toml"); +const UPDATE_LOCK: &str = ".switchyard-desktop-update.lock"; +const PORT_ERROR: &str = "SY_PORT must be a number from 1 to 65535."; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Settings { + pub home: PathBuf, + pub sy_home: PathBuf, + pub codex_home: PathBuf, + pub service_dir: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_dir: Option, + pub source: PathBuf, + pub cargo: PathBuf, + pub port: u16, + pub profile: String, + pub model: String, +} + +impl Settings { + pub fn for_uninstall() -> Result { + let defaults = Self::from_env()?; + let recorded = defaults.install_record(); + if !present(&recorded)? { + return Ok(defaults); + } + let selected = Self::load(&recorded)?; + for (name, differs) in [ + ( + "SY_HOME", + defaults.sy_home.as_os_str() != selected.sy_home.as_os_str(), + ), + ( + "CODEX_HOME", + defaults.codex_home.as_os_str() != selected.codex_home.as_os_str(), + ), + ("SY_PROFILE", defaults.profile != selected.profile), + ] { + if differs && std::env::var_os(name).is_some() { + return Err(format!( + "{name} differs from the recorded installation at {}. \ + Use uninstall --settings FILE to choose a different installation.", + recorded.display() + )); + } + } + Ok(selected) + } + pub fn from_env() -> Result { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) + .ok_or("Could not find your home directory.")?; + let path = |name, default| std::env::var_os(name).map(PathBuf::from).unwrap_or(default); + let value = |name: &str, default: &str| { + std::env::var(name).or_else(|error| match error { + std::env::VarError::NotPresent => Ok(default.to_owned()), + std::env::VarError::NotUnicode(_) => Err(format!("{name} must be UTF-8.")), + }) + }; + let port = value("SY_PORT", "4123")?; + if !port.bytes().all(|b| b.is_ascii_digit()) { + return Err(PORT_ERROR.into()); + } + let settings = Self { + sy_home: path("SY_HOME", home.join(".switchyard")), + codex_home: path("CODEX_HOME", home.join(".codex")), + service_dir: if cfg!(target_os = "macos") { + home.join("Library/LaunchAgents") + } else if cfg!(windows) { + path("APPDATA", home.join("AppData/Roaming")) + .join("Microsoft/Windows/Start Menu/Programs") + } else { + path("XDG_CONFIG_HOME", home.join(".config")).join("systemd/user") + }, + app_dir: cfg!(windows).then(|| { + path("LOCALAPPDATA", home.join("AppData/Local")).join("Programs/Switchyard") + }), + home, + source: Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .ok_or("Could not find the source checkout.")? + .to_path_buf(), + cargo: PathBuf::from(env!("CARGO")), + port: port.parse().map_err(|_| PORT_ERROR)?, + profile: value("SY_PROFILE", "sy")?, + model: value("SY_MODEL", "composite-gpt-6-sol-gpt-6-luna")?, + }; + settings.validate()?; + Ok(settings) + } + + pub fn load(file: &Path) -> Result { + let text = fs::read_to_string(file).map_err(|e| format!("Read {}: {e}", file.display()))?; + let settings: Self = + toml::from_str(&text).map_err(|e| format!("Parse {}: {e}", file.display()))?; + settings.validate()?; + Ok(settings) + } + + pub fn validate(&self) -> Result<(), String> { + let profile = self.profile.as_bytes(); + if !profile.first().is_some_and(u8::is_ascii_alphanumeric) + || profile.len() > 128 + || !profile + .iter() + .all(|&b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err( + "SY_PROFILE must start with an ASCII letter or digit and contain only ASCII \ + letters, digits, underscores, or hyphens, up to 128 characters." + .into(), + ); + } + if self.model.is_empty() || self.model.bytes().any(|b| b.is_ascii_control()) { + return Err( + "SY_MODEL must be a nonempty public route ID without ASCII control characters." + .into(), + ); + } + if self.port == 0 { + return Err(PORT_ERROR.into()); + } + for (name, path) in [ + ("HOME", &self.home), + ("SY_HOME", &self.sy_home), + ("CODEX_HOME", &self.codex_home), + ("service directory", &self.service_dir), + ("source checkout", &self.source), + ("Cargo executable", &self.cargo), + ] + .into_iter() + .chain(self.app_dir.as_ref().map(|path| ("LOCALAPPDATA", path))) + { + if !path.is_absolute() + || path + .to_str() + .is_none_or(|s| s.bytes().any(|b| b.is_ascii_control())) + { + return Err(format!( + "{name} must be an absolute UTF-8 path without ASCII control characters." + )); + } + } + Ok(()) + } + + pub fn app(&self) -> PathBuf { + self.home.join("Applications/Switchyard.app") + } + pub fn bin_dir(&self) -> PathBuf { + if cfg!(windows) { + self.app_dir + .clone() + .unwrap_or_else(|| self.home.join("AppData/Local/Programs/Switchyard")) + } else { + self.sy_home.join("bin") + } + } + pub fn binary(&self, name: &str) -> PathBuf { + self.bin_dir().join(executable_name(name)) + } + pub fn desktop_settings(&self) -> PathBuf { + self.sy_home.join("desktop.toml") + } + fn profile_file(&self) -> PathBuf { + self.codex_home + .join(format!("{}.config.toml", self.profile)) + } + fn release(&self, binary: &str) -> PathBuf { + self.source + .join("target/release") + .join(executable_name(binary)) + } + /// Uninstall reads this copy of the settings to find the installation. + fn install_record(&self) -> PathBuf { + self.home.join(".switchyard-desktop-install.toml") + } +} + +/// The app reads this metadata to retain a custom SY_HOME when Finder or a Windows shortcut starts it. +pub fn bundle_metadata(executable: &Path) -> PathBuf { + // Windows keeps the metadata beside the executable; macOS keeps it in the bundle's Resources. + let (levels, file) = if cfg!(windows) { + (1, "install.toml") + } else { + (2, "Resources/install.toml") + }; + executable + .ancestors() + .nth(levels) + .unwrap_or(Path::new(".")) + .join(file) +} + +/// The updater runs separately because installation restarts the app that requested it. +pub fn start_update(settings_file: &Path) -> Result { + let settings = Settings::load(settings_file)?; + let guard = update_lock(&settings)?; + if update_starting(&update_status(settings_file)?) { + return Err( + "A Switchyard update is already starting. View its progress in Settings.".into(), + ); + } + set_update_state( + &settings, + "starting", + "start", + "Starting the source update.", + )?; + let log_path = settings.sy_home.join("logs/update.log"); + let log = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .map_err(err)?; + let error_log = log.try_clone().map_err(err)?; + #[cfg(windows)] + let updater_path = windows::temporary_updater(&settings.binary("switchyard-desktop-install"))?; + #[cfg(not(windows))] + let updater_path = settings.binary("switchyard-desktop-install"); + let mut updater = Command::new(&updater_path); + #[cfg(windows)] + windows::hide_console(&mut updater); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // A separate process group keeps launchd from stopping the updater with the app. + updater.process_group(0); + } + // The child must acquire the update lock, so the parent releases it before spawning. + drop(guard); + let mut child = updater + .args(["update", "--settings"]) + .arg(settings_file) + .stdin(Stdio::null()) + .stdout(log) + .stderr(error_log) + .spawn() + .map_err(|e| { + let message = format!("Could not start the source update: {e}"); + let _ = set_update_state(&settings, "failed", "start", &message); + message + })?; + #[cfg(windows)] + updater_path.keep().map_err(err)?; + // This thread reaps the updater if it exits before installation stops the app. + thread::spawn(move || { + let _ = child.wait(); + }); + Ok(log_path) +} + +pub fn execute(settings: &Settings, action: &str, dry_run: bool) -> Result<(), String> { + settings.validate()?; + if !matches!(action, "install" | "uninstall" | "update") { + return Err("Choose install, update, or uninstall.".into()); + } + if !cfg!(any(target_os = "macos", target_os = "linux", windows)) { + return Err("Installation supports macOS, Linux, and Windows.".into()); + } + if dry_run { + println!("{}", installation_plan(settings, action)); + return Ok(()); + } + if action == "uninstall" { + return uninstall(settings); + } + let _job = update_lock(settings)?; + if action == "update" { + set_update_state(settings, "running", "build", "Building the app and server.")?; + } + match install(settings, action == "update") { + Ok(None) => set_update_state( + settings, + "completed", + "completed", + "Installed the app and server. Switchyard is ready.", + ), + Ok(Some(warning)) => set_update_state(settings, "warning", "readiness", &warning), + Err(error) => { + // The failure keeps the stage that the install last recorded. + if settings.sy_home.exists() { + let stage = update_status(&settings.sy_home.join("install.toml")) + .ok() + .and_then(|s| s["stage"].as_str().map(str::to_owned)) + .unwrap_or_else(|| "installation".into()); + let _ = set_update_state(settings, "failed", &stage, &error); + } + Err(error) + } + } +} + +// The persistent file keeps concurrent commands locking the same inode; exit releases the lock. +fn lock(path: &Path, busy: impl FnOnce(fs::TryLockError) -> String) -> Result { + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .map_err(err)?; + file.try_lock().map_err(busy)?; + Ok(file) +} +fn update_lock(settings: &Settings) -> Result { + lock(&settings.home.join(UPDATE_LOCK), |_| { + "A Switchyard update is already running. Open Settings to view its progress.".into() + }) +} +fn lock_installation(s: &Settings) -> Result { + lock(&s.home.join(".switchyard-desktop-install.lock"), |e| { + format!("Could not lock the desktop installation: {e}") + }) +} +fn set_update_state( + settings: &Settings, + state: &str, + stage: &str, + message: &str, +) -> Result<(), String> { + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(err)? + .as_secs(); + let record = serde_json::json!({"state": state, "stage": stage, "message": message, + "source": settings.source, "updated_at": updated_at}); + write( + &settings.sy_home.join("update-status.toml"), + toml::to_string(&record).map_err(err)?.as_bytes(), + false, + ) +} +// A marker under 10 seconds old marks the child as starting before it acquires the lock. +fn update_starting(record: &serde_json::Value) -> bool { + record["state"] == "starting" + && record["updated_at"].as_u64().is_some_and(|stamp| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .is_ok_and(|now| now.as_secs().saturating_sub(stamp) < 10) + }) +} +pub fn update_status(settings_file: &Path) -> Result { + let settings = Settings::load(settings_file)?; + let Some(text) = read_optional(&settings.sy_home.join("update-status.toml")).map_err(err)? + else { + return Ok(serde_json::json!({"state":"idle"})); + }; + let value: toml::Value = toml::from_str(&text).map_err(err)?; + let mut record = serde_json::to_value(value).map_err(err)?; + if !update_starting(&record) + && matches!(record["state"].as_str(), Some("starting" | "running")) + && let Ok(lock) = fs::OpenOptions::new() + .read(true) + .write(true) + .open(settings.home.join(UPDATE_LOCK)) + && lock.try_lock().is_ok() + { + record["state"] = serde_json::json!("interrupted"); + record["message"] = serde_json::json!( + "The update stopped before recording completion. Open the update log, then retry." + ); + } + Ok(record) +} +pub fn preview_update(settings_file: &Path) -> Result { + let settings = Settings::load(settings_file)?; + let git = |args: &[&str]| -> String { + let mut command = Command::new("git"); + #[cfg(windows)] + windows::hide_console(&mut command); + command + .arg("-C") + .arg(&settings.source) + .args(args) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_else(|| "Unavailable".into()) + }; + Ok( + serde_json::json!({"source":settings.source,"revision":git(&["rev-parse","HEAD"]),"branch":git(&["branch","--show-current"]),"changes":git(&["status","--short"]),"plan":installation_plan(&settings,"update"),"status":update_status(settings_file)?}), + ) +} +fn installation_plan(s: &Settings, action: &str) -> String { + let removing = action == "uninstall"; + let pick = |remove, install| if removing { remove } else { install }; + let app = if cfg!(target_os = "macos") { + "macOS desktop app" + } else if cfg!(windows) { + "Windows desktop app" + } else { + "Linux terminal app" + }; + let profile = s.profile_file(); + let profile_action = if removing { + "Preserve recovery copy, then remove" + } else if !profile.exists() { + "Create" + } else if action == "update" { + "Preserve" + } else { + "Back up, then update" + }; + let binaries = if !removing { + "Replace" + } else if cfg!(windows) { + "Remove" + } else { + "Preserve" + }; + let mut rows = vec![ + format!( + "{} {app} and local server from {}", + pick("Remove", "Build and install"), + s.source.display() + ), + format!("Route: {} · Server: http://127.0.0.1:{}", s.model, s.port), + format!( + "{} service definitions in {}. The server {}.", + pick("Remove", "Replace"), + s.service_dir.display(), + pick("will stop", "will restart after build and validation") + ), + ]; + for (path, label) in [ + (s.desktop_settings(), "app settings"), + (s.sy_home.join("composite.toml"), "server config"), + (s.sy_home.join("routing.jsonl"), "usage history"), + ] { + let verb = if removing || path.exists() { + "Preserve" + } else { + "Create" + }; + rows.push(format!("{verb} {label}: {}", path.display())); + } + rows.push(format!( + "{profile_action} Codex profile: {}", + profile.display() + )); + rows.push(format!("{binaries} binaries: {}", s.bin_dir().display())); + rows.push(format!( + "Preserve accounts and logs: {}", + s.sy_home.display() + )); + if cfg!(target_os = "macos") { + rows.push(format!( + "{} app bundle: {}", + pick("Remove", "Replace"), + s.app().display() + )); + } + rows.push( + "Preview changes no files. Build prerequisites, full server config validation, \ + and readiness are checked during installation." + .into(), + ); + if removing { + rows.extend(removal_notes(s)); + } + rows.join("\n") +} + +/// Lists the coding-tool files that uninstall leaves in place for the user to review. +fn removal_notes(s: &Settings) -> Vec { + let mut destinations = vec![ + s.codex_home.join("sy.config.toml"), + s.codex_home.join("config.toml"), + s.home.join(".claude/settings.json"), + s.home.join(".pi/agent/models.json"), + s.home.join(".pi/agent/settings.json"), + ]; + for (tool, file) in [("codex", "config.toml"), ("claude", "settings.json")] { + let root = s.home.join(".switchyard/accounts").join(tool); + // symlink_metadata does not follow a symlink, so a linked accounts directory is skipped. + if fs::symlink_metadata(&root).is_ok_and(|m| m.is_dir()) + && let Ok(accounts) = fs::read_dir(&root) + { + let accounts = accounts + .flatten() + .filter(|a| a.file_type().is_ok_and(|t| t.is_dir())); + destinations.extend(accounts.map(|account| account.path().join(file))); + } + } + let mut notes = Vec::new(); + for destination in destinations { + let saved = ["switchyard-original", "switchyard-original-missing"] + .iter() + .any(|suffix| PathBuf::from(format!("{}.{suffix}", destination.display())).exists()); + if saved { + notes.push(format!( + "Preserve coding-tool destination with a Switchyard backup: {}. \ + Restore it in Connect tools before removal.", + destination.display() + )); + } + } + for path in [ + s.codex_home.join("config.sy.toml"), + s.codex_home.join("config.toml.direct"), + s.home.join(".zshrc"), + s.home.join(".bashrc"), + ] { + if path.exists() { + notes.push(format!( + "Review legacy Switchyard cleanup: {}. Active defaults are copied before \ + restoring config.toml.direct; marked shell/profile blocks are removed.", + path.display() + )); + } + } + notes.push("Custom destinations outside these known locations are not inventoried.".into()); + notes.push( + "Restore routing from Connect tools before uninstalling. \ + Custom and account tool settings are preserved and may still point at this server." + .into(), + ); + notes +} + +fn wait_for_health(port: u16) -> bool { + let deadline = Instant::now() + Duration::from_secs(10); + let address: SocketAddr = ([127, 0, 0, 1], port).into(); + while Instant::now() < deadline { + if let Ok(mut stream) = TcpStream::connect_timeout(&address, Duration::from_millis(250)) { + let _ = stream.set_read_timeout(Some(Duration::from_millis(300))); + let _ = stream.set_write_timeout(Some(Duration::from_millis(300))); + if write!( + stream, + "GET /health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ) + .is_ok() + { + let mut response = String::new(); + if stream.take(8192).read_to_string(&mut response).is_ok() + && let Some((headers, body)) = response.split_once("\r\n\r\n") + && headers + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + == Some("200") + && serde_json::from_str::(body) + .is_ok_and(|v| v["status"] == "ok") + { + return true; + } + } + } + thread::sleep(Duration::from_millis(200)); + } + false +} + +fn run(command: &mut Command) -> Result<(), String> { + #[cfg(windows)] + windows::hide_console(command); + let invocation = format!( + "{:?} {:?}", + command.get_program(), + command.get_args().collect::>() + ); + println!("Running {invocation}"); + let status = command + .status() + .map_err(|e| format!("Could not run {invocation}: {e}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("{invocation} exited with {status}")) + } +} + +fn err(error: impl std::fmt::Display) -> String { + error.to_string() +} + +fn present(path: &Path) -> Result { + path.try_exists().map_err(err) +} + +/// Reads a text file; a missing file is `None`. +fn read_optional(path: &Path) -> std::io::Result> { + match fs::read_to_string(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + result => result.map(Some), + } +} + +// Atomic replacement keeps existing executables readable while an update is staged. +fn write(path: &Path, bytes: &[u8], executable: bool) -> Result<(), String> { + let parent = path + .parent() + .ok_or("The destination has no parent directory.")?; + fs::create_dir_all(parent).map_err(err)?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(err)?; + temp.write_all(bytes).map_err(err)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = if executable { 0o755 } else { 0o600 }; + temp.as_file() + .set_permissions(fs::Permissions::from_mode(mode)) + .map_err(err)?; + } + #[cfg(not(unix))] + let _ = executable; + temp.as_file().sync_all().map_err(err)?; + temp.persist(path) + .map_err(|e| format!("Write {}: {e}", path.display()))?; + Ok(()) +} + +fn write_once(path: &Path, text: &str) -> Result<(), String> { + if present(path)? { + return Ok(()); + } + write(path, text.as_bytes(), false) +} + +fn backup(path: &Path, label: &str) -> Result<(), String> { + let name = path.file_name().ok_or("Missing file name.")?; + let parent = path.parent().ok_or("Missing parent directory.")?; + let mut copy = tempfile::Builder::new() + .prefix(&format!("{}.{label}.", name.to_string_lossy())) + .tempfile_in(parent) + .map_err(err)?; + copy.write_all(&fs::read(path).map_err(err)?).map_err(err)?; + copy.as_file().sync_all().map_err(err)?; + let (_, saved) = copy.keep().map_err(err)?; + println!("Preserved {} at {}", path.display(), saved.display()); + Ok(()) +} + +fn remove(path: &Path) -> Result<(), String> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("Remove {}: {e}", path.display())), + } +} + +/// What an install gathers and checks before it changes the installation. +struct Prepared { + /// The Codex profile to save. + profile: String, + /// App settings rewritten for a new port, when the port changes. + desktop_settings: Option, + /// API key variables in the server config that have no saved key. + missing_keys: Vec, +} + +// Every check and the build run before services stop, so a failure leaves the current +// installation running. +fn install(s: &Settings, preserve_profile: bool) -> Result, String> { + let prepared = prepare(s, preserve_profile)?; + let _lock = lock_installation(s)?; + set_update_state( + s, + "running", + "install", + "Build and config validation passed. Replacing the installation.", + )?; + replace_installation(s, prepared.desktop_settings.as_deref())?; + install_services(s, prepared.missing_keys.is_empty())?; + if !preserve_profile || !present(&s.profile_file())? { + save_profile(s, &prepared.profile)?; + } + report_readiness(s, &prepared.missing_keys) +} + +fn prepare(s: &Settings, preserve_profile: bool) -> Result { + let profile = s.profile_file(); + let context_window = route_context_window(s)?; + let profile_text = if profile.exists() && !preserve_profile { + let current = fs::read_to_string(&profile).map_err(err)?; + merge_codex_profile(s, ¤t, context_window)? + } else { + codex_profile(s, context_window) + }; + if cfg!(target_os = "macos") { + run(Command::new("launchctl").arg("help"))?; + } else if cfg!(target_os = "linux") { + run(systemctl(&["show-environment"]).stdout(Stdio::null()))?; + } + let desktop_settings = reconcile_desktop_port(s)?; + build(s)?; + let missing_keys = check_server_config(s, !preserve_profile || !profile.exists())?; + Ok(Prepared { + profile: profile_text, + desktop_settings, + missing_keys, + }) +} + +fn build(s: &Settings) -> Result<(), String> { + println!( + "Building the desktop app and server from {}", + s.source.display() + ); + // Cargo and rustc must come from the same toolchain, including GUI-triggered updates. + let cargo_dir = s.cargo.parent().ok_or("Cargo has no parent directory.")?; + let mut paths = vec![cargo_dir.to_path_buf()]; + if let Some(path) = std::env::var_os("PATH") { + paths.extend(std::env::split_paths(&path)); + } + run(Command::new(&s.cargo) + .current_dir(&s.source) + .args(["build", "--locked", "--release", "--manifest-path"]) + .arg(s.source.join("Cargo.toml")) + .arg("--target-dir") + .arg(s.source.join("target")) + .args(["-p", "switchyard-server", "-p", "switchyard-desktop"]) + .env("PATH", std::env::join_paths(paths).map_err(err)?)) +} + +/// Checks the server config that the install keeps, or the bundled one on a first install, and +/// returns the API key variables that have no saved key. +fn check_server_config(s: &Settings, require_route: bool) -> Result, String> { + let config = s.sy_home.join("composite.toml"); + let existing = present(&config)?; + let staged = tempfile::NamedTempFile::new().map_err(err)?; + let checked = if existing { + config.as_path() + } else { + fs::write(staged.path(), BUNDLED_CONFIG).map_err(err)?; + staged.path() + }; + // Install never saves keys; the app's Settings → Server keys does. Placeholder values let the + // dry run check the config without reading any key. + let mut checker = Command::new(s.release("switchyard-server")); + for name in credentials::variables(checked)? { + checker.env(name, "placeholder-for-config-check"); + } + run(checker.arg("--config").arg(checked).arg("--dry-run"))?; + // Finding an item does not read its key, so this check never asks for Keychain access. + let missing = credentials::status(checked)? + .into_iter() + .filter(|key| !key.saved) + .map(|key| key.name) + .collect(); + if require_route { + check_public_route(checked, &s.model)?; + } + Ok(missing) +} + +/// Stops the services and replaces the app files. The macOS app bundle is staged and signed +/// before the services stop. +fn replace_installation(s: &Settings, desktop_settings: Option<&str>) -> Result<(), String> { + let metadata = toml::to_string(s).map_err(err)?; + let bundle = cfg!(target_os = "macos") + .then(|| stage_bundle(s, &metadata)) + .transpose()?; + fs::create_dir_all(&s.sy_home).map_err(err)?; + set_update_state( + s, + "running", + "services", + "Stopping services and replacing app files. Existing config and history are retained.", + )?; + stop_services(s)?; + fs::create_dir_all(s.sy_home.join("logs")).map_err(err)?; + #[cfg(windows)] + windows::replace_binaries(s)?; + #[cfg(not(windows))] + for binary in [ + "switchyard-server", + "switchyard-desktop", + "switchyard-desktop-install", + ] { + let bytes = fs::read(s.release(binary)).map_err(err)?; + write(&s.binary(binary), &bytes, true)?; + } + migrate_settings(s, desktop_settings)?; + remove(&s.sy_home.join("bin/switchyard-menubar"))?; + for record in [s.sy_home.join("install.toml"), s.install_record()] { + write(&record, metadata.as_bytes(), false)?; + } + if let Some(stage) = bundle { + replace_app(s, stage)?; + } + Ok(()) +} + +/// Creates the server config and app settings when they are missing and moves the old menu bar +/// settings to desktop.toml. `desktop_settings` replaces the app settings when the port changed. +fn migrate_settings(s: &Settings, desktop_settings: Option<&str>) -> Result<(), String> { + write_once(&s.sy_home.join("composite.toml"), BUNDLED_CONFIG)?; + let legacy = s.sy_home.join("menubar.toml"); + if present(&legacy)? && !present(&s.desktop_settings())? { + fs::rename(&legacy, s.desktop_settings()).map_err(err)?; + } + write_once(&s.desktop_settings(), &desktop_config(s))?; + match desktop_settings { + Some(text) => write(&s.desktop_settings(), text.as_bytes(), false), + None => Ok(()), + } +} + +fn save_profile(s: &Settings, text: &str) -> Result<(), String> { + set_update_state( + s, + "running", + "profile", + "Saving the selected Codex profile.", + )?; + let profile = s.profile_file(); + if present(&profile)? && fs::read(&profile).map_err(err)? != text.as_bytes() { + backup(&profile, "switchyard-backup")?; + } + write(&profile, text.as_bytes(), false) +} + +/// Returns the warning to record when the server cannot start yet or does not answer /health. +fn report_readiness(s: &Settings, missing_keys: &[String]) -> Result, String> { + println!( + "Installed. Server: http://127.0.0.1:{}\nSettings: {}\nUse: codex -p {}", + s.port, + s.desktop_settings().display(), + s.profile + ); + set_update_state( + s, + "running", + "readiness", + "Installed. Waiting for the server to answer /health.", + )?; + if !missing_keys.is_empty() { + let next = if cfg!(any(target_os = "macos", windows)) { + "Open Switchyard, go to Settings → Server keys, save each key, \ + and restart the server." + .to_string() + } else { + format!( + "Save each key with {} key set NAME, \ + then run systemctl --user restart switchyard.", + s.binary("switchyard-desktop-install").display() + ) + }; + let message = format!( + "Installed the app and server. The server starts after you save a key for {}. {next}", + missing_keys.join(", ") + ); + println!("{message}"); + return Ok(Some(message)); + } + if wait_for_health(s.port) { + return Ok(None); + } + let message = format!( + "Installed the app and server, but Switchyard did not answer /health. \ + Check logs at {} and restart the server.", + s.sy_home.join("logs").display() + ); + println!("Warning: {message}"); + Ok(Some(message)) +} + +fn routes(config: &toml::Value) -> impl Iterator { + config + .get("routes") + .and_then(toml::Value::as_table) + .into_iter() + .flat_map(|routes| routes.values()) +} + +fn check_public_route(config: &Path, model: &str) -> Result<(), String> { + let text = fs::read_to_string(config).map_err(err)?; + let document: toml::Value = toml::from_str(&text).map_err(err)?; + let ids: Vec<&str> = routes(&document) + .filter_map(|route| route.get("id")?.as_str()) + .collect(); + if ids.contains(&model) { + return Ok(()); + } + Err(format!( + "Route {model:?} is not in {}. Choose SY_MODEL from: {}. \ + No services or coding-tool settings were changed.", + config.display(), + ids.join(", ") + )) +} + +fn reconcile_desktop_port(s: &Settings) -> Result, String> { + let path = if s.desktop_settings().exists() { + s.desktop_settings() + } else { + s.sy_home.join("menubar.toml") + }; + let Some(text) = read_optional(&path).map_err(|e| format!("Read {}: {e}", path.display()))? + else { + return Ok(None); + }; + let mut document: toml_edit::DocumentMut = text.parse().map_err(|e| { + format!( + "Parse {}: {e}. Fix this file before installing.", + path.display() + ) + })?; + let previous = s.sy_home.join("install.toml"); + let old_port = if previous.exists() { + Settings::load(&previous)?.port + } else { + 4123 + }; + let expected = format!("http://127.0.0.1:{old_port}"); + let current = document + .get("server_url") + .map(|value| { + value.as_str().ok_or_else(|| { + format!( + "The server_url value in {} must be a string: invalid data. \ + Fix this file before installing.", + path.display() + ) + }) + }) + .transpose()?; + if s.port != old_port && current.is_some_and(|url| url != expected) { + return Err(format!( + "{} uses a custom server_url, but SY_PORT changes from {old_port} to {}. \ + Review the app URL and port together before reinstalling. No services were stopped.", + path.display(), + s.port + )); + } + // Unchanged and custom URLs must retain the original TOML bytes, including quotes and line endings. + if current.is_some_and(|url| url != expected || s.port == old_port) { + return Ok(None); + } + document["server_url"] = toml_edit::value(format!("http://127.0.0.1:{}", s.port)); + let updated = document.to_string(); + Ok((updated != text).then_some(updated)) +} + +// Returns the `context_window` of the route that SY_MODEL names, from the existing server +// config or, on a new install, the bundled one. A missing route returns `None` here; +// check_public_route rejects it later with the list of valid route IDs. +fn route_context_window(s: &Settings) -> Result, String> { + let path = s.sy_home.join("composite.toml"); + let text = read_optional(&path).map_err(|e| format!("Read {}: {e}", path.display()))?; + let document: toml::Value = toml::from_str(text.as_deref().unwrap_or(BUNDLED_CONFIG)) + .map_err(|e| format!("Parse {}: {e}", path.display()))?; + Ok(routes(&document) + .find(|route| route.get("id").and_then(toml::Value::as_str) == Some(&s.model)) + .and_then(|route| route.get("context_window")?.as_integer())) +} + +// Codex compacts a thread when its usage nears the model's context window. Codex does not +// know a Switchyard route ID, so the profile names the route's window. Without it, Codex +// never compacts on the route, and a long thread fails when the upstream rejects it. +fn codex_profile(s: &Settings, context_window: Option) -> String { + let mut value: toml::Value = toml::from_str( + include_str!("../../../scripts/config/codex.sy.toml") + .replace("@SY_PORT@", &s.port.to_string()) + .as_str(), + ) + .expect("bundled profile is valid TOML"); + value["model"] = s.model.clone().into(); + if let (Some(window), Some(table)) = (context_window, value.as_table_mut()) { + table.insert("model_context_window".into(), window.into()); + } + toml::to_string(&value).expect("profile values serialize") +} +fn merge_codex_profile( + s: &Settings, + current: &str, + context_window: Option, +) -> Result { + let mut document: toml_edit::DocumentMut = current + .parse() + .map_err(|e| format!("Read Codex profile: {e}"))?; + let template: toml_edit::DocumentMut = codex_profile(s, context_window) + .parse() + .expect("generated profile is valid TOML"); + for key in ["model", "model_provider"] { + document[key] = template[key].clone(); + } + // A window kept from an earlier route would make Codex compact at the wrong size. + match template.get("model_context_window") { + Some(window) => document["model_context_window"] = window.clone(), + None => { + document.remove("model_context_window"); + } + } + if document.get("model_providers").is_none() { + document["model_providers"] = toml_edit::table(); + } + let providers = &mut document["model_providers"]; + if !providers.is_table_like() { + return Err( + "Codex model_providers must be a table. No services or profiles were changed.".into(), + ); + } + let sy = providers.get("sy"); + if sy.is_some_and(|item| !item.is_table_like()) { + return Err( + "Codex model_providers.sy must be a table. No services or profiles were changed." + .into(), + ); + } + if sy.is_none() { + providers["sy"] = toml_edit::table(); + } + for key in ["name", "base_url", "wire_api", "requires_openai_auth"] { + providers["sy"][key] = template["model_providers"]["sy"][key].clone(); + } + Ok(document.to_string()) +} + +fn desktop_config(s: &Settings) -> String { + format!( + "server_url = {:?}\n\ + routing_log = {}\n\ + config_file = {}\n\ + launchd_label = {:?}\n\ + refresh_seconds = 30\n\ + \n\ + {}", + format!("http://127.0.0.1:{}", s.port), + toml_string(&s.sy_home.join("routing.jsonl").to_string_lossy()), + toml_string(&s.sy_home.join("composite.toml").to_string_lossy()), + SERVER, + include_str!("../prices.toml") + ) +} +fn toml_string(value: &str) -> String { + toml::Value::String(value.into()).to_string() +} +fn xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} +fn plist(label: &str, args: &[String], log: &Path, app: bool) -> String { + let args: String = args + .iter() + .map(|a| format!("{}", xml(a))) + .collect(); + let keep_alive = if app { + "SuccessfulExit\ + LimitLoadToSessionTypeAqua" + } else { + "EnvironmentVariables\ + RUST_LOGinfo" + }; + format!( + "\ + \ + \ + Label{label}\ + ProgramArguments{args}\ + RunAtLoad\ + KeepAlive{keep_alive}\ + StandardOutPath{}\ + StandardErrorPath{}\ + ", + xml(&log.with_extension("log").to_string_lossy()), + xml(&log.with_extension("err.log").to_string_lossy()) + ) +} + +fn domain() -> Result { + let output = Command::new("id").arg("-u").output().map_err(err)?; + if !output.status.success() { + return Err("Could not read the current user ID.".into()); + } + Ok(format!( + "gui/{}", + String::from_utf8_lossy(&output.stdout).trim() + )) +} +fn systemctl(args: &[&str]) -> Command { + let mut command = Command::new("systemctl"); + command.arg("--user").args(args); + command +} + +fn stop_services(s: &Settings) -> Result<(), String> { + if cfg!(target_os = "macos") { + let domain = domain()?; + for label in [SERVER, DESKTOP, LEGACY_DESKTOP] { + stop_launchd_job(&format!("{domain}/{label}"))?; + } + remove(&s.service_dir.join(format!("{LEGACY_DESKTOP}.plist")))?; + } else if cfg!(windows) { + #[cfg(windows)] + windows::stop(s)?; + } else if present(&s.service_dir.join(UNIT))? { + run(&mut systemctl(&["stop", UNIT]))?; + } + Ok(()) +} +fn install_services(s: &Settings, keys_saved: bool) -> Result<(), String> { + if cfg!(target_os = "macos") { + install_launch_agents(s)?; + } else if cfg!(windows) { + #[cfg(windows)] + windows::install(s)?; + } else { + install_systemd_unit(s, keys_saved)?; + } + Ok(()) +} +/// Removes the service definitions and the app. SY_HOME keeps settings, accounts, and history. +fn remove_app_and_services(s: &Settings) -> Result<(), String> { + if cfg!(target_os = "macos") { + for label in [SERVER, DESKTOP, LEGACY_DESKTOP] { + remove(&s.service_dir.join(format!("{label}.plist")))?; + } + if present(&s.app())? { + fs::remove_dir_all(s.app()).map_err(err)?; + } + } else if cfg!(windows) { + #[cfg(windows)] + windows::uninstall(s)?; + } else { + let unit = s.service_dir.join(UNIT); + if present(&unit)? { + run(&mut systemctl(&["disable", UNIT]))?; + remove(&unit)?; + } + run(&mut systemctl(&["daemon-reload"]))?; + } + Ok(()) +} + +fn launchd_job_loaded(job: &str) -> Result { + Command::new("launchctl") + .args(["print", job]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .map_err(err) +} +fn stop_launchd_job(job: &str) -> Result<(), String> { + if !launchd_job_loaded(job)? { + return Ok(()); + } + run(Command::new("launchctl").args(["bootout", job]))?; + for _ in 0..50 { + if !launchd_job_loaded(job)? { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err(format!( + "{job} did not stop within five seconds. Try again." + )) +} + +fn stage_bundle(s: &Settings, metadata: &str) -> Result { + let app = s.app(); + let parent = app.parent().ok_or("The app has no parent directory.")?; + fs::create_dir_all(parent).map_err(err)?; + let stage = tempfile::tempdir_in(parent).map_err(err)?; + let app = stage.path().join("Switchyard.app"); + let info = format!( + "\ + \ + CFBundleIdentifiercom.nvidia.switchyard\ + CFBundleNameSwitchyard\ + CFBundleDisplayNameSwitchyard\ + CFBundleExecutableswitchyard-desktop\ + CFBundleIconFileSwitchyard.icns\ + CFBundlePackageTypeAPPL\ + CFBundleShortVersionString{}\ + LSMinimumSystemVersion12.0\ + NSHighResolutionCapable\ + ", + env!("CARGO_PKG_VERSION") + ); + let desktop = fs::read(s.release("switchyard-desktop")).map_err(err)?; + for (file, bytes, executable) in [ + ("MacOS/switchyard-desktop", &desktop[..], true), + ("Resources/install.toml", metadata.as_bytes(), false), + ( + "Resources/Switchyard.icns", + &include_bytes!("../icons/icon.icns")[..], + false, + ), + ("Info.plist", info.as_bytes(), false), + ] { + write(&app.join("Contents").join(file), bytes, executable)?; + } + run(Command::new("codesign") + .args(["--force", "--deep", "--sign", "-"]) + .arg(&app))?; + Ok(stage) +} + +fn replace_app(s: &Settings, stage: tempfile::TempDir) -> Result<(), String> { + let previous = stage.path().join("Previous.app"); + let existed = present(&s.app())?; + if existed { + fs::rename(s.app(), &previous).map_err(err)?; + } + let Err(error) = fs::rename(stage.path().join("Switchyard.app"), s.app()) else { + return Ok(()); + }; + if existed { + fs::rename(&previous, s.app()) + .map_err(|restore| format!("Replace app: {error}. Restore previous app: {restore}"))?; + } + Err(format!("Replace app: {error}")) +} + +fn install_launch_agents(s: &Settings) -> Result<(), String> { + let server = [ + s.binary("switchyard-desktop-install").display().to_string(), + "serve".into(), + "--settings".into(), + s.sy_home.join("install.toml").display().to_string(), + ]; + let desktop = [ + s.app() + .join("Contents/MacOS/switchyard-desktop") + .display() + .to_string(), + s.desktop_settings().display().to_string(), + ]; + for (label, args, log, app) in [ + (SERVER, &server[..], "logs/server", false), + (DESKTOP, &desktop[..], "logs/desktop", true), + ] { + let job = plist(label, args, &s.sy_home.join(log), app); + write( + &s.service_dir.join(format!("{label}.plist")), + job.as_bytes(), + false, + )?; + } + let domain = domain()?; + for label in [SERVER, DESKTOP] { + run(Command::new("launchctl") + .arg("bootstrap") + .arg(&domain) + .arg(s.service_dir.join(format!("{label}.plist"))))?; + // macOS can defer RunAtLoad jobs until an explicit start is requested. + run(Command::new("launchctl") + .arg("kickstart") + .arg(format!("{domain}/{label}")))?; + } + Ok(()) +} + +fn systemd_arg(path: &Path) -> String { + // systemd expands percent specifiers and environment variables inside quoted arguments. + format!( + "\"{}\"", + path.to_string_lossy() + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('%', "%%") + .replace('$', "$$") + ) +} +fn install_systemd_unit(s: &Settings, expect_running: bool) -> Result<(), String> { + let unit = format!( + "[Unit]\n\ + Description=Switchyard local server\n\ + \n\ + [Service]\n\ + Type=simple\n\ + ExecStart={} serve --settings {}\n\ + Restart=on-failure\n\ + RestartSec=10\n\ + Environment=RUST_LOG=info\n\ + \n\ + [Install]\n\ + WantedBy=default.target\n", + systemd_arg(&s.binary("switchyard-desktop-install")), + systemd_arg(&s.sy_home.join("install.toml")), + ); + write(&s.service_dir.join(UNIT), unit.as_bytes(), false)?; + run(&mut systemctl(&["daemon-reload"]))?; + run(&mut systemctl(&["enable", UNIT]))?; + run(&mut systemctl(&["restart", UNIT]))?; + // A server waiting for a key exits and restarts until the key is saved. + if !expect_running { + return Ok(()); + } + thread::sleep(Duration::from_secs(2)); + run(&mut systemctl(&["is-active", "--quiet", UNIT])) + .map_err(|e| format!("{e}. See journalctl --user -u switchyard.")) +} + +fn strip_block(path: &Path, start: &str, end: &str) -> Result<(), String> { + let Some(text) = read_optional(path).map_err(err)? else { + return Ok(()); + }; + let mut output = String::new(); + let mut skipping = false; + for line in text.split_inclusive('\n') { + skipping |= line.contains(start); + if !skipping { + output.push_str(line); + } + if line.contains(end) { + skipping = false; + } + } + if skipping { + return Err(format!( + "{} has an unfinished Switchyard block. No edits were made to this file.", + path.display() + )); + } + if text != output { + write(path, output.as_bytes(), false)?; + } + Ok(()) +} + +fn uninstall(s: &Settings) -> Result<(), String> { + let _lock = lock_installation(s)?; + println!("{}", installation_plan(s, "uninstall")); + let profile = s.profile_file(); + if profile.exists() { + backup(&profile, "switchyard-uninstall-recovery")?; + } + stop_services(s)?; + remove_app_and_services(s)?; + remove(&profile)?; + remove_legacy_setup(s)?; + println!( + "Removed services, app, and profile. Kept settings, accounts, {}and history at {}.", + if cfg!(windows) { "" } else { "binaries, " }, + s.sy_home.display() + ); + let recorded = s.install_record(); + if recorded.exists() && Settings::load(&recorded)?.sy_home == s.sy_home { + remove(&recorded)?; + } + Ok(()) +} + +/// Removes legacy Switchyard edits to Codex and shell startup files. The active config.toml is +/// backed up before config.toml.direct replaces it. +fn remove_legacy_setup(s: &Settings) -> Result<(), String> { + remove(&s.codex_home.join("config.sy.toml"))?; + let config = s.codex_home.join("config.toml"); + strip_block( + &config, + "# >>> switchyard sy profile >>>", + "# <<< switchyard sy profile <<<", + )?; + let direct = s.codex_home.join("config.toml.direct"); + if present(&direct)? { + if present(&config)? { + backup(&config, "switchyard-current")?; + } + write(&config, &fs::read(&direct).map_err(err)?, false)?; + remove(&direct)?; + } + for name in [".zshrc", ".bashrc"] { + strip_block( + &s.home.join(name), + "# >>> switchyard codex alias >>>", + "# <<< switchyard codex alias <<<", + )?; + } + Ok(()) +} diff --git a/crates/switchyard-desktop/src/install/credentials.rs b/crates/switchyard-desktop/src/install/credentials.rs new file mode 100644 index 0000000000..90f7efc97c --- /dev/null +++ b/crates/switchyard-desktop/src/install/credentials.rs @@ -0,0 +1,446 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Server keys for the installed server. +//! +//! launchd, systemd, or Task Scheduler starts the installed server, never a shell, so the +//! server cannot see the variables that a shell exports. Before it starts the server, the +//! installed helper reads each variable that the config names with `api_key_env` from the +//! system credential store: macOS Keychain, Windows Credential Manager, or the Linux Secret +//! Service (GNOME Keyring or KDE Wallet). A saved key replaces any value the service manager +//! passes, and a variable with no saved key stops the start. Keys are saved under the variable +//! name alone, so editing, moving, or replacing the config keeps them. +//! +//! The helper is the only program that reads or writes these keys. macOS ties Keychain access +//! to the program that saved an item, so one program keeps one identity. + +use std::{collections::BTreeSet, path::Path, process::Command}; + +#[cfg(target_os = "macos")] +pub const STORE: &str = "macOS Keychain"; +#[cfg(windows)] +pub const STORE: &str = "Windows Credential Manager"; +#[cfg(target_os = "linux")] +pub const STORE: &str = "the Secret Service keyring (GNOME Keyring or KDE Wallet)"; +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +pub const STORE: &str = "a system credential store"; + +/// KeyStatus reports whether one variable that the config declares has a saved key. +#[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyStatus { + pub name: String, + pub saved: bool, +} + +/// This function returns the names that the config gives with `api_key_env`. +pub fn variables(config: &Path) -> Result, String> { + let text = std::fs::read_to_string(config) + .map_err(|error| format!("Could not read {}: {error}", config.display()))?; + switchyard_runner::api_key_variables(&text).map_err(|e| e.to_string()) +} + +/// This function reports which declared variables have a saved key. +pub fn status(config: &Path) -> Result, String> { + variables(config)? + .into_iter() + .map(|name| { + Ok(KeyStatus { + saved: store::exists(&name)?, + name, + }) + }) + .collect() +} + +/// This function gives `command` the saved key for every variable that the config declares. +/// It fails when any of them has no saved key. +pub fn configure(command: &mut Command, config: &Path) -> Result<(), String> { + supply(command, &variables(config)?, store::read) +} + +/// This function waits up to a minute for the login keyring to unlock. A systemd user service +/// can start before the desktop login unlocks it, and the helper never shows an unlock prompt +/// from the background. +#[cfg(target_os = "linux")] +pub fn wait_for_keyring() -> Result<(), String> { + store::wait_for_unlock(std::time::Duration::from_secs(60)) +} + +/// This function runs the server's `--dry-run` on `config` with the saved keys. +pub fn validate(server: &Path, config: &Path) -> Result<(), String> { + let mut validator = Command::new(server); + configure(&mut validator, config)?; + let output = validator + .arg("--config") + .arg(config) + .arg("--dry-run") + .output() + .map_err(|e| format!("Could not run {}: {e}", server.display()))?; + if output.status.success() { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr); + Err(if stderr.trim().is_empty() { + String::from_utf8_lossy(&output.stdout).into_owned() + } else { + stderr.into_owned() + }) +} + +/// This function saves `key` for the variable `name`, replacing any saved key. +pub fn set(name: &str, key: &str) -> Result<(), String> { + check_name(name)?; + let key = key.trim(); + if key.is_empty() { + return Err(format!("The key for {name} is empty.")); + } + store::write(name, key) +} + +/// This function reads `name` once from the user's login shell and saves it. +pub fn import(name: &str) -> Result<(), String> { + check_name(name)?; + #[cfg(unix)] + let key = shell_value(&login_shell()?, name)?; + #[cfg(windows)] + let key = environment_value(name)?; + set(name, &key) +} + +/// This function deletes the saved key for `name`. A missing key is not an error. +pub fn remove(name: &str) -> Result<(), String> { + check_name(name)?; + store::delete(name) +} + +/// This function gives `command` the key that `saved` returns for each name, replacing any +/// inherited value. It fails, naming every variable that has no saved key. +pub fn supply( + command: &mut Command, + names: &BTreeSet, + saved: impl Fn(&str) -> Result, String>, +) -> Result<(), String> { + let mut missing = Vec::new(); + for name in names { + match saved(name)? { + Some(key) => { + command.env(name, key); + } + None => missing.push(name.as_str()), + } + } + let example = match missing.as_slice() { + [] => return Ok(()), + [name] => *name, + _ => "NAME", + }; + let helper = std::env::current_exe().unwrap_or_else(|_| "switchyard-desktop-install".into()); + let app = if cfg!(any(target_os = "macos", windows)) { + "Save it in Switchyard under Settings → Server keys, or run" + } else { + "Save it with" + }; + Err(format!( + "No saved key for {}. The installed server reads api_key_env keys from {STORE}, \ + not from your shell. {app}: {} key set {example}", + missing.join(", "), + helper.display(), + )) +} + +fn check_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() || name.contains('=') || name.chars().any(char::is_control) { + return Err("Invalid environment variable name.".into()); + } + Ok(()) +} + +/// This function starts `shell` as a login shell, the way a terminal does, and prints one +/// variable. Markers separate the value from anything the shell's startup files print. +#[cfg(unix)] +pub fn shell_value(shell: &Path, name: &str) -> Result { + use std::io::Read; + use std::process::Stdio; + const MARK: &str = "\u{1e}switchyard\u{1e}"; + // SHELL_WAIT limits how long a login shell may take to print one variable. + const SHELL_WAIT: std::time::Duration = std::time::Duration::from_secs(30); + let mut child = Command::new(shell) + .args([ + "-i", + "-l", + "-c", + "printf '\\036switchyard\\036'; printenv -- \"$SWITCHYARD_KEY_NAME\"; \ + printf '\\036switchyard\\036'", + ]) + .env("SWITCHYARD_KEY_NAME", name) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|e| format!("Could not start your login shell {}: {e}", shell.display()))?; + let mut stdout = child + .stdout + .take() + .ok_or("The login shell has no output.")?; + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut output = Vec::new(); + let _ = stdout.read_to_end(&mut output); + let _ = sender.send(output); + }); + let output = receiver.recv_timeout(SHELL_WAIT); + let _ = child.kill(); + let _ = child.wait(); + let output = output.map_err(|_| { + format!( + "Your login shell {} did not finish within {} seconds.", + shell.display(), + SHELL_WAIT.as_secs() + ) + })?; + let text = String::from_utf8(output).map_err(|_| format!("{name} is not UTF-8 text."))?; + let value = text.split(MARK).nth(1).unwrap_or_default(); + let value = value.strip_suffix('\n').unwrap_or(value); + if value.trim().is_empty() { + return Err(format!( + "{name} is not set in your login shell ({}).", + shell.display() + )); + } + Ok(value.to_string()) +} + +/// This function returns the login shell from the user's account record. +#[cfg(unix)] +fn login_shell() -> Result { + use std::os::unix::ffi::OsStrExt; + // SAFETY: getpwuid returns null or a record in static storage. The shell is copied before + // any other call can reuse that storage. + let shell = unsafe { + let entry = libc::getpwuid(libc::getuid()); + if entry.is_null() || (*entry).pw_shell.is_null() { + return Err("Could not find your login shell.".into()); + } + std::ffi::CStr::from_ptr((*entry).pw_shell) + .to_bytes() + .to_vec() + }; + if shell.is_empty() { + return Err("Your account has no login shell.".into()); + } + Ok(std::ffi::OsStr::from_bytes(&shell).into()) +} + +/// Windows has no login shell. Task Scheduler and Explorer pass the user's environment +/// variables, so the helper reads its own. +#[cfg(windows)] +fn environment_value(name: &str) -> Result { + std::env::var(name) + .ok() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| format!("{name} is not set in your Windows environment variables.")) +} + +#[cfg(target_os = "macos")] +mod store { + use security_framework::{base::Error, passwords}; + + /// SERVICE names the server keys in Keychain. Each item's account is the variable name. + const SERVICE: &str = "Switchyard server key"; + + /// This function turns Keychain's `errSecItemNotFound` status into `None`. + fn found(result: Result) -> Result, Error> { + match result { + Err(e) if e.code() == -25300 => Ok(None), + result => result.map(Some), + } + } + + /// This function looks for the item without reading its key, so Keychain never asks + /// for permission. + pub fn exists(name: &str) -> Result { + use security_framework::item::{ItemClass, ItemSearchOptions}; + let mut search = ItemSearchOptions::new(); + search + .class(ItemClass::generic_password()) + .service(SERVICE) + .account(name) + .load_attributes(true); + let items = found(search.search()) + .map_err(|e| format!("Could not search Keychain for {name}: {e}"))?; + Ok(items.is_some_and(|items| !items.is_empty())) + } + + pub fn read(name: &str) -> Result, String> { + let key = found(passwords::get_generic_password(SERVICE, name)) + .map_err(|e| format!("Could not read the saved key for {name} from Keychain: {e}"))?; + key.map(String::from_utf8) + .transpose() + .map_err(|_| format!("The saved key for {name} is not text.")) + } + + pub fn write(name: &str, key: &str) -> Result<(), String> { + passwords::set_generic_password(SERVICE, name, key.as_bytes()) + .map_err(|e| format!("Could not save the key for {name} in Keychain: {e}")) + } + + pub fn delete(name: &str) -> Result<(), String> { + found(passwords::delete_generic_password(SERVICE, name)) + .map(|_| ()) + .map_err(|e| format!("Could not delete the saved key for {name} from Keychain: {e}")) + } +} + +#[cfg(windows)] +mod store { + fn target(name: &str) -> String { + format!("Switchyard:server-key:{name}") + } + + pub fn exists(name: &str) -> Result { + read(name).map(|key| key.is_some()) + } + + pub fn read(name: &str) -> Result, String> { + crate::install::windows::read_credential(&target(name)) + } + + pub fn write(name: &str, key: &str) -> Result<(), String> { + crate::install::windows::write_credential(&target(name), key) + } + + pub fn delete(name: &str) -> Result<(), String> { + crate::install::windows::delete_credential(&target(name)) + } +} + +#[cfg(target_os = "linux")] +mod store { + use dbus_secret_service::{EncryptionType, Item, SearchItemsResult, SecretService}; + use std::collections::HashMap; + + const LOCKED: &str = + "The login keyring is locked. Log in to the desktop or unlock the keyring, then try again."; + + fn attributes(name: &str) -> HashMap<&str, &str> { + HashMap::from([("application", "switchyard"), ("server-key", name)]) + } + + fn connect() -> Result { + SecretService::connect(EncryptionType::Dh).map_err(|e| { + format!( + "Could not reach the Secret Service, \ + which GNOME Keyring or KDE Wallet provides: {e}" + ) + }) + } + + fn search<'a>( + service: &'a SecretService, + name: &str, + ) -> Result>, String> { + service + .search_items(attributes(name)) + .map_err(|e| format!("Could not search the keyring for {name}: {e}")) + } + + /// This function finds locked items too, because finding an item does not read its key. + pub fn exists(name: &str) -> Result { + let service = connect()?; + let found = search(&service, name)?; + Ok(!(found.unlocked.is_empty() && found.locked.is_empty())) + } + + pub fn read(name: &str) -> Result, String> { + let service = connect()?; + let found = search(&service, name)?; + if let Some(item) = found.unlocked.first() { + let bytes = item + .get_secret() + .map_err(|e| format!("Could not read the saved key for {name}: {e}"))?; + return String::from_utf8(bytes) + .map(Some) + .map_err(|_| format!("The saved key for {name} is not text.")); + } + if found.locked.is_empty() { + Ok(None) + } else { + Err(LOCKED.into()) + } + } + + /// This function saves into the default keyring. Unlocking it can show the desktop's + /// password prompt, which is expected when the user saves a key. + pub fn write(name: &str, key: &str) -> Result<(), String> { + let service = connect()?; + let collection = service + .get_default_collection() + .or_else(|_| service.get_collection_by_alias("login")) + .map_err(|e| format!("The Secret Service has no default keyring: {e}"))?; + collection + .ensure_unlocked() + .map_err(|e| format!("Could not unlock the default keyring: {e}"))?; + collection + .create_item( + &format!("Switchyard server key {name}"), + attributes(name), + key.as_bytes(), + true, + "text/plain", + ) + .map(drop) + .map_err(|e| format!("Could not save the key for {name} in the keyring: {e}")) + } + + pub fn delete(name: &str) -> Result<(), String> { + let service = connect()?; + let found = search(&service, name)?; + for item in found.unlocked.iter().chain(&found.locked) { + item.delete() + .map_err(|e| format!("Could not delete the saved key for {name}: {e}"))?; + } + Ok(()) + } + + pub fn wait_for_unlock(limit: std::time::Duration) -> Result<(), String> { + let deadline = std::time::Instant::now() + limit; + loop { + let error = match connect().and_then(|service| { + service + .search_items(HashMap::from([("application", "switchyard")])) + .map_err(|e| e.to_string()) + .map(|found| found.locked.is_empty()) + }) { + Ok(true) => return Ok(()), + Ok(false) => LOCKED.to_string(), + Err(error) => error, + }; + if std::time::Instant::now() >= deadline { + return Err(error); + } + std::thread::sleep(std::time::Duration::from_secs(2)); + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))] +mod store { + const UNSUPPORTED: &str = "Saved server keys need macOS, Windows, or Linux."; + + pub fn exists(_name: &str) -> Result { + Err(UNSUPPORTED.into()) + } + + pub fn read(_name: &str) -> Result, String> { + Err(UNSUPPORTED.into()) + } + + pub fn write(_name: &str, _key: &str) -> Result<(), String> { + Err(UNSUPPORTED.into()) + } + + pub fn delete(_name: &str) -> Result<(), String> { + Err(UNSUPPORTED.into()) + } +} diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs new file mode 100644 index 0000000000..154a8628ea --- /dev/null +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -0,0 +1,563 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs per-user Windows tasks and shortcuts and replaces stopped app executables. + +use super::*; +use std::os::windows::{ + ffi::{OsStrExt, OsStringExt}, + process::CommandExt, +}; +use windows_sys::Win32::{ + Foundation::{CloseHandle, ERROR_NOT_FOUND, HANDLE, INVALID_HANDLE_VALUE, WAIT_OBJECT_0}, + Security::Credentials::{ + CRED_PERSIST_LOCAL_MACHINE, CRED_TYPE_GENERIC, CREDENTIALW, CredDeleteW, CredFree, + CredReadW, CredWriteW, + }, + System::{ + Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, + TH32CS_SNAPPROCESS, + }, + Threading::{ + CREATE_NO_WINDOW, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, + PROCESS_TERMINATE, QueryFullProcessImageNameW, TerminateProcess, WaitForSingleObject, + }, + }, +}; + +/// The managed executables, in the order they are replaced and stopped. +const BINARIES: [&str; 3] = [ + "switchyard-server", + "switchyard-desktop", + "switchyard-desktop-install", +]; + +struct Handle(HANDLE); +impl Drop for Handle { + fn drop(&mut self) { + unsafe { + CloseHandle(self.0); + } + } +} +fn wide(value: &std::ffi::OsStr) -> Vec { + value.encode_wide().chain(Some(0)).collect() +} +fn error() -> String { + std::io::Error::last_os_error().to_string() +} +/// Credential Manager reports a missing target as ERROR_NOT_FOUND; this returns `absent` for it. +fn absent_or_error(absent: T) -> Result { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { + Ok(absent) + } else { + Err(error.to_string()) + } +} +pub fn hide_console(command: &mut Command) { + command.creation_flags(CREATE_NO_WINDOW); +} + +pub fn task_name(s: &Settings, desktop: bool) -> String { + // The binary directory keeps task names stable when SY_HOME changes or a Rust toolchain changes. + let mut directory = PathBuf::new(); + for component in s.bin_dir().components() { + if component == std::path::Component::ParentDir { + directory.pop(); + } else if component != std::path::Component::CurDir { + directory.push(component); + } + } + let key = directory + .to_string_lossy() + .replace('\\', "/") + .to_lowercase(); + let key = if let Some(unc) = key.strip_prefix("//?/unc/") { + format!("//{unc}") + } else { + key.strip_prefix("//?/").unwrap_or(&key).to_owned() + }; + let hash = key.bytes().fold(0xcbf29ce484222325u64, |h, b| { + (h ^ u64::from(b)).wrapping_mul(0x100000001b3) + }); + format!( + "Switchyard-{}-{hash:016x}", + if desktop { "Desktop" } else { "Server" } + ) +} +fn scheduler() -> Command { + let mut c = Command::new("schtasks.exe"); + hide_console(&mut c); + c +} +/// This function runs `schtasks /TN ` without output and reports whether it succeeded. +fn task_succeeds(s: &Settings, desktop: bool, action: &str) -> Result { + scheduler() + .args([action, "/TN", &task_name(s, desktop)]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|status| status.success()) + .map_err(|e| e.to_string()) +} +fn identity() -> Result { + use windows_sys::Win32::{ + Foundation::LocalFree, + Security::{ + Authorization::ConvertSidToStringSidW, GetTokenInformation, TOKEN_QUERY, TOKEN_USER, + TokenUser, + }, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + unsafe { + let mut token = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) == 0 { + return Err(error()); + } + let token = Handle(token); + let mut length = 0; + GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut length); + if length < std::mem::size_of::() as u32 { + return Err(error()); + } + let mut buffer = vec![0usize; (length as usize).div_ceil(std::mem::size_of::())]; + if GetTokenInformation( + token.0, + TokenUser, + buffer.as_mut_ptr().cast(), + length, + &mut length, + ) == 0 + { + return Err(error()); + } + let user = &*buffer.as_ptr().cast::(); + let mut sid = std::ptr::null_mut(); + if ConvertSidToStringSidW(user.User.Sid, &mut sid) == 0 { + return Err(error()); + } + let result = match (0..256).find(|&index| *sid.add(index) == 0) { + Some(length) => String::from_utf16(std::slice::from_raw_parts(sid, length)) + .map_err(|e| e.to_string()), + None => Err("Invalid Windows account SID.".to_owned()), + }; + LocalFree(sid.cast()); + result + } +} +// Separate tasks let the server continue after the desktop app exits. +fn task_xml(s: &Settings, user: &str, desktop: bool) -> String { + let program = s.binary("switchyard-desktop-install"); + let args = format!( + "{} --settings {}", + if desktop { "desktop" } else { "serve" }, + quote_arg(&s.sy_home.join("install.toml").display().to_string()) + ); + let user = xml(user); + let restart = if desktop { + "" + } else { + "PT1M999" + }; + format!( + "\ + \ + true\ + {user}\ + {user}\ + InteractiveToken\ + LeastPrivilege\ + IgnoreNew\ + false\ + false\ + truetrue\ + PT0S{restart}\ + {}{}\ + {}\ + ", + xml(&program.display().to_string()), + xml(&args), + xml(&s.bin_dir().display().to_string()) + ) +} +// Task Scheduler parses a Windows argument string; trailing slashes must not escape its closing quote. +pub fn quote_arg(value: &str) -> String { + let mut out = String::from("\""); + let mut slashes = 0; + for ch in value.chars() { + if ch == '\\' { + slashes += 1; + continue; + } + out.extend(std::iter::repeat_n( + '\\', + if ch == '"' { slashes * 2 + 1 } else { slashes }, + )); + out.push(ch); + slashes = 0; + } + out.extend(std::iter::repeat_n('\\', slashes * 2)); + out.push('"'); + out +} +fn shortcut_paths(s: &Settings) -> [PathBuf; 2] { + [ + s.service_dir.join("Switchyard.lnk"), + s.service_dir.join("Startup/Switchyard.lnk"), + ] +} +pub fn install(s: &Settings) -> Result<(), String> { + write( + &s.bin_dir().join("install.toml"), + toml::to_string(s).map_err(|e| e.to_string())?.as_bytes(), + false, + )?; + let user = identity()?; + for desktop in [false, true] { + create_task(s, &user, desktop)?; + } + let mut link = + mslnk::ShellLink::new(s.binary("switchyard-desktop")).map_err(|e| e.to_string())?; + link.set_arguments(Some(quote_arg(&s.desktop_settings().display().to_string()))); + // The desktop logon task starts the app, so only the Start menu shortcut is created. + let [start_menu, _startup] = shortcut_paths(s); + fs::create_dir_all(&s.service_dir).map_err(|e| e.to_string())?; + link.create_lnk(start_menu).map_err(|e| e.to_string())?; + for desktop in [false, true] { + run(scheduler().args(["/Run", "/TN", &task_name(s, desktop)]))?; + } + Ok(()) +} +/// This function registers one task from its XML, written as UTF-16 to match the XML declaration. +fn create_task(s: &Settings, user: &str, desktop: bool) -> Result<(), String> { + let mut file = tempfile::NamedTempFile::new().map_err(|e| e.to_string())?; + let data: Vec = [0xfeffu16] + .into_iter() + .chain(task_xml(s, user, desktop).encode_utf16()) + .flat_map(u16::to_le_bytes) + .collect(); + file.write_all(&data).map_err(|e| e.to_string())?; + // Closing the writer lets schtasks read the XML; TempPath keeps cleanup active during import. + let path = file.into_temp_path(); + run(scheduler() + .args(["/Create", "/F", "/TN", &task_name(s, desktop), "/XML"]) + .arg(&path)) +} +fn stop_task(s: &Settings, desktop: bool) -> Result<(), String> { + if task_succeeds(s, desktop, "/Query")? { + // /End reports an error for a task that has already exited. Path-checked handles below verify shutdown. + task_succeeds(s, desktop, "/End")?; + } + Ok(()) +} +// Full executable paths distinguish this installation from other copies running under the same user. +fn stop_images(images: &[PathBuf]) -> Result<(), String> { + let targets = images + .iter() + .filter(|p| p.exists()) + .map(fs::canonicalize) + .collect::, _>>() + .map_err(|e| e.to_string())?; + unsafe { + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if snapshot == INVALID_HANDLE_VALUE { + return Err(error()); + } + let snapshot = Handle(snapshot); + let mut entry: PROCESSENTRY32W = std::mem::zeroed(); + entry.dwSize = std::mem::size_of::() as u32; + let mut found = Process32FirstW(snapshot.0, &mut entry); + while found != 0 { + if entry.th32ProcessID != std::process::id() { + stop_process(entry.th32ProcessID, &targets)?; + } + found = Process32NextW(snapshot.0, &mut entry); + } + } + Ok(()) +} +/// This function terminates process `pid` and waits for it to exit when its executable is one of +/// `targets`. Processes this user cannot open or query are skipped. +fn stop_process(pid: u32, targets: &[PathBuf]) -> Result<(), String> { + let access = PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | PROCESS_SYNCHRONIZE; + let process = unsafe { OpenProcess(access, 0, pid) }; + if process.is_null() { + return Ok(()); + } + let process = Handle(process); + let mut buffer = vec![0u16; 32768]; + let mut length = buffer.len() as u32; + if unsafe { QueryFullProcessImageNameW(process.0, 0, buffer.as_mut_ptr(), &mut length) } == 0 { + return Ok(()); + } + let image = PathBuf::from(std::ffi::OsString::from_wide(&buffer[..length as usize])); + if !fs::canonicalize(image).is_ok_and(|path| targets.contains(&path)) { + return Ok(()); + } + unsafe { + if TerminateProcess(process.0, 0) == 0 && WaitForSingleObject(process.0, 0) != WAIT_OBJECT_0 + { + return Err(error()); + } + if WaitForSingleObject(process.0, 10000) != WAIT_OBJECT_0 { + return Err("A managed Windows process did not stop within ten seconds.".into()); + } + } + Ok(()) +} +pub fn stop(s: &Settings) -> Result<(), String> { + for desktop in [false, true] { + stop_task(s, desktop)?; + } + stop_images(&BINARIES.map(|name| s.binary(name))) +} +pub fn restart(s: &Settings) -> Result<(), String> { + stop_task(s, false)?; + stop_images(&[s.binary("switchyard-server")])?; + run(scheduler().args(["/Run", "/TN", &task_name(s, false)])) +} +pub fn uninstall(s: &Settings) -> Result<(), String> { + for desktop in [false, true] { + if task_succeeds(s, desktop, "/Query")? { + run(scheduler().args(["/Delete", "/F", "/TN", &task_name(s, desktop)]))?; + } + } + for path in shortcut_paths(s) { + remove(&path)?; + } + // Config and history live in SY_HOME. Only the managed application directory is removed. + if s.bin_dir().exists() { + fs::remove_dir_all(s.bin_dir()).map_err(|e| e.to_string())?; + } + Ok(()) +} +/// This function stages all binaries and attempts to restore earlier replacements if one fails. +pub fn replace_binaries(s: &Settings) -> Result<(), String> { + fs::create_dir_all(s.bin_dir()).map_err(|e| e.to_string())?; + let stage = tempfile::tempdir_in(s.bin_dir()).map_err(|e| e.to_string())?; + for name in BINARIES { + fs::copy(s.release(name), stage.path().join(executable_name(name))) + .map_err(|e| e.to_string())?; + } + let mut changed = Vec::new(); + let Err(error) = BINARIES + .into_iter() + .try_for_each(|name| swap(s, stage.path(), name, &mut changed)) + else { + return Ok(()); + }; + let mut failures = Vec::new(); + for (name, saved) in changed.iter().rev() { + if let Err(e) = restore(&s.binary(name), saved.as_deref()) { + failures.push(format!("{name}: {e}")); + } + } + if failures.is_empty() { + return Err(format!( + "Could not replace app files; restored the previous binaries: {error}" + )); + } + // Failed restoration must retain the previous files for manual recovery. + let recovery = stage.keep(); + Err(format!( + "Could not replace app files: {error}. Restore failures: {}. \ + Previous files remain at {}.", + failures.join("; "), + recovery.display() + )) +} +/// This function saves any installed `name` in `stage`, then moves the staged copy into place. +/// It records `name` and the saved copy as soon as the installed file has moved, so a failure +/// in the second move still restores it. +fn swap( + s: &Settings, + stage: &Path, + name: &'static str, + changed: &mut Vec<(&'static str, Option)>, +) -> std::io::Result<()> { + let dest = s.binary(name); + let saved = dest + .exists() + .then(|| stage.join(format!("{name}.previous"))); + if let Some(saved) = &saved { + fs::rename(&dest, saved)?; + } + changed.push((name, saved)); + fs::rename(stage.join(executable_name(name)), dest) +} +fn restore(dest: &Path, saved: Option<&Path>) -> Result<(), String> { + remove(dest)?; + match saved { + Some(saved) => fs::rename(saved, dest).map_err(|e| e.to_string()), + None => Ok(()), + } +} +/// The updater runs from a temporary copy because Windows locks running executables against replacement. +pub fn temporary_updater(source: &Path) -> Result { + let file = tempfile::Builder::new() + .prefix("switchyard-update-") + .suffix(".exe") + .tempfile() + .map_err(|e| e.to_string())?; + fs::copy(source, file.path()).map_err(|e| e.to_string())?; + Ok(file.into_temp_path()) +} +pub fn wait_parent(pid: u32) -> Result<(), String> { + use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, GetLastError}; + if pid == 0 { + return Err("Invalid parent process ID.".into()); + } + unsafe { + let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid); + if handle.is_null() { + return if GetLastError() == ERROR_INVALID_PARAMETER { + Ok(()) + } else { + Err(error()) + }; + } + let handle = Handle(handle); + if WaitForSingleObject(handle.0, 30000) != WAIT_OBJECT_0 { + return Err("The installer parent did not exit within thirty seconds.".into()); + } + } + Ok(()) +} +/// This function waits for its child so Task Scheduler tracks the app or server's lifetime. +pub fn launch_managed(s: &Settings, desktop: bool) -> Result<(), String> { + let (program, log) = if desktop { + ("switchyard-desktop", "logs/desktop.log") + } else { + ("switchyard-server", "logs/server.log") + }; + fs::create_dir_all(s.sy_home.join("logs")).map_err(|e| e.to_string())?; + let log = fs::OpenOptions::new() + .create(true) + .append(true) + .open(s.sy_home.join(log)) + .map_err(|e| e.to_string())?; + let mut c = Command::new(s.binary(program)); + hide_console(&mut c); + c.env("HOME", &s.home) + .env("USERPROFILE", &s.home) + .env("CODEX_HOME", &s.codex_home) + .env("SY_HOME", &s.sy_home) + .current_dir(s.bin_dir()); + if desktop { + c.arg(s.desktop_settings()); + } else { + let config = s.sy_home.join("composite.toml"); + c.arg("--config") + .arg(&config) + .args([ + "--host", + "127.0.0.1", + "--port", + &s.port.to_string(), + "--routing-log-file", + ]) + .arg(s.sy_home.join("routing.jsonl")); + // The helper has no console, so a missing key is reported in the server log. + credentials::configure(&mut c, &config).inspect_err(|error| { + let _ = writeln!(&log, "switchyard-desktop-install: {error}"); + })?; + } + let status = c + .stdin(Stdio::null()) + .stderr(log.try_clone().map_err(|e| e.to_string())?) + .stdout(log) + .status() + .map_err(|e| e.to_string())?; + if status.success() { + Ok(()) + } else { + Err(format!("Managed application exited with {status}.")) + } +} +pub fn open(value: &str) -> Result<(), String> { + use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}; + let value = wide(std::ffi::OsStr::new(value)); + let verb = wide(std::ffi::OsStr::new("open")); + let status = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + verb.as_ptr(), + value.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ) + } as isize; + if status > 32 { + Ok(()) + } else { + Err(format!( + "Windows could not open the file or URL (error {status})." + )) + } +} +pub fn saved_key(base_url: &str) -> Result, String> { + read_credential(&format!("Switchyard:model-list:{base_url}")) +} + +pub(crate) fn read_credential(target: &str) -> Result, String> { + let target = wide(std::ffi::OsStr::new(target)); + let mut credential: *mut CREDENTIALW = std::ptr::null_mut(); + if unsafe { CredReadW(target.as_ptr(), CRED_TYPE_GENERIC, 0, &mut credential) } == 0 { + return absent_or_error(None); + } + let bytes = unsafe { + let size = (*credential).CredentialBlobSize as usize; + // An empty secret may have a null blob, which `from_raw_parts` does not accept. + let bytes = if size == 0 { + Vec::new() + } else { + std::slice::from_raw_parts((*credential).CredentialBlob, size).to_vec() + }; + CredFree(credential.cast()); + bytes + }; + String::from_utf8(bytes) + .map(|s| Some(s).filter(|s| !s.trim().is_empty())) + .map_err(|e| e.to_string()) +} +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + write_credential(&format!("Switchyard:model-list:{base_url}"), key.trim()) +} + +pub(crate) fn write_credential(target: &str, key: &str) -> Result<(), String> { + let mut target = wide(std::ffi::OsStr::new(target)); + let credential = CREDENTIALW { + Type: CRED_TYPE_GENERIC, + TargetName: target.as_mut_ptr(), + Persist: CRED_PERSIST_LOCAL_MACHINE, + CredentialBlobSize: key.len().try_into().map_err(|_| "The key is too long.")?, + // CredWriteW only reads the blob, so the key needs no mutable copy. + CredentialBlob: key.as_ptr().cast_mut(), + ..unsafe { std::mem::zeroed() } + }; + if unsafe { CredWriteW(&credential, 0) } == 0 { + return Err(error()); + } + Ok(()) +} + +pub(crate) fn delete_credential(target: &str) -> Result<(), String> { + let target = wide(std::ffi::OsStr::new(target)); + if unsafe { CredDeleteW(target.as_ptr(), CRED_TYPE_GENERIC, 0) } == 0 { + return absent_or_error(()); + } + Ok(()) +} + +pub fn record_handoff(s: &Settings) -> Result<(), String> { + let _lock = update_lock(s)?; + set_update_state( + s, + "starting", + "start", + "Starting the Windows source installer.", + ) +} diff --git a/crates/switchyard-desktop/src/lib.rs b/crates/switchyard-desktop/src/lib.rs new file mode 100644 index 0000000000..be41a3ff19 --- /dev/null +++ b/crates/switchyard-desktop/src/lib.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The installer that `cargo desktop`, the installed `switchyard-desktop-install` helper, and the +//! desktop app share. + +pub mod install; diff --git a/crates/switchyard-desktop/tests/desktop.rs b/crates/switchyard-desktop/tests/desktop.rs new file mode 100644 index 0000000000..2e71d8b17e --- /dev/null +++ b/crates/switchyard-desktop/tests/desktop.rs @@ -0,0 +1,498 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Checks the installer, the server key helper, and the app icons through their public entry +//! points. The installer runs against fake build and service tools, never real ones. + +use std::{fs, path::Path, process::Command}; +use switchyard_desktop::install::credentials; + +#[path = "../examples/desktop-icons/export.rs"] +mod icons; + +#[test] +fn committed_icons_match_their_sources_and_a_stale_export_is_reported() { + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("icons"); + icons::export(&source, true).expect("committed icons are stale; run cargo desktop-icons"); + let copy = tempfile::tempdir().expect("copy"); + for name in "icon.png tray-source.png icon.icns tray.png tray.rgba".split(' ') { + fs::copy(source.join(name), copy.path().join(name)).expect("icon"); + } + fs::write(copy.path().join("icon.ico"), "stale").expect("stale icon"); + let error = icons::export(copy.path(), true).expect_err("stale"); + assert!(error.to_string().contains("icon.ico is stale"), "{error}"); + let kept = fs::read(copy.path().join("icon.ico")).expect("kept"); + assert_eq!(kept, b"stale"); +} + +#[test] +fn saved_keys_replace_inherited_values_and_a_missing_key_stops_the_start() { + let dir = tempfile::tempdir().expect("fixture"); + let config = dir.path().join("composite.toml"); + let text = r#"schema_version = 1 +targets = {} +routes = {} +llm_clients.gateway = { format = "openai_chat", base_url = "https://e.test", api_key_env = "GATEWAY_TOKEN" } +decision_clients.judge = { format = "system_one", endpoint = "https://e.test", api_key_env = "JUDGE_TOKEN", timeout_ms = 1 } +"#; + fs::write(&config, text).expect("config"); + let names = credentials::variables(&config).expect("names"); + assert_eq!(Vec::from_iter(&names), ["GATEWAY_TOKEN", "JUDGE_TOKEN"]); + + // A stale value from the service manager must not reach the server. + let mut server = Command::new("server"); + server.env("GATEWAY_TOKEN", "stale-launchd-value"); + let saved = |name: &str| Ok(Some(format!("saved-{name}"))); + credentials::supply(&mut server, &names, saved).expect("supplied"); + let mut env: Vec<_> = server.get_envs().collect(); + env.sort(); + let env = format!("{env:?}"); + let expected = r#"[("GATEWAY_TOKEN", Some("saved-GATEWAY_TOKEN")), ("JUDGE_TOKEN", Some("saved-JUDGE_TOKEN"))]"#; + assert_eq!(env, expected); + + // A value exported in a shell does not stand in for a missing saved key. + let mut server = Command::new("server"); + server.env("JUDGE_TOKEN", "exported-in-shell"); + let gateway_only = |name: &str| Ok((name == "GATEWAY_TOKEN").then(|| "saved".into())); + let error = credentials::supply(&mut server, &names, gateway_only).expect_err("missing"); + assert!(error.contains("No saved key for JUDGE_TOKEN."), "{error}"); + assert!(error.contains("key set JUDGE_TOKEN"), "{error}"); + assert!(!error.contains("GATEWAY"), "{error}"); + let failed = credentials::supply(&mut server, &names, |_| Err("store unavailable".into())); + assert_eq!(failed, Err("store unavailable".into())); + + // Bad names and empty keys fail before the credential store is opened. + for name in ["", " ", "A=B", "A\nB"] { + assert!(credentials::set(name, "key").is_err(), "{name:?}"); + assert!(credentials::import(name).is_err(), "{name:?}"); + assert!(credentials::remove(name).is_err(), "{name:?}"); + } + let empty = credentials::set("SWITCHYARD_UNUSED_TEST_KEY", " \n"); + assert!(empty.is_err_and(|e| e.ends_with("is empty."))); + + // Import reads one variable from a login shell whose startup files print around it. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let shell = dir.path().join("shell"); + let script = "#!/bin/sh\necho Hi\nexport SY_VALUE=set\n/bin/sh -c \"$4\"\necho Bye\n"; + fs::write(&shell, script).expect("shell"); + fs::set_permissions(&shell, fs::Permissions::from_mode(0o700)).expect("mode"); + let value = credentials::shell_value(&shell, "SY_VALUE"); + assert_eq!(value, Ok("set".into())); + let error = credentials::shell_value(&shell, "SY_UNSET").expect_err("unset"); + assert!(error.contains("SY_UNSET is not set"), "{error}"); + } +} + +#[cfg(unix)] +mod installer { + use std::{ + collections::BTreeMap, + ffi::OsString, + fs, + io::{Read, Write}, + os::unix::{ffi::OsStringExt, fs::PermissionsExt}, + path::{Path, PathBuf}, + process::{Command, Output}, + }; + use switchyard_desktop::install::{self, Settings}; + + const HELPER: &str = env!("CARGO_BIN_EXE_switchyard-desktop-install"); + #[cfg(target_os = "macos")] + const SERVICES: &str = "Library/LaunchAgents"; + #[cfg(not(target_os = "macos"))] + const SERVICES: &str = ".config/systemd/user"; + /// Only these service commands may run before an install fails. + const PREFLIGHT: [&str; 2] = ["launchctl help", "systemctl --user show-environment"]; + /// One script fakes cargo, codesign, id, launchctl, systemctl, and the built binaries. It + /// logs service commands, and the server's config check fails when HOME has `fail-check`. + const TOOL: &str = r#"#!/bin/sh +name=$(basename "$0") +case "$name" in launchctl|systemctl) echo "$name $*" >> "$HOME/service_calls" ;; esac +case "$name $1" in +"cargo build") + while [ "$1" != --target-dir ]; do shift; done + mkdir -p "$2/release" + cp "$0" "$2/release/switchyard-server" + cp "$0" "$2/release/switchyard-desktop" + cp "$INSTALLER_BIN" "$2/release/switchyard-desktop-install" + ps -o pgid= -p $$ > "$2/build-process-group" ;; +"id -u") echo 501 ;; +"launchctl print") exit 1 ;; +"switchyard-server --config") [ ! -e "$HOME/fail-check" ] ;; +esac +"#; + + /// Tests rerun this binary to install with the fake tools on PATH; alone, it does nothing. + #[test] + fn child_process() { + if let Some(file) = std::env::var_os("FIXTURE_SETTINGS") { + let action = std::env::var("FIXTURE_ACTION").expect("action"); + if let Err(error) = run_child(Path::new(&file), &action) { + eprintln!("{error}"); + std::process::exit(1); + } + } + } + + fn run_child(file: &Path, action: &str) -> Result<(), String> { + if action != "start_update" { + return install::execute(&Settings::load(file)?, action, false); + } + install::start_update(file)?; + if install::start_update(file).is_ok() { + return Err("A second update started while the first was starting.".into()); + } + for _ in 0..1500 { + let status = install::update_status(file)?; + match status["state"].as_str() { + Some("completed") => return Ok(()), + Some("failed") => return Err(status.to_string()), + _ => std::thread::sleep(std::time::Duration::from_millis(20)), + } + } + Err("The updater did not finish within 30 seconds.".into()) + } + + /// This function serves a healthy /health endpoint for the rest of the test process. + fn health_server() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("health socket"); + let port = listener.local_addr().expect("address").port(); + std::thread::spawn(move || { + for mut stream in listener.incoming().flatten() { + let _ = stream.read(&mut [0; 1024]); + let response = "HTTP/1.1 200 OK\r\nContent-Length: 15\r\n\r\n{\"status\":\"ok\"}"; + let _ = stream.write_all(response.as_bytes()); + } + }); + port + } + + struct Fixture { + dir: tempfile::TempDir, + s: Settings, + file: PathBuf, + } + + impl Fixture { + fn new() -> Self { + let dir = tempfile::tempdir().expect("fixture"); + let (home, bin) = (dir.path().join("home"), dir.path().join("bin")); + // These characters must stay literal in TOML, plist, and systemd arguments. + let source = dir.path().join("checkout with ' $() & "); + for name in ["cargo", "codesign", "id", "launchctl", "systemctl"] { + write(&bin.join(name), TOOL); + let mode = fs::Permissions::from_mode(0o755); + fs::set_permissions(bin.join(name), mode).expect("mode"); + } + fs::create_dir_all(&home).expect("home"); + fs::create_dir_all(&source).expect("checkout"); + let s = Settings { + sy_home: home.join("Switchyard ' $(touch unsafe) \\ & % $"), + codex_home: home.join(".codex"), + service_dir: home.join(SERVICES), + app_dir: None, + home, + source, + cargo: bin.join("cargo"), + port: health_server(), + profile: "team-dev".into(), + model: "composite-gpt-6-sol-gpt-6-luna".into(), + }; + let file = dir.path().join("install.toml"); + let f = Self { dir, s, file }; + f.save(); + f + } + fn save(&self) { + write(&self.file, &toml::to_string(&self.s).expect("settings")); + } + fn path(&self) -> OsString { + let path = std::env::var_os("PATH").expect("PATH"); + let paths = std::env::split_paths(&path); + std::env::join_paths(std::iter::once(self.dir.path().join("bin")).chain(paths)) + .expect("PATH") + } + fn command(&self, program: &Path) -> Command { + let mut command = Command::new(program); + command.env("PATH", self.path()).env("HOME", &self.s.home); + command + } + fn run(&self, action: &str) -> Output { + let mut child = self.command(&std::env::current_exe().expect("test binary")); + child.args(["--exact", "installer::child_process", "--nocapture"]); + child + .env("FIXTURE_SETTINGS", &self.file) + .env("FIXTURE_ACTION", action); + child + .env("INSTALLER_BIN", HELPER) + .output() + .expect("installer") + } + fn ok(&self, action: &str) { + let output = self.run(action); + assert!(output.status.success(), "{action}: {}", stderr(&output)); + } + fn helper(&self, args: &str, env: Option<(&str, OsString)>) -> Output { + let mut helper = self.command(Path::new(HELPER)); + helper + .args(args.split(' ')) + .envs(env) + .output() + .expect("helper") + } + fn profile(&self) -> PathBuf { + self.s.codex_home.join("team-dev.config.toml") + } + fn codex_files(&self, marker: &str) -> Vec { + let entries = fs::read_dir(&self.s.codex_home).expect("Codex settings"); + let paths = entries.flatten().map(|entry| entry.path()); + let marked = paths.filter(|path| path.to_string_lossy().contains(marker)); + marked.map(|path| read(&path)).collect() + } + /// This function returns every file under HOME but locks, status, and the service log. + fn files(&self) -> BTreeMap> { + let mut files = BTreeMap::new(); + let mut pending = vec![self.s.home.clone()]; + while let Some(directory) = pending.pop() { + let entries = fs::read_dir(directory).expect("directory"); + for path in entries.flatten().map(|entry| entry.path()) { + let name = path.file_name().expect("name").to_string_lossy(); + let skip = ["service_calls", "update-status.toml"].contains(&&*name); + if path.is_dir() { + pending.push(path); + } else if !skip && !name.ends_with(".lock") { + files.insert(path.clone(), fs::read(&path).expect("file")); + } + } + } + files + } + } + + fn write(path: &Path, text: &str) { + fs::create_dir_all(path.parent().expect("parent")).expect("directory"); + fs::write(path, text).expect("write"); + } + fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())) + } + fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() + } + fn toml(path: &Path) -> toml::Value { + toml::from_str(&read(path)).expect("TOML") + } + + #[test] + fn install_reinstall_update_and_uninstall_keep_user_data() { + let mut f = Fixture::new(); + let (sy_home, codex) = (f.s.sy_home.clone(), f.s.codex_home.clone()); + write(&sy_home.join("menubar.toml"), "baseline_model = 'custom'\n"); + write(&sy_home.join("routing.jsonl"), "history"); + write(&codex.join("config.toml"), "user defaults"); + let legacy_job = f.s.service_dir.join("com.nvidia.switchyard.menubar.plist"); + if cfg!(target_os = "macos") { + write(&legacy_job, "old menu bar job"); + } + + // A first install moves the old menu bar settings and adds the server URL. + f.ok("install"); + let settings = f.s.desktop_settings(); + let url = |port| format!("server_url = \"http://127.0.0.1:{port}\"\n"); + assert_eq!( + read(&settings), + "baseline_model = 'custom'\n".to_owned() + &url(f.s.port) + ); + assert!(!sy_home.join("menubar.toml").exists()); + let first = read(&f.profile()); + let profile = toml(&f.profile()); + assert_eq!(profile["model"].as_str(), Some(f.s.model.as_str())); + assert_eq!(profile["model_context_window"].as_integer(), Some(272000)); + assert!(profile.get("approval_policy").is_none()); + if cfg!(target_os = "macos") { + let contents = f.s.app().join("Contents"); + let dict = |path: PathBuf| plist::from_file::<_, plist::Dictionary>(path); + let info = dict(contents.join("Info.plist")).expect("Info.plist"); + let executable = info["CFBundleExecutable"].as_string(); + assert_eq!(executable, Some("switchyard-desktop")); + let icon = info["CFBundleIconFile"].as_string().expect("icon"); + let icon = fs::read(contents.join("Resources").join(icon)).expect("icon"); + assert_eq!(icon, include_bytes!("../icons/icon.icns")); + let bundled = install::bundle_metadata(&contents.join("MacOS/switchyard-desktop")); + assert_eq!(Settings::load(&bundled).expect("bundle").sy_home, sy_home); + // The plist parses only if the installer escaped the < and & in SY_HOME. + let job = dict(f.s.service_dir.join("com.nvidia.switchyard.server.plist")); + let job = job.expect("server job"); + let helper = f.s.binary("switchyard-desktop-install"); + assert_eq!( + job["ProgramArguments"].as_array().expect("args")[0].as_string(), + helper.to_str() + ); + assert!(!legacy_job.exists()); + } else { + let unit = read(&f.s.service_dir.join("switchyard.service")); + assert!(unit.contains(" %% $$/install.toml\""), "{unit}"); + } + // A path in the settings never runs as a shell command. + assert!(!f.s.home.join("unsafe").exists()); + + // A reinstall on a new port with a route that sets no window backs up the profile, + // drops the old window, follows the port, and keeps history and configs. + f.s.port = health_server(); + f.s.model = "codex-auto-review".into(); + f.save(); + f.ok("install"); + assert!(read(&settings).ends_with(&url(f.s.port))); + let profile = toml(&f.profile()); + assert_eq!(profile["model"].as_str(), Some("codex-auto-review")); + assert!(profile.get("model_context_window").is_none()); + assert_eq!(f.codex_files("switchyard-backup"), [first]); + assert_eq!(read(&sy_home.join("routing.jsonl")), "history"); + assert_eq!(read(&codex.join("config.toml")), "user defaults"); + let server_config = include_str!("../../../scripts/config/composite.toml"); + assert_eq!(read(&sy_home.join("composite.toml")), server_config); + let recorded = Settings::load(&sy_home.join("install.toml")).expect("record"); + assert_eq!((recorded.port, &recorded.model), (f.s.port, &f.s.model)); + + // The detached updater keeps the edited profile and app settings byte for byte. + let edited = "# Personal\nmodel='custom-selection'\nexperimental='kept'\n"; + write(&f.profile(), edited); + let custom = format!("# Mine\r\n{}baseline_model='x'\r\n", url(f.s.port)); + write(&settings, &custom); + // An interrupted earlier update must not block this one. + write( + &sy_home.join("update-status.toml"), + "state = 'running'\nupdated_at = 0\n", + ); + f.ok("start_update"); + assert_eq!(read(&f.profile()), edited); + assert_eq!(read(&settings), custom); + let status = install::update_status(&f.file).expect("status"); + assert_eq!(status["state"], "completed"); + // launchd stops the app's whole process group, so the updater must leave it. + let build = read(&f.s.source.join("target/build-process-group")); + assert_ne!(build.trim(), unsafe { libc::getpgrp() }.to_string()); + + // Uninstall restores the pre-Switchyard Codex defaults, keeps copies of what it + // replaces, removes the old shell alias, and keeps history. + write(&codex.join("config.toml"), "current"); + write(&codex.join("config.toml.direct"), "original"); + let alias = "# >>> switchyard codex alias >>>\nalias codex=old\n"; + let rc = f.s.home.join(".bashrc"); + let block = format!("before\n{alias}# <<< switchyard codex alias <<<\nafter\n"); + write(&rc, &block); + f.ok("uninstall"); + assert_eq!(read(&codex.join("config.toml")), "original"); + assert_eq!(f.codex_files("switchyard-current"), ["current"]); + assert_eq!(f.codex_files("switchyard-uninstall-recovery"), [edited]); + assert_eq!(read(&rc), "before\nafter\n"); + assert!(!f.profile().exists() && !f.s.app().exists()); + assert_eq!(read(&sy_home.join("routing.jsonl")), "history"); + f.ok("uninstall"); + + // An unfinished alias block is not edited. + let unfinished = format!("before\n{alias}after\n"); + write(&rc, &unfinished); + assert!(!f.run("uninstall").status.success()); + assert_eq!(read(&rc), unfinished); + } + + #[test] + fn rejected_installs_change_no_files_or_services() { + let cases = [ + ("install", "check", "--dry-run"), + ("install", "route", "\"missing-route\""), + ("install", "profile", "model_providers must be a table"), + ("install", "url", "server_url value"), + ("install", "lock", "Could not lock"), + ("uninstall", "lock", "Could not lock"), + ]; + for (action, setup, expected) in cases { + let mut f = Fixture::new(); + let lock = fs::File::create(f.s.home.join(".switchyard-desktop-install.lock")); + let lock = lock.expect("lock"); + match setup { + "check" => write(&f.s.home.join("fail-check"), ""), + "route" => f.s.model = "missing-route".into(), + "profile" => write(&f.profile(), "model_providers = 'not a table'\n"), + "url" => write(&f.s.desktop_settings(), "server_url = 42\n"), + _ => lock.lock().expect("hold lock"), + } + f.save(); + let before = f.files(); + let output = f.run(action); + let error = stderr(&output); + let rejected = !output.status.success() && error.contains(expected); + assert!(rejected, "{expected}: {error}"); + assert_eq!(f.files(), before, "{expected}"); + let calls = fs::read_to_string(f.s.home.join("service_calls")).unwrap_or_default(); + let preflight_only = calls.lines().all(|call| PREFLIGHT.contains(&call)); + assert!(preflight_only, "{expected}: {calls}"); + } + + // The helper rejects malformed commands and settings before it writes anything. + let f = Fixture::new(); + let bad = "key;key set;key status --config;install --dryrun;install --dry-run extra;\ + uninstall extra;validate --server relative --config /config;\ + validate --server /server --config /config\n"; + for args in bad.split(';') { + assert!(!f.helper(args, None).status.success(), "{args:?}"); + } + let bad = "SY_PROFILE ../escape|;SY_MODEL |a\nb;SY_PORT 0|65536|+1"; + for (name, values) in bad.split(';').filter_map(|case| case.split_once(' ')) { + let values = values.split('|').map(OsString::from); + for value in values.chain([OsString::from_vec(vec![0xff])]) { + let output = f.helper("install", Some((name, value.clone()))); + let error = stderr(&output); + let rejected = !output.status.success() && error.contains(name); + assert!(rejected, "{value:?}: {error}"); + } + } + + // Previews succeed, name the selected installation, and write nothing. + let previews = "install --dry-run;uninstall --dry-run;\ + uninstall --settings FILE --dry-run;uninstall --dry-run --settings FILE"; + for args in previews.split(';') { + let output = f.helper(&args.replace("FILE", &f.file.display().to_string()), None); + assert!(output.status.success(), "{args}: {}", stderr(&output)); + let names_profile = String::from_utf8_lossy(&output.stdout).contains("team-dev"); + assert_eq!(names_profile, args.contains("FILE"), "{args}"); + } + assert!(f.files().is_empty()); + write(&f.file, &format!("{}\nunknown = true\n", read(&f.file))); + assert!(Settings::load(&f.file).is_err()); + } +} + +#[cfg(windows)] +#[test] +fn a_locked_windows_executable_rolls_back_every_earlier_replacement() { + use std::os::windows::fs::OpenOptionsExt; + use switchyard_desktop::install::{Settings, executable_name, windows}; + let root = tempfile::tempdir().expect("fixture"); + let mut s = Settings::from_env().expect("settings"); + s.source = root.path().join("source"); + s.app_dir = Some(root.path().join("app")); + let release = s.source.join("target/release"); + fs::create_dir_all(&release).expect("release"); + fs::create_dir_all(s.bin_dir()).expect("app"); + let binaries = "switchyard-server switchyard-desktop switchyard-desktop-install"; + for name in binaries.split(' ') { + fs::write(release.join(executable_name(name)), "new").expect("new"); + fs::write(s.binary(name), "old").expect("old"); + } + let mut open = fs::OpenOptions::new(); + let lock = open.read(true).share_mode(0); + let lock = lock.open(s.binary("switchyard-desktop")); + assert!(windows::replace_binaries(&s).is_err()); + drop(lock); + for name in binaries.split(' ') { + assert_eq!(fs::read(s.binary(name)).expect("old"), b"old"); + } + windows::replace_binaries(&s).expect("retry"); + for name in binaries.split(' ') { + assert_eq!(fs::read(s.binary(name)).expect("new"), b"new"); + } +} diff --git a/scripts/config/codex.sy.toml b/scripts/config/codex.sy.toml index 9ecdff1efd..39a8c1e4b3 100644 --- a/scripts/config/codex.sy.toml +++ b/scripts/config/codex.sy.toml @@ -1,4 +1,4 @@ -model = "switchyard" +model = "composite-gpt-6-sol-gpt-6-luna" model_provider = "sy" [model_providers.sy] diff --git a/scripts/config/composite.toml b/scripts/config/composite.toml index 4abef0e9b8..543f0c70ff 100644 --- a/scripts/config/composite.toml +++ b/scripts/config/composite.toml @@ -26,8 +26,11 @@ extra_body = { store = false, stream = true } omit_body_fields = ["max_output_tokens"] [routes.switchyard] -id = "switchyard" +id = "composite-gpt-6-sol-gpt-6-luna" type = "composite" +# Codex lists both gpt-6-sol and gpt-6-luna with a 272,000-token window. The installer +# copies this value into the Codex profile so Codex compacts at the right size. +context_window = 272000 [routes.switchyard.classifier] target = "terra"