diff --git a/Cargo.lock b/Cargo.lock index ab6ad1f7..895e834a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,7 +15,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -29,6 +31,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -44,6 +64,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -68,6 +94,32 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "arrayvec" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -277,6 +329,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.16.1" @@ -363,6 +458,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -375,6 +476,18 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -390,6 +503,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -442,6 +564,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.19.1" @@ -542,6 +670,15 @@ dependencies = [ "toml 0.9.11+spec-1.1.0", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.55" @@ -627,6 +764,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -637,6 +780,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -646,6 +804,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "const-random" version = "0.1.18" @@ -717,7 +888,7 @@ dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -768,6 +939,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -827,6 +1017,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.21.3" @@ -847,6 +1047,20 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + [[package]] name = "darling_core" version = "0.21.3" @@ -874,6 +1088,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.21.3" @@ -896,6 +1121,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -949,13 +1183,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -970,6 +1213,37 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1059,7 +1333,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1191,6 +1465,12 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1233,6 +1513,26 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1257,9 +1557,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "event-listener" version = "5.4.1" @@ -1281,6 +1587,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1293,12 +1614,35 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastembed" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f54fc1188b7f7eac8f47be2ab7b3a79ffd842cc8ff2e38316dd59ba4858890e" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1357,6 +1701,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +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 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1364,7 +1723,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1378,6 +1737,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1690,6 +2055,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1858,8 +2233,19 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" @@ -1878,7 +2264,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -1886,6 +2272,13 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -1920,6 +2313,27 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-hub" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" +dependencies = [ + "dirs 6.0.0", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.2", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq 2.12.1", + "windows-sys 0.60.2", +] + [[package]] name = "hmac" version = "0.12.1" @@ -1929,6 +2343,12 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + [[package]] name = "html5ever" version = "0.29.1" @@ -1974,6 +2394,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -2023,7 +2449,23 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] @@ -2207,11 +2649,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png 0.18.0", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.15", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "1.9.3" @@ -2235,6 +2704,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + [[package]] name = "infer" version = "0.19.0" @@ -2273,6 +2755,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2308,6 +2801,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -2401,6 +2903,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2471,6 +2988,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2510,6 +3033,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -2542,6 +3075,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -2569,18 +3108,49 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + [[package]] name = "markup5ever" version = "0.14.1" @@ -2627,12 +3197,33 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "mcpmux" version = "0.3.0" dependencies = [ "anyhow", "async-trait", + "block2", "chrono", "dirs 5.0.1", "dotenvy", @@ -2643,6 +3234,9 @@ dependencies = [ "mcpmux-storage", "notify", "notify-debouncer-mini", + "objc2", + "objc2-contacts", + "objc2-foundation", "open", "reqwest 0.13.2", "serde", @@ -2703,10 +3297,12 @@ dependencies = [ "chrono", "dashmap", "dirs 5.0.1", + "fastembed", "futures", "hmac", "http", "http-body-util", + "jsonwebtoken", "mcpmux-core", "mcpmux-storage", "oauth2", @@ -2718,16 +3314,20 @@ dependencies = [ "serde", "serde_json", "sha2", + "strsim", + "subtle", "thiserror 1.0.69", "tokio", "tokio-util", "tower", "tower-http", "tracing", + "tracing-test", "url", "urlencoding", "uuid", "which", + "yaml_serde", "zeroize", ] @@ -2796,6 +3396,22 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.4" @@ -2824,6 +3440,28 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -2855,6 +3493,38 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework 3.5.1", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2903,12 +3573,46 @@ dependencies = [ "libc", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nodrop" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify" version = "7.0.0" @@ -2955,14 +3659,64 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", ] [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] [[package]] name = "num-traits" @@ -3005,6 +3759,12 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "oauth2" version = "5.0.0" @@ -3067,6 +3827,17 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-contacts" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b034b578389f89a85c055eacc8d8b368be5f04a6c1b07f672bf3aec21d0ef621" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -3255,6 +4026,28 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.10.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "open" version = "5.3.3" @@ -3267,12 +4060,49 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types 0.3.2", + "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.114", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3299,6 +4129,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq 3.3.0", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq 3.3.0", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -3306,7 +4160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -3377,6 +4231,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.1" @@ -3389,6 +4255,25 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3611,6 +4496,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3743,6 +4643,25 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -3752,6 +4671,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3951,19 +4885,106 @@ dependencies = [ ] [[package]] -name = "rand_pcg" -version = "0.2.1" +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" dependencies = [ - "rand_core 0.5.1", + "either", + "itertools", + "rayon", ] [[package]] -name = "raw-window-handle" -version = "0.6.2" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] [[package]] name = "redox_syscall" @@ -4062,15 +5083,21 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-core", + "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "mime", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -4081,15 +5108,18 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -4132,7 +5162,7 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] @@ -4160,6 +5190,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -4189,7 +5225,7 @@ dependencies = [ "http-body", "http-body-util", "oauth2", - "pastey", + "pastey 0.2.1", "pin-project-lite", "process-wrap", "rand 0.10.0", @@ -4271,7 +5307,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4281,6 +5317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4329,7 +5366,7 @@ dependencies = [ "security-framework 3.5.1", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4362,6 +5399,17 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +[[package]] +name = "safetensors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "675656c1eabb620b921efea4f9199f97fc86e36dd6ffd1fbbe48d0f59a4987f5" +dependencies = [ + "hashbrown 0.16.1", + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4745,6 +5793,27 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -4779,6 +5848,17 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + [[package]] name = "softbuffer" version = "0.4.8" @@ -4827,6 +5907,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sse-stream" version = "0.2.1" @@ -4846,6 +5938,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "string_cache" version = "0.8.9" @@ -5436,7 +6534,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5531,14 +6629,27 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg 0.4.21", +] + [[package]] name = "time" -version = "0.3.46" +version = "0.3.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" dependencies = [ "deranged", - "itoa", "num-conv", "powerfmt", "serde_core", @@ -5548,15 +6659,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" dependencies = [ "num-conv", "time-core", @@ -5596,6 +6707,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.2", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.49.0" @@ -5624,6 +6768,16 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -5779,11 +6933,20 @@ checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", + "futures-core", "futures-util", "http", "http-body", + "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -5876,6 +7039,27 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn 2.0.114", +] + [[package]] name = "tray-icon" version = "0.21.3" @@ -5968,30 +7152,107 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64 0.22.1", + "flate2", + "log", + "native-tls", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "der", + "log", + "native-tls", + "percent-encoding", + "rustls-pki-types", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -6029,6 +7290,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6047,6 +7314,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6221,6 +7499,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -6319,6 +7610,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -6364,6 +7664,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "7.0.3" @@ -6398,7 +7704,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7185,6 +8491,25 @@ dependencies = [ "rustix", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yaml_serde" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c7c1b1a6a7c8a6b2741a6c21a4f8918e51899b111cfa08d1288202656e3975" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] + [[package]] name = "yansi" version = "1.0.1" @@ -7387,6 +8712,45 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", +] + [[package]] name = "zvariant" version = "5.9.2" diff --git a/Cargo.toml b/Cargo.toml index 059f29a4..62ae7886 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ futures = "0.3" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["preserve_order"] } +serde_yaml = { package = "yaml_serde", version = "0.10" } # Error handling anyhow = "1.0" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 38b5412d..a098add2 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "tauri": "tauri", "dev:web": "vite", + "dev:web:admin": "VITE_ADMIN_WEB=true vite", "build:web": "tsc && vite build", + "build:web:admin": "node ../../scripts/build-web-admin.mjs", "lint": "eslint src", "lint:fix": "eslint src --fix", "typecheck": "tsc --noEmit", @@ -22,11 +24,13 @@ "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2", "@tauri-apps/plugin-updater": "^2", + "i18next": "^26.3.1", "immer": "^11.0.1", "lucide-react": "^0.561.0", "posthog-js": "^1.387.0", "react": "^19.1.0", "react-dom": "^19.1.0", + "react-i18next": "^17.0.8", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 5da705be..945ae509 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -51,3 +51,9 @@ notify-debouncer-mini = "0.5" mcpmux-core.workspace = true mcpmux-gateway.workspace = true mcpmux-storage.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-contacts = { version = "0.3", features = ["CNContactStore", "block2"] } +block2 = "0.6" diff --git a/apps/desktop/src-tauri/Info.plist b/apps/desktop/src-tauri/Info.plist new file mode 100644 index 00000000..53b5cfb3 --- /dev/null +++ b/apps/desktop/src-tauri/Info.plist @@ -0,0 +1,14 @@ + + + + + NSContactsUsageDescription + McpMux relays MCP servers that may read macOS Contacts (e.g. resolving phone numbers and emails to display names in messaging tools). + NSCalendarsUsageDescription + McpMux relays MCP servers that may read macOS Calendar events. + NSRemindersUsageDescription + McpMux relays MCP servers that may read macOS Reminders. + NSAppleEventsUsageDescription + McpMux relays MCP servers that may automate other apps via AppleScript. + + diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs index f32d35f3..cb14abfe 100644 --- a/apps/desktop/src-tauri/build.rs +++ b/apps/desktop/src-tauri/build.rs @@ -1,9 +1,8 @@ use std::fs; use std::path::Path; +use std::process::Command; fn main() { - // Read tauri.conf.json to extract the app identifier - // This ensures a single source of truth for the identifier let config_path = Path::new("tauri.conf.json"); if let Ok(contents) = fs::read_to_string(config_path) { if let Ok(json) = serde_json::from_str::(&contents) { @@ -12,9 +11,68 @@ fn main() { } } } - - // Tell Cargo to re-run this script if tauri.conf.json changes println!("cargo:rerun-if-changed=tauri.conf.json"); + // Stamp git/build metadata into the binary so the admin UI can detect a stale + // SPA build (web-admin serves a pre-built bundle from `apps/desktop/dist`). + let git_sha = git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_default(); + let git_branch = + git_output(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_else(|| "unknown".to_string()); + let commit_time = + git_output(&["log", "-1", "--format=%ci"]).unwrap_or_else(|| "unknown".to_string()); + let build_time = std::env::var("SOURCE_DATE_EPOCH") + .ok() + .and_then(|s| s.parse::().ok()) + .map(format_epoch) + .unwrap_or_else(|| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| format_epoch(d.as_secs())) + .unwrap_or_else(|_| "unknown".to_string()) + }); + + println!("cargo:rustc-env=MCPMUX_BUILD_GIT_SHA={}", git_sha); + println!("cargo:rustc-env=MCPMUX_BUILD_GIT_BRANCH={}", git_branch); + println!("cargo:rustc-env=MCPMUX_BUILD_COMMIT_TIME={}", commit_time); + println!("cargo:rustc-env=MCPMUX_BUILD_TIME={}", build_time); + println!("cargo:rerun-if-changed=../../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../../.git/logs/HEAD"); + tauri_build::build() } + +fn git_output(args: &[&str]) -> Option { + Command::new("git") + .args(args) + .output() + .ok() + .filter(|out| out.status.success()) + .and_then(|out| String::from_utf8(out.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Format a Unix timestamp as a naive UTC datetime string (no external deps). +fn format_epoch(secs: u64) -> String { + let days = secs / 86400; + let rem = secs % 86400; + let hh = rem / 3600; + let mm = (rem % 3600) / 60; + let ss = rem % 60; + + let jdn = days + 2440588; + let a = jdn + 32044; + let b = (4 * a + 3) / 146097; + let c = a - (146097 * b) / 4; + let d = (4 * c + 3) / 1461; + let e = c - (1461 * d) / 4; + let m = (5 * e + 2) / 153; + let day = e - (153 * m + 2) / 5 + 1; + let month = m + 3 - 12 * (m / 10); + let year = 100 * b + d - 4800 + m / 10; + + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + year, month, day, hh, mm, ss + ) +} diff --git a/apps/desktop/src-tauri/src/commands/feature_set.rs b/apps/desktop/src-tauri/src/commands/feature_set.rs index 297b941d..806825e3 100644 --- a/apps/desktop/src-tauri/src/commands/feature_set.rs +++ b/apps/desktop/src-tauri/src/commands/feature_set.rs @@ -391,6 +391,7 @@ pub async fn add_feature_set_member( member_type, member_id: input.member_id, mode, + surfaced: false, }; feature_set.members.push(member); @@ -515,6 +516,7 @@ pub async fn set_feature_set_members( member_type, member_id: input.member_id, mode, + surfaced: false, } }) .collect(); diff --git a/apps/desktop/src-tauri/src/commands/gateway.rs b/apps/desktop/src-tauri/src/commands/gateway.rs index bd1adc74..11fa2efb 100644 --- a/apps/desktop/src-tauri/src/commands/gateway.rs +++ b/apps/desktop/src-tauri/src/commands/gateway.rs @@ -12,7 +12,7 @@ use mcpmux_gateway::{ }; use serde::Serialize; use std::sync::Arc; -use tauri::{AppHandle, Emitter, State}; +use tauri::{AppHandle, Emitter, Manager, State}; use tokio::sync::RwLock; use tracing::{error, info, trace, warn}; use uuid::Uuid; @@ -144,26 +144,6 @@ pub(crate) async fn attach_approval_publisher( approval_broker: &Arc, app_handle: tauri::AppHandle, ) { - // Restore the persisted "require approval" switch onto the broker (which is - // recreated on every gateway start). Default ON when unset. This is the - // single chokepoint both start paths (auto-start + start_gateway command) - // funnel through, so the setting always survives a restart. - { - use tauri::Manager; - let required = match app_handle.try_state::() { - Some(app_state) => app_state - .settings_repository - .get("meta_tools.require_approval") - .await - .ok() - .flatten() - .map(|v| v != "false") - .unwrap_or(true), - None => true, - }; - approval_broker.set_require_approval(required); - } - let publisher: mcpmux_gateway::services::meta_tools::ApprovalPublisher = Arc::new(move |req| { let app_handle = app_handle.clone(); Box::pin(async move { @@ -338,9 +318,21 @@ pub fn start_domain_event_bridge( "[Gateway] Forwarding domain event to UI" ); - if let Err(e) = app_handle_clone.emit(channel, payload) { - error!("[Gateway] Failed to emit {} event: {}", channel, e); - } + // Emit to Tauri webview and admin SSE subscribers. + let ui_event_bus = { + let admin_state: tauri::State< + '_, + Arc>, + > = app_handle_clone.state(); + let guard = admin_state.read().await; + guard.ui_event_bus.clone() + }; + crate::services::ui_events::emit_ui_channel( + &app_handle_clone, + Some(&ui_event_bus), + channel, + payload, + ); } info!("[Gateway] Domain event bridge stopped"); @@ -730,6 +722,36 @@ fn map_domain_event_to_ui(event: &DomainEvent) -> (&'static str, serde_json::Val "builtin-server-config-changed", serde_json::json!({ "space_id": space_id }), ), + DomainEvent::WorkspaceAppearanceChanged { workspace_root } => ( + "workspace-appearance-changed", + serde_json::json!({ "workspace_root": workspace_root }), + ), + + DomainEvent::ServerVersionChecked { + space_id, + server_id, + } => ( + "server-version-checked", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + }), + ), + + DomainEvent::ServerUpdateAvailable { + space_id, + server_id, + current_version, + latest_version, + } => ( + "server-update-available", + serde_json::json!({ + "space_id": space_id, + "server_id": server_id, + "current_version": current_version, + "latest_version": latest_version, + }), + ), } } @@ -988,6 +1010,24 @@ pub async fn start_gateway( warn!("[Gateway] Failed to emit gateway-changed(started): {}", e); } + // Sync admin server health endpoint and register SSE stream. + { + use crate::services::admin_server::{register_gateway_sse, set_gateway_running}; + let admin_state: tauri::State< + '_, + Arc>, + > = app_handle.state(); + let guard = admin_state.read().await; + set_gateway_running(&guard, true); + if let Some(gw_state) = state.gateway_state.clone() { + let admin_guard_clone = admin_state.clone(); + let gw_state_clone = gw_state; + drop(guard); + let guard2 = admin_guard_clone.read().await; + register_gateway_sse(&guard2, &gw_state_clone).await; + } + } + Ok(url) } @@ -1020,6 +1060,18 @@ pub async fn stop_gateway( warn!("[Gateway] Failed to emit gateway-changed(stopped): {}", e); } + // Sync admin server health endpoint and clear SSE stream. + { + use crate::services::admin_server::{clear_gateway_sse, set_gateway_running}; + let admin_state: tauri::State< + '_, + Arc>, + > = app_handle.state(); + let guard = admin_state.read().await; + set_gateway_running(&guard, false); + clear_gateway_sse(&guard).await; + } + Ok(()) } @@ -1339,6 +1391,7 @@ pub async fn connect_server( &server_definition.transport, &installed, Some(app_state.data_dir()), + mcpmux_gateway::pool::transport::resolution::TransportResolutionOptions::default(), ); // Connect using pool service (manual connect from API) @@ -1577,6 +1630,7 @@ pub async fn connect_all_enabled_servers( &server_definition.transport, &installed, Some(app_state.data_dir()), + mcpmux_gateway::pool::transport::resolution::TransportResolutionOptions::default(), ); servers_to_connect.push((server_info, transport, server_definition, installed)); @@ -1692,3 +1746,28 @@ pub struct PoolStatsResponse { pub connected_instances: usize, pub total_space_server_mappings: usize, } + +/// Reload (or stop-then-start) the web admin server based on current settings. +/// +/// Call this after the user toggles `gateway.admin_enabled`, changes the admin +/// port, or modifies Cloudflare Access settings so the admin server picks up the +/// new configuration without requiring a full app restart. +#[tauri::command] +pub async fn reload_admin_server( + app_handle: tauri::AppHandle, + gateway_state: State<'_, Arc>>, + server_manager_state: State<'_, Arc>>, +) -> Result<(), String> { + let admin_state: tauri::State<'_, Arc>> = + app_handle.state(); + let event_bus = mcpmux_core::create_shared_event_bus(); + crate::services::admin_server::reload_admin_server( + app_handle.clone(), + admin_state.inner().clone(), + gateway_state.inner().clone(), + server_manager_state.inner().clone(), + event_bus, + ) + .await; + Ok(()) +} diff --git a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs index 1ba5c278..ef740be3 100644 --- a/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs +++ b/apps/desktop/src-tauri/src/commands/meta_tool_approval.rs @@ -142,7 +142,6 @@ pub async fn get_meta_tools_require_approval( pub async fn set_meta_tools_require_approval( required: bool, app_state: State<'_, AppState>, - gateway_state: State<'_, Arc>>, ) -> Result { app_state .settings_repository @@ -150,13 +149,13 @@ pub async fn set_meta_tools_require_approval( .await .map_err(|e| e.to_string())?; - let broker = { - let state = gateway_state.read().await; - state.approval_broker.clone() - }; - if let Some(broker) = broker { - broker.set_require_approval(required); - } - warn!(required, "[meta-tool] require-approval switch updated"); + // ponytail: the split-module ApprovalBroker dropped the global bypass — + // its only write tool (`mcpmux_bind_current_workspace`) always prompts. + // The setting is still persisted for the Settings UI, but no longer toggles + // a live broker. Full removal of this toggle is deferred to the UI phase. + warn!( + required, + "[meta-tool] require-approval preference persisted" + ); Ok(required) } diff --git a/apps/desktop/src-tauri/src/commands/mod.rs b/apps/desktop/src-tauri/src/commands/mod.rs index d6df8c7b..e3488ffc 100644 --- a/apps/desktop/src-tauri/src/commands/mod.rs +++ b/apps/desktop/src-tauri/src/commands/mod.rs @@ -15,11 +15,13 @@ pub mod logs; pub mod meta_tool_approval; pub mod oauth; pub mod server; +pub mod server_clone; pub mod server_discovery; pub mod server_feature; pub mod server_manager; pub mod settings; pub mod space; +pub mod workspace_appearance; pub mod workspace_binding; // Re-export commands for convenience @@ -34,9 +36,11 @@ pub use logs::*; pub use meta_tool_approval::*; pub use oauth::*; pub use server::*; +pub use server_clone::*; pub use server_discovery::*; pub use server_feature::*; pub use server_manager::*; pub use settings::*; pub use space::*; +pub use workspace_appearance::*; pub use workspace_binding::*; diff --git a/apps/desktop/src-tauri/src/commands/server.rs b/apps/desktop/src-tauri/src/commands/server.rs index e2c8f44a..45f09031 100644 --- a/apps/desktop/src-tauri/src/commands/server.rs +++ b/apps/desktop/src-tauri/src/commands/server.rs @@ -128,6 +128,7 @@ pub async fn set_server_oauth_connected( } #[tauri::command] +#[allow(clippy::too_many_arguments)] pub async fn save_server_inputs( app_service: State<'_, Arc>>>, id: String, @@ -136,6 +137,8 @@ pub async fn save_server_inputs( env_overrides: Option>, args_append: Option>, extra_headers: Option>, + update_policy: Option, + pinned_version: Option, ) -> Result { let service_lock = app_service.read().await; let service = service_lock @@ -152,7 +155,33 @@ pub async fn save_server_inputs( env_overrides, args_append, extra_headers, + update_policy.map(|p| mcpmux_core::domain::UpdatePolicy::from_db_str(&p)), + pinned_version, ) .await .map_err(|e| e.to_string()) } + +/// Set (or clear) the display name override for an installed server. +/// +/// Empty/whitespace clears the override and the UI falls back to the cached +/// definition name. Does not change `server_id`, alias, or tool prefixes. +#[tauri::command] +pub async fn set_server_display_name( + app_service: State<'_, Arc>>>, + id: String, + space_id: String, + display_name: Option, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .set_display_name_override(space_uuid, &id, display_name) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/server_clone.rs b/apps/desktop/src-tauri/src/commands/server_clone.rs new file mode 100644 index 00000000..ac7b30ca --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/server_clone.rs @@ -0,0 +1,99 @@ +//! Server clone commands + +use mcpmux_core::application::ServerAppService; +use mcpmux_core::domain::InstalledServer; +use std::sync::Arc; +use tauri::State; +use tokio::sync::RwLock; + +/// Clone an installed server into a new suffixed manual-entry install in the same space. +/// +/// `display_name` (optional) is stored as `display_name_override` so the user-supplied +/// label survives later definition refreshes (e.g. user-config sync). When omitted, the +/// auto `"Source (suffix)"` label on the cached definition is used as fallback. +#[tauri::command] +pub async fn clone_server( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, + suffix: String, + alias: Option, + display_name: Option, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .clone_server( + space_uuid, + &source_server_id, + &suffix, + alias.as_deref(), + display_name.as_deref(), + ) + .await + .map_err(|e| e.to_string()) +} + +/// Return whether a suffixed clone ID is available in the given space. +#[tauri::command] +pub async fn is_clone_id_available( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, + suffix: String, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .is_clone_id_available(space_uuid, &source_server_id, &suffix) + .await + .map_err(|e| e.to_string()) +} + +/// Suggest the first available default suffix for cloning a server. +#[tauri::command] +pub async fn suggest_clone_suffix( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, +) -> Result { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + let space_uuid = uuid::Uuid::parse_str(&space_id).map_err(|e| e.to_string())?; + + service + .suggest_clone_suffix(space_uuid, &source_server_id) + .await + .map_err(|e| e.to_string()) +} + +/// List installed servers in a space that were cloned from the given source. +#[tauri::command] +pub async fn list_clone_dependents( + app_service: State<'_, Arc>>>, + space_id: String, + source_server_id: String, +) -> Result, String> { + let service_lock = app_service.read().await; + let service = service_lock + .as_ref() + .ok_or("ServerAppService not initialized")?; + + service + .list_clone_dependents(&space_id, &source_server_id) + .await + .map_err(|e| e.to_string()) +} diff --git a/apps/desktop/src-tauri/src/commands/server_manager.rs b/apps/desktop/src-tauri/src/commands/server_manager.rs index 91715514..1f206c71 100644 --- a/apps/desktop/src-tauri/src/commands/server_manager.rs +++ b/apps/desktop/src-tauri/src/commands/server_manager.rs @@ -7,7 +7,12 @@ //! - Connect/Reconnect button based on connection history use crate::AppState; -use mcpmux_gateway::pool::transport::resolution::build_transport_config; // Import from gateway +use chrono::Utc; +use mcpmux_core::{ApplicationServices, DomainEvent}; +use mcpmux_gateway::pool::transport::resolution::{ + build_transport_config, TransportResolutionOptions, +}; +use mcpmux_gateway::services::ServerVersionProbeService; use mcpmux_gateway::{ ConnectionContext, ConnectionResult, ConnectionStatus, ServerKey, ServerManager, }; @@ -72,6 +77,12 @@ pub async fn get_server_statuses( .collect()) } +/// Options for enable / reconnect server manager flows. +#[derive(Debug, Clone, Copy, Default)] +struct ConnectServerOptions { + apply_package_update: bool, +} + /// Enable a server and attempt connection /// /// This replaces the old `set_server_enabled(true)` + `connect_server()` pattern. @@ -89,6 +100,92 @@ pub async fn enable_server_v2( state: State<'_, Arc>>, gateway_state: State<'_, Arc>>, app_state: State<'_, AppState>, +) -> Result<(), String> { + connect_enabled_server( + space_id, + server_id, + ConnectServerOptions::default(), + state, + gateway_state, + app_state, + ) + .await +} + +/// Reconnect an enabled server and apply latest package resolution (notify/auto npx/uvx). +#[tauri::command] +pub async fn update_server_package( + space_id: String, + server_id: String, + state: State<'_, Arc>>, + gateway_state: State<'_, Arc>>, + app_state: State<'_, AppState>, + application_services: State<'_, Arc>, +) -> Result<(), String> { + let space_uuid = Uuid::parse_str(&space_id).map_err(|e| format!("Invalid space_id: {}", e))?; + + { + let manager_state = state.read().await; + if let Some(pool_service) = manager_state.pool_service.as_ref() { + pool_service.remove_instance(space_uuid, &server_id); + info!( + "[ServerManager] Removed existing instance for package update: {}", + server_id + ); + } + } + + let installed_repo = app_state.installed_server_repository.clone(); + let settings_repo = app_state.settings_repository.clone(); + let event_bus = application_services.event_bus.clone(); + + connect_enabled_server( + space_id.clone(), + server_id.clone(), + ConnectServerOptions { + apply_package_update: true, + }, + state, + gateway_state, + app_state, + ) + .await?; + + // Immediately record current_version = latest_available_version so the + // badge clears before the probe runs. The probe will confirm or refine + // this value from the npx cache / uv tool list. + if let Ok(Some(server)) = installed_repo.get_by_server_id(&space_id, &server_id).await { + if let Some(latest) = server.latest_available_version { + let _ = installed_repo + .update_version_cache(&server.id, Some(latest.clone()), Some(latest), Utc::now()) + .await; + } + } + + let probe = ServerVersionProbeService::new(installed_repo, settings_repo, event_bus.clone()); + if let Err(error) = probe.probe_server(&space_id, &server_id).await { + warn!( + "[ServerManager] Post-update version probe failed for {}: {}", + server_id, error + ); + } + + event_bus.sender().emit(DomainEvent::ServerVersionChecked { + space_id: space_uuid, + server_id: server_id.clone(), + }); + + Ok(()) +} + +/// Connect an enabled installed server with optional one-shot package update resolution. +async fn connect_enabled_server( + space_id: String, + server_id: String, + options: ConnectServerOptions, + state: State<'_, Arc>>, + gateway_state: State<'_, Arc>>, + app_state: State<'_, AppState>, ) -> Result<(), String> { let space_uuid = Uuid::parse_str(&space_id).map_err(|e| format!("Invalid space_id: {}", e))?; @@ -135,6 +232,9 @@ pub async fn enable_server_v2( &server_definition.transport, &installed, Some(app_state.data_dir()), + TransportResolutionOptions { + apply_package_update: options.apply_package_update, + }, ); // Attempt connection with auto_reconnect=true to avoid starting OAuth flow @@ -324,6 +424,7 @@ pub async fn start_auth_v2( &server_definition.transport, &installed, Some(app_state.data_dir()), + TransportResolutionOptions::default(), ); let ctx = ConnectionContext::new(space_uuid, server_id.clone(), transport); let result = pool_service.connect_server(&ctx).await; @@ -433,8 +534,16 @@ pub async fn retry_connection( } } - // Now enable_server_v2 will create a fresh connection with current config - enable_server_v2(space_id, server_id, state, gateway_state, app_state).await + // Fresh connection with current config (no one-shot package update). + connect_enabled_server( + space_id, + server_id, + ConnectServerOptions::default(), + state, + gateway_state, + app_state, + ) + .await } /// Logout server - Clear OAuth tokens but keep enabled diff --git a/apps/desktop/src-tauri/src/commands/settings.rs b/apps/desktop/src-tauri/src/commands/settings.rs index 8cc46c40..ac9f011a 100644 --- a/apps/desktop/src-tauri/src/commands/settings.rs +++ b/apps/desktop/src-tauri/src/commands/settings.rs @@ -5,7 +5,14 @@ use tauri::State; use tauri_plugin_autostart::AutoLaunchManager; use tracing::{debug, info}; +use crate::services::admin_server::reload_admin_server; use crate::state::AppState; +use crate::{commands::gateway::GatewayAppState, commands::server_manager::ServerManagerState}; +use mcpmux_core::{AppSettingsService, ApplicationServices, UpdatePolicy}; +use mcpmux_gateway::services::ServerVersionProbeService; +use serde_json::json; +use std::sync::Arc; +use tokio::sync::RwLock; /// Startup and system tray settings #[derive(Debug, Clone, Serialize, Deserialize)] @@ -122,101 +129,117 @@ pub async fn update_startup_settings( Ok(()) } -/// App-settings key for the "auto-install updates on launch" switch. -const AUTO_INSTALL_UPDATES_KEY: &str = "updates.auto_install"; +/// Default update policy applied to newly installed servers. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerUpdateSettings { + pub default_update_policy: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_checked_at: Option, +} -/// Whether the app downloads + installs updates automatically on launch -/// (then relaunches into the new version). Default **true** — a missing -/// setting means auto-install is on. +/// Get the app-wide default server update policy. #[tauri::command] -pub async fn get_auto_install_updates(app_state: State<'_, AppState>) -> Result { - let stored = app_state +pub async fn get_server_update_settings( + app_state: State<'_, AppState>, +) -> Result { + let policy = app_state .settings_repository - .get(AUTO_INSTALL_UPDATES_KEY) + .get("servers.default_update_policy") .await - .map_err(|e| e.to_string())?; - Ok(stored.map(|v| v != "false").unwrap_or(true)) + .ok() + .flatten() + .unwrap_or_else(|| UpdatePolicy::Notify.as_db_str().to_string()); + + Ok(ServerUpdateSettings { + default_update_policy: policy, + last_checked_at: app_state + .settings_repository + .get("servers.last_version_probe_at") + .await + .ok() + .flatten(), + }) } -/// Enable/disable automatic update installation on launch. Persisted. +/// Persist the app-wide default server update policy. #[tauri::command] -pub async fn set_auto_install_updates( - enabled: bool, +pub async fn update_server_update_settings( + settings: ServerUpdateSettings, app_state: State<'_, AppState>, -) -> Result { +) -> Result<(), String> { + let policy = UpdatePolicy::from_db_str(&settings.default_update_policy); app_state .settings_repository - .set(AUTO_INSTALL_UPDATES_KEY, &enabled.to_string()) + .set("servers.default_update_policy", policy.as_db_str()) .await - .map_err(|e| e.to_string())?; - info!("[Settings] Auto-install updates set to {}", enabled); - Ok(enabled) + .map_err(|e| format!("Failed to save default update policy: {}", e))?; + Ok(()) } -/// App-settings key for the update channel ("stable" | "prerelease"). -const UPDATE_CHANNEL_KEY: &str = "updates.channel"; -/// Default update channel when the setting is missing. -const UPDATE_CHANNEL_STABLE: &str = "stable"; -const UPDATE_CHANNEL_PRERELEASE: &str = "prerelease"; - -/// Normalize an arbitrary stored/incoming value to a known channel, defaulting -/// to "stable". Keeps the gateway between the frontend and the updater header -/// strict so a corrupt setting can never select an unknown channel. -fn normalize_channel(raw: &str) -> &'static str { - if raw.eq_ignore_ascii_case(UPDATE_CHANNEL_PRERELEASE) { - UPDATE_CHANNEL_PRERELEASE - } else { - UPDATE_CHANNEL_STABLE - } +/// Build a version probe service wired to the desktop app state and event bus. +fn build_version_probe( + app_state: &AppState, + application_services: &ApplicationServices, +) -> ServerVersionProbeService { + ServerVersionProbeService::new( + app_state.installed_server_repository.clone(), + app_state.settings_repository.clone(), + application_services.event_bus.clone(), + ) } -/// Which update channel the app follows. The frontend sends this as the -/// `X-Mcpmux-Channel` header on update checks so the resolver returns the -/// newest stable or pre-release manifest. Default **stable** — a missing -/// setting means the stable channel. +/// Probe all notify/auto package-managed servers for available updates. #[tauri::command] -pub async fn get_update_channel(app_state: State<'_, AppState>) -> Result { - let stored = app_state - .settings_repository - .get(UPDATE_CHANNEL_KEY) - .await - .map_err(|e| e.to_string())?; - Ok(stored - .map(|v| normalize_channel(&v).to_string()) - .unwrap_or_else(|| UPDATE_CHANNEL_STABLE.to_string())) +pub async fn check_all_server_updates( + app_state: State<'_, AppState>, + application_services: State<'_, Arc>, +) -> Result { + let probe = build_version_probe(&app_state, &application_services); + let summary = probe.probe_all().await.map_err(|e| e.to_string())?; + Ok(json!({ + "checked": summary.checked, + "updatesAvailable": summary.updates_available, + "checkedAt": summary.checked_at.to_rfc3339(), + })) } -/// Set the update channel ("stable" | "prerelease"). Unknown values are -/// coerced to "stable". Persisted; returns the normalized value actually saved. +/// Probe one installed server for package updates. #[tauri::command] -pub async fn set_update_channel( - channel: String, +pub async fn check_server_version( + space_id: String, + server_id: String, app_state: State<'_, AppState>, -) -> Result { - let normalized = normalize_channel(&channel); - app_state - .settings_repository - .set(UPDATE_CHANNEL_KEY, normalized) + application_services: State<'_, Arc>, +) -> Result { + let probe = build_version_probe(&app_state, &application_services); + let result = probe + .probe_server(&space_id, &server_id) .await .map_err(|e| e.to_string())?; - info!("[Settings] Update channel set to {}", normalized); - Ok(normalized.to_string()) + Ok(json!({ + "spaceId": result.space_id, + "serverId": result.server_id, + "currentVersion": result.current_version, + "latestVersion": result.latest_version, + "updateAvailable": result.update_available, + "checkedAt": result.checked_at.to_rfc3339(), + })) +} + +/// Check if app should start hidden (for auto-launch with --hidden flag) +pub fn should_start_hidden() -> bool { + let args: Vec = std::env::args().collect(); + args.contains(&"--hidden".to_string()) } -/// App-settings key for the "ask to map new folders" prompt switch. const WORKSPACE_MAPPING_PROMPT_KEY: &str = "workspaces.mapping_prompt_enabled"; -/// Interpret a stored value for the workspace mapping-prompt toggle. Missing or -/// any non-`"false"` value means **enabled** — the prompt is on by default, so -/// only an explicit opt-out turns it off. fn mapping_prompt_enabled_from(stored: Option<&str>) -> bool { - stored.map(|v| v != "false").unwrap_or(true) + stored.map(|s| s != "false").unwrap_or(true) } -/// Whether McpMux pops the "map this folder?" sheet when a connected client -/// opens a folder that has no explicit binding (it's on the default Starter -/// set). Default **true**. Users who find the prompt noisy can turn it off -/// here or via the link in the sheet itself. +/// Get whether the "map this folder?" prompt is enabled. Default true. #[tauri::command] pub async fn get_workspace_mapping_prompt_enabled( app_state: State<'_, AppState>, @@ -229,8 +252,7 @@ pub async fn get_workspace_mapping_prompt_enabled( Ok(mapping_prompt_enabled_from(stored.as_deref())) } -/// Enable/disable the "map this folder?" prompt. Persisted; returns the value -/// actually saved. +/// Enable/disable the "map this folder?" prompt. Persisted; returns the value actually saved. #[tauri::command] pub async fn set_workspace_mapping_prompt_enabled( enabled: bool, @@ -245,16 +267,119 @@ pub async fn set_workspace_mapping_prompt_enabled( Ok(enabled) } -/// Check if app should start hidden (for auto-launch with --hidden flag) -pub fn should_start_hidden() -> bool { - let args: Vec = std::env::args().collect(); - args.contains(&"--hidden".to_string()) +/// Get the current value of the meta-tools master switch. +/// +/// When disabled, the gateway hides the entire `mcpmux_*` namespace from +/// connected MCP clients — no introspection, no self-management. Default +/// ON. +#[tauri::command] +pub async fn get_meta_tools_enabled(app_state: State<'_, AppState>) -> Result { + match app_state + .settings_repository + .get("gateway.meta_tools_enabled") + .await + { + Ok(Some(v)) => Ok(!matches!(v.as_str(), "false" | "0")), + _ => Ok(true), + } +} + +/// Flip the meta-tools master switch. The change takes effect on the NEXT +/// `list_tools` / `call_tool` from any connected client — existing cached +/// tool lists are invalidated by the usual `tools/list_changed` push. +#[tauri::command] +pub async fn set_meta_tools_enabled( + enabled: bool, + app_state: State<'_, AppState>, +) -> Result<(), String> { + app_state + .settings_repository + .set( + "gateway.meta_tools_enabled", + if enabled { "true" } else { "false" }, + ) + .await + .map_err(|e| format!("Failed to save meta_tools_enabled: {}", e))?; + info!("[Settings] meta_tools_enabled = {}", enabled); + Ok(()) +} + +/// Web admin HTTP server settings (loopback-only remote UI). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminWebSettings { + pub enabled: bool, + pub port: u16, + pub trust_cf_access: bool, + pub cf_team_domain: String, } -// The meta-tools master switch moved out of global app-settings into per-Space -// built-in-server config — see `commands::builtin_servers` -// (`list_builtin_servers` / `set_builtin_server_enabled` / -// `set_builtin_tool_enabled`). +/// Load web admin settings from the app settings store. +#[tauri::command] +pub async fn get_admin_web_settings( + app_state: State<'_, AppState>, +) -> Result { + let settings = AppSettingsService::new(app_state.settings_repository.clone()); + Ok(AdminWebSettings { + enabled: settings.get_admin_enabled().await, + port: settings.get_admin_port().await, + trust_cf_access: settings.get_admin_trust_cf_access().await, + cf_team_domain: settings + .get_admin_cf_team_domain() + .await + .unwrap_or_default(), + }) +} + +/// Persist web admin settings and restart the admin server to apply. +#[tauri::command] +pub async fn update_admin_web_settings( + settings: AdminWebSettings, + app: tauri::AppHandle, + app_state: State<'_, AppState>, + admin_state: State<'_, Arc>>, + gateway_state: State<'_, Arc>>, + server_manager_state: State<'_, Arc>>, + application_services: State<'_, Arc>, +) -> Result<(), String> { + if settings.port < 1024 { + return Err("Admin port must be between 1024 and 65535".to_string()); + } + if settings.trust_cf_access && settings.cf_team_domain.trim().is_empty() { + return Err( + "Cloudflare team domain is required when Trust CF Access is enabled".to_string(), + ); + } + + let store = AppSettingsService::new(app_state.settings_repository.clone()); + store + .set_admin_enabled(settings.enabled) + .await + .map_err(|e| format!("Failed to save admin_enabled: {}", e))?; + store + .set_admin_port(settings.port) + .await + .map_err(|e| format!("Failed to save admin_port: {}", e))?; + store + .set_admin_trust_cf_access(settings.trust_cf_access) + .await + .map_err(|e| format!("Failed to save admin_trust_cf_access: {}", e))?; + store + .set_admin_cf_team_domain(Some(settings.cf_team_domain.trim())) + .await + .map_err(|e| format!("Failed to save admin_cf_team_domain: {}", e))?; + + reload_admin_server( + app, + admin_state.inner().clone(), + gateway_state.inner().clone(), + server_manager_state.inner().clone(), + application_services.event_bus.clone(), + ) + .await; + + Ok(()) +} #[cfg(test)] mod tests { @@ -350,40 +475,4 @@ mod tests { assert!(!settings.start_minimized); assert!(!settings.close_to_tray); } - - #[test] - fn test_normalize_channel_prerelease_variants() { - assert_eq!(normalize_channel("prerelease"), UPDATE_CHANNEL_PRERELEASE); - assert_eq!(normalize_channel("Prerelease"), UPDATE_CHANNEL_PRERELEASE); - assert_eq!(normalize_channel("PRERELEASE"), UPDATE_CHANNEL_PRERELEASE); - } - - #[test] - fn test_normalize_channel_defaults_to_stable() { - assert_eq!(normalize_channel("stable"), UPDATE_CHANNEL_STABLE); - assert_eq!(normalize_channel(""), UPDATE_CHANNEL_STABLE); - assert_eq!(normalize_channel("beta"), UPDATE_CHANNEL_STABLE); - assert_eq!(normalize_channel("garbage"), UPDATE_CHANNEL_STABLE); - } - - #[test] - fn test_normalize_channel_returns_canonical_static() { - // Always returns one of the two canonical lowercase tokens. - for input in ["StAbLe", "pre", "prerelease", "x"] { - let out = normalize_channel(input); - assert!(out == UPDATE_CHANNEL_STABLE || out == UPDATE_CHANNEL_PRERELEASE); - } - } - - #[test] - fn test_mapping_prompt_enabled_defaults_on() { - // Missing setting → on by default. - assert!(mapping_prompt_enabled_from(None)); - // Only an explicit "false" disables it. - assert!(!mapping_prompt_enabled_from(Some("false"))); - assert!(mapping_prompt_enabled_from(Some("true"))); - // Any unexpected value is treated as enabled (fail-open to the default). - assert!(mapping_prompt_enabled_from(Some(""))); - assert!(mapping_prompt_enabled_from(Some("garbage"))); - } } diff --git a/apps/desktop/src-tauri/src/commands/space.rs b/apps/desktop/src-tauri/src/commands/space.rs index a63a08d2..7a32f2fa 100644 --- a/apps/desktop/src-tauri/src/commands/space.rs +++ b/apps/desktop/src-tauri/src/commands/space.rs @@ -103,6 +103,45 @@ pub async fn create_space( Ok(space) } +/// Update a space's display metadata (name, icon, description). +#[tauri::command] +pub async fn update_space( + id: String, + name: Option, + icon: Option, + description: Option, + app: AppHandle, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let uuid = Uuid::parse_str(&id).map_err(|e| e.to_string())?; + + let space = state + .space_service + .update(uuid, name, icon, description) + .await + .map_err(|e| e.to_string())?; + + // Emit domain event if gateway is running + let gw_state = gateway_state.read().await; + if let Some(ref gw) = gw_state.gateway_state { + let gw = gw.read().await; + gw.emit_domain_event(mcpmux_core::DomainEvent::SpaceUpdated { + space_id: uuid, + name: space.name.clone(), + }); + } + + // Update system tray menu to reflect the rename + if let Err(e) = tray::update_tray_spaces(&app, &state).await { + warn!("Failed to update tray menu: {}", e); + } + + info!("[update_space] Space '{}' updated successfully", uuid); + + Ok(space) +} + /// Delete a space. #[tauri::command] pub async fn delete_space( diff --git a/apps/desktop/src-tauri/src/commands/workspace_appearance.rs b/apps/desktop/src-tauri/src/commands/workspace_appearance.rs new file mode 100644 index 00000000..dc2ef16b --- /dev/null +++ b/apps/desktop/src-tauri/src/commands/workspace_appearance.rs @@ -0,0 +1,259 @@ +//! Tauri commands for workspace appearance metadata and local icon files. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use image::GenericImageView; +use mcpmux_core::{validate_workspace_root as validate_root, DomainEvent, WorkspaceAppearance}; +use serde::{Deserialize, Serialize}; +use tauri::State; +use tokio::sync::RwLock; +use tracing::{debug, warn}; +use uuid::Uuid; + +use super::gateway::GatewayAppState; +use crate::state::AppState; + +const LOCAL_ICON_PREFIX: &str = "local:workspace-icons/"; +const WORKSPACE_ICON_DIR: &str = "workspace-icons"; +const MAX_UPLOAD_BYTES: u64 = 2 * 1024 * 1024; +const MAX_ICON_DIMENSION: u32 = 256; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceAppearanceDto { + pub workspace_root: String, + pub icon: String, + pub updated_at: String, +} + +impl From for WorkspaceAppearanceDto { + fn from(value: WorkspaceAppearance) -> Self { + Self { + workspace_root: value.workspace_root, + icon: value.icon, + updated_at: value.updated_at.to_rfc3339(), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct WorkspaceAppearanceInput { + pub workspace_root: String, + pub icon: String, +} + +fn normalize_and_validate(raw: &str) -> Result { + match validate_root(raw) { + mcpmux_core::WorkspaceRootValidation::Empty => Err("workspace_root cannot be empty".into()), + mcpmux_core::WorkspaceRootValidation::Ok { normalized } => Ok(normalized), + mcpmux_core::WorkspaceRootValidation::Invalid { reason } => Err(reason), + } +} + +fn normalize_icon(icon: &str) -> Option { + let trimmed = icon.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed.to_string()) +} + +pub(crate) fn local_ref_to_file_name(icon: &str) -> Option<&str> { + let file_name = icon.strip_prefix(LOCAL_ICON_PREFIX)?; + if file_name.contains('/') || file_name.contains('\\') { + return None; + } + if Path::new(file_name) + .extension() + .and_then(|ext| ext.to_str()) + != Some("png") + { + return None; + } + Some(file_name) +} + +fn icon_ref_to_path(data_dir: &Path, icon_ref: &str) -> Option { + let file_name = local_ref_to_file_name(icon_ref)?; + Some(data_dir.join(WORKSPACE_ICON_DIR).join(file_name)) +} + +pub(crate) async fn maybe_remove_orphaned_icon_file( + state: &AppState, + icon_ref: Option<&str>, +) -> Result<(), String> { + let Some(icon_ref) = icon_ref else { + return Ok(()); + }; + let Some(file_name) = local_ref_to_file_name(icon_ref) else { + return Ok(()); + }; + + let icon_ref_owned = icon_ref.to_string(); + let appearances = state + .workspace_appearance_repository + .list() + .await + .map_err(|e| e.to_string())?; + + // ponytail: WorkspaceBinding.icon is added in Phase 7; only check appearances for now. + if appearances.iter().any(|a| a.icon == icon_ref_owned) { + return Ok(()); + } + + let file_path = state.data_dir().join(WORKSPACE_ICON_DIR).join(file_name); + match tokio::fs::remove_file(&file_path).await { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(format!("failed to remove orphaned icon file: {err}")), + } + Ok(()) +} + +pub(crate) async fn emit_workspace_appearance_changed( + gateway_state: &Arc>, + workspace_root: String, +) { + let guard = gateway_state.read().await; + let Some(ref gateway_state) = guard.gateway_state else { + debug!("[workspace_appearance] gateway not running — skipping emit"); + return; + }; + gateway_state + .read() + .await + .emit_domain_event(DomainEvent::WorkspaceAppearanceChanged { workspace_root }); +} + +#[tauri::command] +pub async fn list_workspace_appearances( + state: State<'_, AppState>, +) -> Result, String> { + state + .workspace_appearance_repository + .list() + .await + .map(|items| items.into_iter().map(Into::into).collect()) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn upsert_workspace_appearance( + input: WorkspaceAppearanceInput, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result { + let workspace_root = normalize_and_validate(&input.workspace_root)?; + let icon = normalize_icon(&input.icon).ok_or_else(|| "icon cannot be empty".to_string())?; + let previous_icon = state + .workspace_appearance_repository + .get(&workspace_root) + .await + .map_err(|e| e.to_string())? + .map(|a| a.icon); + + let appearance = WorkspaceAppearance::new(workspace_root.clone(), icon); + state + .workspace_appearance_repository + .upsert(&appearance) + .await + .map_err(|e| e.to_string())?; + + if let Some(previous_icon) = previous_icon { + if previous_icon != appearance.icon { + maybe_remove_orphaned_icon_file(&state, Some(previous_icon.as_str())).await?; + } + } + + emit_workspace_appearance_changed(gateway_state.inner(), workspace_root).await; + Ok(appearance.into()) +} + +#[tauri::command] +pub async fn delete_workspace_appearance( + workspace_root: String, + state: State<'_, AppState>, + gateway_state: State<'_, Arc>>, +) -> Result<(), String> { + let normalized = normalize_and_validate(&workspace_root)?; + let previous = state + .workspace_appearance_repository + .get(&normalized) + .await + .map_err(|e| e.to_string())?; + + state + .workspace_appearance_repository + .delete(&normalized) + .await + .map_err(|e| e.to_string())?; + + if let Some(previous) = previous { + maybe_remove_orphaned_icon_file(&state, Some(previous.icon.as_str())).await?; + } + + emit_workspace_appearance_changed(gateway_state.inner(), normalized).await; + Ok(()) +} + +#[tauri::command] +pub async fn upload_workspace_icon( + source_path: String, + state: State<'_, AppState>, +) -> Result { + let source = PathBuf::from(source_path); + let metadata = tokio::fs::metadata(&source) + .await + .map_err(|e| format!("failed to inspect source file: {e}"))?; + if metadata.len() > MAX_UPLOAD_BYTES { + return Err("icon file must be 2MB or smaller".to_string()); + } + + let bytes = tokio::fs::read(&source) + .await + .map_err(|e| format!("failed to read icon file: {e}"))?; + let image = + image::load_from_memory(&bytes).map_err(|e| format!("failed to decode image file: {e}"))?; + + let (width, height) = image.dimensions(); + let normalized = if width > MAX_ICON_DIMENSION || height > MAX_ICON_DIMENSION { + image.resize( + MAX_ICON_DIMENSION, + MAX_ICON_DIMENSION, + image::imageops::FilterType::Lanczos3, + ) + } else { + image + }; + + let icon_dir = state.data_dir().join(WORKSPACE_ICON_DIR); + tokio::fs::create_dir_all(&icon_dir) + .await + .map_err(|e| format!("failed to create workspace icon directory: {e}"))?; + + let file_name = format!("{}.png", Uuid::new_v4()); + let target_path = icon_dir.join(&file_name); + normalized + .save_with_format(&target_path, image::ImageFormat::Png) + .map_err(|e| format!("failed to store icon file: {e}"))?; + + Ok(format!("{LOCAL_ICON_PREFIX}{file_name}")) +} + +#[tauri::command] +pub async fn resolve_workspace_icon_path( + icon_ref: String, + state: State<'_, AppState>, +) -> Result, String> { + let Some(path) = icon_ref_to_path(state.data_dir(), &icon_ref) else { + return Ok(None); + }; + match tokio::fs::metadata(&path).await { + Ok(_) => Ok(Some(path.to_string_lossy().to_string())), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + warn!(path = %path.display(), "[workspace_appearance] icon file missing"); + Ok(None) + } + Err(err) => Err(format!("failed to resolve icon path: {err}")), + } +} diff --git a/apps/desktop/src-tauri/src/commands/workspace_binding.rs b/apps/desktop/src-tauri/src/commands/workspace_binding.rs index f62cc07e..b8763a54 100644 --- a/apps/desktop/src-tauri/src/commands/workspace_binding.rs +++ b/apps/desktop/src-tauri/src/commands/workspace_binding.rs @@ -322,6 +322,8 @@ pub async fn update_workspace_binding( let updated = WorkspaceBinding { id: existing.id, workspace_root: normalized, + client_id: existing.client_id, + label: existing.label, space_id, feature_set_ids, created_at: existing.created_at, diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 6cc6b1a7..902002ba 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -9,6 +9,9 @@ use tokio::sync::RwLock; use tracing::{debug, error, info, warn}; mod commands; +mod macos_dock; +mod macos_permissions; +mod main_window; mod services; mod state; mod tray; @@ -128,6 +131,21 @@ fn get_version() -> String { env!("CARGO_PKG_VERSION").to_string() } +/// Return git/build metadata stamped into the binary by `build.rs`. +/// +/// The admin UI uses this to detect a stale SPA bundle — it compares the +/// SHA here against the one baked into the on-disk `web-admin` bundle, and +/// prompts the user to run `pnpm build:web:admin` after pulling new UI changes. +#[tauri::command] +fn get_build_info() -> serde_json::Value { + serde_json::json!({ + "git_sha": env!("MCPMUX_BUILD_GIT_SHA"), + "git_branch": env!("MCPMUX_BUILD_GIT_BRANCH"), + "commit_time": env!("MCPMUX_BUILD_COMMIT_TIME"), + "build_time": env!("MCPMUX_BUILD_TIME"), + }) +} + /// Get the on-disk bundle version (macOS only). /// /// After a Homebrew Cask upgrade, the `.app` bundle on disk has the new version @@ -245,19 +263,7 @@ pub fn run() { } } - if let Some(window) = app.get_webview_window("main") { - if let Err(e) = window.show() { - warn!("Failed to show window: {}", e); - } - if let Err(e) = window.unminimize() { - warn!("Failed to unminimize window: {}", e); - } - if let Err(e) = window.set_focus() { - warn!("Failed to focus window: {}", e); - } - } else { - warn!("Main window not found"); - } + main_window::show_main_window(app); })) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -269,6 +275,16 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .setup(|app| { + if commands::should_start_hidden() { + macos_dock::set_dock_visible(app.handle(), false); + } + + // Register McpMux with macOS TCC for resources that spawned MCP + // servers may need. Without this, the app never appears in + // Privacy & Security → Contacts and child reads of AddressBook + // hit a silent EPERM. Idempotent + non-blocking. + macos_permissions::ensure_contacts_registered(); + info!("Initializing application state..."); // Get data directory (Local, not Roaming - machine-specific data) @@ -555,8 +571,31 @@ pub fn run() { } }); - app.manage(gateway_state); - app.manage(server_manager_state); + app.manage(gateway_state.clone()); + app.manage(server_manager_state.clone()); + + // Create and manage admin server state, then start admin server + { + let admin_state = Arc::new(tokio::sync::RwLock::new( + services::admin_server::AdminServerState::new(), + )); + let app_handle_for_admin = app.handle().clone(); + let gw_state_for_admin = gateway_state.clone(); + let sm_state_for_admin = server_manager_state.clone(); + let event_bus_for_admin = event_bus.clone(); + let admin_state_clone = admin_state.clone(); + app.manage(admin_state); + tauri::async_runtime::spawn(async move { + services::admin_server::start_admin_server_if_enabled( + app_handle_for_admin, + admin_state_clone, + gw_state_for_admin, + sm_state_for_admin, + event_bus_for_admin, + ) + .await; + }); + } // Start file watcher for user space config files (hot-reload) { @@ -669,9 +708,7 @@ pub fn run() { Ok(Some(value)) if value == "true" => { // Close to tray - hide window instead of closing info!("[Window] Close requested, hiding to tray"); - if let Some(window) = app_handle_clone.get_webview_window("main") { - let _ = window.hide(); - } + main_window::hide_main_window_to_tray(&app_handle_clone); } Ok(Some(value)) if value == "false" => { // Actually close the app @@ -681,9 +718,7 @@ pub fn run() { _ => { // Default behavior: close to tray info!("[Window] Close requested (default), hiding to tray"); - if let Some(window) = app_handle_clone.get_webview_window("main") { - let _ = window.hide(); - } + main_window::hide_main_window_to_tray(&app_handle_clone); } } }); @@ -696,7 +731,7 @@ pub fn run() { // Check if app should start hidden (auto-launch with --hidden flag) if commands::should_start_hidden() { info!("[Window] Starting hidden (--hidden flag present)"); - let _ = main_window.hide(); + main_window::hide_main_window_to_tray(app.handle()); } } @@ -855,10 +890,12 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_version, get_bundle_version, + get_build_info, // Space commands commands::list_spaces, commands::get_space, commands::create_space, + commands::update_space, commands::delete_space, commands::list_space_base_dirs, commands::add_space_base_dir, @@ -883,6 +920,12 @@ pub fn run() { commands::set_server_enabled, commands::set_server_oauth_connected, commands::save_server_inputs, + commands::set_server_display_name, + commands::clone_server, + commands::is_clone_id_available, + commands::suggest_clone_suffix, + commands::list_clone_dependents, + commands::update_server_package, // FeatureSet commands commands::list_feature_sets, commands::list_feature_sets_by_space, @@ -921,6 +964,15 @@ pub fn run() { commands::delete_workspace_binding, commands::validate_workspace_root, commands::get_workspace_effective_features, + // Workspace appearance commands + commands::list_workspace_appearances, + commands::upsert_workspace_appearance, + commands::delete_workspace_appearance, + commands::upload_workspace_icon, + commands::resolve_workspace_icon_path, + // Meta-tools toggle + commands::get_meta_tools_enabled, + commands::set_meta_tools_enabled, // Meta-tool approval (self-management mcpmux_* tools) commands::respond_to_meta_tool_approval, commands::list_meta_tool_grants, @@ -950,6 +1002,7 @@ pub fn run() { commands::start_gateway, commands::stop_gateway, commands::restart_gateway, + commands::reload_admin_server, commands::generate_gateway_config, commands::connect_server, commands::disconnect_server, @@ -991,10 +1044,12 @@ pub fn run() { // Startup settings commands commands::get_startup_settings, commands::update_startup_settings, - commands::get_auto_install_updates, - commands::set_auto_install_updates, - commands::get_update_channel, - commands::set_update_channel, + commands::get_server_update_settings, + commands::update_server_update_settings, + commands::check_all_server_updates, + commands::check_server_version, + commands::get_admin_web_settings, + commands::update_admin_web_settings, commands::get_workspace_mapping_prompt_enabled, commands::set_workspace_mapping_prompt_enabled, ]) diff --git a/apps/desktop/src-tauri/src/macos_dock.rs b/apps/desktop/src-tauri/src/macos_dock.rs new file mode 100644 index 00000000..bdd48bd9 --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_dock.rs @@ -0,0 +1,26 @@ +//! macOS Dock visibility for tray-only mode. + +#[cfg(target_os = "macos")] +use tauri::{ActivationPolicy, AppHandle, Runtime}; +#[cfg(target_os = "macos")] +use tracing::warn; + +/// Show or hide the app in the macOS Dock (no-op on other platforms). +#[cfg(target_os = "macos")] +pub fn set_dock_visible(app: &AppHandle, visible: bool) { + let policy = if visible { + ActivationPolicy::Regular + } else { + ActivationPolicy::Accessory + }; + if let Err(e) = app.set_activation_policy(policy) { + warn!("[macOS] Failed to set activation policy (visible={visible}): {e}"); + } + if let Err(e) = app.set_dock_visibility(visible) { + warn!("[macOS] Failed to set dock visibility (visible={visible}): {e}"); + } +} + +/// Show or hide the app in the macOS Dock (no-op on other platforms). +#[cfg(not(target_os = "macos"))] +pub fn set_dock_visible(_app: &tauri::AppHandle, _visible: bool) {} diff --git a/apps/desktop/src-tauri/src/macos_permissions.rs b/apps/desktop/src-tauri/src/macos_permissions.rs new file mode 100644 index 00000000..ec739f7a --- /dev/null +++ b/apps/desktop/src-tauri/src/macos_permissions.rs @@ -0,0 +1,114 @@ +//! macOS TCC (Transparency, Consent, and Control) permission registration. +//! +//! McpMux spawns child MCP server processes that may read TCC-restricted +//! resources (Contacts, Calendar, Reminders, AppleEvents). macOS evaluates +//! TCC against the *responsible process* — for child processes spawned via +//! posix_spawn, that's the McpMux app bundle. So McpMux itself must: +//! +//! 1. Declare `NS*UsageDescription` keys in its Info.plist (done in +//! `apps/desktop/src-tauri/Info.plist`). +//! 2. Call into the corresponding framework once at runtime so macOS +//! registers the bundle in System Settings → Privacy & Security → +//! and the user can toggle access on. +//! +//! Without (2), the System Settings panel never lists McpMux at all (Apple +//! only shows apps that have actually requested access), and every child +//! MCP server hits a silent EPERM with no path to fix it. + +#[cfg(target_os = "macos")] +use tracing::{debug, info, warn}; + +/// Triggers the macOS Contacts permission prompt if undetermined. +/// +/// Idempotent — safe to call on every app launch. macOS only prompts the +/// first time; subsequent calls return the cached decision instantly. +/// +/// Runs the actual `requestAccess` call on a background thread because the +/// completion handler fires on an arbitrary queue and we don't want to +/// block the Tauri setup hook. +#[cfg(target_os = "macos")] +pub fn ensure_contacts_registered() { + use objc2_contacts::{CNAuthorizationStatus, CNContactStore, CNEntityType}; + + // SAFETY: `authorizationStatusForEntityType` is a pure read of the + // system TCC database — no side effects, no main-thread requirement. + let status = + unsafe { CNContactStore::authorizationStatusForEntityType(CNEntityType::Contacts) }; + + match status { + CNAuthorizationStatus::Authorized => { + debug!("[Permissions] Contacts: authorized"); + } + CNAuthorizationStatus::Denied => { + warn!( + "[Permissions] Contacts: denied. Child MCP servers reading AddressBook \ + will fail with EPERM until granted in System Settings → Privacy & Security \ + → Contacts → McpMux." + ); + } + CNAuthorizationStatus::Restricted => { + warn!("[Permissions] Contacts: restricted by system policy."); + } + CNAuthorizationStatus::NotDetermined => { + info!( + "[Permissions] Contacts: not determined — requesting access \ + to register McpMux in the system Privacy panel." + ); + request_contacts_access(); + } + // CNAuthorizationStatus is non-exhaustive — newer macOS versions may + // add cases (e.g. Limited). Treat unknown as a soft warning rather + // than re-prompting, since requestAccess is a no-op past first call. + other => { + debug!( + ?other, + "[Permissions] Contacts: unknown status, skipping prompt" + ); + } + } +} + +/// Calls `CNContactStore.requestAccess(for:.contacts, completionHandler:)`. +/// +/// macOS shows the system prompt on first call only; the completion handler +/// fires on an arbitrary queue and the result is cached in TCC.db. We log +/// the outcome but don't block on it — by the time the user clicks Allow/Deny +/// the app is already running. +#[cfg(target_os = "macos")] +fn request_contacts_access() { + use block2::RcBlock; + use objc2::rc::Retained; + use objc2::runtime::Bool; + use objc2_contacts::{CNContactStore, CNEntityType}; + use objc2_foundation::NSError; + + let store: Retained = unsafe { CNContactStore::new() }; + + // Block fires on an arbitrary GCD queue once the user dismisses the + // prompt (or immediately, if a decision is cached). + let handler = RcBlock::new(|granted: Bool, error: *mut NSError| { + if granted.as_bool() { + info!("[Permissions] Contacts: user granted access"); + } else if !error.is_null() { + // SAFETY: CN guarantees error is a valid NSError when non-null. + let err = unsafe { &*error }; + warn!( + "[Permissions] Contacts request failed: {}", + err.localizedDescription() + ); + } else { + warn!("[Permissions] Contacts: user denied access"); + } + }); + + // SAFETY: `requestAccessForEntityType_completionHandler` is the + // documented entry point. The block stays alive for the duration of + // the async request because RcBlock retains it. + unsafe { + store.requestAccessForEntityType_completionHandler(CNEntityType::Contacts, &handler); + } +} + +/// No-op on non-macOS platforms. +#[cfg(not(target_os = "macos"))] +pub fn ensure_contacts_registered() {} diff --git a/apps/desktop/src-tauri/src/main_window.rs b/apps/desktop/src-tauri/src/main_window.rs new file mode 100644 index 00000000..b75dbe31 --- /dev/null +++ b/apps/desktop/src-tauri/src/main_window.rs @@ -0,0 +1,23 @@ +//! Main window show/hide helpers shared by tray, deep links, and gateway focus. + +use tauri::{AppHandle, Manager, Runtime}; + +use crate::macos_dock; + +/// Unminimize, show, and focus the main window; restore Dock presence on macOS. +pub fn show_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + } + macos_dock::set_dock_visible(app, true); +} + +/// Hide the main window to the tray and remove Dock presence on macOS. +pub fn hide_main_window_to_tray(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + let _ = window.hide(); + } + macos_dock::set_dock_visible(app, false); +} diff --git a/apps/desktop/src-tauri/src/services/admin_server.rs b/apps/desktop/src-tauri/src/services/admin_server.rs new file mode 100644 index 00000000..4823d0ee --- /dev/null +++ b/apps/desktop/src-tauri/src/services/admin_server.rs @@ -0,0 +1,619 @@ +//! Web admin HTTP server startup (loopback :45819 by default). + +use super::admin_write_runtime::DesktopGatewayWriteRuntime; +use crate::state::AppState; +use crate::{ + commands::{gateway::GatewayAppState, server_manager::ServerManagerState}, + get_bundle_version, +}; +use async_trait::async_trait; +#[cfg(debug_assertions)] +use mcpmux_core::service::app_settings_service::keys; +use mcpmux_core::service::is_port_available; +use mcpmux_core::{AppSettingsService, ApplicationServices, EventBus}; +use mcpmux_gateway::admin::event_hub::AdminEventHub; +use mcpmux_gateway::admin::runtime::GatewayRuntime; +use mcpmux_gateway::admin::ui_events::AdminUiEventBus; +use mcpmux_gateway::admin::{AdminBridgeCtx, BackendBuildStamp}; +use mcpmux_gateway::pool::ConnectionStatus as GatewayConnectionStatus; +use mcpmux_gateway::{AdminConfig, AdminServer, AdminServerHandle}; +use serde::Deserialize; +use serde_json::json; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::RwLock; +use tracing::{info, warn}; + +/// Tracks the running admin server and shared gateway liveness flag. +pub struct AdminServerState { + /// Background task handle for graceful shutdown. + pub handle: Option, + /// Updated when the MCP gateway starts or stops (admin `/api/v1/health`). + pub gateway_running: Arc, + /// Direct UI events from Tauri `app.emit` paths (oauth, session overrides). + pub ui_event_bus: Arc, + /// Merged SSE fan-in hub (EventBus + gateway domain + direct emits). + pub event_hub: Arc, +} + +impl AdminServerState { + /// Create admin server state with shared event buses for SSE fan-in. + pub fn new() -> Self { + let ui_event_bus = Arc::new(AdminUiEventBus::new()); + let event_hub = Arc::new(AdminEventHub::new(ui_event_bus.clone())); + Self { + handle: None, + gateway_running: Arc::new(AtomicBool::new(false)), + ui_event_bus, + event_hub, + } + } +} + +impl Default for AdminServerState { + fn default() -> Self { + Self::new() + } +} + +/// Whether the admin HTTP server should start (settings + dev overrides). +async fn resolve_admin_enabled_for_startup(app_state: &AppState) -> bool { + if std::env::var("MCPMUX_DEV_DISABLE_ADMIN").as_deref() == Ok("1") { + info!("[Admin] Skipped (MCPMUX_DEV_DISABLE_ADMIN=1)"); + return false; + } + + let settings = AppSettingsService::new(app_state.settings_repository.clone()); + + #[cfg(debug_assertions)] + { + if std::env::var("MCPMUX_DEV_ADMIN").as_deref() == Ok("1") { + info!("[Admin] Enabled for this dev session (MCPMUX_DEV_ADMIN=1)"); + return true; + } + + match mcpmux_core::AppSettingsRepository::get( + app_state.settings_repository.as_ref(), + keys::gateway::ADMIN_ENABLED, + ) + .await + { + Ok(None) => { + if let Err(e) = settings.set_admin_enabled(true).await { + warn!("[Admin] Failed to persist dev default admin_enabled: {}", e); + } else { + info!("[Admin] Dev default: enabled web admin (setting was unset)"); + } + return true; + } + Ok(Some(_)) => {} + Err(e) => warn!("[Admin] Could not read admin_enabled: {}", e), + } + } + + settings.get_admin_enabled().await +} + +/// Resolve the built frontend directory for static SPA serving. +pub fn resolve_frontend_dist(app: &AppHandle) -> PathBuf { + if let Ok(resource) = app.path().resource_dir() { + let dist = resource.join("dist"); + if dist.join("index.html").is_file() { + return dist; + } + } + + let dev_dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../dist"); + if dev_dist.join("index.html").is_file() { + return dev_dist; + } + + dev_dist +} + +/// Build `ApplicationServices` for admin handlers from desktop `AppState`. +fn build_application_services( + app_state: &AppState, + event_bus: Arc, +) -> anyhow::Result> { + Ok(Arc::new(app_state.build_application_services(event_bus)?)) +} + +struct DesktopGatewayRuntime { + gateway_port_service: Arc, + gateway_state: Arc>, + server_manager_state: Arc>, +} + +impl DesktopGatewayRuntime { + fn new( + gateway_port_service: Arc, + gateway_state: Arc>, + server_manager_state: Arc>, + ) -> Self { + Self { + gateway_port_service, + gateway_state, + server_manager_state, + } + } +} + +#[async_trait] +impl GatewayRuntime for DesktopGatewayRuntime { + async fn get_gateway_status( + &self, + space_id: Option, + ) -> anyhow::Result { + let state = self.gateway_state.read().await; + let active_sessions = if let Some(ref gateway_state) = state.gateway_state { + gateway_state.read().await.sessions.len() + } else { + 0 + }; + + let connected_backends = { + let manager_state = self.server_manager_state.read().await; + if let Some(ref manager) = manager_state.manager { + if let Some(space_id) = space_id { + let space_id = uuid::Uuid::parse_str(&space_id)?; + manager.connected_count_for_space(&space_id).await + } else { + manager.connected_count().await + } + } else { + 0 + } + }; + + Ok(json!({ + "running": state.running, + "url": state.url, + "active_sessions": active_sessions, + "connected_backends": connected_backends, + })) + } + + async fn probe_gateway_start(&self, port: Option) -> anyhow::Result { + let (preferred_port, source) = if let Some(port) = port { + (port, "override") + } else if let Some(port) = self.gateway_port_service.load_persisted_port().await { + (port, "configured") + } else { + (mcpmux_core::DEFAULT_GATEWAY_PORT, "default") + }; + Ok(json!({ + "preferredPort": preferred_port, + "preferredAvailable": is_port_available(preferred_port), + "source": source, + })) + } + + async fn take_pending_port_conflict(&self) -> anyhow::Result { + let mut state = self.gateway_state.write().await; + Ok(state + .pending_port_conflict + .take() + .map(|conflict| { + json!({ + "preferredPort": conflict.preferred_port, + "source": conflict.source, + }) + }) + .unwrap_or(serde_json::Value::Null)) + } + + async fn get_gateway_port_settings(&self) -> anyhow::Result { + let configured_port = self.gateway_port_service.load_persisted_port().await; + let active_port = { + let state = self.gateway_state.read().await; + state + .url + .as_deref() + .and_then(|url| url.split("://").nth(1)) + .and_then(|host_port| host_port.split('/').next()) + .and_then(|host_port| host_port.rsplit(':').next()) + .and_then(|port| port.parse::().ok()) + }; + Ok(json!({ + "configuredPort": configured_port, + "defaultPort": mcpmux_core::DEFAULT_GATEWAY_PORT, + "activePort": active_port, + })) + } + + async fn reset_gateway_port(&self) -> anyhow::Result { + self.gateway_port_service.clear_persisted_port().await?; + Ok(json!({ "ok": true })) + } + + async fn list_connected_servers(&self) -> anyhow::Result { + Ok(json!([])) + } + + async fn get_pool_stats(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + let stats = match &state.pool_service { + Some(pool) => pool.stats(), + None => mcpmux_gateway::PoolStats::default(), + }; + Ok(json!({ + "total_instances": stats.total_instances, + "connected_instances": stats.connected_instances, + "total_space_server_mappings": stats.connecting_instances + stats.failed_instances + stats.oauth_pending_instances, + })) + } + + async fn list_reported_workspace_roots(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + Ok(json!(state + .session_roots + .as_ref() + .map(|registry| registry.list_all_roots()) + .unwrap_or_default())) + } + + async fn list_meta_tool_grants(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + let Some(ref broker) = state.approval_broker else { + return Ok(json!([])); + }; + Ok(json!(broker + .list_always_allow() + .into_iter() + .map(|(client_id, tool_name)| json!({ + "client_id": client_id, + "tool_name": tool_name, + })) + .collect::>())) + } + + async fn get_oauth_clients(&self) -> anyhow::Result { + let state = self.gateway_state.read().await; + let Some(ref gateway_state) = state.gateway_state else { + return Err(anyhow::anyhow!("Gateway not running")); + }; + let gateway_state = gateway_state.read().await; + let Some(repository) = gateway_state.inbound_client_repository() else { + return Err(anyhow::anyhow!("Database not available")); + }; + let clients = repository.list_clients().await?; + let approved = clients + .into_iter() + .filter(|client| client.approved) + .map(|client| { + json!({ + "client_id": client.client_id, + "registration_type": client.registration_type.as_str(), + "client_name": client.client_name, + "client_alias": client.client_alias, + "redirect_uris": client.redirect_uris, + "scope": client.scope, + "approved": client.approved, + "logo_uri": client.logo_uri, + "client_uri": client.client_uri, + "software_id": client.software_id, + "software_version": client.software_version, + "metadata_url": client.metadata_url, + "metadata_cached_at": client.metadata_cached_at, + "metadata_cache_ttl": client.metadata_cache_ttl, + "last_seen": client.last_seen, + "created_at": client.created_at, + "reports_roots": client.reports_roots, + "roots_capability_known": client.roots_capability_known, + }) + }) + .collect::>(); + Ok(json!(approved)) + } + + async fn get_oauth_client_grants( + &self, + client_id: String, + space_id: String, + ) -> anyhow::Result { + let state = self.gateway_state.read().await; + let Some(ref grant_service) = state.grant_service else { + return Err(anyhow::anyhow!("Gateway not running")); + }; + Ok(json!( + grant_service + .get_grants_for_space(&client_id, &space_id) + .await? + )) + } + + async fn get_server_statuses(&self, space_id: String) -> anyhow::Result { + let space_uuid = uuid::Uuid::parse_str(&space_id) + .map_err(|e| anyhow::anyhow!("Invalid space_id: {e}"))?; + + let manager_state = self.server_manager_state.read().await; + let Some(ref manager) = manager_state.manager else { + return Err(anyhow::anyhow!("ServerManager not initialized")); + }; + + let statuses = manager.get_all_statuses(space_uuid).await; + let mut result = serde_json::Map::new(); + for (server_id, (status, flow_id, has_connected_before, message)) in statuses { + result.insert( + server_id.clone(), + json!({ + "server_id": server_id, + "status": gateway_status_to_ui(status), + "flow_id": flow_id, + "has_connected_before": has_connected_before, + "message": message, + }), + ); + } + Ok(json!(result)) + } +} + +/// Map gateway pool status to the UI-facing string (`oauth_required`, not `auth_required`). +fn gateway_status_to_ui(status: GatewayConnectionStatus) -> &'static str { + match status { + GatewayConnectionStatus::Disconnected => "disconnected", + GatewayConnectionStatus::Connecting => "connecting", + GatewayConnectionStatus::Connected => "connected", + GatewayConnectionStatus::Refreshing => "refreshing", + GatewayConnectionStatus::AuthRequired => "oauth_required", + GatewayConnectionStatus::Authenticating => "authenticating", + GatewayConnectionStatus::Error => "error", + } +} + +/// Stop the web admin server if it is running. +pub async fn stop_admin_server(admin_state: &Arc>) { + let handle = { + let mut guard = admin_state.write().await; + guard.handle.take() + }; + if let Some(handle) = handle { + handle.shutdown(); + if let Err(e) = handle.task.await { + warn!("[Admin] Admin server task join error: {:?}", e); + } + info!("[Admin] Stopped"); + } +} + +/// Apply current settings: stop any running admin server, then start if enabled. +pub async fn reload_admin_server( + app: AppHandle, + admin_state: Arc>, + gateway_state: Arc>, + server_manager_state: Arc>, + event_bus: Arc, +) { + stop_admin_server(&admin_state).await; + start_admin_server_if_enabled( + app, + admin_state, + gateway_state, + server_manager_state, + event_bus, + ) + .await; +} + +/// Start the admin server when `gateway.admin_enabled` is true. +pub async fn start_admin_server_if_enabled( + app: AppHandle, + admin_state: Arc>, + gateway_state: Arc>, + server_manager_state: Arc>, + event_bus: Arc, +) { + let app_state: tauri::State<'_, AppState> = app.state(); + let settings = AppSettingsService::new(app_state.settings_repository.clone()); + if !resolve_admin_enabled_for_startup(app_state.inner()).await { + info!("[Admin] Web admin disabled (gateway.admin_enabled=false)"); + return; + } + + let port = settings.get_admin_port().await; + let trust_cf_access = settings.get_admin_trust_cf_access().await; + let cf_team_domain = settings.get_admin_cf_team_domain().await; + + let config = AdminConfig { + host: "127.0.0.1".to_string(), + port, + trust_cf_access, + cf_team_domain, + cf_access_audience: None, + cf_validator_override: None, + }; + + let gateway_running = { + let guard = admin_state.read().await; + guard.gateway_running.clone() + }; + let event_hub = { + let guard = admin_state.read().await; + guard.event_hub.clone() + }; + + let services = match build_application_services(&app_state, event_bus) { + Ok(s) => s, + Err(e) => { + warn!("[Admin] Failed to build ApplicationServices: {}", e); + return; + } + }; + + let cf_validator = match AdminServer::build_cf_validator(&config).await { + Ok(v) => v, + Err(e) => { + warn!("[Admin] CF Access validator init failed: {}", e); + return; + } + }; + + let frontend_dist = resolve_frontend_dist(&app); + let dist_ready = frontend_dist.join("index.html").is_file(); + let auto_launch_enabled = app + .try_state::() + .and_then(|manager| manager.is_enabled().ok()); + let gateway_runtime = Arc::new(DesktopGatewayRuntime::new( + app_state.gateway_port_service.clone(), + gateway_state.clone(), + server_manager_state.clone(), + )); + let gateway_writes = Arc::new(DesktopGatewayWriteRuntime::new( + app.clone(), + gateway_state.clone(), + )); + let bridge = Arc::new(AdminBridgeCtx { + services: services.clone(), + spaces_dir: app_state.spaces_dir().to_path_buf(), + data_dir: app_state.data_dir().to_path_buf(), + gateway_port_service: app_state.gateway_port_service.clone(), + server_discovery: app_state.server_discovery.clone(), + settings_repository: app_state.settings_repository.clone(), + workspace_binding_repository: app_state.workspace_binding_repository.clone(), + workspace_appearance_repository: app_state.workspace_appearance_repository.clone(), + server_feature_repository: app_state.server_feature_repository_core.clone(), + server_log_manager: app_state.server_log_manager.clone(), + space_service: Arc::new(mcpmux_core::SpaceService::new( + app_state.space_service.space_repository(), + )), + gateway_runtime, + gateway_writes, + feature_set_repository: app_state.feature_set_repository.clone(), + auto_launch_enabled, + app_version: env!("CARGO_PKG_VERSION").to_string(), + bundle_version: get_bundle_version(), + backend_build: BackendBuildStamp { + git_sha: option_env!("MCPMUX_BUILD_GIT_SHA") + .unwrap_or("dev") + .to_string(), + git_branch: option_env!("MCPMUX_BUILD_GIT_BRANCH") + .unwrap_or("dev") + .to_string(), + commit_time: option_env!("MCPMUX_BUILD_COMMIT_TIME") + .unwrap_or("") + .to_string(), + build_time: option_env!("MCPMUX_BUILD_TIME").unwrap_or("").to_string(), + }, + }); + let backend_git_sha = option_env!("MCPMUX_BUILD_GIT_SHA") + .unwrap_or("dev") + .to_string(); + let frontend_dist_log = frontend_dist.clone(); + let server = match AdminServer::new( + config.clone(), + services, + bridge, + event_hub, + gateway_running, + frontend_dist, + cf_validator, + ) + .await + { + Ok(s) => s, + Err(e) => { + warn!("[Admin] Failed to build admin server: {}", e); + return; + } + }; + + let handle = server.spawn(); + info!( + "[Admin] Started on http://{}:{} (cf_access={}, static_spa={})", + config.host, config.port, config.trust_cf_access, dist_ready + ); + info!( + "[Admin] Backend | sha: {} | branch: {} | committed: {} | built: {}", + backend_git_sha, + option_env!("MCPMUX_BUILD_GIT_BRANCH").unwrap_or("dev"), + option_env!("MCPMUX_BUILD_COMMIT_TIME").unwrap_or(""), + option_env!("MCPMUX_BUILD_TIME").unwrap_or(""), + ); + if dist_ready { + log_spa_build_stamp(&frontend_dist_log, &backend_git_sha); + } + info!( + "[Admin] Dev HMR UI: http://127.0.0.1:1420 (Vite proxies /api → :{}) — run pnpm dev:web:admin or pnpm dev:admin", + config.port + ); + if !dist_ready { + info!( + "[Admin] Production-parity UI: run `pnpm build:web:admin` then open http://127.0.0.1:{}/", + config.port + ); + } + + let mut guard = admin_state.write().await; + guard.handle = Some(handle); +} + +/// SPA build metadata written by `pnpm build:web:admin` into `dist/build-stamp.json`. +#[derive(Debug, Deserialize)] +struct SpaBuildStamp { + git_sha: String, + git_branch: String, + commit_time: String, + #[serde(default)] + commit_at: String, + build_time: String, + #[serde(default)] + build_at: String, +} + +/// Log SPA bundle stamp and warn when it diverges from the running backend binary. +fn log_spa_build_stamp(dist: &Path, backend_sha: &str) { + let path = dist.join("build-stamp.json"); + let Ok(contents) = fs::read_to_string(&path) else { + return; + }; + let Ok(stamp) = serde_json::from_str::(&contents) else { + warn!("[Admin] SPA bundle build-stamp.json is invalid or unreadable"); + return; + }; + let committed = if stamp.commit_at.is_empty() { + stamp.commit_time.as_str() + } else { + stamp.commit_at.as_str() + }; + let built = if stamp.build_at.is_empty() { + stamp.build_time.as_str() + } else { + stamp.build_at.as_str() + }; + info!( + "[Admin] SPA bundle | sha: {} | branch: {} | committed: {} | built: {}", + stamp.git_sha, stamp.git_branch, committed, built + ); + if !backend_sha.is_empty() && stamp.git_sha != backend_sha { + warn!( + "[Admin] SPA bundle sha {} != backend sha {} — run `pnpm build:web:admin`", + stamp.git_sha, backend_sha + ); + } +} + +/// Sync gateway liveness into the admin health endpoint. +pub fn set_gateway_running(admin_state: &AdminServerState, running: bool) { + admin_state + .gateway_running + .store(running, Ordering::Relaxed); +} + +/// Register gateway domain events with the admin SSE hub. +pub async fn register_gateway_sse( + admin_state: &AdminServerState, + gateway_state: &Arc>, +) { + let tx = gateway_state.read().await.domain_event_sender(); + admin_state.event_hub.register_gateway_events(tx).await; +} + +/// Clear gateway domain event fan-in when the MCP gateway stops. +pub async fn clear_gateway_sse(admin_state: &AdminServerState) { + admin_state.event_hub.clear_gateway_events().await; +} diff --git a/apps/desktop/src-tauri/src/services/admin_write_runtime.rs b/apps/desktop/src-tauri/src/services/admin_write_runtime.rs new file mode 100644 index 00000000..4fd205bd --- /dev/null +++ b/apps/desktop/src-tauri/src/services/admin_write_runtime.rs @@ -0,0 +1,310 @@ +//! Desktop implementation of admin gateway write runtime — delegates to Tauri commands. + +use async_trait::async_trait; +use mcpmux_gateway::admin::write_runtime::GatewayWriteRuntime; +use serde_json::{json, Value}; +use std::sync::Arc; +use tauri::{AppHandle, Manager}; +use tokio::sync::RwLock; + +use crate::commands::gateway::{ + connect_all_enabled_servers, disconnect_server, refresh_oauth_tokens_on_startup, + restart_gateway, set_gateway_port, start_gateway, stop_gateway, GatewayAppState, +}; +use crate::commands::meta_tool_approval::{respond_to_meta_tool_approval, revoke_meta_tool_grant}; +use crate::commands::oauth::{ + delete_oauth_client, grant_oauth_client_feature_set, revoke_oauth_client_feature_set, + update_oauth_client, UpdateClientSettingsRequest, +}; +use crate::commands::server_manager::{ + cancel_auth_v2, disable_server_v2, enable_server_v2, logout_server, retry_connection, + start_auth_v2, +}; + +/// Delegates admin write operations to existing Tauri command handlers. +pub struct DesktopGatewayWriteRuntime { + app_handle: AppHandle, + app_gateway_state: Arc>, +} + +impl DesktopGatewayWriteRuntime { + /// Create a write runtime bound to the running Tauri app handle. + pub fn new(app_handle: AppHandle, app_gateway_state: Arc>) -> Self { + Self { + app_handle, + app_gateway_state, + } + } +} + +#[async_trait] +impl GatewayWriteRuntime for DesktopGatewayWriteRuntime { + async fn start_gateway( + &self, + port: Option, + allow_dynamic_fallback: Option, + ) -> anyhow::Result { + let url = start_gateway( + port, + allow_dynamic_fallback, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "url": url })) + } + + async fn stop_gateway(&self) -> anyhow::Result { + stop_gateway( + self.app_handle + .state::>>(), + self.app_handle.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn restart_gateway( + &self, + port: Option, + allow_dynamic_fallback: Option, + ) -> anyhow::Result { + let url = restart_gateway( + port, + allow_dynamic_fallback, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.clone(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "url": url })) + } + + async fn disconnect_server( + &self, + server_id: String, + space_id: String, + logout: Option, + ) -> anyhow::Result { + disconnect_server( + server_id, + space_id, + logout, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn connect_all_enabled_servers(&self) -> anyhow::Result { + let result = connect_all_enabled_servers(self.app_handle.state(), self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!(result)) + } + + async fn refresh_oauth_tokens_on_startup(&self) -> anyhow::Result { + let result = refresh_oauth_tokens_on_startup(self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!(result)) + } + + async fn set_gateway_port(&self, port: u16) -> anyhow::Result { + set_gateway_port(port, self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn enable_server_v2(&self, space_id: String, server_id: String) -> anyhow::Result { + enable_server_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn disable_server_v2( + &self, + space_id: String, + server_id: String, + ) -> anyhow::Result { + disable_server_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn start_auth_v2(&self, space_id: String, server_id: String) -> anyhow::Result { + start_auth_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn cancel_auth_v2(&self, space_id: String, server_id: String) -> anyhow::Result { + cancel_auth_v2( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn retry_connection(&self, space_id: String, server_id: String) -> anyhow::Result { + retry_connection( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn update_server_package( + &self, + _space_id: String, + _server_id: String, + ) -> anyhow::Result { + // ponytail: Tauri update_server_package command lands in Phase 5 + Err(anyhow::anyhow!("Server package update not yet available")) + } + + async fn logout_server(&self, space_id: String, server_id: String) -> anyhow::Result { + logout_server( + space_id, + server_id, + self.app_handle.state(), + self.app_handle.state(), + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn respond_to_meta_tool_approval( + &self, + request_id: String, + client_id: String, + tool_name: String, + decision: String, + ) -> anyhow::Result { + let approved = respond_to_meta_tool_approval( + request_id, + client_id, + tool_name, + decision, + self.app_handle.state(), + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "approved": approved })) + } + + async fn revoke_meta_tool_grant( + &self, + client_id: String, + tool_name: String, + ) -> anyhow::Result { + let revoked = revoke_meta_tool_grant(client_id, tool_name, self.app_handle.state()) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "revoked": revoked })) + } + + async fn update_oauth_client( + &self, + client_id: String, + client_alias: Option, + ) -> anyhow::Result { + let client = update_oauth_client( + self.app_handle.state(), + client_id, + UpdateClientSettingsRequest { client_alias }, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!(client)) + } + + async fn delete_oauth_client(&self, client_id: String) -> anyhow::Result { + delete_oauth_client(self.app_handle.state(), client_id) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn grant_oauth_client_feature_set( + &self, + client_id: String, + space_id: String, + feature_set_id: String, + ) -> anyhow::Result { + grant_oauth_client_feature_set( + self.app_handle.clone(), + self.app_handle.state(), + client_id, + space_id, + feature_set_id, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn revoke_oauth_client_feature_set( + &self, + client_id: String, + space_id: String, + feature_set_id: String, + ) -> anyhow::Result { + revoke_oauth_client_feature_set( + self.app_handle.clone(), + self.app_handle.state(), + client_id, + space_id, + feature_set_id, + ) + .await + .map_err(|e| anyhow::anyhow!(e))?; + Ok(json!({ "ok": true })) + } + + async fn gateway_state(&self) -> Option>> { + let app_state = self.app_gateway_state.read().await; + app_state.gateway_state.clone() + } +} diff --git a/apps/desktop/src-tauri/src/services/mod.rs b/apps/desktop/src-tauri/src/services/mod.rs index 7d3bb92d..a45dcaed 100644 --- a/apps/desktop/src-tauri/src/services/mod.rs +++ b/apps/desktop/src-tauri/src/services/mod.rs @@ -2,6 +2,10 @@ //! //! Background services for the desktop application. +pub mod admin_server; +pub mod admin_write_runtime; pub mod file_watcher; +pub mod ui_events; +pub use admin_server::AdminServerState; pub use file_watcher::SpaceFileWatcher; diff --git a/apps/desktop/src-tauri/src/services/ui_events.rs b/apps/desktop/src-tauri/src/services/ui_events.rs new file mode 100644 index 00000000..f93a63a6 --- /dev/null +++ b/apps/desktop/src-tauri/src/services/ui_events.rs @@ -0,0 +1,20 @@ +//! Shared desktop UI event emission (Tauri + admin SSE fan-in). + +use mcpmux_gateway::admin::ui_events::AdminUiEventBus; +use serde_json::Value; +use tauri::{AppHandle, Emitter}; + +/// Emit a UI channel event to the Tauri webview and admin SSE subscribers. +pub fn emit_ui_channel( + app: &AppHandle, + ui_event_bus: Option<&AdminUiEventBus>, + channel: &str, + payload: Value, +) { + if let Err(e) = app.emit(channel, payload.clone()) { + tracing::warn!("[UI] Failed to emit {channel}: {e}"); + } + if let Some(bus) = ui_event_bus { + bus.publish(channel, payload); + } +} diff --git a/apps/desktop/src-tauri/src/state/mod.rs b/apps/desktop/src-tauri/src/state/mod.rs index 78050b78..99916469 100644 --- a/apps/desktop/src-tauri/src/state/mod.rs +++ b/apps/desktop/src-tauri/src/state/mod.rs @@ -4,18 +4,19 @@ //! between Tauri commands. use mcpmux_core::{ - AppSettingsRepository, AppSettingsService, CredentialRepository, FeatureSetRepository, - GatewayPortService, InboundMcpClientRepository, InstalledServerRepository, LogConfig, - OutboundOAuthRepository, ServerDiscoveryService, - ServerFeatureRepository as CoreServerFeatureRepository, ServerLogManager, - SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, SpaceService, - WorkspaceBindingRepository, + AppSettingsRepository, AppSettingsService, ApplicationServices, ApplicationServicesBuilder, + CredentialRepository, EventBus, FeatureSetRepository, GatewayPortService, + InboundMcpClientRepository, InstalledServerRepository, LogConfig, OutboundOAuthRepository, + ServerDiscoveryService, ServerFeatureRepository as CoreServerFeatureRepository, + ServerLogManager, SpaceBaseDirRepository, SpaceBuiltinConfigRepository, SpaceRepository, + SpaceService, WorkspaceAppearanceRepository, WorkspaceBindingRepository, }; use mcpmux_storage::{ Database, FieldEncryptor, SqliteAppSettingsRepository, SqliteCredentialRepository, SqliteFeatureSetRepository, SqliteInboundMcpClientRepository, SqliteInstalledServerRepository, SqliteOutboundOAuthRepository, SqliteServerFeatureRepository, SqliteSpaceBaseDirRepository, - SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceBindingRepository, + SqliteSpaceBuiltinConfigRepository, SqliteSpaceRepository, SqliteWorkspaceAppearanceRepository, + SqliteWorkspaceBindingRepository, }; use std::path::PathBuf; use std::sync::Arc; @@ -54,6 +55,8 @@ pub struct AppState { pub space_base_dir_repository: Arc, /// Per-Space built-in server config (Tool Optimization enablement + tool toggles) pub space_builtin_config_repository: Arc, + /// Workspace appearances (icons, theme accent colours) + pub workspace_appearance_repository: Arc, /// Server feature repository for discovered MCP features (implements core trait) pub server_feature_repository: Arc, /// Server feature repository cast to core trait (for gateway services) @@ -118,6 +121,9 @@ impl AppState { let space_builtin_config_repository: Arc = Arc::new(SqliteSpaceBuiltinConfigRepository::new(db.clone())); + let workspace_appearance_repository: Arc = + Arc::new(SqliteWorkspaceAppearanceRepository::new(db.clone())); + let server_feature_repository = Arc::new(SqliteServerFeatureRepository::new(db.clone())); let server_feature_repository_core: Arc = server_feature_repository.clone(); @@ -177,6 +183,7 @@ impl AppState { workspace_binding_repository, space_base_dir_repository, space_builtin_config_repository, + workspace_appearance_repository, server_feature_repository, server_feature_repository_core, encryptor, @@ -208,4 +215,20 @@ impl AppState { mcpmux_core::get_space_config_path(&self.spaces_dir, space_id) .map_err(|e| format!("Invalid space id '{space_id}': {e}")) } + + /// Build shared `ApplicationServices` for command bridge and admin HTTP. + pub fn build_application_services( + &self, + event_bus: Arc, + ) -> anyhow::Result { + ApplicationServicesBuilder::new() + .with_event_bus(event_bus) + .with_space_repo(self.space_service.space_repository()) + .with_installed_server_repo(self.installed_server_repository.clone()) + .with_feature_set_repo(self.feature_set_repository.clone()) + .with_server_feature_repo(self.server_feature_repository_core.clone()) + .with_client_repo(self.client_repository.clone()) + .with_credential_repo(self.credential_repository.clone()) + .build() + } } diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index b0d64656..6a5da16f 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -1,11 +1,14 @@ import { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; import { invoke } from '@tauri-apps/api/core'; import { Sun, Moon, Download, X } from 'lucide-react'; import { AppShell, Sidebar, SidebarItem, SidebarSection } from '@mcpmux/ui'; import { ThemeProvider } from '@/components/ThemeProvider'; +import { isTauri, performWindowControl } from '@/lib/backend/shell'; import { OAuthConsentModal } from '@/components/OAuthConsentModal'; import { ServerInstallModal } from '@/components/ServerInstallModal'; import { SpaceSwitcher } from '@/components/SpaceSwitcher'; +import { StaleBuildBanner } from '@/components/StaleBuildBanner'; import { useDataSync } from '@/hooks/useDataSync'; import { useAnalytics } from '@/hooks/useAnalytics'; import { startMetaToolActivityListener } from '@/stores/metaToolActivityStore'; @@ -17,10 +20,12 @@ import { useAnalyticsEnabled, useActiveNav, useNavigateTo, + useIsLoading, } from '@/stores'; import { NAV_ZONES, NAV_SETTINGS } from '@/lib/navigation'; import { spaceAccentColor } from '@/lib/spaceAccent'; import { HomePage } from '@/features/home'; +import { DashboardPage } from '@/features/dashboard'; import { RegistryPage } from '@/features/registry'; import { FeatureSetsPage } from '@/features/featuresets'; import { ClientsPage } from '@/features/clients'; @@ -33,6 +38,8 @@ import { AutoStartConflictResolver } from '@/features/gateway/AutoStartConflictR import { WorkspaceBindingSheet } from '@/features/workspaces'; import { MetaToolApprovalDialog } from '@/features/metaTools'; import { useGatewayEvents } from '@/hooks/useDomainEvents'; +import { getVersion } from '@/lib/api/app'; +import { checkForUpdate } from '@/lib/updates'; /** McpMux title-bar icon — miniature cat icon */ function McpMuxGlyph({ className }: { className?: string }) { @@ -51,14 +58,12 @@ function McpMuxGlyph({ className }: { className?: string }) { - {/* Cat silhouette with transparent eyes/nose */} - {/* Smile */} - {/* Whiskers left */} - {/* Whiskers right */} (null); - // Auto-check for updates on startup (silent check after 5 seconds). - // When auto-install is enabled (the default), download + install + relaunch - // into the new version — so a restart picks up updates with no clicks. - // Otherwise just surface the dismissible banner for a manual install. useEffect(() => { - // Never auto-update under `pnpm dev`. A dev build would otherwise detect a - // newer published release, install it over this build, and relaunch — so - // your local changes would vanish before you could see them. Production - // builds (import.meta.env.DEV === false) are unaffected. - if (import.meta.env.DEV) return; + if (import.meta.env.DEV && !import.meta.env.VITEST) return; const checkForUpdates = async () => { try { - const { checkForUpdate } = await import('@/lib/updates'); const update = await checkForUpdate(); if (!update) return; console.log(`[Auto-Update] Update available: ${update.version}`); - // Default to auto-install; honor the persisted opt-out. let autoInstall = true; try { autoInstall = await invoke('get_auto_install_updates'); @@ -142,21 +138,22 @@ function AppContent() { return () => clearTimeout(timer); }, []); - // Get state from store const theme = useTheme(); const setTheme = useAppStore((state) => state.setTheme); const viewSpace = useViewSpace(); + const isLoadingSpaces = useIsLoading('spaces'); const analyticsEnabled = useAnalyticsEnabled(); - // App version from Rust backend const [appVersion, setAppVersion] = useState(''); useEffect(() => { - invoke('get_version') + if (isLoadingSpaces) { + return; + } + getVersion() .then(setAppVersion) .catch((err) => console.error('Failed to get version:', err)); - }, []); + }, [isLoadingSpaces]); - // Initialize analytics once we have the app version useEffect(() => { if (!appVersion) return; initAnalytics(appVersion); @@ -168,14 +165,10 @@ function AppContent() { } }, [appVersion]); // eslint-disable-line react-hooks/exhaustive-deps - // Start the app-wide meta-tool activity listener once at launch so the - // "Recent meta-tool activity" panel accumulates rows for the whole session - // and survives tab changes (the listener is idempotent and app-scoped). useEffect(() => { startMetaToolActivityListener(); }, []); - // Sync opt-in/out when user toggles analytics useEffect(() => { if (!appVersion) return; if (analyticsEnabled) { @@ -185,15 +178,12 @@ function AppContent() { } }, [analyticsEnabled, appVersion]); - // Track domain events (server install/uninstall) useAnalytics(); - // Track page navigation useEffect(() => { capture('page_viewed', { page: activeNav }); }, [activeNav]); - // Gateway status for sidebar footer const [gatewayUrl, setGatewayUrl] = useState(null); const loadGatewayUrl = useCallback(async () => { try { @@ -206,8 +196,11 @@ function AppContent() { }, [viewSpace?.id]); useEffect(() => { - loadGatewayUrl(); - }, [loadGatewayUrl]); + if (isLoadingSpaces) { + return; + } + void loadGatewayUrl(); + }, [loadGatewayUrl, isLoadingSpaces]); useGatewayEvents((payload) => { if (payload.action === 'started') { @@ -217,7 +210,6 @@ function AppContent() { } }); - // Toggle dark mode const toggleDarkMode = () => { setTheme(theme === 'dark' ? 'light' : 'dark'); }; @@ -232,16 +224,14 @@ function AppContent() { } })(); - // Sidebar renders entirely from the navigation model (lib/navigation.ts) — - // future surfaces (Chat, Agents, Models) are config additions, not layout work. const sidebar = ( } footer={ } - label={NAV_SETTINGS.label} - hint={NAV_SETTINGS.hint} + label={t(NAV_SETTINGS.labelKey)} + hint={t(NAV_SETTINGS.hintKey)} active={activeNav === NAV_SETTINGS.key} onClick={() => navigateTo(NAV_SETTINGS.key)} data-testid={NAV_SETTINGS.testId} @@ -249,13 +239,13 @@ function AppContent() { } > {NAV_ZONES.map((zone, i) => ( - + {zone.entries.map((entry) => ( } - label={entry.label} - hint={entry.hint} + label={t(entry.labelKey)} + hint={t(entry.hintKey)} active={activeNav === entry.key} onClick={() => navigateTo(entry.key)} data-testid={entry.testId} @@ -274,14 +264,18 @@ function AppContent() { onClick={() => navigateTo('home')} className="flex items-center gap-1.5 transition-colors hover:text-[rgb(var(--foreground))]" data-testid="statusbar-gateway" - title="View connection details on the dashboard" + title={tDashboard('statusbar.gatewayTitle')} > - {gatewayRunning ? `Gateway${gatewayPort ? ` · :${gatewayPort}` : ''}` : 'Gateway stopped'} + {gatewayRunning + ? `${tDashboard('statusbar.gatewayRunning')}${ + gatewayPort ? tDashboard('statusbar.gatewayPortSuffix', { port: gatewayPort }) : '' + }` + : tDashboard('statusbar.gatewayStopped')} - Space: {viewSpace?.name || 'None'} + {tDashboard('statusbar.spaceLabel', { + name: viewSpace?.name || tCommon('none'), + })} {appVersion && ( @@ -301,17 +297,20 @@ function AppContent() { ); const titleBar = ( -
- - - Mcp - Mux - -
+
+
+ + + Mcp + Mux + +
+
)} + {activeNav === 'home' && } + {activeNav === 'dashboard' && } {activeNav === 'registry' && } {activeNav === 'servers' && } {activeNav === 'spaces' && } @@ -384,16 +389,10 @@ function App() { return ( - {/* Resolves deferred auto-start port conflicts — runs once on mount */} - {/* OAuth consent modal - shown when MCP clients request authorization */} - {/* Workspace binding sheet - slides in when a session reports a root - that has no binding yet and resolved via the Space default */} - {/* Server install modal - shown when install deep link is received */} - {/* Meta-tool approval dialog — gates every mcpmux_* write tool */} ); @@ -401,12 +400,8 @@ function App() { /** Window control button for custom title bar */ function WindowButton({ action }: { action: 'minimize' | 'maximize' | 'close' }) { - const handleClick = async () => { - const { getCurrentWindow } = await import('@tauri-apps/api/window'); - const appWindow = getCurrentWindow(); - if (action === 'minimize') appWindow.minimize(); - else if (action === 'maximize') appWindow.toggleMaximize(); - else appWindow.close(); + const handleClick = () => { + void performWindowControl(action); }; return ( diff --git a/apps/desktop/src/components/ConfigEditorModal.tsx b/apps/desktop/src/components/ConfigEditorModal.tsx index 4c8ffca6..5e01df70 100644 --- a/apps/desktop/src/components/ConfigEditorModal.tsx +++ b/apps/desktop/src/components/ConfigEditorModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { X, Save, Loader2, AlertTriangle, Wand2 } from 'lucide-react'; import { readSpaceConfig, saveSpaceConfig } from '@/lib/api/spaces'; import { refreshRegistry } from '@/lib/api/registry'; @@ -8,6 +9,8 @@ import { useToast, ToastContainer } from '@mcpmux/ui'; import USER_SPACE_CONFIG_SCHEMA from '../../../../schemas/user-space.schema.json'; import { RequestServerCTA } from './Contribute'; +const EDITOR_MOUNT_TIMEOUT_MS = 10_000; + interface ConfigEditorModalProps { spaceId: string; spaceName: string; @@ -16,6 +19,7 @@ interface ConfigEditorModalProps { } export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: ConfigEditorModalProps) { + const { t } = useTranslation('servers'); const [content, setContent] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -23,6 +27,8 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf const [isValidJson, setIsValidJson] = useState(true); const [validationErrors, setValidationErrors] = useState([]); const [editorReady, setEditorReady] = useState(false); + const [editorMounted, setEditorMounted] = useState(false); + const [editorLoadFailed, setEditorLoadFailed] = useState(false); const editorRef = useRef(null); const monacoRef = useRef(null); const { toasts, success, error: showError } = useToast(); @@ -33,11 +39,10 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf return () => clearTimeout(timer); }, []); - useEffect(() => { - loadConfig(); - }, [spaceId]); - - const loadConfig = async () => { + /** + * Load the space JSON config from disk. + */ + const loadConfig = useCallback(async () => { try { setIsLoading(true); setError(null); @@ -54,17 +59,37 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf } finally { setIsLoading(false); } - }; + }, [spaceId]); + + useEffect(() => { + void loadConfig(); + setEditorMounted(false); + setEditorLoadFailed(false); + }, [loadConfig]); - const handleSave = async () => { + useEffect(() => { + if (isLoading || !editorReady || editorMounted || editorLoadFailed) { + return; + } + + const timer = setTimeout(() => { + setEditorLoadFailed(true); + setError(t('configEditorModal.editorLoadFailed')); + }, EDITOR_MOUNT_TIMEOUT_MS); + + return () => clearTimeout(timer); + }, [isLoading, editorReady, editorMounted, editorLoadFailed, t]); + + const handleSave = useCallback(async () => { try { // Validate JSON try { JSON.parse(content); } catch (e) { setIsValidJson(false); - setError(`Invalid JSON: ${(e as Error).message}`); - showError('Invalid JSON', (e as Error).message); + const message = (e as Error).message; + setError(t('configEditorModal.validation.invalidJson', { message })); + showError(t('configEditorModal.toast.invalidJsonTitle'), message); return; } @@ -74,24 +99,37 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf // Refresh server discovery to pick up new/changed servers await refreshRegistry(); - success('Configuration saved', 'Space configuration updated successfully'); + success(t('configEditorModal.toast.saved'), t('configEditorModal.toast.savedBody')); onSaved(); onClose(); } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); setError(errorMsg); - showError('Failed to save configuration', errorMsg); + showError(t('configEditorModal.toast.saveFailed'), errorMsg); } finally { setIsSaving(false); } - }; + }, [content, onClose, onSaved, showError, spaceId, success, t]); + /** + * Format JSON via Monaco or plain parse/stringify when the editor failed to load. + */ const handleFormat = useCallback(() => { if (editorRef.current) { - // Use Monaco's built-in formatter editorRef.current.getAction('editor.action.formatDocument')?.run(); + return; } - }, []); + + try { + const parsed = JSON.parse(content); + setContent(JSON.stringify(parsed, null, 2)); + setIsValidJson(true); + setValidationErrors([]); + } catch (e) { + setIsValidJson(false); + setError(t('configEditorModal.validation.cannotFormat', { message: (e as Error).message })); + } + }, [content, t]); // Configure Monaco before mount to set up JSON schema validation const handleEditorBeforeMount = (monaco: Monaco) => { @@ -111,28 +149,52 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf }); }; - const handleEditorMount = (editor: editor.IStandaloneCodeEditor, monaco: Monaco) => { - editorRef.current = editor; + /** + * Mount handler — marks Monaco ready and focuses the editor. + */ + const handleEditorMount = (mountedEditor: editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorRef.current = mountedEditor; monacoRef.current = monaco; - - // Focus editor on mount - editor.focus(); + setEditorMounted(true); + mountedEditor.focus(); }; const handleEditorValidation = (markers: editor.IMarker[]) => { - const errors = markers.map(m => `Line ${m.startLineNumber}: ${m.message}`); + const errors = markers.map((m) => + t('configEditorModal.validation.line', { line: m.startLineNumber, message: m.message }), + ); setValidationErrors(errors); setIsValidJson(markers.length === 0); }; + /** + * Sync editor content and clear stale parse errors on edit. + */ const handleContentChange = (newValue: string | undefined) => { - if (newValue !== undefined) { - setContent(newValue); - // Clear any manual errors when content changes - if (error && (error.startsWith('Invalid JSON') || error.startsWith('Cannot format'))) { - setError(null); + if (newValue === undefined) { + return; + } + + setContent(newValue); + + if (editorLoadFailed) { + try { + JSON.parse(newValue); + setIsValidJson(true); + setValidationErrors([]); + } catch (e) { + setIsValidJson(false); + setValidationErrors([(e as Error).message]); } } + + if ( + error && + (error.startsWith(t('configEditorModal.validation.invalidJsonPrefix')) || + error.startsWith(t('configEditorModal.validation.cannotFormatPrefix'))) + ) { + setError(null); + } }; // Keyboard shortcuts @@ -155,13 +217,16 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleFormat, onClose]); + }, [handleFormat, handleSave, onClose]); return ( <> toasts.find(t => t.id === id)?.onClose(id)} /> -
-
+
+
{/* Header */}
@@ -170,10 +235,10 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf

- Custom Server Configuration + {t('configEditorModal.title')}

- {spaceName} · JSON config + {t('configEditorModal.subtitle', { spaceName })}

@@ -193,7 +258,7 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf className="flex items-center gap-2 px-3.5 py-1.5 text-sm font-medium bg-[rgb(var(--primary))] text-[rgb(var(--primary-foreground))] rounded-lg hover:bg-[rgb(var(--primary-hover))] disabled:opacity-50 shadow-sm transition-colors" > {isSaving ? : } - Save + {t('configEditorModal.save')}
@@ -202,10 +267,10 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf onClick={handleFormat} disabled={isLoading || !isValidJson} className="flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-[rgb(var(--muted))] hover:text-[rgb(var(--foreground))] hover:bg-[rgb(var(--surface-hover))] rounded-lg transition-colors disabled:opacity-50" - title="Format JSON (Ctrl+Shift+F)" + title={t('configEditorModal.formatTitle')} > - Format + {t('configEditorModal.format')}
@@ -213,12 +278,14 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf {!isValidJson && ( - {validationErrors.length > 0 ? 'Schema Error' : 'Invalid JSON'} + {validationErrors.length > 0 + ? t('configEditorModal.schemaError') + : t('configEditorModal.invalidJson')} )} - Ctrl+S save · Ctrl+Shift+F format + {t('configEditorModal.keyboardHints')}
@@ -230,10 +297,17 @@ export function ConfigEditorModal({ spaceId, spaceName, onClose, onSaved }: Conf {/* Editor Area */}
- {(isLoading || !editorReady) ? ( + {isLoading || !editorReady ? (
+ ) : editorLoadFailed ? ( +