diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json index d682a4041ab..3957e635acc 100644 --- a/.release/desktop-candidate.json +++ b/.release/desktop-candidate.json @@ -1,10 +1,10 @@ { "schema": 2, - "version": "0.5.9", - "base_sha": "f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b", - "previous_tag": "desktop-v0.5.8", - "previous_base_sha": "6a17d035f79ad582ca3f4f3cdc38d376f2c4087f", - "previous_merge_sha": "c815a9c6e1a1d1818a0547f60f894e4a5388761a", - "tag": "desktop-v0.5.9", - "commit_count": 29 + "version": "0.5.10", + "base_sha": "f35930104bcbdb1332ff13735214ecb9fce1fc7b", + "previous_tag": "desktop-v0.5.9", + "previous_base_sha": "f8f2ef0440e7a074223ec04dc3b32d817b8b9d9b", + "previous_merge_sha": "538e5e113fc33571f939c87b925567fd4e277109", + "tag": "desktop-v0.5.10", + "commit_count": 18 } diff --git a/CHANGELOG.md b/CHANGELOG.md index 38c4bca7259..63783b36b8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## v0.5.10 + +### Desktop and shared changes + +- fix(desktop): remove 0.5.9+ perf regressions, speed up get_channels ([#5599](https://github.com/block/buzz/pull/5599)) ([`f35930104bcbdb1332ff13735214ecb9fce1fc7b`](https://github.com/block/buzz/commit/f35930104bcbdb1332ff13735214ecb9fce1fc7b)) +- perf(desktop): coalesce read state localStorage persistence ([#5591](https://github.com/block/buzz/pull/5591)) ([`9203bf60eea44875cafb36410252f8705ce54e2d`](https://github.com/block/buzz/commit/9203bf60eea44875cafb36410252f8705ce54e2d)) +- fix(desktop): bound initial timeline retention ([#5603](https://github.com/block/buzz/pull/5603)) ([`d9dc76c0aa7ab8a96b2ecf89325eef6b1536039d`](https://github.com/block/buzz/commit/d9dc76c0aa7ab8a96b2ecf89325eef6b1536039d)) +- Improve desktop search scoping ([#5306](https://github.com/block/buzz/pull/5306)) ([`cf03bd7c37cb3918afd4fe2a561360d01e11b68e`](https://github.com/block/buzz/commit/cf03bd7c37cb3918afd4fe2a561360d01e11b68e)) +- Add glass appearance and cohesive settings ([#5478](https://github.com/block/buzz/pull/5478)) ([`cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4`](https://github.com/block/buzz/commit/cd2aa5c12d1c802ea9d93c30809f3625c49e9bd4)) +- Add Send to channel for thread messages ([#5305](https://github.com/block/buzz/pull/5305)) ([`b0795a10ea0f63f2382f4028a1adc2bc3e039d79`](https://github.com/block/buzz/commit/b0795a10ea0f63f2382f4028a1adc2bc3e039d79)) +- Fix macOS attachment picker lifecycle and allow inert HTML downloads ([#5569](https://github.com/block/buzz/pull/5569)) ([`bba3e06386b8a0ca22e9867dc81aac1ca2b1b737`](https://github.com/block/buzz/commit/bba3e06386b8a0ca22e9867dc81aac1ca2b1b737)) +- fix(desktop): preserve fresh channel timelines ([#5577](https://github.com/block/buzz/pull/5577)) ([`d3ec831e0cecbff347d55a236e34b27d79961503`](https://github.com/block/buzz/commit/d3ec831e0cecbff347d55a236e34b27d79961503)) +- fix(desktop): suppress fresh focus-return refetches for channels and home-feed ([#5535](https://github.com/block/buzz/pull/5535)) ([`49357244945c2f4b8432eb8b5cebbba5b1c30a08`](https://github.com/block/buzz/commit/49357244945c2f4b8432eb8b5cebbba5b1c30a08)) +- chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh ([#5289](https://github.com/block/buzz/pull/5289)) ([`240cdd3ea17a8f4d521c8398a929294210bd1e1a`](https://github.com/block/buzz/commit/240cdd3ea17a8f4d521c8398a929294210bd1e1a)) +- fix(desktop): preserve theme when opening communities ([#5266](https://github.com/block/buzz/pull/5266)) ([`83ca595adadae32238197d9c34a5895a34950968`](https://github.com/block/buzz/commit/83ca595adadae32238197d9c34a5895a34950968)) +- fix(link-preview): resolve YouTube videos through oEmbed ([#5520](https://github.com/block/buzz/pull/5520)) ([`7eb8cc5a5f03c454a84f2b5c4369819ba6d4d11b`](https://github.com/block/buzz/commit/7eb8cc5a5f03c454a84f2b5c4369819ba6d4d11b)) +- fix(buzz-agent): harden Databricks OAuth token cache and callback ([#5534](https://github.com/block/buzz/pull/5534)) ([`5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6`](https://github.com/block/buzz/commit/5e4d0fe92508fc5e0c812ff3edbe8877d86b8ec6)) +- fix(link-preview): reliably render previews sent right after they resolve ([#5245](https://github.com/block/buzz/pull/5245)) ([`be48ce98bd163899197b79a82ad5b2bcf0bc9b54`](https://github.com/block/buzz/commit/be48ce98bd163899197b79a82ad5b2bcf0bc9b54)) +- fix(link-preview): restore Buzz entity link cards ([#5494](https://github.com/block/buzz/pull/5494)) ([`7e6e9c547fa97abff6929cf2702b956586eec9bc`](https://github.com/block/buzz/commit/7e6e9c547fa97abff6929cf2702b956586eec9bc)) + +### Other repository changes + +- fix(relay): stop panicking the ingest worker on reactions to project events ([#5294](https://github.com/block/buzz/pull/5294)) ([`16b7ae7ce623a57be1461adee3b8fce4115b3c3a`](https://github.com/block/buzz/commit/16b7ae7ce623a57be1461adee3b8fce4115b3c3a)) +- fix(relay): log event kind on the HTTP bridge /events line ([#5291](https://github.com/block/buzz/pull/5291)) ([`e8153f8f27f5a35f56b2a578ab749190787d9e91`](https://github.com/block/buzz/commit/e8153f8f27f5a35f56b2a578ab749190787d9e91)) +- feat(tracing): add PostgreSQL tracing spans ([#3678](https://github.com/block/buzz/pull/3678)) ([`397796c5f343db4251198f44505b1afebe88223f`](https://github.com/block/buzz/commit/397796c5f343db4251198f44505b1afebe88223f)) + +[Compare desktop-v0.5.9...desktop-v0.5.10](https://github.com/block/buzz/compare/desktop-v0.5.9...desktop-v0.5.10) + ## v0.5.9 ### Desktop and shared changes diff --git a/Cargo.lock b/Cargo.lock index fcd4e5a3ddb..a512a8e2107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,20 +352,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -398,12 +399,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -614,6 +609,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -626,7 +627,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -661,6 +662,21 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -674,7 +690,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals", + "hex-conservative 1.2.0", "serde", ] @@ -850,7 +878,7 @@ dependencies = [ "hex", "httparse", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rustls", "serde", @@ -883,7 +911,7 @@ dependencies = [ "clap", "deadpool-redis", "hex", - "nostr", + "nostr 0.44.7", "rustls", "serde_json", "sqlx", @@ -924,6 +952,7 @@ name = "buzz-audit" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "chrono", "futures-util", "hex", @@ -943,7 +972,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -965,7 +994,7 @@ dependencies = [ "http-body-util", "k8s-openapi", "kube", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "rustls", "serde", @@ -993,7 +1022,7 @@ dependencies = [ "dirs", "hex", "infer", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rustls", @@ -1030,7 +1059,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.1", "secp256k1 0.29.1", @@ -1046,16 +1075,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "buzz-datastore-tracing" +version = "0.1.0" +dependencies = [ + "opentelemetry 0.32.0", + "opentelemetry_sdk 0.32.1", + "proc-macro2", + "quote", + "syn 2.0.117", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", +] + [[package]] name = "buzz-db" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "chrono", "hex", "metrics", "metrics-util", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -1079,7 +1124,7 @@ dependencies = [ "ignore", "image", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rmcp", "rustls", @@ -1112,7 +1157,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1151,7 +1196,7 @@ dependencies = [ "clap", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1166,7 +1211,7 @@ version = "0.1.0" dependencies = [ "buzz-core", "buzz-ws-client", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "serde", "serde_json", @@ -1201,7 +1246,7 @@ dependencies = [ "chrono", "deadpool-redis", "futures-util", - "nostr", + "nostr 0.44.7", "redis", "serde", "serde_json", @@ -1227,7 +1272,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "minicbor", - "nostr", + "nostr 0.44.7", "p256", "proptest", "rand 0.10.1", @@ -1259,6 +1304,7 @@ dependencies = [ "buzz-auth", "buzz-conformance", "buzz-core", + "buzz-datastore-tracing", "buzz-db", "buzz-media", "buzz-pubsub", @@ -1284,7 +1330,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-util", "moka", - "nostr", + "nostr 0.44.7", "opentelemetry 0.32.0", "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", @@ -1325,7 +1371,7 @@ dependencies = [ "hex", "hmac 0.13.0", "iroh", - "nostr", + "nostr 0.44.7", "postcard", "proptest", "redis", @@ -1343,7 +1389,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1355,9 +1401,11 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", + "buzz-datastore-tracing", "sqlx", "thiserror 2.0.18", "tokio", + "tracing", "uuid 1.23.1", ] @@ -1374,7 +1422,7 @@ dependencies = [ "chrono", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rust-s3", @@ -1423,7 +1471,7 @@ dependencies = [ "evalexpr", "hex", "moka", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "serde", "serde_json", @@ -1439,7 +1487,7 @@ name = "buzz-ws-client" version = "0.1.0" dependencies = [ "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1914,7 +1962,7 @@ dependencies = [ "anyhow", "buzz-sdk", "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "tokio", "tokio-tungstenite 0.29.0", @@ -2176,43 +2224,16 @@ dependencies = [ "phf", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "2d83cb7e7a873830708d6b02a78cd36a592c6fa14bf267b68725103b85c0d77f" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctr" version = "0.9.2" @@ -2694,21 +2715,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -3019,6 +3025,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3423,7 +3439,7 @@ name = "git-credential-nostr" version = "0.1.0" dependencies = [ "base64 0.22.1", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3436,7 +3452,7 @@ dependencies = [ "chrono", "hex", "libc", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3516,6 +3532,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbag" version = "0.1.13" @@ -3580,6 +3605,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3608,45 +3643,31 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" dependencies = [ + "anyhow", "async-trait", "bytes", "http", "more-asserts", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "uuid 1.23.1", "xet-client", @@ -4435,7 +4456,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.0", + "lru", "n0-error", "n0-future", "noq", @@ -4910,6 +4931,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "link-section" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee1a0d6e252afe82e7bc2db42fba60e02ddf3b1accaf8cb21d96e34ba61f3d4" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348d0075b1fc163b26d72a7f75fc5141daf2fd1bdf128d873cbaf6785d495bdf" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4995,12 +5028,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.0" @@ -5147,8 +5174,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "mesh-llm-client", @@ -5157,8 +5184,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5168,13 +5195,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5205,8 +5232,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5221,8 +5248,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5231,8 +5258,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "clap", @@ -5243,12 +5270,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -5256,8 +5280,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -5265,16 +5289,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "argon2", @@ -5292,7 +5345,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -5307,6 +5359,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -5319,6 +5372,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5366,8 +5420,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "argon2", "base64 0.22.1", @@ -5388,8 +5442,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "serde", @@ -5399,8 +5453,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-types", @@ -5413,8 +5467,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5430,8 +5484,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5449,8 +5503,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "hex", @@ -5460,18 +5514,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5493,8 +5556,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5508,8 +5571,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5519,8 +5582,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "chrono", @@ -5528,8 +5591,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5542,8 +5609,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "serde", @@ -5553,13 +5620,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5570,6 +5637,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "anyhow", + "libloading", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "metrics" version = "0.24.6" @@ -5707,8 +5790,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5718,14 +5801,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5736,14 +5819,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5756,16 +5839,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "model-artifact", @@ -6143,6 +6226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -6183,9 +6267,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -6201,56 +6285,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.1", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.1", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6531,10 +6630,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "axum", @@ -7954,7 +8059,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.0", + "lru", "palette", "serde", "strum", @@ -8745,13 +8850,24 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.6", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.9.4", "secp256k1-sys 0.11.0", ] @@ -9093,7 +9209,6 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -9107,15 +9222,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sha3" version = "0.10.9" @@ -9269,8 +9375,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "blake3", @@ -9279,40 +9385,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "prost 0.14.3", "prost-build 0.14.3", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "libc", @@ -9325,8 +9432,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "ahash", "anyhow", @@ -9337,6 +9444,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto 0.31.0", "serde", @@ -9346,16 +9455,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -10407,6 +10525,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -10475,9 +10602,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -10485,7 +10612,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -10525,6 +10652,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -10542,6 +10670,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -10843,9 +10995,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -11034,6 +11186,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -12066,20 +12224,18 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "clap", "crc32fast", "futures", "http", "hyper", - "lazy_static", "more-asserts", "rand 0.10.1", "redb", @@ -12093,8 +12249,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-retry", + "tokio_with_wasm", "tracing", - "tracing-subscriber", "url", "urlencoding", "web-time", @@ -12104,24 +12260,21 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" dependencies = [ "async-trait", "base64 0.22.1", "blake3", "bytemuck", "bytes", - "clap", "countio", - "csv", "futures", "futures-util", "getrandom 0.4.3", "heapify", "itertools", - "lazy_static", "lz4_flex", "more-asserts", "rand 0.10.1", @@ -12129,7 +12282,6 @@ dependencies = [ "safe-transmute", "serde", "static_assertions", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -12141,32 +12293,31 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "clap", "gearhash", "http", "itertools", - "lazy_static", "more-asserts", "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "url", "uuid 1.23.1", - "walkdir", + "web-time", "xet-client", "xet-core-structures", "xet-runtime", @@ -12174,9 +12325,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ "anyhow", "async-trait", @@ -12190,7 +12341,6 @@ dependencies = [ "git-version", "humantime", "konst 0.4.3", - "lazy_static", "libc", "more-asserts", "oneshot", @@ -12204,9 +12354,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "tracing-appender", "tracing-subscriber", + "web-time", "whoami", "winapi", ] diff --git a/Cargo.toml b/Cargo.toml index a8a1b02a318..7705a27f22a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/buzz-dev-mcp", "crates/buzz-voice", "crates/buzz-backend-kubernetes", + "crates/buzz-datastore-tracing", "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] @@ -144,6 +145,7 @@ buzz-media = { path = "crates/buzz-media" } buzz-sdk = { path = "crates/buzz-sdk" } buzz-ws-client = { path = "crates/buzz-ws-client" } buzz-relay-mesh = { path = "crates/buzz-relay-mesh" } +buzz-datastore-tracing = { path = "crates/buzz-datastore-tracing" } # CI profile — builds the relay for desktop e2e. Dependencies keep full # release optimization (warm from main's cache; they carry the runtime hot diff --git a/Justfile b/Justfile index 0a43249d5fc..2e62599dacf 100644 --- a/Justfile +++ b/Justfile @@ -355,7 +355,6 @@ mesh-dev-fresh: mesh-e2e-hardware: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_serve_client_smoke # Three isolated node processes: trusted member joins and infers; stranger is rejected. @@ -363,14 +362,12 @@ mesh-e2e-hardware: mesh-e2e-admission: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo run -p buzz-relay --example mesh_admission_smoke # Full hardware confidence suite: routing, owner admission, and real agent inference. mesh-e2e-confidence: #!/usr/bin/env bash set -euo pipefail - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" cargo build --release -p buzz-agent -p buzz-dev-mcp cargo run -p buzz-relay --example mesh_serve_client_smoke cargo run -p buzz-relay --example mesh_admission_smoke @@ -457,9 +454,6 @@ dev *ARGS: bootstrap _ensure-sidecar-stubs _ensure-migrations done fi cargo build -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p buzz-cli -p git-credential-nostr -p buzz-relay - if [[ -n "{{mesh}}" ]]; then - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" - fi # Docker Desktop's forwarded MinIO port can stall under the deployment # probe's 32 concurrent writers. Keep the gate enabled in local dev, using # the bounded profile already used by the relay test launcher. @@ -536,7 +530,6 @@ staging *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the @@ -572,7 +565,6 @@ production *ARGS: bootstrap _ensure-sidecar-stubs FEATURES=() if [[ -n "{{mesh}}" ]]; then FEATURES=(--features mesh-llm) - export MESH_LLM_NATIVE_RUNTIME_CACHE_DIR="$(./scripts/ensure-mesh-native-runtime.sh)" fi # Replace 0-byte sidecar stubs with real binaries so tauri dev picks them up. # buzz: the CLI sidecar. buzz-backend-kubernetes: provider discovery scans the diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 3f43925de36..a78a499bdd1 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -16,7 +16,8 @@ //! calls hit the cache and silently refresh when expired. use std::fs; -use std::path::PathBuf; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -188,15 +189,16 @@ impl PkceOAuthTokenSource { } /// Persist a token to disk and the in-memory cell. + /// + /// The cache holds both the access and refresh tokens, so the on-disk + /// file is written owner-only (`0o600` on Unix) via an atomic + /// inode-swapping rename — see [`write_private_cache`]. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { let body = serde_json::to_vec_pretty(&token) .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - // Atomic rename so a concurrent reader never sees a partial write. - let tmp = self.cache_path.with_extension("json.tmp"); - fs::write(&tmp, &body) - .map_err(|e| AgentError::Llm(format!("oauth cache write {tmp:?}: {e}")))?; - fs::rename(&tmp, &self.cache_path) - .map_err(|e| AgentError::Llm(format!("oauth cache rename: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; *state = Some(token); Ok(()) } @@ -463,11 +465,162 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } -fn read_cache(path: &PathBuf) -> Option { - let body = fs::read(path).ok()?; +/// Load a cached token, enforcing the owner-only invariant on load. +/// +/// Owner-only permissions are a cache *lifecycle* invariant, not just a +/// write-path property: a world-readable cache left by an older buzz-agent +/// (or any tampering) must be tightened the moment we touch it, before the +/// tokens are used — otherwise a file that never expires stays exposed until +/// some future refresh happens to rewrite it. Every load path (initial and +/// cross-process re-reads) funnels through here, so the repair covers them +/// all. Returns `None` when the cache is absent, unreadable, unparseable, or +/// cannot be secured; the caller then falls through to refresh/browser. +fn read_cache(path: &Path) -> Option { + let body = read_private_cache(path).ok()?; serde_json::from_slice(&body).ok() } +/// Open the cache, reject symlinks, tighten loose permissions to `0o600`, and +/// return its bytes. +/// +/// On Unix `O_NOFOLLOW` rejects a symlinked cache path at the kernel level +/// (no stat/open TOCTOU), and `fchmod` on the already-open handle repairs a +/// loose mode against the pinned inode rather than re-resolving the path. +/// A cache that exists but cannot be secured is an error, so the caller fails +/// closed instead of using an exposed file. +#[cfg(unix)] +fn read_private_cache(path: &Path) -> io::Result> { + use std::io::Read; + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + let mut file = fs::OpenOptions::new() + .read(true) + .custom_flags(nix::libc::O_NOFOLLOW) + .open(path)?; + + let meta = file.metadata()?; + if !meta.file_type().is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "oauth cache is not a regular file", + )); + } + // Tighten in place on the open fd if any group/other bit is set. fchmod + // targets the inode we already hold, so no attacker can swap the path + // between the check and the repair. + if meta.permissions().mode() & 0o077 != 0 { + file.set_permissions(fs::Permissions::from_mode(0o600))?; + } + + let mut body = Vec::new(); + file.read_to_end(&mut body)?; + Ok(body) +} + +/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the +/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +#[cfg(not(unix))] +fn read_private_cache(path: &Path) -> io::Result> { + fs::read(path) +} + +/// Removes a temp file on drop unless it was already renamed away. Keeps a +/// failed/partial write from leaving a stray token file behind. +struct TmpFileGuard<'a>(&'a Path); + +impl Drop for TmpFileGuard<'_> { + fn drop(&mut self) { + let _ = fs::remove_file(self.0); + } +} + +/// A per-write-unique temp suffix so concurrent savers — sibling threads or +/// separate processes sharing `$HOME` — never collide on one temp path. +/// Falls back to a timestamp if the RNG is unavailable rather than panicking +/// mid-auth. +fn unique_suffix() -> String { + let mut bytes = [0u8; 8]; + if getrandom::fill(&mut bytes).is_ok() { + return hex::encode(bytes); + } + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{nanos:x}") +} + +/// Write `body` to `path` as an owner-only file via an atomic rename. +/// +/// The cache holds both the refresh and access tokens, so it must never be +/// readable by other users. We create a uniquely-named temp file in the same +/// directory with owner-only protection at creation time — mode `0o600` on +/// Unix (see [`create_private_temp_file`]) — so it is never briefly +/// world/other readable, write and fsync it, then rename over the +/// destination. The rename swaps the inode/entry wholesale, so a pre-existing +/// cache file with loose permissions is *replaced* by the new private one; +/// its old mode never survives. `fs::rename` maps to +/// `MOVEFILE_REPLACE_EXISTING` on Windows, so the atomic replace holds on +/// both platforms; the Windows owner-only DACL is pending the unsafe-FFI +/// decision noted at the seam. +fn write_private_cache(path: &Path, body: &[u8]) -> io::Result<()> { + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "oauth cache path has no parent directory", + ) + })?; + fs::create_dir_all(parent)?; + + let file_name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("oauth-cache"); + let tmp = parent.join(format!(".{file_name}.{}.tmp", unique_suffix())); + let guard = TmpFileGuard(&tmp); + + let mut f = create_private_temp_file(&tmp)?; + f.write_all(body)?; + f.sync_all()?; + drop(f); + + fs::rename(&tmp, path)?; + // The rename consumed the temp path; nothing left to clean up. + std::mem::forget(guard); + Ok(()) +} + +/// Create `tmp` for writing with owner-only permissions from the moment it +/// exists. Fails if the file already exists (`create_new`), which the +/// per-write-unique suffix makes effectively impossible. +#[cfg(unix)] +fn create_private_temp_file(tmp: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(tmp) +} + +/// Non-Unix fallback: create the temp file if it does not already exist. +/// +/// On Windows the owner-only equivalent is an explicit DACL set at creation +/// (`CreateFileW` with SDDL `D:P(A;;FA;;;OW)`, matching goose's +/// `private_file.rs`), but that FFI needs `unsafe`, which this crate forbids. +/// Reconciling the two — an isolated helper crate, a vetted safe dependency, +/// or descoping Windows — is an open decision escalated to the maintainer, so +/// this interim relies on the default per-user ACLs and drops the owner-only +/// implementation in behind this seam once the decision lands. `create_new` +/// fails if the file already exists. +#[cfg(not(unix))] +fn create_private_temp_file(tmp: &Path) -> io::Result { + fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(tmp) +} + /// Parse a token-endpoint JSON response. Fails loudly when `access_token` /// is missing or empty — without this, a malformed server response would /// be cached and `bearer()` would silently return `""` until the entry @@ -518,6 +671,47 @@ fn random_state() -> Result { Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)) } +/// Decide the OAuth callback result and the HTML page to serve. +/// +/// Returns `(result, page)`: `result` carries the auth code (or a detail +/// string on failure) to the waiting flow via the oneshot channel; `page` is +/// the *static* HTML shown in the browser. The page never embeds any request +/// parameter — the `error` query value is attacker-influenceable, so +/// reflecting it would be an XSS sink on the localhost callback. Failure +/// detail travels only through `result`, which surfaces in the process error +/// and logs, never in the served markup. +fn callback_outcome( + params: &std::collections::HashMap, + expected_state: &str, +) -> (Result, String) { + let result = match (params.get("code"), params.get("state")) { + (Some(code), Some(st)) if st == expected_state => Ok(code.clone()), + (Some(_), Some(_)) => Err("state mismatch".to_string()), + _ => Err(params + .get("error") + .map(|e| sanitize_callback_detail(e)) + .unwrap_or_else(|| "missing code".into())), + }; + let page = match result { + Ok(_) => "

Buzz: signed in

You can close this window.

", + Err(_) => "

Buzz auth failed

You can close this window and try again.

", + } + .to_string(); + (result, page) +} + +/// Neutralize an attacker-controllable OAuth `error` value before it enters +/// an error string that later reaches the logs. Control characters (CR/LF in +/// particular) enable log-line injection, and an unbounded value could flood +/// the logs — replace control chars with spaces and cap the length. +fn sanitize_callback_detail(raw: &str) -> String { + const MAX: usize = 200; + raw.chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .take(MAX) + .collect() +} + /// Spin up a localhost callback server, open the authorize URL in a /// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then /// exchange the code for a token. @@ -544,23 +738,11 @@ async fn browser_pkce_flow( let tx = Arc::clone(&tx); let expected = expected_state.clone(); async move { - let result = match (params.get("code"), params.get("state")) { - (Some(code), Some(st)) if st == &expected => Ok(code.clone()), - (Some(_), Some(_)) => Err("state mismatch".to_string()), - _ => Err(params - .get("error") - .cloned() - .unwrap_or_else(|| "missing code".into())), - }; + let (result, page) = callback_outcome(¶ms, &expected); if let Some(sender) = tx.lock().await.take() { - let _ = sender.send(result.clone()); - } - match result { - Ok(_) => Html( - "

Buzz: signed in

You can close this window.

".to_string(), - ), - Err(e) => Html(format!("

Buzz auth failed

{e}
")), + let _ = sender.send(result); } + Html(page) } }), ); @@ -844,4 +1026,320 @@ mod tests { ), } } + + // ---- callback HTML must never reflect input -------------------------- + + #[test] + fn test_callback_failure_page_omits_reflected_error_param() { + // A hostile `error` query value carrying markup must not appear in + // the served HTML — otherwise the localhost callback is an XSS sink. + let payload = ""; + let mut params = std::collections::HashMap::new(); + params.insert("error".to_string(), payload.to_string()); + + let (result, page) = callback_outcome(¶ms, "expected-state"); + + // The failure detail still reaches the waiting flow via `result`... + assert_eq!(result.as_ref().err().map(String::as_str), Some(payload)); + // ...but the browser page is static and inert. + assert!( + !page.contains(payload), + "callback page reflected the raw error param: {page}" + ); + assert!( + !page.contains(""; - let result = validate_file_content(html, &config); + // Sanity: this fixture is exactly the shape `infer` classifies as HTML. + assert_eq!(infer::get(html).map(|k| k.mime_type()), Some("text/html")); + let (mime, ext) = validate_file_content(html, &config).unwrap(); + assert_eq!(mime, "text/html"); + assert_eq!(ext, "html"); + assert!( + !serve_inline(&mime), + "text/html must never be served inline — it must force download" + ); + } + + #[test] + fn test_validate_file_executable_still_rejected() { + // Removing HTML from the deny-list must not weaken the executable + // block. `infer` classifies an ELF header as `application/x-executable`, + // which the generic path must still reject via the deny-list. + let config = test_config(); + // `infer`'s ELF matcher requires the magic plus >52 bytes of header. + let mut elf = b"\x7fELF".to_vec(); + elf.extend_from_slice(&[0u8; 60]); + assert_eq!( + infer::get(&elf).map(|k| k.mime_type()), + Some("application/x-executable") + ); assert!( - matches!(result, Err(MediaError::DisallowedContentType(ref m)) if m == "text/html"), - "expected DisallowedContentType(text/html), got {result:?}" + matches!(validate_file_content(&elf, &config), Err(MediaError::DisallowedContentType(ref m)) if m == "application/x-executable"), + "ELF executable must still be rejected by the generic file path" ); } + #[test] + fn test_generic_deny_list_keeps_active_content_and_executables() { + // Static guard on the deny-list itself: HTML is intentionally gone, but + // SVG, JavaScript, XHTML, and the native-executable types remain. These + // are the entries that keep the inert-download boundary honest even if a + // future `infer` upgrade starts classifying more of them by content. + assert!(!BLOCKED_FILE_MIME_TYPES.contains(&"text/html")); + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-msdownload", + "application/x-executable", + "application/vnd.microsoft.portable-executable", + "application/x-mach-binary", + "application/x-msi", + "application/x-apple-diskimage", + ] { + assert!( + BLOCKED_FILE_MIME_TYPES.contains(&kept), + "{kept} must remain in the generic-file deny-list" + ); + } + } + #[test] fn test_validate_file_too_large_rejected() { let mut config = test_config(); diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index 65fe32b6b31..124457fe4e3 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -19,6 +19,7 @@ path = "src/main.rs" buzz-core = { workspace = true } buzz-conformance = { workspace = true } buzz-db = { workspace = true } +buzz-datastore-tracing = { workspace = true } buzz-auth = { workspace = true } buzz-pubsub = { workspace = true } buzz-audit = { workspace = true } @@ -84,8 +85,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } # Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): # the relay client for discovery notes and the exact ed25519 the mesh owner # keys use for binding verification. diff --git a/crates/buzz-relay/examples/mesh_admission_smoke.rs b/crates/buzz-relay/examples/mesh_admission_smoke.rs index a541f35bbcd..94c86c2a68e 100644 --- a/crates/buzz-relay/examples/mesh_admission_smoke.rs +++ b/crates/buzz-relay/examples/mesh_admission_smoke.rs @@ -54,16 +54,11 @@ fn env(name: &str) -> anyhow::Result { std::env::var(name).map_err(|_| anyhow::anyhow!("{name} is required for this role")) } +/// Installs the signed release runtime when none is cached — the same path the +/// desktop takes. Deliberately no "is it installed?" precondition: this runs in +/// three separate processes, and a guard here would refuse before reaching the +/// call that does the installing. async fn init_native_runtime() -> anyhow::Result<()> { - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !cache - .installed()? - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` once to prepare it"); - } mesh_llm_host_runtime::initialize_host_runtime() .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}")) diff --git a/crates/buzz-relay/examples/mesh_agent_e2e.rs b/crates/buzz-relay/examples/mesh_agent_e2e.rs index 345ca4c746c..db0fc6090be 100644 --- a/crates/buzz-relay/examples/mesh_agent_e2e.rs +++ b/crates/buzz-relay/examples/mesh_agent_e2e.rs @@ -112,10 +112,12 @@ async fn run() -> anyhow::Result<()> { Err(e) => record("P1 explicit-model chat", false, e.to_string()), } - // P2: auto — router picks the model. + // P2: the virtual `mesh` model — exactly what apply_relay_mesh_env now puts + // on the wire for shared compute. With one served model there is no + // committee, so this proves MeshLLM's degrade_to_single_model path. let r = agent_chat( &base, - "auto", + "mesh", None, "Reply with exactly one word: PONG", &[], @@ -123,11 +125,15 @@ async fn run() -> anyhow::Result<()> { .await; match r { Ok(text) => record( - "P2 auto-model chat", + "P2 virtual-mesh chat (degrades to single model)", text.to_uppercase().contains("PONG"), text, ), - Err(e) => record("P2 auto-model chat", false, e.to_string()), + Err(e) => record( + "P2 virtual-mesh chat (degrades to single model)", + false, + e.to_string(), + ), } // P3: regression — an output budget no served model's context can hold @@ -139,7 +145,7 @@ async fn run() -> anyhow::Result<()> { // GLM-4.7-Flash. let r = agent_chat( &base, - &served_id, + "mesh", Some("150000"), "Reply with exactly one word: PONG", &[], @@ -169,7 +175,7 @@ async fn run() -> anyhow::Result<()> { ); let mcp = vec![("dev".to_string(), repo_bin("buzz-dev-mcp")?)]; let (r, marker) = - agent_chat_with_marker(&base, &served_id, None, &prompt, &mcp, &marker_name).await; + agent_chat_with_marker(&base, "mesh", None, &prompt, &mcp, &marker_name).await; let file_ok = std::fs::read_to_string(&marker) .map(|c| c.contains("BUZZ_OK")) .unwrap_or(false); @@ -270,7 +276,9 @@ async fn agent_chat_in_isolated_home( .env_clear() .env("PATH", std::env::var("PATH").unwrap_or_default()) .env("HOME", &home) - // Exactly the environment apply_relay_mesh_env() supplies. + // The transport subset of apply_relay_mesh_env(): provider, base URL, + // model, key, and chat API. Not BUZZ_AGENT_REQUIRE_REPLY, which needs + // Buzz's publish tools to mean anything. .env("BUZZ_AGENT_PROVIDER", "openai") .env("BUZZ_AGENT_MODEL", model) .env("OPENAI_COMPAT_BASE_URL", base) diff --git a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs index 903d1a73c0e..19e6c775d74 100644 --- a/crates/buzz-relay/examples/mesh_serve_client_smoke.rs +++ b/crates/buzz-relay/examples/mesh_serve_client_smoke.rs @@ -41,15 +41,11 @@ const CLIENT_CONSOLE_PORT: u16 = 13132; async fn main() -> anyhow::Result<()> { let model = std::env::var("MESH_SMOKE_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()); eprintln!("[smoke] model: {model}"); - let cache = mesh_llm_sdk::native_runtime::native_runtime_cache(None)?; - let current = mesh_llm_sdk::native_runtime::CURRENT_MESH_VERSION; - if !cache - .installed()? - .iter() - .any(|runtime| runtime.mesh_version == current) - { - anyhow::bail!("MeshLLM native runtime for MeshLLM {current} is not installed; run `just mesh-e2e-hardware` to prepare it"); - } + // No cache precondition: `initialize_host_runtime` installs the signed + // release runtime itself when none is present, which is how the desktop + // gets one too. The guard that used to stand here refused before reaching + // this line and told the reader to run the very recipe that runs this + // example. mesh_llm_host_runtime::initialize_host_runtime() .await .map_err(|error| anyhow::anyhow!("MeshLLM host runtime init failed: {error}"))?; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453fd..dfce484494a 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -654,12 +654,13 @@ pub async fn submit_event( submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await; match &outcome { - SubmitOutcome::Ok { accepted, .. } => { + SubmitOutcome::Ok { accepted, kind, .. } => { tracing::info!( pubkey = %pubkey_hex, route = "/events", status = 200u16, accepted, + kind, "HTTP bridge request" ); } @@ -713,6 +714,7 @@ enum SubmitOutcome { /// Ingest pipeline ran and returned a result (accepted or not). Ok { accepted: bool, + kind: u32, response: Json, }, /// JSON parse failure before ingest — log category/line/column, not msg. @@ -843,6 +845,7 @@ async fn submit_event_authed( })); SubmitOutcome::Ok { accepted: result.accepted, + kind: kind_u32, response, } } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index 2d82736807a..efdb307e157 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -19,6 +19,7 @@ use uuid::Uuid; use buzz_core::kind::*; use buzz_core::tenant::{CommunityId, TenantContext}; +use buzz_datastore_tracing::datastore_span; use buzz_db::workflow::{ApprovalStatus, RunStatus}; use buzz_db::DbError; use buzz_workflow::executor::TriggerContext; @@ -97,6 +98,7 @@ enum PersistResult { /// persists without the event record. On retry, the event INSERT succeeds /// (no conflict), and the mutation re-executes — which is safe for idempotent /// operations (open_dm, hide_dm, update_approval, upsert_workflow). +#[datastore_span(name = "persist_command_event", system = "postgresql")] async fn persist_command_event( state: &Arc, tenant: &TenantContext, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 1926b808162..a8651f5c02c 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -2851,24 +2851,29 @@ async fn ingest_event_inner( }; let pubkey_hex = auth.pubkey().to_hex(); - // Spec WriteInsert (line 514) / WriteDuplicate (line 606): emit - // the abstract write action. The persist API returns - // `was_inserted` (true → Insert, false → Duplicate). This branch - // is the reaction path; channel_id is always Some here, so - // WriteInsertGlobal does not apply. + // Spec WriteInsert (line 514) / WriteDuplicate (line 606) / + // WriteInsertGlobal (line 559): emit the abstract write action. The + // persist API returns `was_inserted` (true → Insert/Global, false → + // Duplicate). Reactions on project events (issue/PR roots and their + // comments) carry no `h` tag, so `channel_id` can be `None` here — + // mirror the message write's three-way split instead of asserting a + // channel, which panicked the ingest worker on those events. let claimed = claimed_community_from_event(&event); - let action = if was_inserted { - TraceAction::WriteInsert { + let action = match (channel_id, was_inserted) { + (Some(ch), true) => TraceAction::WriteInsert { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(channel_id.expect("reaction path has channel")), + channel: channel_label(ch), claimed_community: claimed, - } - } else { - TraceAction::WriteDuplicate { + }, + (Some(ch), false) => TraceAction::WriteDuplicate { msg_id: msg_id_label(event.id.as_bytes()), - channel: channel_label(channel_id.expect("reaction path has channel")), + channel: channel_label(ch), claimed_community: claimed, - } + }, + (None, _) => TraceAction::WriteInsertGlobal { + msg_id: msg_id_label(event.id.as_bytes()), + claimed_community: claimed, + }, }; emit(tracer, action, state_for_request(tenant, auth.pubkey())); dispatch_persistent_event( diff --git a/crates/buzz-search/Cargo.toml b/crates/buzz-search/Cargo.toml index e42bcc8041b..e28c5b68409 100644 --- a/crates/buzz-search/Cargo.toml +++ b/crates/buzz-search/Cargo.toml @@ -9,9 +9,11 @@ description = "Postgres full-text search for Buzz, scoped by community" [dependencies] buzz-core = { workspace = true } +buzz-datastore-tracing = { workspace = true } sqlx = { workspace = true } uuid = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } [dev-dependencies] tokio = { workspace = true } diff --git a/crates/buzz-search/src/query.rs b/crates/buzz-search/src/query.rs index d191353afc8..bd95e8cdbbc 100644 --- a/crates/buzz-search/src/query.rs +++ b/crates/buzz-search/src/query.rs @@ -8,10 +8,12 @@ //! //! See conformance row 50. -use buzz_core::CommunityId; use sqlx::{PgPool, QueryBuilder, Row}; use uuid::Uuid; +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; + use crate::error::SearchError; /// Channel-scope filter for a community-scoped FTS query. @@ -213,6 +215,7 @@ fn normalized_search_text(q: &str) -> Option { /// /// `community_id = $ctx` is the first predicate and is non-negotiable. There /// is no code path through this function that omits it. +#[datastore_span(name = "search", system = "postgresql")] pub async fn search(pool: &PgPool, query: &SearchQuery) -> Result { let Some(search_text) = normalized_search_text(&query.q) else { return Ok(SearchResult { diff --git a/crates/buzz-test-client/tests/e2e_media_extended.rs b/crates/buzz-test-client/tests/e2e_media_extended.rs index 8a9283c0409..d8adfaed984 100644 --- a/crates/buzz-test-client/tests/e2e_media_extended.rs +++ b/crates/buzz-test-client/tests/e2e_media_extended.rs @@ -423,6 +423,72 @@ async fn test_upload_svg_accepted_as_text_xml() { println!("✅ SVG (XML declaration) → 200 as text/xml"); } +#[tokio::test] +#[ignore] +async fn test_upload_html_served_as_inert_attachment() { + // HTML is accepted on the generic file path and MUST be served as an inert + // download: the security property the whole feature relies on is that the + // relay returns `Content-Disposition: attachment` + `X-Content-Type-Options: + // nosniff` + `Content-Security-Policy: default-src 'none'` so the payload can + // never execute or render as active content. This response-level regression + // pins that end to end (upload → GET), not just the deny-list membership. + let client = http_client(); + let keys = Keys::generate(); + // Exactly the shape `infer` classifies as text/html (leading recognised tag). + let html = b""; + let resp = upload(&client, &keys, html).await; + let status = resp.status().as_u16(); + assert_eq!( + status, 200, + "HTML should upload via file path, got {status}" + ); + let desc: serde_json::Value = resp.json().await.unwrap(); + assert_eq!(desc["type"].as_str().unwrap(), "text/html"); + let url = desc["url"].as_str().unwrap(); + assert!( + url.ends_with(".html"), + "served URL must carry the .html extension, got {url}" + ); + let sha256 = desc["sha256"].as_str().unwrap(); + + let get_resp = client + .get(url) + .header( + "Authorization", + blossom_auth_header(&sign_blossom_get_auth(&keys, sha256)), + ) + .send() + .await + .expect("GET request"); + assert_eq!(get_resp.status(), 200, "HTML GET roundtrip should succeed"); + + let header = |name: &str| { + get_resp + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() + }; + assert_eq!(header("content-type"), "text/html"); + assert_eq!( + header("content-disposition"), + "attachment", + "HTML must be forced to download, never rendered inline" + ); + assert_eq!( + header("x-content-type-options"), + "nosniff", + "nosniff must prevent MIME re-sniffing to an executable type" + ); + assert_eq!( + header("content-security-policy"), + "default-src 'none'", + "restrictive CSP must neutralise any active content" + ); + println!("✅ HTML → 200, served as inert attachment (disposition+nosniff+CSP)"); +} + #[tokio::test] #[ignore] async fn test_upload_pdf_accepted() { diff --git a/crates/buzz-test-client/tests/e2e_mesh_llm.rs b/crates/buzz-test-client/tests/e2e_mesh_llm.rs index 4b1bfb3d213..21a8ec29d79 100644 --- a/crates/buzz-test-client/tests/e2e_mesh_llm.rs +++ b/crates/buzz-test-client/tests/e2e_mesh_llm.rs @@ -11,7 +11,9 @@ //! # Running (manual / runbook) //! //! ```text -//! # 1. prepare the matching native runtime with `scripts/ensure-mesh-native-runtime.sh` +//! # 1. the mesh-enabled desktop installs the signed native runtime itself on +//! # first init; stale pre-0.75 cache entries are skipped rather than +//! # fatal (MeshLLM >= 0.75.1), so no manual cleanup is needed //! # 2. start the normal membership-gated relay and a mesh-enabled desktop //! # 3. have that desktop publish status, then run the trust assertions: //! RELAY_URL=ws://localhost:3000 \ diff --git a/deny.toml b/deny.toml index c432a20ea4f..d3c5fcd4bc7 100644 --- a/deny.toml +++ b/deny.toml @@ -15,10 +15,6 @@ ignore = [ # remove these when upstream catches up. { id = "RUSTSEC-2026-0194", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, { id = "RUSTSEC-2026-0195", reason = "transitive via rust-s3 and mesh-llm→plist; trusted-input XML only; no upstream fix available yet" }, - # nostr-relay-pool 0.44.3 — informational/unmaintained, not a vulnerability. - # Transitive via mesh-llm 0.74 → nostr-sdk 0.44.1. Remove after mesh-llm - # migrates to nostr-sdk >= 0.45, which absorbed the standalone relay pool. - { id = "RUSTSEC-2026-0243", reason = "transitive via mesh-llm; upstream nostr-sdk 0.45 migration requires API changes" }, ] [licenses] diff --git a/desktop/package.json b/desktop/package.json index bff16710833..0c1fb137339 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.9", + "version": "0.5.10", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index b574e026e51..a9ec17a249f 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/search-scope-screenshots.spec.ts", "**/onboarding-docked-cta-screenshots.spec.ts", "**/identity-key-help.spec.ts", "**/key-import-reveal.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 2ff7d183cd1..9833847ece5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -447,20 +447,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -507,12 +508,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -762,6 +757,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -774,7 +775,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -815,7 +816,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] [[package]] @@ -827,6 +828,12 @@ dependencies = [ "hex-conservative 0.3.2", ] +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -847,6 +854,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "hex-conservative 1.2.0", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1047,10 +1066,10 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.2", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "sha2 0.11.0", @@ -1065,7 +1084,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.9" +version = "0.5.10" dependencies = [ "anyhow", "arboard", @@ -1104,13 +1123,14 @@ dependencies = [ "mesh-llm-sdk", "mesh-llm-system", "neteq", - "nostr", + "nostr 0.44.7", "notify-rust", "objc2", "objc2-app-kit", "objc2-foundation", "objc2-user-notifications", "opus", + "percent-encoding", "plist", "png 0.18.1", "portable-pty", @@ -1172,7 +1192,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1201,7 +1221,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -2834,6 +2854,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3577,6 +3607,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3635,6 +3674,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3678,29 +3727,12 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] @@ -4463,7 +4495,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.1", + "lru", "n0-error", "n0-future", "noq", @@ -4982,12 +5014,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.1" @@ -5185,8 +5211,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "mesh-llm-client", @@ -5195,8 +5221,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5206,13 +5232,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5243,8 +5269,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5259,8 +5285,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5269,8 +5295,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "clap", @@ -5281,12 +5307,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -5294,8 +5317,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -5303,16 +5326,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "argon2", @@ -5330,7 +5382,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -5345,6 +5396,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -5357,6 +5409,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5404,8 +5457,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "argon2", "base64 0.22.1", @@ -5426,8 +5479,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "serde", @@ -5437,8 +5490,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-types", @@ -5451,8 +5504,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5468,8 +5521,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5487,8 +5540,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "hex", @@ -5498,18 +5551,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5531,8 +5593,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5546,8 +5608,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "dirs", @@ -5557,8 +5619,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "chrono", @@ -5566,8 +5628,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading 0.8.9", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5580,8 +5646,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "hex", "serde", @@ -5591,13 +5657,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5608,6 +5674,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "anyhow", + "libloading 0.8.9", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "miette" version = "7.6.0" @@ -5700,8 +5782,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", @@ -5711,14 +5793,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5729,14 +5811,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5749,16 +5831,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "model-artifact", @@ -6212,6 +6294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -6252,9 +6335,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -6262,7 +6345,7 @@ dependencies = [ "hex", "instant", "scrypt", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "unicode-normalization", @@ -6270,56 +6353,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.2", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.2", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6824,6 +6922,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "open" version = "5.3.6" @@ -6837,8 +6941,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "async-trait", "axum", @@ -8231,7 +8335,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.1", + "lru", "palette", "serde", "strum", @@ -9071,6 +9175,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.8.6", + "secp256k1-sys", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -9648,8 +9763,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "blake3", @@ -9658,40 +9773,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "prost 0.14.4", "prost-build 0.14.4", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "anyhow", "libc", @@ -9704,8 +9820,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "ahash", "anyhow", @@ -9716,6 +9832,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto", "serde", @@ -9725,16 +9843,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.75.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.75.1#3295c902d4c4f859aaadf9240042ffdaf06dd07e" dependencies = [ "serde", "serde_json", @@ -11135,6 +11262,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -11203,9 +11339,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -11213,7 +11349,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -11253,6 +11389,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -11643,9 +11780,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -11863,6 +12000,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6b6f2b9a7be..31092de99a9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ members = ["crates/buzz-terminal"] [package] name = "buzz-desktop" -version = "0.5.9" +version = "0.5.10" description = "Buzz desktop app" authors = ["you"] edition = "2021" @@ -98,6 +98,7 @@ nostr = { version = "0.44", features = ["nip44", "nip49"] } # transitive dependency; pinned here for direct use). getrandom = "0.2" zeroize = "1" +percent-encoding = "2" reqwest = { version = "0.13", features = ["json", "query", "stream", "blocking"] } rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std"] } url = "2" @@ -109,14 +110,14 @@ buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } portable-pty = "0.9" iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.75.1", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 59c80c48076..2688346ffd0 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -3,7 +3,7 @@ use tauri::State; use crate::{ app_state::AppState, events, - models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse}, + models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse, GetChannelsPayload}, nostr_convert, relay::{query_relay, relay_api_base_url_with_override, submit_event, submit_event_with_keys}, }; @@ -75,79 +75,189 @@ fn classify_pending_owner(state: &AppState, my_pubkey: &str, d_tag: Option<&str> d_tag.is_some_and(|d| state.is_pending_owned_channel(my_pubkey, d)) } -#[tauri::command] -pub async fn get_channels(state: State<'_, AppState>) -> Result, String> { - let _profile_start = std::time::Instant::now(); - let my_pubkey = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() - }; +// ── FNV-1a hash for the not-modified short-circuit ─────────────────────────── + +/// FNV-1a 64-bit hash over arbitrary bytes. Used in preference to +/// `std::collections::hash_map::DefaultHasher` because the standard library +/// does not guarantee cross-invocation stability. +fn fnv1a_64(data: &[u8]) -> u64 { + const OFFSET: u64 = 14695981039346656037; + const PRIME: u64 = 1099511628211; + let mut hash = OFFSET; + for &byte in data { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(PRIME); + } + hash +} - // Step 1: find all kind:39002 (members) events that mention me, then - // pull the channel ids out of their `d` tags. - let member_events = query_relay_all( - &state, - serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), - ) - .await?; +/// Stable projection of `ChannelInfo` for hashing. Excludes `last_message_at` +/// so routine message traffic does not invalidate the not-modified short-circuit +/// for the channel list. +#[derive(serde::Serialize)] +struct ChannelInfoForHash<'a> { + id: &'a str, + name: &'a str, + channel_type: &'a str, + visibility: &'a str, + description: &'a str, + topic: &'a Option, + purpose: &'a Option, + member_count: i64, + member_pubkeys: &'a Vec, + archived_at: &'a Option, + participants: &'a Vec, + participant_pubkeys: &'a Vec, + is_member: bool, + ttl_seconds: &'a Option, + ttl_deadline: &'a Option, +} - #[cfg(debug_assertions)] - let t_members = _profile_start.elapsed(); +/// Compute a stable 64-bit FNV-1a hash over the channel list, canonicalized +/// by sorting on channel id and excluding `last_message_at`. Returns a +/// 16-character lowercase hex string. +fn compute_channels_hash(channels: &[ChannelInfo]) -> String { + let mut sorted: Vec<&ChannelInfo> = channels.iter().collect(); + sorted.sort_by(|a, b| a.id.cmp(&b.id)); - let mut channel_ids: Vec = member_events + let projections: Vec> = sorted .iter() - .filter_map(|ev| { - ev.tags.iter().find_map(|t| { - let s = t.as_slice(); - if s.len() >= 2 && s[0] == "d" { - Some(s[1].clone()) - } else { - None - } - }) + .map(|c| ChannelInfoForHash { + id: &c.id, + name: &c.name, + channel_type: &c.channel_type, + visibility: &c.visibility, + description: &c.description, + topic: &c.topic, + purpose: &c.purpose, + member_count: c.member_count, + member_pubkeys: &c.member_pubkeys, + archived_at: &c.archived_at, + participants: &c.participants, + participant_pubkeys: &c.participant_pubkeys, + is_member: c.is_member, + ttl_seconds: &c.ttl_seconds, + ttl_deadline: &c.ttl_deadline, }) .collect(); - channel_ids.sort(); - channel_ids.dedup(); - - // The real kind:39002 membership has now resolved for these channels — - // drop them from the pending-owner overlay (see `AppState::pending_owned_channels`) - // so a channel this identity created no longer speaks through the overlay - // once genuine membership is observable, and a later leave correctly - // flips it back to `is_member=false`. - for id in &channel_ids { - state.clear_pending_owned_channel(&my_pubkey, id); - } - // Step 2: fetch channel metadata events (kind:39000) for member channels. - // kind:39000 is addressable: exactly one event per `d` tag, so a limit - // equal to the number of ids is both necessary and sufficient. Without - // an explicit limit, multi-value `#d` filters fall through to the relay's - // default LIMIT and can drop results when there are many channels. - let meta_events = if !channel_ids.is_empty() { - query_relay( - &state, - &[serde_json::json!({ - "kinds": [39000], - "#d": channel_ids, - "limit": channel_ids.len(), - })], - ) - .await? - } else { - Vec::new() - }; + let canonical = serde_json::to_string(&projections).unwrap_or_default(); + format!("{:016x}", fnv1a_64(canonical.as_bytes())) +} +// ── Core fetch implementation ───────────────────────────────────────────────── + +/// Fetch the full channel list from the relay. Called by both `get_channels` +/// (the Tauri command, which wraps the result with hash-based short-circuit +/// logic) and `ensure_starter_channels` (which needs the raw list directly). +/// +/// Relay round-trips run in two concurrent phases: +/// - Phase 1 (parallel): member-chain (kind:39002→kind:39000), open directory +/// (kind:39000 all-open), and hidden-DM snapshot (kind:30622). +/// - Phase 2 (parallel): member counts (kind:39002 batch) and last-message +/// timestamps (per-channel kind:9/40002). +async fn fetch_channels(state: &AppState) -> Result, String> { #[cfg(debug_assertions)] - let t_member_meta = _profile_start.elapsed(); + let _profile_start = std::time::Instant::now(); + + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; - // Step 3: fetch ALL open channel metadata so the channel browser can show - // discoverable channels the user hasn't joined yet. The relay's access - // control allows reading kind:39000 for open channels regardless of membership. - let open_meta_events = query_relay_all(&state, serde_json::json!({"kinds": [39000]})).await?; + // Phase 1 — concurrent: member-chain (steps 1→2), open directory (step 3), + // and hidden-DM snapshot (step 6). These three have no mutual dependencies. + let (member_chain_result, open_meta_result, hidden_dms) = tokio::join!( + // Steps 1+2: find the channels this identity belongs to, then fetch + // their metadata events. + async { + // Step 1: kind:39002 events listing my pubkey as a member. + let member_events = query_relay_all( + state, + serde_json::json!({"kinds": [39002], "#p": [&my_pubkey]}), + ) + .await?; + + let mut member_channel_ids: Vec = member_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + member_channel_ids.sort(); + member_channel_ids.dedup(); + + // Real kind:39002 membership has landed — clear the pending-owner + // overlay so a subsequent leave correctly flips `is_member` back + // to false. See `AppState::pending_owned_channels`. + for id in &member_channel_ids { + state.clear_pending_owned_channel(&my_pubkey, id); + } + + // Step 2: fetch channel metadata events (kind:39000) for member channels. + // kind:39000 is addressable: exactly one event per `d` tag, so a limit + // equal to the number of ids is both necessary and sufficient. + let meta_events = if !member_channel_ids.is_empty() { + query_relay( + state, + &[serde_json::json!({ + "kinds": [39000], + "#d": &member_channel_ids, + "limit": member_channel_ids.len(), + })], + ) + .await? + } else { + Vec::new() + }; + + Ok::<_, String>(meta_events) + }, + // Step 3: fetch ALL open channel metadata so the channel browser can show + // discoverable channels the user hasn't joined yet. + query_relay_all(state, serde_json::json!({"kinds": [39000]})), + // Step 6: NIP-DV hidden-DM snapshot. Tolerant — a failure means no DMs + // are hidden rather than aborting the whole fetch. + async { + let events = query_relay( + state, + &[serde_json::json!({ + "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], + "#p": [&my_pubkey], + "limit": 1, + })], + ) + .await + .unwrap_or_default(); + events + .iter() + .max_by_key(|e| e.created_at.as_secs()) + .map(|e| { + e.tags + .iter() + .filter_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) + }) + .collect::>() + }) + .unwrap_or_default() + }, + ); #[cfg(debug_assertions)] - let t_open_meta = _profile_start.elapsed(); + let t_phase1 = _profile_start.elapsed(); + + let meta_events = member_chain_result?; + let open_meta_events = open_meta_result?; + // hidden_dms is already a resolved HashSet (tolerant path above) // Merge: member channels (marked as member) + open channels (not yet joined). let member_d_tags: std::collections::HashSet = meta_events @@ -187,58 +297,19 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result } // The overlay (`AppState::pending_owned_channels`) marks channels this // identity just created via `create_channel` whose kind:39002 owner - // membership hasn't propagated yet (#1761) — a fresh channel has no - // member event and would otherwise fall through to `is_member=false` - // here, disabling the owner's own composer until that snapshot lands. - // The overlay can only be populated by this process's own - // `create_channel` call (never by relay data) and is keyed by - // `(my_pubkey, d_tag)`, so it adds no trust-boundary risk and can - // never speak for a channel a different identity created; `channel_ids` - // above clears it once real membership is observed for `my_pubkey`. - let is_pending_owner = classify_pending_owner(&state, &my_pubkey, d_tag.as_deref()); + // membership hasn't propagated yet (#1761). + let is_pending_owner = classify_pending_owner(state, &my_pubkey, d_tag.as_deref()); if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(is_pending_owner)) { channels.push(info); } } - // Populate member_count by batch-fetching kind:39002 for every listed - // channel and counting unique p-tag pubkeys. The kind:40901 summary - // sidecar that channel_info_from_event prefers isn't emitted by the - // relay today, so without this step every channel reports 0 members - // in the channel browser (the active-channel top bar masks this with - // its own live members query). - let all_d_tags: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !all_d_tags.is_empty() { - let members_events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [39002], - "#d": all_d_tags, - "limit": all_d_tags.len(), - })], - ) - .await - .unwrap_or_default(); - - let membership = collect_members_by_channel(&members_events); - for channel in &mut channels { - if let Some(info) = membership.get(&channel.id) { - channel.member_count = info.count; - channel.member_pubkeys = info.pubkeys.clone(); - } - } - } - - #[cfg(debug_assertions)] - let t_member_counts = _profile_start.elapsed(); - - // Populate last_message_at by fetching the most recent human message per - // channel. Uses per-channel filters (single #h value each) so the relay can - // push the query to its indexed channel_id column. Multi-value #h is NOT - // SQL-pushed and would silently drop quieter channels under the global limit. - let channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); - if !channel_ids.is_empty() { - let filters: Vec = channel_ids + // Phase 2 — concurrent: member counts (step 4) and last-message timestamps + // (step 5). Both tolerate failures — empty defaults leave counts at 0 and + // timestamps at None rather than aborting. + let all_channel_ids: Vec = channels.iter().map(|c| c.id.clone()).collect(); + if !all_channel_ids.is_empty() { + let last_msg_filters: Vec = all_channel_ids .iter() .map(|id| { serde_json::json!({ @@ -249,11 +320,32 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result }) .collect(); - let message_events = query_relay(&state, &filters).await.unwrap_or_default(); + // Bind both filter arrays before the join so their lifetimes cover + // both branches of the concurrent pair. + let member_count_filters = [serde_json::json!({ + "kinds": [39002], + "#d": &all_channel_ids, + "limit": all_channel_ids.len(), + })]; + let (members_result, message_result) = tokio::join!( + // Step 4: batch-fetch kind:39002 for member counts. + query_relay(state, &member_count_filters), + // Step 5: per-channel last-message filter. Uses per-channel `#h` + // so the relay can push each query to its indexed channel_id column. + query_relay(state, &last_msg_filters), + ); + + let membership = collect_members_by_channel(&members_result.unwrap_or_default()); + for channel in &mut channels { + if let Some(info) = membership.get(&channel.id) { + channel.member_count = info.count; + channel.member_pubkeys = info.pubkeys.clone(); + } + } let mut last_message_by_channel: std::collections::HashMap = std::collections::HashMap::new(); - for ev in &message_events { + for ev in &message_result.unwrap_or_default() { if let Some(ch_id) = ev.tags.iter().find_map(|t| { let s = t.as_slice(); (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) @@ -269,7 +361,6 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result .or_insert(ts); } } - for channel in &mut channels { if let Some(&ts) = last_message_by_channel.get(&channel.id) { channel.last_message_at = Some(nostr_convert::timestamp_to_iso(ts)); @@ -277,63 +368,71 @@ pub async fn get_channels(state: State<'_, AppState>) -> Result } } - #[cfg(debug_assertions)] - let t_last_message = _profile_start.elapsed(); - - // NIP-DV: drop DMs the viewer has hidden. The relay maintains a per-viewer - // parameterized-replaceable snapshot (kind:30622, d=my pubkey) whose `h` - // tags list currently-hidden DM channel ids. The snapshot also carries - // `p`=my pubkey so the relay's #p read-gate scopes it to me; we query by - // `#p` for that reason. Reading the latest one is the only way the client - // learns hide state, which the relay tracks privately. - let hidden_dms: std::collections::HashSet = { - let events = query_relay( - &state, - &[serde_json::json!({ - "kinds": [buzz_core_pkg::kind::KIND_DM_VISIBILITY], - "#p": [&my_pubkey], - "limit": 1, - })], - ) - .await - .unwrap_or_default(); - events - .iter() - .max_by_key(|e| e.created_at.as_secs()) - .map(|e| { - e.tags - .iter() - .filter_map(|t| { - let s = t.as_slice(); - (s.len() >= 2 && s[0] == "h").then(|| s[1].clone()) - }) - .collect() - }) - .unwrap_or_default() - }; - if !hidden_dms.is_empty() { - channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); - } - #[cfg(debug_assertions)] { let total = _profile_start.elapsed(); eprintln!( - "buzz-desktop: get_channels profile channels={} members={:?} member_meta={:?} open_meta={:?} member_counts={:?} last_message={:?} hidden_dm={:?} total={:?}", + "buzz-desktop: get_channels profile channels={} phase1(member_chain+open_meta+hidden_dm)={:?} phase2(member_counts+last_msg)={:?} total={:?}", channels.len(), - t_members, - t_member_meta - t_members, - t_open_meta - t_member_meta, - t_member_counts - t_open_meta, - t_last_message - t_member_counts, - total - t_last_message, + t_phase1, + total - t_phase1, total, ); } + // NIP-DV: drop DMs the viewer has hidden. + if !hidden_dms.is_empty() { + channels.retain(|c| c.channel_type != "dm" || !hidden_dms.contains(&c.id)); + } + Ok(channels) } +// ── Tauri commands ──────────────────────────────────────────────────────────── + +/// Return the full channel list for the active identity. +/// +/// `known_hash` is a previously returned `hash` value. When it matches the +/// computed stable hash (which excludes `last_message_at`), the response +/// carries `channels: null` so the multi-MB list is not serialized across IPC. +/// `last_messages` is always included because it is cheap and changes with +/// every new message. +#[tauri::command] +pub async fn get_channels( + known_hash: Option, + state: State<'_, AppState>, +) -> Result { + let channels = fetch_channels(&state).await?; + + let last_messages: std::collections::HashMap = channels + .iter() + .filter_map(|c| { + c.last_message_at + .as_ref() + .map(|ts| (c.id.clone(), ts.clone())) + }) + .collect(); + + let hash = compute_channels_hash(&channels); + + // Not-modified short-circuit: skip the multi-MB IPC payload when the + // caller's hash matches. `last_messages` still ships so the TS side can + // update sidebar timestamps without re-rendering the full list. + if known_hash.as_deref() == Some(hash.as_str()) { + return Ok(GetChannelsPayload { + hash, + channels: None, + last_messages, + }); + } + + Ok(GetChannelsPayload { + hash, + channels: Some(channels), + last_messages, + }) +} + struct ChannelMembership { count: i64, pubkeys: Vec, @@ -612,7 +711,7 @@ pub async fn create_channel( pub async fn ensure_starter_channels( state: State<'_, AppState>, ) -> Result, String> { - let mut existing_channels = get_channels(state.clone()).await?; + let mut existing_channels = fetch_channels(&state).await?; let relay_scope = relay_api_base_url_with_override(&state); let creator_keys = state.signing_keys()?; let creator_pubkey = creator_keys.public_key().to_hex(); @@ -671,7 +770,7 @@ pub async fn ensure_starter_channels( } if !has_all_starter_channels(&existing_channels) { - existing_channels = get_channels(state.clone()).await?; + existing_channels = fetch_channels(&state).await?; } if !has_all_starter_channels(&existing_channels) { diff --git a/desktop/src-tauri/src/commands/channels_tests.rs b/desktop/src-tauri/src/commands/channels_tests.rs index 5b65695a913..43da15703c8 100644 --- a/desktop/src-tauri/src/commands/channels_tests.rs +++ b/desktop/src-tauri/src/commands/channels_tests.rs @@ -2,6 +2,7 @@ // channels.rs under the per-file line cap. use super::*; +use crate::models::ChannelInfo; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; /// Build a signed event for testing with the given kind, content, and tags. @@ -266,6 +267,131 @@ fn duplicate_channel_rejection_is_ensure_success_only() { )); } +// ── compute_channels_hash ───────────────────────────────────────────────────── + +fn make_channel(id: &str, name: &str, last_message_at: Option) -> ChannelInfo { + ChannelInfo { + id: id.to_string(), + name: name.to_string(), + channel_type: "stream".to_string(), + visibility: "open".to_string(), + description: "".to_string(), + topic: None, + purpose: None, + member_count: 0, + member_pubkeys: Vec::new(), + last_message_at, + archived_at: None, + participants: Vec::new(), + participant_pubkeys: Vec::new(), + is_member: true, + ttl_seconds: None, + ttl_deadline: None, + } +} + +#[test] +fn hash_is_order_insensitive() { + let c1 = make_channel("aaa", "Alpha", None); + let c2 = make_channel("bbb", "Beta", None); + let c3 = make_channel("aaa", "Alpha", None); + let c4 = make_channel("bbb", "Beta", None); + + assert_eq!( + compute_channels_hash(&[c1, c2]), + compute_channels_hash(&[c4, c3]), + "hash must be insensitive to channel list ordering", + ); +} + +#[test] +fn hash_ignores_last_message_at() { + let c_none = make_channel("chan-1", "Alpha", None); + let c_some = make_channel("chan-1", "Alpha", Some("2026-01-01T00:00:00Z".to_string())); + + assert_eq!( + compute_channels_hash(&[c_none]), + compute_channels_hash(&[c_some]), + "hash must be insensitive to last_message_at", + ); +} + +#[test] +fn hash_changes_on_metadata_change() { + let c1 = make_channel("chan-1", "Alpha", None); + let c2 = make_channel("chan-1", "AlphaRenamed", None); + + assert_ne!( + compute_channels_hash(&[c1]), + compute_channels_hash(&[c2]), + "hash must change when channel name changes", + ); +} + +#[test] +fn hash_changes_on_membership_change() { + let mut c1 = make_channel("chan-1", "Alpha", None); + let mut c2 = make_channel("chan-1", "Alpha", None); + c1.member_pubkeys = vec![PK_A.to_string()]; + c2.member_pubkeys = vec![PK_A.to_string(), PK_B.to_string()]; + + assert_ne!( + compute_channels_hash(&[c1]), + compute_channels_hash(&[c2]), + "hash must change when member_pubkeys changes", + ); +} + +#[test] +fn not_modified_returns_none_when_hash_matches() { + let channels = vec![make_channel("chan-1", "General", None)]; + let hash = compute_channels_hash(&channels); + + // Mirror the get_channels command decision logic. + let known_hash = Some(hash.clone()); + let is_not_modified = known_hash.as_deref() == Some(hash.as_str()); + + assert!( + is_not_modified, + "identical hash must trigger the not-modified short-circuit", + ); +} + +#[test] +fn not_modified_does_not_trigger_on_hash_mismatch() { + let channels = vec![make_channel("chan-1", "General", None)]; + let hash = compute_channels_hash(&channels); + let known_hash = Some("0000000000000000".to_string()); + + let is_not_modified = known_hash.as_deref() == Some(hash.as_str()); + + assert!( + !is_not_modified, + "stale hash must NOT trigger the not-modified short-circuit", + ); +} + +#[test] +fn hash_is_stable_for_same_input() { + // Verifies that the FNV-1a output is deterministic across calls within + // the same process (unlike std DefaultHasher which uses random seeds). + let channels = vec![ + make_channel("aaa", "General", Some("2026-01-01T00:00:00Z".to_string())), + make_channel("bbb", "Random", None), + ]; + let first = compute_channels_hash(&channels); + let channels2 = vec![ + make_channel("aaa", "General", None), // last_message_at change is ignored + make_channel("bbb", "Random", None), + ]; + let second = compute_channels_hash(&channels2); + + assert_eq!( + first, second, + "hash must be deterministic and ignore last_message_at" + ); +} + #[test] fn starter_match_requires_open_unarchived_stream_by_normalized_name() { let spec = &STARTER_CHANNELS[0]; diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5ada1b38840..732c750a135 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -1,9 +1,8 @@ use std::{io::Cursor, net::IpAddr, time::Duration}; use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; -use image::ImageDecoder; - use futures_util::StreamExt; +use image::ImageDecoder; use reqwest::{ header::{ACCEPT, CONTENT_TYPE, LOCATION, USER_AGENT}, redirect::Policy, @@ -13,6 +12,8 @@ use url::Url; #[path = "link_preview_rate_limit.rs"] mod rate_limit; +#[path = "link_preview_youtube.rs"] +mod youtube; use rate_limit::{image_host_cooldown_remaining, retry_after_duration, set_image_host_cooldown}; @@ -67,6 +68,10 @@ async fn fetch_link_preview_metadata_inner( let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; validate_public_https_url(&url).await?; + if youtube::is_video_url(&url) { + return youtube::fetch_oembed_metadata(&url).await; + } + for redirect_count in 0..=MAX_REDIRECTS { let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?; diff --git a/desktop/src-tauri/src/commands/link_preview_youtube.rs b/desktop/src-tauri/src/commands/link_preview_youtube.rs new file mode 100644 index 00000000000..722c481b5e9 --- /dev/null +++ b/desktop/src-tauri/src/commands/link_preview_youtube.rs @@ -0,0 +1,361 @@ +use percent_encoding::percent_decode_str; +use reqwest::header::CONTENT_TYPE; +use serde::Deserialize; +use url::Url; + +use super::{ + apply_image_result, fetch_sanitized_image, normalize_metadata_description, + normalize_metadata_text, read_limited_bytes, send_pinned_request, ImageFetchError, + LinkPreviewImageFetchState, LinkPreviewMetadata, PREVIEW_FETCH_TIMEOUT, +}; + +const MAX_OEMBED_FETCH_BYTES: usize = 64 * 1024; +const OEMBED_ENDPOINT: &str = "https://www.youtube.com/oembed"; + +#[derive(Deserialize)] +struct OEmbedResponse { + title: String, + author_name: Option, + provider_name: Option, + thumbnail_url: Option, +} + +pub(super) fn is_video_url(url: &Url) -> bool { + let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else { + return false; + }; + match host.as_str() { + "youtu.be" | "www.youtu.be" => url + .path_segments() + .and_then(|mut segments| segments.next()) + .is_some_and(|segment| !segment.is_empty()), + "youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com" => { + (url.path() == "/watch" + && url + .query_pairs() + .any(|(key, value)| key == "v" && !value.is_empty())) + || ["shorts", "live", "embed"].iter().any(|prefix| { + url.path_segments().is_some_and(|mut segments| { + segments.next() == Some(prefix) + && segments.next().is_some_and(|segment| !segment.is_empty()) + }) + }) + } + _ => false, + } +} + +pub(super) async fn fetch_oembed_metadata( + video_url: &Url, +) -> Result, String> { + let oembed_url = oembed_url(video_url)?; + let response = send_pinned_request(&oembed_url, "application/json").await?; + let Some((mut metadata, thumbnail_url)) = parse_oembed_response(response).await? else { + return Ok(None); + }; + let image_result = match thumbnail_url { + Some(thumbnail_url) => Some( + tokio::time::timeout( + PREVIEW_FETCH_TIMEOUT, + fetch_sanitized_image(thumbnail_url, false), + ) + .await + .unwrap_or(Err(ImageFetchError::Transient { retry_after: None })), + ), + None => None, + }; + apply_image_result(&mut metadata, image_result); + Ok(Some(metadata)) +} + +fn oembed_url(video_url: &Url) -> Result { + let mut canonical_video_url = video_url.clone(); + if matches!( + video_url.host_str(), + Some("youtube.com" | "www.youtube.com" | "m.youtube.com" | "music.youtube.com") + ) { + let mut segments = video_url.path_segments(); + if segments.as_mut().and_then(|segments| segments.next()) == Some("embed") { + let encoded_video_id = segments + .and_then(|mut segments| segments.next()) + .ok_or_else(|| "YouTube embed URL has no video ID".to_string())?; + let video_id = percent_decode_str(encoded_video_id) + .decode_utf8() + .map_err(|_| "YouTube embed URL has an invalid video ID".to_string())?; + if !video_id.chars().all(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) { + return Err("YouTube embed URL has an invalid video ID".to_string()); + } + canonical_video_url.set_path("/watch"); + canonical_video_url.set_query(None); + canonical_video_url + .query_pairs_mut() + .append_pair("v", &video_id); + canonical_video_url.set_fragment(None); + } + } + + let mut oembed_url = Url::parse(OEMBED_ENDPOINT) + .map_err(|error| format!("invalid YouTube oEmbed endpoint: {error}"))?; + oembed_url + .query_pairs_mut() + .append_pair("format", "json") + .append_pair("url", canonical_video_url.as_str()); + Ok(oembed_url) +} + +async fn parse_oembed_response( + response: reqwest::Response, +) -> Result)>, String> { + if !response.status().is_success() || !is_json_response(&response) { + return Ok(None); + } + let body = read_limited_bytes(response, MAX_OEMBED_FETCH_BYTES).await?; + let response: OEmbedResponse = match serde_json::from_slice(&body) { + Ok(response) => response, + Err(_) => return Ok(None), + }; + Ok(oembed_metadata(response)) +} + +fn oembed_metadata(response: OEmbedResponse) -> Option<(LinkPreviewMetadata, Option)> { + let title = normalize_metadata_text(&response.title)?; + let thumbnail_url = response + .thumbnail_url + .as_deref() + .and_then(|thumbnail| Url::parse(thumbnail).ok()); + let metadata = LinkPreviewMetadata { + title, + site_name: response + .provider_name + .as_deref() + .and_then(normalize_metadata_text) + .or_else(|| Some("YouTube".to_string())), + description: response + .author_name + .as_deref() + .and_then(normalize_metadata_description), + image_data_url: None, + image_domain: None, + image_fetch_state: LinkPreviewImageFetchState::None, + image_retry_after_ms: None, + favicon_data_url: None, + }; + Some((metadata, thumbnail_url)) +} + +fn is_json_response(response: &reqwest::Response) -> bool { + response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| { + value + .split(';') + .next() + .unwrap_or_default() + .trim() + .eq_ignore_ascii_case("application/json") + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{body::Body, http::Response, routing::get, Router}; + + async fn test_response(router: Router, path: &str) -> reqwest::Response { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + reqwest::get(format!("http://{address}{path}")) + .await + .unwrap() + } + + #[test] + fn recognizes_supported_video_urls_only() { + for href in [ + "https://www.youtube.com/watch?v=hLFs9JtMaRg", + "https://m.youtube.com/watch?v=hLFs9JtMaRg&feature=share", + "https://music.youtube.com/watch?v=hLFs9JtMaRg", + "https://youtu.be/hLFs9JtMaRg?t=10", + "https://www.youtube.com/shorts/hLFs9JtMaRg", + "https://www.youtube.com/live/hLFs9JtMaRg", + "https://www.youtube.com/embed/hLFs9JtMaRg", + ] { + assert!(is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + for href in [ + "https://www.youtube.com/", + "https://www.youtube.com/watch", + "https://www.youtube.com/watch?v=", + "https://www.youtube.com/@buzz", + "https://youtube.com.evil.example/watch?v=hLFs9JtMaRg", + "https://notyoutube.com/watch?v=hLFs9JtMaRg", + ] { + assert!(!is_video_url(&Url::parse(href).unwrap()), "{href}"); + } + } + + #[test] + fn canonicalizes_embed_url_for_oembed() { + for (video_url, expected_video_id) in [ + ( + "https://www.youtube.com/embed/dQw4w9WgXcQ?start=10#player", + "dQw4w9WgXcQ", + ), + ("https://www.youtube.com/embed/%64Qw4w9WgXcQ", "dQw4w9WgXcQ"), + ("https://www.youtube.com/embed/dQw4w9WgX%63Q", "dQw4w9WgXcQ"), + ] { + let oembed_url = oembed_url(&Url::parse(video_url).unwrap()).unwrap(); + let params = oembed_url + .query_pairs() + .collect::>(); + assert_eq!( + params.get("format").map(|value| value.as_ref()), + Some("json") + ); + let provider_video_url = + Url::parse(params.get("url").expect("oEmbed URL parameter")).unwrap(); + assert_eq!(provider_video_url.path(), "/watch"); + assert_eq!( + provider_video_url + .query_pairs() + .find(|(key, _)| key == "v") + .map(|(_, value)| value.into_owned()), + Some(expected_video_id.to_string()) + ); + } + } + + #[test] + fn rejects_invalid_encoded_embed_video_ids() { + for href in [ + "https://www.youtube.com/embed/video%2Fid", + "https://www.youtube.com/embed/video%5Cid", + "https://www.youtube.com/embed/video%00id", + "https://www.youtube.com/embed/video%25id", + "https://www.youtube.com/embed/video%252Fid", + "https://www.youtube.com/embed/video%FFid", + ] { + assert!(oembed_url(&Url::parse(href).unwrap()).is_err(), "{href}"); + } + } + + #[tokio::test] + async fn response_requires_successful_bounded_json() { + let valid_json = + r#"{"title":"Video title","author_name":"Creator","provider_name":"YouTube"}"#; + let response = test_response( + Router::new().route( + "/valid", + get(move || async move { + Response::builder() + .header("content-type", "application/json; charset=UTF-8") + .body(Body::from(valid_json)) + .unwrap() + }), + ), + "/valid", + ) + .await; + let (metadata, _) = parse_oembed_response(response).await.unwrap().unwrap(); + assert_eq!(metadata.title, "Video title"); + + for response in [ + test_response( + Router::new().route( + "/not-found", + get(|| async { + Response::builder() + .status(404) + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap() + }), + ), + "/not-found", + ) + .await, + test_response( + Router::new().route( + "/html", + get(|| async { + Response::builder() + .header("content-type", "text/html") + .body(Body::from("Not JSON")) + .unwrap() + }), + ), + "/html", + ) + .await, + ] { + assert_eq!(parse_oembed_response(response).await.unwrap(), None); + } + + let oversized = vec![b' '; MAX_OEMBED_FETCH_BYTES + 1]; + let response = test_response( + Router::new().route( + "/oversized", + get(move || { + let oversized = oversized.clone(); + async move { + Response::builder() + .header("content-type", "application/json") + .body(Body::from(oversized)) + .unwrap() + } + }), + ), + "/oversized", + ) + .await; + assert!(parse_oembed_response(response).await.is_err()); + } + + #[test] + fn converts_response_to_bounded_preview_metadata() { + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: " Video title ".to_string(), + author_name: Some("Buzz Creator".to_string()), + provider_name: Some("YouTube".to_string()), + thumbnail_url: Some("https://i.ytimg.com/vi/example/hqdefault.jpg".to_string()), + }) + .unwrap(); + assert_eq!(metadata.title, "Video title"); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(metadata.description.as_deref(), Some("Buzz Creator")); + assert_eq!(metadata.image_fetch_state, LinkPreviewImageFetchState::None); + assert_eq!( + thumbnail_url.unwrap().as_str(), + "https://i.ytimg.com/vi/example/hqdefault.jpg" + ); + } + + #[test] + fn rejects_titleless_response_and_ignores_invalid_thumbnail() { + assert!(oembed_metadata(OEmbedResponse { + title: " ".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: None, + }) + .is_none()); + + let (metadata, thumbnail_url) = oembed_metadata(OEmbedResponse { + title: "Video title".to_string(), + author_name: None, + provider_name: None, + thumbnail_url: Some("not a URL".to_string()), + }) + .unwrap(); + assert_eq!(metadata.site_name.as_deref(), Some("YouTube")); + assert_eq!(thumbnail_url, None); + } +} diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 070381f55e8..8da845c07d4 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -115,11 +115,10 @@ fn fd_real_path(_file: &std::fs::File) -> Result { /// MIME types blocked from upload — mirrors the server's generic-file deny-list. /// -/// Active-content XSS carriers and native executables. Everything else (images, -/// video, documents, archives, audio, text, data) is accepted; un-sniffable -/// files fall back to `application/octet-stream` and are served as downloads. +/// Active-content XSS carriers (JS, SVG) and native executables. Other types, +/// including HTML, are accepted as downloads; un-sniffable files fall back to +/// `application/octet-stream`. XHTML remains blocked in lockstep with the relay. const BLOCKED_MIME: &[&str] = &[ - "text/html", "application/xhtml+xml", "image/svg+xml", "application/javascript", @@ -895,9 +894,29 @@ mod tests { } #[test] - fn test_detect_and_validate_mime_rejects_html() { + fn test_detect_and_validate_mime_accepts_html_as_inert_download() { let html = b""; - assert!(detect_and_validate_mime(html).is_err()); + assert_eq!(detect_and_validate_mime(html).unwrap(), "text/html"); + } + + #[test] + fn test_detect_and_validate_mime_still_rejects_executable() { + let elf = [b"\x7fELF".as_slice(), &[0u8; 60]].concat(); + assert!(detect_and_validate_mime(&elf).is_err()); + } + + #[test] + fn test_blocked_mime_keeps_active_content_and_executables() { + for kept in [ + "image/svg+xml", + "application/xhtml+xml", + "application/javascript", + "text/javascript", + "application/x-executable", + "application/x-mach-binary", + ] { + assert!(BLOCKED_MIME.contains(&kept), "{kept} must stay blocked"); + } } #[test] diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index 528ca387679..7356cd7fc0c 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use sha2::{Digest, Sha256}; use tauri::{AppHandle, Manager, State}; +use super::mesh_readiness::wait_for_mesh_inference; use crate::{app_state::AppState, mesh_llm, relay}; #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] @@ -530,142 +531,6 @@ pub async fn mesh_start_node( Ok(status) } -/// Mesh can bind its HTTP ingress and advertise a model shortly before the -/// router has installed a usable target. Probe the exact chat path agents use -/// so startup cannot race that gap (`single target None unavailable`). -/// Which startup stage a mesh client is stuck at when it never becomes -/// inference-ready. The two live-observed failure modes are physically -/// distinct and want different user copy: -/// -/// * `CatalogNeverSynced` — the local client node came up and connected to -/// the host at the control level (ping/RTT fine), but the served model -/// never appeared in the local `/v1/models` catalog. That catalog is -/// populated by the peer gossip exchange; when the gossip bi-stream can't -/// establish across the network (observed as iroh -/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays -/// empty forever and every request is rejected "model not available". -/// Root cause is the network path between this machine and the host. -/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but -/// inference requests never completed (routing/transport to the host -/// failing per-request). The host is discoverable and advertised but not -/// actually serving us. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MeshReadinessFailure { - CatalogNeverSynced, - RoutingNeverCompleted, -} - -/// Pure classifier: given whether the served model was ever observed in the -/// local `/v1/models` catalog during the wait, decide which stage failed. -/// Split out so the diagnosis is unit-testable without a live mesh. -fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { - if model_ever_visible { - MeshReadinessFailure::RoutingNeverCompleted - } else { - MeshReadinessFailure::CatalogNeverSynced - } -} - -/// Actionable, non-technical copy for a readiness failure. `last_detail` is the -/// last raw transport/HTTP error, appended for support triage. -fn mesh_readiness_failure_message( - failure: MeshReadinessFailure, - model_id: &str, - last_detail: &str, -) -> String { - match failure { - MeshReadinessFailure::CatalogNeverSynced => format!( - "Buzz shared compute connected to the serving member but could not sync \ - the model list for \"{model_id}\" — this is a network path problem \ - between this machine and the host (the compute node is reachable for \ - pings but the model-sync stream did not establish). Try again, or have \ - the host and this machine on a more direct network. (last: {last_detail})" - ), - MeshReadinessFailure::RoutingNeverCompleted => format!( - "Buzz shared compute found \"{model_id}\" on a serving member but inference \ - requests did not complete — the host is discoverable but not currently \ - reachable for requests. Try again shortly. (last: {last_detail})" - ), - } -} - -/// Poll the local mesh OpenAI ingress until a real inference for `model_id` -/// succeeds, or a deadline elapses. On failure, returns a stage-specific, -/// actionable message (see [`MeshReadinessFailure`]) rather than a raw -/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach -/// the host". -async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); - let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); - let chat_url = format!( - "{}/chat/completions", - crate::managed_agents::RELAY_MESH_API_BASE_URL - ); - let mut last_error = "mesh inference is not ready".to_string(); - // Track whether the served model ever reached the local catalog — the - // signal that splits "catalog never synced" from "routing never completed". - let mut model_ever_visible = false; - - while tokio::time::Instant::now() < deadline { - // Refresh catalog visibility. "auto" delegates model choice to the - // router, so any advertised model counts as the catalog having synced. - if let Ok(response) = client - .get(&models_url) - .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) - .send() - .await - { - if let Ok(body) = response.json::().await { - if let Some(data) = body.get("data").and_then(|d| d.as_array()) { - let wanted = model_id.trim().replace("@main", ""); - let visible = !data.is_empty() - && (model_id == crate::mesh_llm::AUTO_MODEL_ID - || data.iter().any(|m| { - m.get("id") - .and_then(|id| id.as_str()) - .map(|id| id.replace("@main", "") == wanted) - .unwrap_or(false) - })); - model_ever_visible |= visible; - } - } - } - - match client - .post(&chat_url) - .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) - .json(&serde_json::json!({ - "model": model_id, - "messages": [{"role": "user", "content": "Reply OK"}], - "max_tokens": 1, - "stream": false - })) - .send() - .await - { - Ok(response) if response.status().is_success() => return Ok(()), - Ok(response) => { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - last_error = format!("HTTP {status}: {body}"); - } - Err(error) => last_error = error.to_string(), - } - tokio::time::sleep(std::time::Duration::from_secs(2)).await; - } - - let failure = classify_mesh_readiness_failure(model_ever_visible); - Err(mesh_readiness_failure_message( - failure, - model_id, - &last_error, - )) -} - pub(crate) async fn ensure_client_node_for_model( state: &AppState, model_id: impl AsRef, diff --git a/desktop/src-tauri/src/commands/mesh_llm_tests.rs b/desktop/src-tauri/src/commands/mesh_llm_tests.rs index 26eb1f5fbae..c4e1ae2e425 100644 --- a/desktop/src-tauri/src/commands/mesh_llm_tests.rs +++ b/desktop/src-tauri/src/commands/mesh_llm_tests.rs @@ -178,42 +178,6 @@ fn role_switch_checkpoint_starts_exactly_once_after_restart() { assert_eq!(consumed.relay_url, config.relay_url); } -#[test] -fn readiness_failure_is_catalog_sync_when_model_never_visible() { - assert_eq!( - classify_mesh_readiness_failure(false), - MeshReadinessFailure::CatalogNeverSynced - ); -} - -#[test] -fn readiness_failure_is_routing_when_model_was_visible() { - assert_eq!( - classify_mesh_readiness_failure(true), - MeshReadinessFailure::RoutingNeverCompleted - ); -} - -#[test] -fn readiness_messages_are_distinct_and_actionable() { - let catalog = mesh_readiness_failure_message( - MeshReadinessFailure::CatalogNeverSynced, - "auto", - "HTTP 429", - ); - let routing = mesh_readiness_failure_message( - MeshReadinessFailure::RoutingNeverCompleted, - "auto", - "HTTP 503", - ); - // Distinct diagnoses, each names the model and carries the raw detail. - assert_ne!(catalog, routing); - assert!(catalog.contains("network path")); - assert!(catalog.contains("HTTP 429")); - assert!(routing.contains("did not complete")); - assert!(routing.contains("HTTP 503")); -} - #[test] fn mesh_status_cursor_uses_relay_composite_tiebreak() { let event = nostr::EventBuilder::new(nostr::Kind::TextNote, "status") diff --git a/desktop/src-tauri/src/commands/mesh_readiness.rs b/desktop/src-tauri/src/commands/mesh_readiness.rs new file mode 100644 index 00000000000..023695a6f9c --- /dev/null +++ b/desktop/src-tauri/src/commands/mesh_readiness.rs @@ -0,0 +1,251 @@ +//! Startup readiness for Buzz shared compute. +//! +//! Mesh can bind its HTTP ingress and advertise a model shortly before the +//! router has installed a usable target. These helpers probe the exact chat +//! path agents use, so startup cannot race that gap +//! (`single target None unavailable`), and classify a timeout into copy that +//! names the actual stage rather than a raw `HTTP 429`. + +use super::CmdResult; + +/// Which startup stage a mesh client is stuck at when it never becomes +/// inference-ready. The two live-observed failure modes are physically +/// distinct and want different user copy: +/// +/// * `CatalogNeverSynced` — the local client node came up and connected to +/// the host at the control level (ping/RTT fine), but the served model +/// never appeared in the local `/v1/models` catalog. That catalog is +/// populated by the peer gossip exchange; when the gossip bi-stream can't +/// establish across the network (observed as iroh +/// `MultipathNotNegotiated` / unreachable direct path), the catalog stays +/// empty forever and every request is rejected "model not available". +/// Root cause is the network path between this machine and the host. +/// * `RoutingNeverCompleted` — the model *did* sync into the catalog, but +/// inference requests never completed (routing/transport to the host +/// failing per-request). The host is discoverable and advertised but not +/// actually serving us. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MeshReadinessFailure { + CatalogNeverSynced, + RoutingNeverCompleted, +} + +/// Pure classifier: given whether the served model was ever observed in the +/// local `/v1/models` catalog during the wait, decide which stage failed. +/// Split out so the diagnosis is unit-testable without a live mesh. +/// Whether the catalog has synced enough to count, for the model actually being +/// requested (a wire name — see `relay_mesh_wire_model`). +/// +/// The virtual `mesh` model delegates the choice to the router, so any +/// advertised model proves the catalog synced. It has to work that way: MeshLLM +/// only advertises `mesh` itself once two non-virtual models are reachable +/// (`should_advertise_virtual_mesh`), so requiring it by name would leave a +/// single-worker mesh looking permanently unsynced and misreport a slow model +/// load as a network path problem. +fn mesh_catalog_shows_model(advertised: &[String], wire_model: &str) -> bool { + if advertised.is_empty() { + return false; + } + if wire_model == crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID { + return true; + } + let wanted = wire_model.trim().replace("@main", ""); + advertised + .iter() + .any(|id| id.replace("@main", "") == wanted) +} + +fn classify_mesh_readiness_failure(model_ever_visible: bool) -> MeshReadinessFailure { + if model_ever_visible { + MeshReadinessFailure::RoutingNeverCompleted + } else { + MeshReadinessFailure::CatalogNeverSynced + } +} + +/// Actionable, non-technical copy for a readiness failure. `last_detail` is the +/// last raw transport/HTTP error, appended for support triage. +fn mesh_readiness_failure_message( + failure: MeshReadinessFailure, + model_id: &str, + last_detail: &str, +) -> String { + match failure { + MeshReadinessFailure::CatalogNeverSynced => format!( + "Buzz shared compute connected to the serving member but could not sync \ + the model list for \"{model_id}\" — this is a network path problem \ + between this machine and the host (the compute node is reachable for \ + pings but the model-sync stream did not establish). Try again, or have \ + the host and this machine on a more direct network. (last: {last_detail})" + ), + MeshReadinessFailure::RoutingNeverCompleted => format!( + "Buzz shared compute found \"{model_id}\" on a serving member but inference \ + requests did not complete — the host is discoverable but not currently \ + reachable for requests. Try again shortly. (last: {last_detail})" + ), + } +} + +/// Poll the local mesh OpenAI ingress until a real inference for `model_id` +/// succeeds, or a deadline elapses. On failure, returns a stage-specific, +/// actionable message (see [`MeshReadinessFailure`]) rather than a raw +/// `HTTP 429`, so the UI can tell "still warming up" apart from "can't reach +/// the host". +pub(crate) async fn wait_for_mesh_inference(model_id: &str) -> CmdResult<()> { + // Probe the name that will actually be requested. Callers pass a stored + // value, which for shared-compute `auto` is not a model the mesh + // advertises: probing it would validate a route no agent uses, and could + // fail readiness while the real route works. Named models pass through + // unchanged, so this is safe for the serve-side callers too. + let requested_model = model_id; + let model_id = crate::managed_agents::relay_mesh_wire_model(model_id); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|error| format!("failed to build mesh readiness client: {error}"))?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(120); + let models_url = format!("{}/models", crate::managed_agents::RELAY_MESH_API_BASE_URL); + let chat_url = format!( + "{}/chat/completions", + crate::managed_agents::RELAY_MESH_API_BASE_URL + ); + let mut last_error = "mesh inference is not ready".to_string(); + // Track whether the served model ever reached the local catalog — the + // signal that splits "catalog never synced" from "routing never completed". + let mut model_ever_visible = false; + + while tokio::time::Instant::now() < deadline { + // Refresh catalog visibility. The virtual `mesh` model delegates the + // choice to the router, so any advertised model counts as the catalog + // having synced — and it must, because MeshLLM only advertises `mesh` + // itself once two non-virtual models are reachable + // (`should_advertise_virtual_mesh`). Requiring it by name would leave a + // single-worker mesh looking permanently unsynced. + if let Ok(response) = client + .get(&models_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .send() + .await + { + if let Ok(body) = response.json::().await { + if let Some(data) = body.get("data").and_then(|d| d.as_array()) { + let advertised: Vec = data + .iter() + .filter_map(|m| m.get("id").and_then(|id| id.as_str())) + .map(str::to_owned) + .collect(); + model_ever_visible |= mesh_catalog_shows_model(&advertised, model_id); + } + } + } + + match client + .post(&chat_url) + .bearer_auth(crate::managed_agents::RELAY_MESH_API_KEY_PLACEHOLDER) + .json(&serde_json::json!({ + "model": model_id, + "messages": [{"role": "user", "content": "Reply OK"}], + "max_tokens": 1, + "stream": false + })) + .send() + .await + { + Ok(response) if response.status().is_success() => return Ok(()), + Ok(response) => { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + last_error = format!("HTTP {status}: {body}"); + } + Err(error) => last_error = error.to_string(), + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + + let failure = classify_mesh_readiness_failure(model_ever_visible); + Err(mesh_readiness_failure_message( + failure, + requested_model, + &last_error, + )) +} +#[cfg(test)] +mod tests { + use super::*; + + /// The regression this guards: MeshLLM only advertises the virtual `mesh` model + /// once two non-virtual models are reachable, so a single-worker mesh never + /// lists it by name. Keying visibility on the literal name would report a lone + /// host that is still loading weights as a network path problem. + #[test] + fn virtual_mesh_counts_any_advertised_model_as_a_synced_catalog() { + let one_worker = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()]; + assert!(mesh_catalog_shows_model( + &one_worker, + crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID + )); + } + + #[test] + fn an_empty_catalog_is_never_synced_even_for_the_virtual_model() { + assert!(!mesh_catalog_shows_model( + &[], + crate::managed_agents::RELAY_MESH_VIRTUAL_MODEL_ID + )); + } + + #[test] + fn a_named_model_must_actually_be_advertised() { + let advertised = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M".to_string()]; + assert!(mesh_catalog_shows_model( + &advertised, + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + )); + assert!(!mesh_catalog_shows_model(&advertised, "some/other-model")); + } + + #[test] + fn a_named_model_ignores_the_main_revision_suffix() { + let advertised = vec!["unsloth/gemma-4-E4B-it-GGUF:Q4_K_M@main".to_string()]; + assert!(mesh_catalog_shows_model( + &advertised, + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + )); + } + + #[test] + fn readiness_failure_is_catalog_sync_when_model_never_visible() { + assert_eq!( + classify_mesh_readiness_failure(false), + MeshReadinessFailure::CatalogNeverSynced + ); + } + + #[test] + fn readiness_failure_is_routing_when_model_was_visible() { + assert_eq!( + classify_mesh_readiness_failure(true), + MeshReadinessFailure::RoutingNeverCompleted + ); + } + + #[test] + fn readiness_messages_are_distinct_and_actionable() { + let catalog = mesh_readiness_failure_message( + MeshReadinessFailure::CatalogNeverSynced, + "auto", + "HTTP 429", + ); + let routing = mesh_readiness_failure_message( + MeshReadinessFailure::RoutingNeverCompleted, + "auto", + "HTTP 503", + ); + // Distinct diagnoses, each names the model and carries the raw detail. + assert_ne!(catalog, routing); + assert!(catalog.contains("network path")); + assert!(catalog.contains("HTTP 429")); + assert!(routing.contains("did not complete")); + assert!(routing.contains("HTTP 503")); + } +} diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 7b4b9b785f1..4f839638b93 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -486,6 +486,7 @@ pub async fn send_channel_message( emoji_tags: Option>>, mention_tags: Option>>, link_preview_tags: Option>>, + sent_from_thread_tag: Option>, mention_pubkeys: Option>, kind: Option, state: State<'_, AppState>, @@ -500,6 +501,9 @@ pub async fn send_channel_message( let link_previews = link_preview_tags.unwrap_or_default(); let relay_base = crate::relay::relay_api_base_url_with_override(&state); let kind_num = kind.unwrap_or(buzz_core_pkg::kind::KIND_STREAM_MESSAGE); + if sent_from_thread_tag.is_some() && kind_num != buzz_core_pkg::kind::KIND_STREAM_MESSAGE { + return Err("sent-from-thread provenance requires a stream message".into()); + } let mut resolved_root: Option = None; @@ -544,6 +548,7 @@ pub async fn send_channel_message( &emoji, &mention_refs_only, &link_previews, + sent_from_thread_tag.as_deref(), &relay_base, )? } @@ -712,6 +717,7 @@ fn build_managed_agent_channel_message( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), client_tags, ) @@ -890,6 +896,10 @@ pub struct EditMessageInput { // tag, so a typo-fix edit never re-wakes existing mentions. #[serde(default)] mention_pubkeys: Vec, + // Full stable mention identity set selected in the edited composer. `None` + // means a partial edit that must preserve the existing snapshot; `Some`, + // including an empty set, authoritatively replaces it. + mention_tags: Option>>, #[serde(default)] suppress_link_previews: bool, } @@ -914,9 +924,12 @@ pub async fn edit_message( channel_uuid, target_eid, trimmed, - &input.media_tags, - &input.emoji_tags, - &mention_refs, + events::MessageEditTags { + media: &input.media_tags, + custom_emoji: &input.emoji_tags, + mentions: &mention_refs, + mention_refs: input.mention_tags.as_deref(), + }, input.suppress_link_previews, )?; submit_event(builder, &state).await?; diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 322834630a3..1ab3bb70d74 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -35,6 +35,8 @@ mod media_transcode; mod media_upload_progress; #[cfg(feature = "mesh-llm")] pub(crate) mod mesh_llm; +#[cfg(feature = "mesh-llm")] +pub(crate) mod mesh_readiness; mod messages; mod notifications; mod observer_archive; diff --git a/desktop/src-tauri/src/commands/window_vibrancy.rs b/desktop/src-tauri/src/commands/window_vibrancy.rs index 5eb1f16b8b9..39dcef3c6e5 100644 --- a/desktop/src-tauri/src/commands/window_vibrancy.rs +++ b/desktop/src-tauri/src/commands/window_vibrancy.rs @@ -1,15 +1,29 @@ //! Runtime macOS window vibrancy (blur-behind) toggle. //! +//! **Invariant:** the main window is created opaque (`tauri.conf.json` +//! `transparent: false`) and the NSWindow is never made transparent at runtime. +//! Behind-window vibrancy renders correctly inside opaque windows — this is +//! exactly how Finder and Notes render vibrant sidebars — so glass only requires +//! runtime webview-canvas transparency, which this command sets on the enable +//! path. When glass is disabled the webview canvas may remain non-drawing +//! (wry's `drawsBackground` flag is one-way at runtime), but that is harmless: +//! glass-off CSS paints the full background opaque and the always-opaque NSWindow +//! is beneath it. +//! +//! Why not `transparent: true`? A creation-time transparent window causes tao to +//! call `NSWindow.setOpaque(false)` and `setBackgroundColor(clearColor)`. The +//! runtime `Window::set_background_color(None)` then resolves `None` to +//! `clearColor` instead of the opaque system default — and there is no runtime +//! `setOpaque(true)` path through tauri — leaving the compositor blending the +//! whole window even with glass off. +//! //! Vibrancy applies an `NSVisualEffectView` behind the webview so the desktop -//! (and windows behind Buzz) blur through wherever the app's CSS is +//! (and windows behind Buzz) blurs through wherever the WKWebView canvas is //! transparent. It is a native, macOS-only effect: there is no "intensity" //! setting at the OS level, only a set of material presets. The frontend tunes -//! perceived intensity by changing CSS surface opacity while this command -//! handles the native material. -//! -//! This is fully reversible at runtime: enabling applies the chosen material, -//! disabling clears it. On non-macOS platforms the command is a no-op so the -//! shared frontend can call it unconditionally. +//! perceived intensity by adjusting CSS surface opacity while this command +//! handles the native material. On non-macOS platforms the command is a no-op +//! so the shared frontend can call it unconditionally. #[cfg(target_os = "macos")] use tauri::Manager; @@ -35,6 +49,16 @@ pub fn set_window_vibrancy( .ok_or_else(|| "main window not found".to_string())?; if !enabled { + // The NSWindow layer is permanently opaque, so no window-layer + // reset is needed here. Skipping `set_background_color(None)` at + // the webview layer also avoids tauri mapping `None` to opaque + // white, which would still force `drawsBackground=false` on the + // WKWebView (counterproductive). After a glass session the webview + // canvas may stay non-drawing — wry's `drawsBackground` flag is + // one-way at runtime — but that is harmless: glass-off CSS paints + // the full background opaque and the always-opaque NSWindow is + // beneath it. If `clear_vibrancy` fails, the opaque CSS already + // covers everything, so no see-through state is reachable. clear_vibrancy(&window).map_err(|e| e.to_string())?; return Ok(()); } @@ -58,7 +82,22 @@ pub fn set_window_vibrancy( // clear is a no-op (returns `false`) when none is present. let _ = clear_vibrancy(&window); + // Install the blur layer first: a failure of the canvas write leaves + // the window with vibrancy behind an opaque webview, not a see-through + // one. Either mixed state self-corrects on the next toggle. apply_vibrancy(&window, material, None, None).map_err(|e| e.to_string())?; + + // Make only the WKWebView canvas transparent so native vibrancy shows + // through; the NSWindow layer stays opaque by design. Targeting the + // webview layer directly (via `AsRef`) avoids the + // `WebviewWindow::set_background_color` path, which also writes the + // NSWindow layer. Must follow `apply_vibrancy` so the blur layer is + // present before the canvas becomes see-through. + let webview: &tauri::Webview<_> = window.as_ref(); + webview + .set_background_color(Some(tauri::window::Color(0, 0, 0, 0))) + .map_err(|e| e.to_string())?; + Ok(()) } diff --git a/desktop/src-tauri/src/egress_guard_tests.rs b/desktop/src-tauri/src/egress_guard_tests.rs index 23cb2ba220c..1513742beaf 100644 --- a/desktop/src-tauri/src/egress_guard_tests.rs +++ b/desktop/src-tauri/src/egress_guard_tests.rs @@ -165,6 +165,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); @@ -181,6 +182,7 @@ fn boundary_huddle_stt_blocks_ncryptsec() { &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) .unwrap(); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index b7937419bf1..df814afb36f 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -11,6 +11,12 @@ use buzz_core_pkg::kind::{KIND_IA_ARCHIVE_REQUEST, KIND_IA_UNARCHIVE_REQUEST}; use nostr::{EventBuilder, EventId, Kind, Tag}; use uuid::Uuid; + +mod message_tags; + +use message_tags::{ + append_client_tags, append_sent_from_thread_tag, emoji_tags, imeta_tags, mention_reference_tags, +}; // ── Constants ──────────────────────────────────────────────────────────────── /// Maximum content size — matches buzz-sdk (64 KiB). @@ -74,56 +80,6 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { Ok(tags) } -fn mention_reference_tags(mentions: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mention in mentions { - if mention.first().map(String::as_str) != Some("mention") { - return Err(format!( - "mention reference tags must use 'mention' prefix (got {:?})", - mention.first() - )); - } - let Some(pubkey) = mention.get(1) else { - return Err("mention reference tag missing pubkey".into()); - }; - check_pubkey(pubkey)?; - tags.push(tag(vec!["mention", &pubkey.to_ascii_lowercase()])?); - } - Ok(()) -} - -/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" -/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). -fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for mt in media_tags { - if mt.first().map(String::as_str) != Some("imeta") { - return Err(format!( - "media tags must use 'imeta' prefix (got {:?})", - mt.first() - )); - } - let parts: Vec<&str> = mt.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); - } - Ok(()) -} - -/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects -/// any tag whose first element is not "emoji" so this path can't be used to -/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. -fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for et in emoji_tags { - if et.first().map(String::as_str) != Some("emoji") { - return Err(format!( - "emoji tags must use 'emoji' prefix (got {:?})", - et.first() - )); - } - let parts: Vec<&str> = et.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); - } - Ok(()) -} - /// Validate a hex pubkey is exactly 64 hex characters. fn check_pubkey(pubkey: &str) -> Result<(), String> { if pubkey.len() != 64 || !pubkey.chars().all(|c| c.is_ascii_hexdigit()) { @@ -302,6 +258,7 @@ pub fn build_message( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, ) -> Result { build_message_with_client_tags( @@ -313,6 +270,7 @@ pub fn build_message( custom_emoji_tags, mention_ref_tags, link_preview_tags, + sent_from_thread_tag, relay_base, &[], ) @@ -333,9 +291,13 @@ pub fn build_message_with_client_tags( custom_emoji_tags: &[Vec], mention_ref_tags: &[Vec], link_preview_tags: &[Vec], + sent_from_thread_tag: Option<&[String]>, relay_base: &str, client_tags: &[Vec], ) -> Result { + if sent_from_thread_tag.is_some() && thread_ref.is_some() { + return Err("sent-from-thread provenance requires a top-level message".into()); + } check_content(content)?; let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?]; if let Some(tr) = thread_ref { @@ -346,27 +308,11 @@ pub fn build_message_with_client_tags( emoji_tags(custom_emoji_tags, &mut tags)?; mention_reference_tags(mention_ref_tags, &mut tags)?; crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; + append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?; append_client_tags(client_tags, &mut tags)?; Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } -fn append_client_tags(client_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { - for client_tag in client_tags { - if client_tag.first().map(String::as_str) != Some("client") { - return Err(format!( - "client tags must use 'client' prefix (got {:?})", - client_tag.first() - )); - } - if client_tag.len() < 2 { - return Err("client tag missing marker".into()); - } - let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); - tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); - } - Ok(()) -} - /// Kind 45001 — forum post. pub fn build_forum_post( channel_id: Uuid, @@ -401,15 +347,20 @@ pub fn build_forum_comment( Ok(EventBuilder::new(Kind::Custom(45003), content).tags(tags)) } +pub struct MessageEditTags<'a> { + pub media: &'a [Vec], + pub custom_emoji: &'a [Vec], + pub mentions: &'a [&'a str], + pub mention_refs: Option<&'a [Vec]>, +} + /// Kind 40003 — edit a message with full content, media, emoji, mentions, /// and optional monotonic link-preview suppression. pub fn build_message_edit( channel_id: Uuid, target_event_id: EventId, content: &str, - media_tags: &[Vec], - custom_emoji_tags: &[Vec], - mentions: &[&str], + edit_tags: MessageEditTags<'_>, suppress_link_previews: bool, ) -> Result { check_content(content)?; @@ -417,9 +368,13 @@ pub fn build_message_edit( tag(vec!["h", &channel_id.to_string()])?, tag(vec!["e", &target_event_id.to_hex()])?, ]; - tags.extend(mention_tags(mentions)?); - imeta_tags(media_tags, &mut tags)?; - emoji_tags(custom_emoji_tags, &mut tags)?; + tags.extend(mention_tags(edit_tags.mentions)?); + imeta_tags(edit_tags.media, &mut tags)?; + emoji_tags(edit_tags.custom_emoji, &mut tags)?; + if let Some(mention_refs) = edit_tags.mention_refs { + mention_reference_tags(mention_refs, &mut tags)?; + tags.push(tag(vec!["buzz:mention-snapshot"])?); + } if suppress_link_previews { tags.push(tag(vec!["link-preview", "none"])?); } @@ -930,25 +885,35 @@ mod tests { assert_eq!(event.pubkey.to_hex(), TARGET_HEX); } - // ── build_message_edit `p`-tag emission (lane 8ace8eed) ────────────── - // - // The composer diffs the edited body's mentions against the original and - // hands `build_message_edit` only the *newly added* pubkeys. These tests - // pin the builder's contract given that contract: emit a `p` per added - // mention (deduped, lowercased), and none when the added set is empty - // (typo-fix edit) — so an unchanged mention set re-wakes nobody. - const CH_ID: &str = "11111111-1111-4111-8111-111111111111"; const ALICE_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; const BOB_HEX: &str = "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; fn edit_tags(mentions: &[&str]) -> Vec> { + edit_tags_with_refs(mentions, Some(&[])) + } + + fn edit_tags_with_refs( + mentions: &[&str], + mention_refs: Option<&[Vec]>, + ) -> Vec> { let channel = Uuid::parse_str(CH_ID).unwrap(); let target = EventId::from_hex("d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1") .unwrap(); - let builder = - build_message_edit(channel, target, "hi @alice", &[], &[], mentions, false).unwrap(); + let builder = build_message_edit( + channel, + target, + "hi @alice", + MessageEditTags { + media: &[], + custom_emoji: &[], + mentions, + mention_refs, + }, + false, + ) + .unwrap(); let secret = nostr::SecretKey::from_hex( "0000000000000000000000000000000000000000000000000000000000000003", ) @@ -962,7 +927,6 @@ mod tests { let tags = edit_tags(&[ALICE_HEX]); assert_eq!(tags[0][0], "h"); assert_eq!(tags[1][0], "e"); - // The `p` tag rides right after the `e` tag (insertion order). assert_eq!(tags[2], vec!["p".to_string(), ALICE_HEX.to_string()]); } @@ -979,6 +943,42 @@ mod tests { ); } + #[test] + fn edit_emits_full_mention_reference_snapshot() { + let tags = edit_tags_with_refs(&[], Some(&[vec!["mention".into(), ALICE_HEX.into()]])); + assert!( + tags.iter().any(|tag| tag == &["mention", ALICE_HEX]), + "stable mention reference must be present: {tags:?}" + ); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "snapshot marker must be present: {tags:?}" + ); + } + + #[test] + fn empty_edit_mention_snapshot_is_explicit() { + let tags = edit_tags_with_refs(&[], Some(&[])); + assert!( + tags.iter().any(|tag| tag == &["buzz:mention-snapshot"]), + "empty snapshot must still clear stale references: {tags:?}" + ); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + } + + #[test] + fn partial_edit_omits_mention_snapshot() { + let tags = edit_tags_with_refs(&[], None); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("mention"))); + assert!(!tags + .iter() + .any(|tag| tag.first().map(String::as_str) == Some("buzz:mention-snapshot"))); + } + #[test] fn edit_mentions_are_deduped_and_lowercased() { let alice_upper = ALICE_HEX.to_ascii_uppercase(); diff --git a/desktop/src-tauri/src/events/message_tags.rs b/desktop/src-tauri/src/events/message_tags.rs new file mode 100644 index 00000000000..c43a8874def --- /dev/null +++ b/desktop/src-tauri/src/events/message_tags.rs @@ -0,0 +1,140 @@ +use nostr::{EventId, Tag}; + +use super::check_pubkey; + +const MAX_THREAD_ROOT_EXCERPT_CHARS: usize = 64; +const SENT_FROM_THREAD_TAG: &str = "buzz:sent-from-thread"; + +pub(super) fn mention_reference_tags( + mentions: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for mention in mentions { + if mention.first().map(String::as_str) != Some("mention") { + return Err(format!( + "mention reference tags must use 'mention' prefix (got {:?})", + mention.first() + )); + } + let Some(pubkey) = mention.get(1) else { + return Err("mention reference tag missing pubkey".into()); + }; + check_pubkey(pubkey)?; + tags.push( + Tag::parse(vec!["mention", &pubkey.to_ascii_lowercase()]) + .map_err(|error| format!("invalid mention reference tag: {error}"))?, + ); + } + Ok(()) +} + +pub(super) fn append_sent_from_thread_tag( + source_tag: Option<&[String]>, + tags: &mut Vec, +) -> Result<(), String> { + let Some(source_tag) = source_tag else { + return Ok(()); + }; + if !matches!(source_tag.len(), 2 | 3) + || source_tag.first().map(String::as_str) != Some(SENT_FROM_THREAD_TAG) + { + return Err("invalid sent-from-thread tag shape".into()); + } + + EventId::from_hex(source_tag[1].trim()) + .map_err(|_| "sent-from-thread tag has invalid root event ID")?; + + if let Some(excerpt) = source_tag.get(2) { + if excerpt.trim().is_empty() + || excerpt.chars().count() > MAX_THREAD_ROOT_EXCERPT_CHARS + || excerpt.chars().any(char::is_control) + { + return Err("sent-from-thread tag has invalid root excerpt".into()); + } + } + + let parts: Vec<&str> = source_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid sent-from-thread tag: {e}"))?); + Ok(()) +} + +/// Validate and append imeta tags. Rejects any tag whose first element is not "imeta" +/// to prevent injection of arbitrary tags (e.g., forged "h", "e", or "p" tags). +pub(super) fn imeta_tags(media_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for media_tag in media_tags { + if media_tag.first().map(String::as_str) != Some("imeta") { + return Err(format!( + "media tags must use 'imeta' prefix (got {:?})", + media_tag.first() + )); + } + let parts: Vec<&str> = media_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid imeta tag: {e}"))?); + } + Ok(()) +} + +/// Validate and append NIP-30 custom-emoji tags. Mirrors `imeta_tags`: rejects +/// any tag whose first element is not "emoji" so this path can't be used to +/// smuggle forged "h"/"e"/"p" tags. Each tag is `["emoji", shortcode, url]`. +pub(super) fn emoji_tags(emoji_tags: &[Vec], tags: &mut Vec) -> Result<(), String> { + for emoji_tag in emoji_tags { + if emoji_tag.first().map(String::as_str) != Some("emoji") { + return Err(format!( + "emoji tags must use 'emoji' prefix (got {:?})", + emoji_tag.first() + )); + } + let parts: Vec<&str> = emoji_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid emoji tag: {e}"))?); + } + Ok(()) +} + +pub(super) fn append_client_tags( + client_tags: &[Vec], + tags: &mut Vec, +) -> Result<(), String> { + for client_tag in client_tags { + if client_tag.first().map(String::as_str) != Some("client") { + return Err(format!( + "client tags must use 'client' prefix (got {:?})", + client_tag.first() + )); + } + if client_tag.len() < 2 { + return Err("client tag missing marker".into()); + } + let parts: Vec<&str> = client_tag.iter().map(String::as_str).collect(); + tags.push(Tag::parse(parts).map_err(|e| format!("invalid client tag: {e}"))?); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_HEX: &str = "d24da132115ca0a46233cf4c2ad8338fbf914250cbcaa9181a6dd59533cb5ac1"; + + #[test] + fn message_accepts_only_valid_sent_from_thread_provenance() { + let source_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + ROOT_HEX.to_string(), + "Root message excerpt".to_string(), + ]; + let mut tags = Vec::new(); + append_sent_from_thread_tag(Some(&source_tag), &mut tags).unwrap(); + assert_eq!(tags[0].as_slice(), source_tag); + + let forged_channel_tag = vec!["h".to_string(), "channel-id".to_string()]; + assert!(append_sent_from_thread_tag(Some(&forged_channel_tag), &mut Vec::new()).is_err()); + + let invalid_root_tag = vec![ + SENT_FROM_THREAD_TAG.to_string(), + "not-an-event-id".to_string(), + ]; + assert!(append_sent_from_thread_tag(Some(&invalid_root_tag), &mut Vec::new()).is_err()); + } +} diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 4d4e840104e..b05b6b7fe47 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -668,6 +668,7 @@ pub(crate) fn spawn_transcription_task( &[], &[], &[], + None, &crate::relay::relay_api_base_url(), ) { Ok(b) => b, diff --git a/desktop/src-tauri/src/initial_window.rs b/desktop/src-tauri/src/initial_window.rs index b1245515125..f6d88259b2e 100644 --- a/desktop/src-tauri/src/initial_window.rs +++ b/desktop/src-tauri/src/initial_window.rs @@ -15,9 +15,16 @@ pub(crate) fn reveal_initial_window(window: &tauri::Window #[cfg(target_os = "macos")] pub(crate) fn set_initial_window_backing(window: &tauri::Window) { - // The window remains transparent at runtime for vibrancy. Use an opaque - // native backing only across the first visible frames so the previous app - // cannot show through before WebKit has submitted its first surface. + // Both this write and the deferred clear target the Window (NSWindow) + // backing color only; they never touch the webview canvas or the + // NSVisualEffectView, so they are not load-bearing for glass. Glass state + // — the effect view and webview-canvas transparency — is managed entirely + // by `set_window_vibrancy`, which the ThemeProvider calls after mount. The + // 250ms-delayed clear cannot clobber a persisted-glass-on cold boot + // regardless of ordering with that call. + // + // Write an opaque dark backing so the previous app cannot show through + // before WebKit submits its first composited surface. if let Err(error) = window.set_background_color(Some(tauri::window::Color(17, 21, 24, 255))) { eprintln!("buzz-desktop: failed to set initial window backing: {error}"); } @@ -26,6 +33,10 @@ pub(crate) fn set_initial_window_backing(window: &tauri::Wind #[cfg(target_os = "macos")] pub(crate) async fn clear_initial_window_backing(window: &tauri::Window) { tokio::time::sleep(std::time::Duration::from_millis(250)).await; + // Restore the default system window background so fast-resize gutter + // flashes match the platform theme rather than the hardcoded dark color + // written at reveal. Targets the Window (NSWindow) layer only; webview + // canvas and glass state are unaffected. if let Err(error) = window.set_background_color(None) { eprintln!("buzz-desktop: failed to clear initial window backing: {error}"); } diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 5c246feedc5..3858212bbba 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,9 +1,33 @@ pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; +/// Stored value for "let the mesh decide", kept as the user-facing word. pub const RELAY_MESH_AUTO_MODEL_ID: &str = "auto"; +/// MeshLLM's virtual model. It resolves per request: a Mixture-of-Agents +/// committee when two or more workers are reachable, and otherwise degrades to +/// a single served model rather than erroring +/// (`moa_gateway::degrade_to_single_model`). That degradation is a pre-flight +/// capacity decision, so a committee that forms and *then* loses a worker still +/// surfaces as a failed turn — MoA repairs partial results internally +/// (`repair_tool_result_answer`) before it gets that far. Buzz translates the +/// stored `auto` here rather than teaching buzz-agent anything about meshes. #[cfg(feature = "mesh-llm")] -pub const RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV: &str = "BUZZ_AGENT_PREFER_MESH_FOR_AUTO"; +pub const RELAY_MESH_VIRTUAL_MODEL_ID: &str = "mesh"; + +/// The wire name for a stored shared-compute model: `auto` (and a blank legacy +/// value) means "let the mesh decide" and becomes MeshLLM's virtual `mesh` +/// model; anything else is a model the user named and is passed through. +/// +/// The single place this mapping happens. Every consumer that has to name a +/// model to the mesh — the LLM transport env and the ACP harness — goes through +/// here, so they cannot disagree. +#[cfg(feature = "mesh-llm")] +pub fn relay_mesh_wire_model(stored: &str) -> &str { + match stored.trim() { + "" | RELAY_MESH_AUTO_MODEL_ID => RELAY_MESH_VIRTUAL_MODEL_ID, + named => named, + } +} /// Translate the native Buzz shared compute provider into the OpenAI-compatible /// transport understood by buzz-agent. These are derived runtime details, not @@ -17,11 +41,7 @@ pub fn apply_relay_mesh_env( if provider.map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { return; } - let model = model - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(); + let model = relay_mesh_wire_model(model.unwrap_or(RELAY_MESH_AUTO_MODEL_ID)).to_string(); env.insert("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()); env.insert("BUZZ_AGENT_MODEL".to_string(), model.clone()); env.insert( @@ -34,14 +54,6 @@ pub fn apply_relay_mesh_env( RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), ); env.insert("OPENAI_COMPAT_API".to_string(), "chat".to_string()); - // Buzz owns the meaning of relay-mesh `auto`: buzz-agent dynamically uses - // mesh-llm's virtual Mixture-of-Agents model whenever the live catalog says - // at least two distinct models are available, and otherwise keeps the - // router's normal single-model `auto` behavior. - env.insert( - RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV.to_string(), - "1".to_string(), - ); // Keep the requested response inside smaller local-model context windows. // These are defaults, not policy: the effective agent/persona/global env // may deliberately choose a smaller cap or a different effort. This function @@ -128,10 +140,76 @@ mod tests { // stops gemma tool-calling; enabling thinking makes Qwen3 burn ~4x the // output budget). assert_eq!(env.get("BUZZ_AGENT_THINKING_EFFORT"), None); + } + + /// Stored `auto` is translated here, so buzz-agent receives a plain model + /// name and needs no knowledge of the mesh. MeshLLM decides per request + /// whether `mesh` becomes a committee or a single served model. + #[test] + fn stored_auto_becomes_the_virtual_mesh_model_on_the_wire() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + assert_eq!( - env.get(RELAY_MESH_PREFER_MESH_FOR_AUTO_ENV) - .map(String::as_str), - Some("1") + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + } + + /// A blank stored model is the legacy encoding of the same intent. + #[test] + fn blank_stored_model_becomes_the_virtual_mesh_model() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some(RELAY_MESH_PROVIDER_ID), Some(" ")); + + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some(RELAY_MESH_VIRTUAL_MODEL_ID) + ); + } + + /// Every consumer that names a model to the mesh goes through one helper, + /// so the LLM transport and the ACP harness cannot be told different things. + #[test] + fn wire_model_maps_auto_and_blank_but_passes_named_through() { + assert_eq!( + relay_mesh_wire_model(RELAY_MESH_AUTO_MODEL_ID), + RELAY_MESH_VIRTUAL_MODEL_ID + ); + assert_eq!(relay_mesh_wire_model(""), RELAY_MESH_VIRTUAL_MODEL_ID); + assert_eq!(relay_mesh_wire_model(" "), RELAY_MESH_VIRTUAL_MODEL_ID); + assert_eq!( + relay_mesh_wire_model("unsloth/gemma-4-E4B-it-GGUF:Q4_K_M"), + "unsloth/gemma-4-E4B-it-GGUF:Q4_K_M" + ); + } + + /// A named model is sent verbatim: picking one is an explicit choice to + /// bypass mesh routing, and must not be rewritten. + #[test] + fn a_named_model_is_sent_verbatim() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M"), + ); + + assert_eq!( + env.get("BUZZ_AGENT_MODEL").map(String::as_str), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M") + ); + assert_eq!( + env.get("OPENAI_COMPAT_MODEL").map(String::as_str), + Some("unsloth/Qwen3-8B-GGUF:Q4_K_M") ); } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index ec804869c42..d95c5954177 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -713,7 +713,19 @@ pub fn spawn_agent_child( } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model.as_deref() { + // Shared compute stores `auto`, but the wire name is MeshLLM's virtual + // `mesh` model. Translate here too, so the harness and the LLM client are + // told the same thing: `BUZZ_ACP_MODEL=auto` would name a model the mesh + // never advertises, leaving buzz-acp to warn and fall back on every new + // session while `BUZZ_AGENT_MODEL` said `mesh`. + #[cfg(feature = "mesh-llm")] + let acp_model = match (&mesh_model_id, effective_model.as_deref()) { + (Some(mesh_model_id), _) => Some(super::relay_mesh_wire_model(mesh_model_id).to_string()), + (None, model) => model.map(str::to_owned), + }; + #[cfg(not(feature = "mesh-llm"))] + let acp_model = effective_model.as_deref().map(str::to_owned); + if let Some(model) = acp_model.as_deref() { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 3f04d3d7a1e..768b2ad7db3 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -358,6 +358,21 @@ fn default_true() -> bool { true } +/// Response payload for `get_channels`. When the caller supplies a hash that +/// matches the computed stable hash, `channels` is `None` so the multi-MB +/// channel list is not serialized across IPC. `last_messages` is always +/// included — it is cheap and changes frequently (every new message). +#[derive(Serialize)] +pub struct GetChannelsPayload { + pub hash: String, + /// `None` on a not-modified response (hash matched); `Some` with the full + /// sorted list otherwise. + pub channels: Option>, + /// Map of channel id → ISO-8601 timestamp of its most recent message. + /// Empty for channels with no messages. + pub last_messages: std::collections::HashMap, +} + // ── Social / Contact list ─────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 980def9283e..856a2dbde73 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "BitcoinMarkets", - "version": "0.5.9", + "version": "0.5.10", "identifier": "app.bitcoinmarkets.desktop", "build": { "beforeDevCommand": { @@ -21,7 +21,7 @@ "height": 600, "maximized": true, "visible": false, - "transparent": true, + "transparent": false, "titleBarStyle": "Overlay", "hiddenTitle": true, "dragDropEnabled": false, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e4233a8fae6..73e40e8cd89 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -15,6 +15,7 @@ import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; +import { useAppShellKeyboardShortcuts } from "@/app/useAppShellKeyboardShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useChannelActivityProjection } from "@/app/useChannelActivityProjection"; @@ -89,7 +90,6 @@ import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBou import { joinChannel } from "@/shared/api/tauri"; import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; @@ -127,6 +127,8 @@ export function AppShell() { null, ); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); + const [scopeSearchFocusRequest, setScopeSearchFocusRequest] = + React.useState(0); const [isCreateChannelOpen, setIsCreateChannelOpen] = React.useState(false); const [isSendFeedbackOpen, setIsSendFeedbackOpen] = React.useState(false); const mainInsetRef = React.useRef(null); @@ -494,6 +496,10 @@ export function AppShell() { setSearchFocusRequest((request) => request + 1); void refetchChannels(); }, [refetchChannels]); + const handleOpenChannelSearch = React.useCallback(() => { + setScopeSearchFocusRequest((request) => request + 1); + void refetchChannels(); + }, [refetchChannels]); const handleBrowseChannelJoin = React.useCallback( async (channelId: string) => { @@ -640,70 +646,17 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { - if (settingsOpen || isHuddleRoom) { - return; - } - - function handleKeyDown(event: KeyboardEvent) { - if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { - return; - } - - // A focused surface may claim the shortcut first — e.g. the composer - // consumes ⌘K to open the link editor when text is selected. Its - // element-level handler runs before this window-level bubble listener - // and calls `preventDefault()`; respect that instead of also opening - // the global dialog. - if (event.defaultPrevented) { - return; - } - - const key = event.key.toLowerCase(); - if (key === "k" && !event.shiftKey) { - event.preventDefault(); - handleOpenSearch(); - return; - } - - if (key === "k" && event.shiftKey) { - event.preventDefault(); - void goNewMessage(); - return; - } - - if (key === "n" && event.shiftKey) { - event.preventDefault(); - handleOpenCreateChannel(); - return; - } - - if (key === "o" && event.shiftKey) { - event.preventDefault(); - handleOpenBrowseChannels(); - return; - } - - if (key === "a" && event.shiftKey) { - event.preventDefault(); - void goHome(); - return; - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - handleOpenBrowseChannels, - handleOpenCreateChannel, - handleOpenSearch, - goNewMessage, - goHome, - isHuddleRoom, - settingsOpen, - ]); + useAppShellKeyboardShortcuts({ + canSearchCurrentChannel: + selectedView === "channel" && Boolean(activeChannel), + disabled: settingsOpen || isHuddleRoom, + onBrowseChannels: handleOpenBrowseChannels, + onCreateChannel: handleOpenCreateChannel, + onGoHome: goHome, + onNewMessage: goNewMessage, + onSearchCurrentChannel: handleOpenChannelSearch, + onSearchEverything: handleOpenSearch, + }); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, @@ -897,7 +850,10 @@ export function AppShell() { onSelectChannel={handleSidebarChannelSelect} onOpenSearchResult={handleOpenSearchResult} searchChannels={channels} - searchFocusRequest={searchFocusRequest} + searchFocusRequests={[ + searchFocusRequest, + scopeSearchFocusRequest, + ]} onSelectHome={() => void goHome()} onSelectProjects={() => void goProjects()} onSelectPulse={() => void goPulse()} diff --git a/desktop/src/app/useAppShellKeyboardShortcuts.ts b/desktop/src/app/useAppShellKeyboardShortcuts.ts new file mode 100644 index 00000000000..26887963cbe --- /dev/null +++ b/desktop/src/app/useAppShellKeyboardShortcuts.ts @@ -0,0 +1,88 @@ +import * as React from "react"; + +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; + +type AppShellKeyboardShortcutsOptions = { + canSearchCurrentChannel: boolean; + disabled: boolean; + onBrowseChannels: () => void; + onCreateChannel: () => void; + onGoHome: () => unknown; + onNewMessage: () => unknown; + onSearchCurrentChannel: () => void; + onSearchEverything: () => void; +}; + +export function useAppShellKeyboardShortcuts({ + canSearchCurrentChannel, + disabled, + onBrowseChannels, + onCreateChannel, + onGoHome, + onNewMessage, + onSearchCurrentChannel, + onSearchEverything, +}: AppShellKeyboardShortcutsOptions) { + React.useLayoutEffect(() => { + if (disabled) return; + + function handleKeyDown(event: KeyboardEvent) { + if ( + !hasPrimaryShortcutModifier(event) || + event.altKey || + event.repeat || + event.defaultPrevented + ) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "f" && !event.shiftKey && canSearchCurrentChannel) { + event.preventDefault(); + onSearchCurrentChannel(); + return; + } + + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + onSearchEverything(); + return; + } + + if (key === "k" && event.shiftKey) { + event.preventDefault(); + void onNewMessage(); + return; + } + + if (key === "n" && event.shiftKey) { + event.preventDefault(); + onCreateChannel(); + return; + } + + if (key === "o" && event.shiftKey) { + event.preventDefault(); + onBrowseChannels(); + return; + } + + if (key === "a" && event.shiftKey) { + event.preventDefault(); + void onGoHome(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + canSearchCurrentChannel, + disabled, + onBrowseChannels, + onCreateChannel, + onGoHome, + onNewMessage, + onSearchCurrentChannel, + onSearchEverything, + ]); +} diff --git a/desktop/src/app/useTauriWindowDrag.ts b/desktop/src/app/useTauriWindowDrag.ts index 1ac7acc93c0..8d9342ab57d 100644 --- a/desktop/src/app/useTauriWindowDrag.ts +++ b/desktop/src/app/useTauriWindowDrag.ts @@ -15,6 +15,10 @@ export function useTauriWindowDrag() { return; } + // A native window drag replaces the browser's normal pointer gesture. + // Cancel that gesture before handing control to Tauri so moving from the + // titlebar across page copy cannot start a text selection. + event.preventDefault(); void getCurrentWindow().startDragging(); } diff --git a/desktop/src/features/agents/focusRefetchPolicy.test.mjs b/desktop/src/features/agents/focusRefetchPolicy.test.mjs new file mode 100644 index 00000000000..ee16a468678 --- /dev/null +++ b/desktop/src/features/agents/focusRefetchPolicy.test.mjs @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { + focusManager, + QueryClient, + QueryObserver, +} from "@tanstack/react-query"; + +import { + agentsFocusRefetchPolicy, + managedAgentLogFocusRefetchPolicy, +} from "./hooks.ts"; + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("agents: skips fresh focus refetch", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: agentsFocusRefetchPolicy.staleTime - 1_000, + policy: agentsFocusRefetchPolicy, + }), + 0, + ); +}); + +test("agents: refetches genuinely stale data on focus", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: agentsFocusRefetchPolicy.staleTime + 1, + policy: agentsFocusRefetchPolicy, + }), + 1, + ); +}); + +test("managed-agent-log: skips focus refetch when data is fresher than one poll tick", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: managedAgentLogFocusRefetchPolicy.staleTime - 1_000, + policy: managedAgentLogFocusRefetchPolicy, + }), + 0, + ); +}); + +test("managed-agent-log: refetches on focus when data is older than one poll tick", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: managedAgentLogFocusRefetchPolicy.staleTime + 1, + policy: managedAgentLogFocusRefetchPolicy, + }), + 1, + ); +}); diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 7cc93b7b613..eb038ee06f1 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -11,7 +11,6 @@ import { ensureChannelAgentPresetInChannel, provisionChannelManagedAgent, } from "@/features/agents/channelAgents"; -import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; import { channelsQueryKey, upsertCachedChannelMember, @@ -54,16 +53,6 @@ import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgen import { createPersona, deletePersona, - exportAgentSnapshot, - encodeAgentSnapshotForSend, - previewAgentSnapshotImport, - confirmAgentSnapshotImport, - type AgentSnapshotImportPreview, - type AgentSnapshotImportConfirm, - type AgentSnapshotImportResult, - type EncodedSnapshotPayload, - type SnapshotMemoryLevel, - type SnapshotFormat, listPersonas, setPersonaActive, updatePersona, @@ -97,6 +86,7 @@ export { useTeamsQuery, useUpdateTeamMutation, } from "@/features/agents/teamHooks"; +export * from "@/features/agents/snapshotHooks"; export type { AttachManagedAgentToChannelInput, AttachManagedAgentToChannelResult, @@ -109,6 +99,27 @@ export type { ProvisionChannelManagedAgentResult, } from "@/features/agents/channelAgents"; +export const AGENTS_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** + * Matches the query's 30 s poll so a focus-return refetches anything older + * than one poll tick. This detail-view query mounts only on the agent-detail + * surface and is not part of the app-wide focus storm. + */ +export const MANAGED_AGENT_LOG_FOCUS_STALE_TIME_MS = 30_000; + +/** Focus-refetch policy for relay-agent and managed-agent queries; consumed by focusRefetchPolicy.test.mjs. */ +export const agentsFocusRefetchPolicy = { + staleTime: AGENTS_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: true, +} as const; + +/** Focus-refetch policy for the managed-agent-log query; consumed by focusRefetchPolicy.test.mjs. */ +export const managedAgentLogFocusRefetchPolicy = { + staleTime: MANAGED_AGENT_LOG_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: true, +} as const; + export const relayAgentsQueryKey = ["relay-agents"] as const; export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; @@ -326,11 +337,10 @@ export function useManagedAgentPrereqsQuery( } export function useRelayAgentsQuery(options?: { enabled?: boolean }) { - const refetchInterval = useFocusedRefetchInterval(5 * 60_000); + const refetchInterval = useFocusedRefetchInterval(AGENTS_FOCUS_STALE_TIME_MS); return useQuery({ queryKey: relayAgentsQueryKey, queryFn: listRelayAgents, - staleTime: 30_000, // Relay agent profiles (kind:10100) are near-static and the backing // `list_relay_agents` command is an unfiltered relay query for the whole // profile set — mounted on ~13 always-live surfaces (channel screen, @@ -340,8 +350,8 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) { // reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we // keep polling but at a relaxed cadence and pause it while backgrounded. refetchInterval, - refetchOnWindowFocus: true, enabled: options?.enabled, + ...agentsFocusRefetchPolicy, }); } @@ -351,7 +361,6 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { enabled: options?.enabled ?? true, queryKey: managedAgentsQueryKey, queryFn: listManagedAgents, - staleTime: 5_000, refetchInterval: (query) => { if (!appFocused) return false; const agents = query.state.data as ManagedAgent[] | undefined; @@ -364,7 +373,7 @@ export function useManagedAgentsQuery(options?: { enabled?: boolean }) { ? 5_000 : false; }, - refetchOnWindowFocus: true, + ...agentsFocusRefetchPolicy, }); } @@ -817,101 +826,20 @@ export function useCreateChannelManagedAgentsMutation( }); } -export function useExportAgentSnapshotMutation() { - return useMutation({ - mutationFn: async ({ - id, - memoryLevel, - format, - memorySourcePubkey, - avatarUrl, - }: { - id: string; - memoryLevel: SnapshotMemoryLevel; - format: SnapshotFormat; - memorySourcePubkey?: string | null; - avatarUrl?: string | null; - }) => { - const avatarPngDataUrl = - format === "png" - ? await resolveSnapshotAvatarPng(avatarUrl) - : undefined; - return exportAgentSnapshot( - id, - memoryLevel, - format, - memorySourcePubkey, - avatarPngDataUrl, - ); - }, - }); -} - -export function useEncodeAgentSnapshotForSendMutation() { - return useMutation({ - mutationFn: ({ - id, - memoryLevel, - format, - memorySourcePubkey, - avatarPngDataUrl, - }: { - id: string; - memoryLevel: SnapshotMemoryLevel; - format: SnapshotFormat; - memorySourcePubkey?: string | null; - avatarPngDataUrl?: string; - }) => - encodeAgentSnapshotForSend( - id, - memoryLevel, - format, - memorySourcePubkey, - avatarPngDataUrl, - ), - }); -} - -export function usePreviewAgentSnapshotImportMutation() { - return useMutation({ - mutationFn: ({ - fileBytes, - fileName, - }: { - fileBytes: number[]; - fileName: string; - }) => previewAgentSnapshotImport(fileBytes, fileName), - }); -} - -export function useConfirmAgentSnapshotImportMutation() { - return useMutation({ - mutationFn: (input: AgentSnapshotImportConfirm) => - confirmAgentSnapshotImport(input), - }); -} - -// Re-export import types for consumers that import from hooks. -export type { - AgentSnapshotImportPreview, - AgentSnapshotImportConfirm, - AgentSnapshotImportResult, - EncodedSnapshotPayload, -}; - export function useManagedAgentLogQuery( pubkey: string | null, lineCount = 120, ) { - const refetchInterval = useFocusedRefetchInterval(pubkey ? 30_000 : false); + const refetchInterval = useFocusedRefetchInterval( + pubkey ? MANAGED_AGENT_LOG_FOCUS_STALE_TIME_MS : false, + ); return useQuery({ queryKey: ["managed-agent-log", pubkey, lineCount], queryFn: () => getManagedAgentLog(pubkey as string, lineCount), enabled: pubkey !== null, retry: false, - staleTime: 3_000, refetchInterval, - refetchOnWindowFocus: true, + ...managedAgentLogFocusRefetchPolicy, }); } @@ -924,9 +852,8 @@ export function useAgentConfigSurface(pubkey: string | null) { queryKey: agentConfigSurfaceQueryKey(pubkey ?? ""), queryFn: () => getAgentConfigSurface(pubkey ?? ""), enabled: !!pubkey, - staleTime: 10_000, refetchInterval, - refetchOnWindowFocus: true, + ...agentsFocusRefetchPolicy, }); } diff --git a/desktop/src/features/agents/lib/focusRefetchPolicy.test.mjs b/desktop/src/features/agents/lib/focusRefetchPolicy.test.mjs new file mode 100644 index 00000000000..7f274203099 --- /dev/null +++ b/desktop/src/features/agents/lib/focusRefetchPolicy.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { + focusManager, + QueryClient, + QueryObserver, +} from "@tanstack/react-query"; + +import { personaCatalogFocusRefetchPolicy } from "./usePersonaCatalogRelay.ts"; + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("persona-catalog: skips fresh focus refetch", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: personaCatalogFocusRefetchPolicy.staleTime - 1_000, + policy: personaCatalogFocusRefetchPolicy, + }), + 0, + ); +}); + +test("persona-catalog: refetches genuinely stale data on focus", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: personaCatalogFocusRefetchPolicy.staleTime + 1, + policy: personaCatalogFocusRefetchPolicy, + }), + 1, + ); +}); diff --git a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts index 9b0b669ba51..2b0f0fdb296 100644 --- a/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts +++ b/desktop/src/features/agents/lib/usePersonaCatalogRelay.ts @@ -15,19 +15,32 @@ import { import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types"; import { KIND_PERSONA } from "@/shared/constants/kinds"; +/** Keeps focused polling at the established 2-minute backstop cadence. */ +export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 120_000; +/** Suppresses the focus refetch until persona catalog data is genuinely stale. + * The live subscription (invalidateQueries) is the primary freshness path. */ +export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy for the persona catalog query; consumed by focusRefetchPolicy.test.mjs. */ +export const personaCatalogFocusRefetchPolicy = { + staleTime: PERSONA_CATALOG_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: true, +} as const; + export function personaCatalogQueryKey(communityId: string | null) { return ["persona-catalog", communityId] as const; } export function usePersonaCatalogQuery(communityId: string | null) { - const refetchInterval = useFocusedRefetchInterval(120_000); + const refetchInterval = useFocusedRefetchInterval( + PERSONA_CATALOG_REFETCH_INTERVAL_MS, + ); return useQuery({ enabled: communityId !== null, queryKey: personaCatalogQueryKey(communityId), queryFn: fetchPersonaCatalogPublications, - staleTime: 30_000, refetchInterval, - refetchOnWindowFocus: true, + ...personaCatalogFocusRefetchPolicy, }); } diff --git a/desktop/src/features/agents/snapshotHooks.ts b/desktop/src/features/agents/snapshotHooks.ts new file mode 100644 index 00000000000..cbbaf122117 --- /dev/null +++ b/desktop/src/features/agents/snapshotHooks.ts @@ -0,0 +1,97 @@ +import { useMutation } from "@tanstack/react-query"; + +import { resolveSnapshotAvatarPng } from "@/features/agents/ui/snapshotAvatarPng"; +import { + confirmAgentSnapshotImport, + encodeAgentSnapshotForSend, + exportAgentSnapshot, + previewAgentSnapshotImport, + type AgentSnapshotImportConfirm, + type AgentSnapshotImportPreview, + type AgentSnapshotImportResult, + type EncodedSnapshotPayload, + type SnapshotFormat, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; + +export function useExportAgentSnapshotMutation() { + return useMutation({ + mutationFn: async ({ + id, + memoryLevel, + format, + memorySourcePubkey, + avatarUrl, + }: { + id: string; + memoryLevel: SnapshotMemoryLevel; + format: SnapshotFormat; + memorySourcePubkey?: string | null; + avatarUrl?: string | null; + }) => { + const avatarPngDataUrl = + format === "png" + ? await resolveSnapshotAvatarPng(avatarUrl) + : undefined; + return exportAgentSnapshot( + id, + memoryLevel, + format, + memorySourcePubkey, + avatarPngDataUrl, + ); + }, + }); +} + +export function useEncodeAgentSnapshotForSendMutation() { + return useMutation({ + mutationFn: ({ + id, + memoryLevel, + format, + memorySourcePubkey, + avatarPngDataUrl, + }: { + id: string; + memoryLevel: SnapshotMemoryLevel; + format: SnapshotFormat; + memorySourcePubkey?: string | null; + avatarPngDataUrl?: string; + }) => + encodeAgentSnapshotForSend( + id, + memoryLevel, + format, + memorySourcePubkey, + avatarPngDataUrl, + ), + }); +} + +export function usePreviewAgentSnapshotImportMutation() { + return useMutation({ + mutationFn: ({ + fileBytes, + fileName, + }: { + fileBytes: number[]; + fileName: string; + }) => previewAgentSnapshotImport(fileBytes, fileName), + }); +} + +export function useConfirmAgentSnapshotImportMutation() { + return useMutation({ + mutationFn: (input: AgentSnapshotImportConfirm) => + confirmAgentSnapshotImport(input), + }); +} + +// Re-export import types for consumers that import from hooks. +export type { + AgentSnapshotImportPreview, + AgentSnapshotImportConfirm, + AgentSnapshotImportResult, + EncodedSnapshotPayload, +}; diff --git a/desktop/src/features/channel-templates/focusRefetchPolicy.test.mjs b/desktop/src/features/channel-templates/focusRefetchPolicy.test.mjs new file mode 100644 index 00000000000..8edc822c868 --- /dev/null +++ b/desktop/src/features/channel-templates/focusRefetchPolicy.test.mjs @@ -0,0 +1,65 @@ +import assert from "node:assert/strict"; +import { afterEach, test } from "node:test"; + +import { + focusManager, + QueryClient, + QueryObserver, +} from "@tanstack/react-query"; + +import { channelTemplatesFocusRefetchPolicy } from "./hooks.ts"; + +afterEach(() => { + focusManager.setFocused(undefined); +}); + +async function focusRefetchCount({ ageMs, policy }) { + focusManager.setFocused(false); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + queryClient.mount(); + + const queryKey = ["focus-refetch-policy", policy.staleTime, ageMs]; + queryClient.setQueryData(queryKey, "cached", { + updatedAt: Date.now() - ageMs, + }); + let fetchCount = 0; + const observer = new QueryObserver(queryClient, { + queryKey, + queryFn: async () => { + fetchCount += 1; + return "refetched"; + }, + refetchOnMount: false, + ...policy, + }); + const unsubscribe = observer.subscribe(() => {}); + + focusManager.setFocused(true); + await new Promise((resolve) => setImmediate(resolve)); + + unsubscribe(); + queryClient.unmount(); + return fetchCount; +} + +test("channel-templates: skips fresh focus refetch", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: channelTemplatesFocusRefetchPolicy.staleTime - 1_000, + policy: channelTemplatesFocusRefetchPolicy, + }), + 0, + ); +}); + +test("channel-templates: refetches genuinely stale data on focus", async () => { + assert.equal( + await focusRefetchCount({ + ageMs: channelTemplatesFocusRefetchPolicy.staleTime + 1, + policy: channelTemplatesFocusRefetchPolicy, + }), + 1, + ); +}); diff --git a/desktop/src/features/channel-templates/hooks.ts b/desktop/src/features/channel-templates/hooks.ts index 412fb0eef72..d0f2e73d02f 100644 --- a/desktop/src/features/channel-templates/hooks.ts +++ b/desktop/src/features/channel-templates/hooks.ts @@ -15,17 +15,30 @@ import type { UpdateChannelTemplateInput, } from "@/shared/api/types"; +/** Keeps focused polling at the established 30-second cadence. */ +export const CHANNEL_TEMPLATES_REFETCH_INTERVAL_MS = 30_000; +/** Suppresses the focus refetch until channel template data is genuinely stale. + * Templates change rarely; mutations cover all writes with push-invalidation. */ +export const CHANNEL_TEMPLATES_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy for the channel templates query; consumed by focusRefetchPolicy.test.mjs. */ +export const channelTemplatesFocusRefetchPolicy = { + staleTime: CHANNEL_TEMPLATES_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: true, +} as const; + export const channelTemplatesQueryKey = ["channel-templates"] as const; export function useChannelTemplatesQuery() { - const refetchInterval = useFocusedRefetchInterval(30_000); + const refetchInterval = useFocusedRefetchInterval( + CHANNEL_TEMPLATES_REFETCH_INTERVAL_MS, + ); return useQuery({ queryKey: channelTemplatesQueryKey, queryFn: listChannelTemplates, - staleTime: 30_000, refetchInterval, - refetchOnWindowFocus: true, + ...channelTemplatesFocusRefetchPolicy, }); } diff --git a/desktop/src/features/channels/hooks.test.mjs b/desktop/src/features/channels/hooks.test.mjs index 7967fe8ce9d..f802fe87b64 100644 --- a/desktop/src/features/channels/hooks.test.mjs +++ b/desktop/src/features/channels/hooks.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { + applyLastMessages, reconcileRefreshedCachedChannel, upsertCachedChannel, upsertCachedChannelMember, @@ -109,6 +110,59 @@ test("reconcileRefreshedCachedChannel_restoresOpenedDmAfterStaleRefresh", () => assert.deepEqual(reconciled[0], openedDm); }); +// ── applyLastMessages ───────────────────────────────────────────────────────── + +test("applyLastMessages_preservesReferenceWhenTimestampUnchanged", () => { + const channel = makeChannel("general", "General"); + channel.lastMessageAt = "2026-01-01T00:00:00Z"; + + const result = applyLastMessages([channel], { + general: "2026-01-01T00:00:00Z", + }); + + // Must be the same object reference — structural sharing avoids re-renders. + assert.strictEqual( + result[0], + channel, + "reference must be preserved when lastMessageAt is unchanged", + ); +}); + +test("applyLastMessages_createsNewObjectWhenTimestampChanges", () => { + const channel = makeChannel("general", "General"); + channel.lastMessageAt = "2026-01-01T00:00:00Z"; + + const result = applyLastMessages([channel], { + general: "2026-06-15T12:00:00Z", + }); + + assert.notStrictEqual( + result[0], + channel, + "must create a new object when timestamp changes", + ); + assert.equal(result[0].lastMessageAt, "2026-06-15T12:00:00Z"); +}); + +test("applyLastMessages_setsNullWhenChannelAbsentFromMap", () => { + const channel = makeChannel("general", "General"); + channel.lastMessageAt = "2026-01-01T00:00:00Z"; + + const result = applyLastMessages([channel], {}); + + assert.notStrictEqual(result[0], channel); + assert.equal(result[0].lastMessageAt, null); +}); + +test("applyLastMessages_preservesReferenceWhenBothNull", () => { + const channel = makeChannel("general", "General"); + // lastMessageAt defaults to null in makeChannel + + const result = applyLastMessages([channel], {}); + + assert.strictEqual(result[0], channel, "null→null must preserve reference"); +}); + test("reconcileRefreshedCachedChannel_preservesRefreshedDmRecency", () => { const charliePubkey = "charlie-pubkey"; const ownerPubkey = "owner-pubkey"; diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 50475fe16e7..9c1a91ea410 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -42,6 +42,23 @@ import { } from "@/features/channels/channelSnapshot"; export const channelsQueryKey = ["channels"] as const; +/** Keeps focused polling at the established one-minute cadence. */ +export const CHANNELS_REFETCH_INTERVAL_MS = 60_000; +/** Suppresses the expensive focus refetch until the channel list is old. */ +export const CHANNELS_FOCUS_STALE_TIME_MS = 5 * 60_000; + +/** Focus-refetch policy for the channels query; consumed by focusRefetchPolicy.test.mjs. */ +export const channelsFocusRefetchPolicy = { + staleTime: CHANNELS_FOCUS_STALE_TIME_MS, + refetchOnWindowFocus: true, +} as const; +/** + * Query-cache key for the channels payload hash. Stored alongside the channel + * list so its lifecycle is tied to the channel cache — a community switch that + * evicts the channel cache implicitly invalidates the stored hash, preventing a + * stale hash from short-circuiting into an empty list. + */ +const channelsHashKey = ["channels", "_hash"] as const; const channelDetailQueryKey = (channelId: string) => ["channels", channelId, "detail"] as const; const channelMembersQueryKey = (channelId: string) => @@ -191,20 +208,74 @@ function setChannelArchivedState( ); } +/** + * Overlays `last_messages` timestamps onto channels without creating new object + * references for channels whose `lastMessageAt` is unchanged. Preserving + * references lets React Query's structural sharing skip re-renders for + * channels that received no new messages. Exported for unit testing. + */ +export function applyLastMessages( + channels: Channel[], + lastMessages: Record, +): Channel[] { + return channels.map((channel) => { + const newTs = lastMessages[channel.id] ?? null; + if (channel.lastMessageAt === newTs) { + return channel; + } + return { ...channel, lastMessageAt: newTs }; + }); +} + export function useChannelsQuery(options?: { enabled?: boolean }) { const { activeCommunity } = useCommunities(); const relayUrl = activeCommunity?.relayUrl ?? null; - const refetchInterval = useFocusedRefetchInterval(60_000); + const refetchInterval = useFocusedRefetchInterval( + CHANNELS_REFETCH_INTERVAL_MS, + ); + const queryClient = useQueryClient(); return useQuery({ enabled: options?.enabled ?? true, queryKey: channelsQueryKey, queryFn: async () => { - const channels = sortChannels(await getChannels()); - if (relayUrl) { - writeChannelSnapshot(relayUrl, channels); + // Supply the stored hash only when the channel list is still in cache. + // If cache is absent (community switch, eviction) we send null so the + // Rust side never short-circuits into an empty list. + const cachedChannels = + queryClient.getQueryData(channelsQueryKey); + const knownHash = cachedChannels + ? (queryClient.getQueryData(channelsHashKey) ?? null) + : null; + + const payload = await getChannels(knownHash); + + // Pin the hash alongside the channel cache so it is implicitly + // invalidated whenever the channel list is evicted. + queryClient.setQueryData(channelsHashKey, payload.hash); + + // Determine the base channel list: full payload on a normal response, + // or the still-valid cached list on a not-modified (channels === null) response. + const base = payload.channels ?? cachedChannels; + + if (!base) { + // hash-match but cache somehow empty — shouldn't happen given the + // null-hash guard above, but handle defensively by re-fetching without + // the stale hash so we always have a channel list to return. + const full = await getChannels(null); + queryClient.setQueryData(channelsHashKey, full.hash); + const sorted = sortChannels( + applyLastMessages(full.channels ?? [], full.lastMessages), + ); + if (relayUrl) writeChannelSnapshot(relayUrl, sorted); + return sorted; } - return channels; + + const sorted = sortChannels( + applyLastMessages(base, payload.lastMessages), + ); + if (relayUrl) writeChannelSnapshot(relayUrl, sorted); + return sorted; }, // Paint the sidebar instantly from the last-known list for this relay, then // revalidate. initialDataUpdatedAt:0 marks the seed as already-stale so the @@ -216,9 +287,8 @@ export function useChannelsQuery(options?: { enabled?: boolean }) { } : undefined, initialDataUpdatedAt: 0, - staleTime: 60_000, refetchInterval, - refetchOnWindowFocus: true, + ...channelsFocusRefetchPolicy, }); } diff --git a/desktop/src/features/channels/lib/channelSearchScore.test.mjs b/desktop/src/features/channels/lib/channelSearchScore.test.mjs index 477620754df..94a0996fa06 100644 --- a/desktop/src/features/channels/lib/channelSearchScore.test.mjs +++ b/desktop/src/features/channels/lib/channelSearchScore.test.mjs @@ -60,6 +60,21 @@ test("scoreChannelName: unrelated query returns null", () => { assert.equal(scoreChannelName("release-notes", "budget"), null); }); +test("scoreChannelName: one typo still matches without outranking a prefix", () => { + const exactPrefix = scoreChannelName("general", "gene"); + const missingLetter = scoreChannelName("general", "genral"); + const transposedLetters = scoreChannelName("engineering", "engienering"); + + assert.notEqual(missingLetter, null); + assert.notEqual(transposedLetters, null); + assert.ok(exactPrefix < missingLetter, "a clean prefix should rank first"); +}); + +test("scoreChannelName: short and multi-edit fuzzy noise is rejected", () => { + assert.equal(scoreChannelName("general", "gxl"), null); + assert.equal(scoreChannelName("general", "gxxral"), null); +}); + test("scoreChannelName: subsequence requires correct order", () => { // "sn" — 's' then 'n' — is NOT in order in "release-notes" (n comes... yes it // is: relea-s-e-n-otes). Use a genuinely out-of-order example instead. diff --git a/desktop/src/features/channels/lib/channelSearchScore.ts b/desktop/src/features/channels/lib/channelSearchScore.ts index 3f27a8fbc59..182fdf8199f 100644 --- a/desktop/src/features/channels/lib/channelSearchScore.ts +++ b/desktop/src/features/channels/lib/channelSearchScore.ts @@ -1,9 +1,11 @@ +import { hasTypoTolerantPrefixMatch } from "@/shared/lib/fuzzyText"; + /** * Lightweight fuzzy matching for the channel browser search box. * * Mirrors the philosophy of `mentionRanking.ts`: cheap, dependency-free, - * separator-aware scoring — no Levenshtein / typo-tolerance (which reorders - * results unpredictably and hides the channel a user can plainly see). + * separator-aware scoring. Exact and prefix matches retain their stable score + * bands; a conservative one-edit typo fallback is considered only afterward. * * The one thing plain substring search gets wrong is contiguity across * separators: typing `releasenotes` or `reln` should still find @@ -11,7 +13,8 @@ * * 1. word-boundary tokens (split on space/`-`/`_`), so multi-word names match * when the separators are dropped or a later word is typed, and - * 2. an in-order subsequence check, so `reln` matches `release-notes`. + * 2. an in-order subsequence check, so `reln` matches `release-notes`, and + * 3. a one-edit word-prefix fallback, so `genral` matches `general`. * * Lower score === better match. `null` means "no match". */ @@ -27,7 +30,8 @@ const SCORE_WORD_PREFIX = 3; const SCORE_SUBSTRING = 4; const SCORE_COLLAPSED_SEPARATORS = 5; const SCORE_SUBSEQUENCE = 6; -const SCORE_DESCRIPTION = 7; +const SCORE_TYPO = 7; +const SCORE_DESCRIPTION = 8; /** Strip separators so `release-notes` and `releasenotes` compare equal. */ function collapseSeparators(value: string): string { @@ -87,6 +91,10 @@ export function scoreChannelName( return SCORE_SUBSEQUENCE; } + if (hasTypoTolerantPrefixMatch(lower, lowerQuery)) { + return SCORE_TYPO; + } + return null; } diff --git a/desktop/src/features/channels/readState/readStateManager.test.mjs b/desktop/src/features/channels/readState/readStateManager.test.mjs index 89d092fae9e..8831a952bf6 100644 --- a/desktop/src/features/channels/readState/readStateManager.test.mjs +++ b/desktop/src/features/channels/readState/readStateManager.test.mjs @@ -16,10 +16,72 @@ import { function makeLocalStorage() { const store = new Map(); + const writes = []; return { getItem: (key) => store.get(key) ?? null, - setItem: (key, value) => store.set(key, value), + setItem: (key, value) => { + writes.push([key, value]); + store.set(key, value); + }, removeItem: (key) => store.delete(key), + writes, + }; +} + +function makeFakeTimers() { + let nextId = 1; + let now = 0; + const timers = new Map(); + + function runThrough(targetTime) { + while (true) { + const next = [...timers.entries()] + .filter(([, timer]) => timer.dueAt <= targetTime) + .sort( + ([firstId, first], [secondId, second]) => + first.dueAt - second.dueAt || firstId - secondId, + )[0]; + if (!next) break; + + const [id, timer] = next; + timers.delete(id); + now = timer.dueAt; + timer.fn(); + } + now = targetTime; + } + + return { + setTimeout(fn, delay = 0) { + const id = nextId++; + timers.set(id, { fn, dueAt: now + delay }); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + advanceBy(ms) { + runThrough(now + ms); + }, + runAll() { + while (timers.size > 0) { + const nextDueAt = Math.min( + ...[...timers.values()].map((timer) => timer.dueAt), + ); + runThrough(nextDueAt); + } + }, + get size() { + return timers.size; + }, + }; +} + +function makeFakeRelay() { + return { + fetchEvents: async () => [], + publishEvent: async () => {}, + subscribeLive: () => () => {}, }; } @@ -27,19 +89,23 @@ function makeLocalStorage() { // replaced per-test for isolation; the bare `localStorage` global proxies to it. { const ls = makeLocalStorage(); - if (typeof globalThis.window === "undefined") { - globalThis.window = { - localStorage: ls, - clearTimeout: (id) => clearTimeout(id), - setTimeout: (fn, ms) => setTimeout(fn, ms), - }; - } else { - globalThis.window.localStorage = ls; - if (!globalThis.window.clearTimeout) { - globalThis.window.clearTimeout = (id) => clearTimeout(id); - globalThis.window.setTimeout = (fn, ms) => setTimeout(fn, ms); - } - } + const windowEvents = new EventTarget(); + const documentEvents = new EventTarget(); + globalThis.window = { + localStorage: ls, + clearTimeout: (id) => clearTimeout(id), + setTimeout: (fn, ms) => setTimeout(fn, ms), + addEventListener: (...args) => windowEvents.addEventListener(...args), + removeEventListener: (...args) => windowEvents.removeEventListener(...args), + dispatchEvent: (...args) => windowEvents.dispatchEvent(...args), + }; + globalThis.document = { + visibilityState: "visible", + addEventListener: (...args) => documentEvents.addEventListener(...args), + removeEventListener: (...args) => + documentEvents.removeEventListener(...args), + dispatchEvent: (...args) => documentEvents.dispatchEvent(...args), + }; // Ensure bare `localStorage` always proxies to window.localStorage. Object.defineProperty(globalThis, "localStorage", { get: () => globalThis.window.localStorage, @@ -52,6 +118,204 @@ const channelKey = "channel-1"; const channelResolver = (ctx) => ctx.startsWith("thread:") ? channelKey : null; +// ── ReadStateManager local persistence ──────────────────────────────────────── + +function withFakeTimers() { + const timers = makeFakeTimers(); + const originalSetTimeout = globalThis.window.setTimeout; + const originalClearTimeout = globalThis.window.clearTimeout; + globalThis.window.setTimeout = (fn, ms) => timers.setTimeout(fn, ms); + globalThis.window.clearTimeout = (id) => timers.clearTimeout(id); + return { + timers, + restore() { + globalThis.window.setTimeout = originalSetTimeout; + globalThis.window.clearTimeout = originalClearTimeout; + }, + }; +} + +test("advanceContext burst coalesces local persistence into one write", () => { + const storage = makeLocalStorage(); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const manager = new ReadStateManager("1".repeat(64), makeFakeRelay()); + const baselineWrites = storage.writes.length; + + try { + manager.seedContextRead("channel-1", 100); + manager.seedContextRead("channel-2", 200); + manager.seedContextRead("channel-3", 300); + + assert.equal(timers.size, 1, "burst should leave one trailing timer"); + assert.equal(storage.writes.length, baselineWrites); + + timers.runAll(); + assert.equal( + storage.writes.length - baselineWrites, + 3, + "one writeStoredReadState call writes its three blobs once", + ); + } finally { + manager.destroy(); + restore(); + } +}); + +test("sustained advances persist within each one-second window", () => { + const storage = makeLocalStorage(); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const manager = new ReadStateManager("5".repeat(64), makeFakeRelay()); + const baselineWrites = storage.writes.length; + + try { + manager.seedContextRead("channel-1", 100); + + for (let timestamp = 200; timestamp <= 3_200; timestamp += 200) { + timers.advanceBy(200); + manager.seedContextRead("channel-1", timestamp); + + if (timestamp % 1_000 === 0) { + assert.equal( + storage.writes.length - baselineWrites, + (timestamp / 1_000) * 3, + "latest state should persist once per one-second window", + ); + } + } + + const contexts = JSON.parse( + storage.getItem(`buzz.channel-read-state.v2:${"5".repeat(64)}`), + ); + assert.equal(contexts["channel-1"], new Date(2_800_000).toISOString()); + assert.equal(timers.size, 1, "latest advance should open the next window"); + } finally { + manager.destroy(); + restore(); + } +}); + +test("visibility hidden flushes pending local state", () => { + const storage = makeLocalStorage(); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const manager = new ReadStateManager("2".repeat(64), makeFakeRelay()); + const baselineWrites = storage.writes.length; + + try { + manager.seedContextRead("channel-1", 100); + assert.equal(storage.writes.length, baselineWrites); + + globalThis.document.visibilityState = "hidden"; + globalThis.document.dispatchEvent(new Event("visibilitychange")); + + assert.equal(timers.size, 0, "flush should cancel the trailing timer"); + assert.equal(storage.writes.length - baselineWrites, 3); + const contexts = JSON.parse( + storage.getItem(`buzz.channel-read-state.v2:${"2".repeat(64)}`), + ); + assert.equal(contexts["channel-1"], new Date(100_000).toISOString()); + } finally { + globalThis.document.visibilityState = "visible"; + manager.destroy(); + restore(); + } +}); + +test("hydrateFromLocalStorage persists immediately", () => { + const storage = makeLocalStorage(); + const pubkey = "3".repeat(64); + storage.setItem( + `buzz.channel-read-state.v2:${pubkey}`, + JSON.stringify({ "channel-1": new Date(100_000).toISOString() }), + ); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const manager = new ReadStateManager(pubkey, makeFakeRelay()); + const baselineWrites = storage.writes.length; + + try { + manager.hydrateFromLocalStorage(); + + assert.equal(timers.size, 0); + assert.equal(storage.writes.length - baselineWrites, 3); + assert.equal(manager.getOwnTimestamp("channel-1"), 100); + } finally { + manager.destroy(); + restore(); + } +}); + +test("publish flushes pending local state first", async () => { + const storage = makeLocalStorage(); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const manager = new ReadStateManager("4".repeat(64), makeFakeRelay()); + const baselineWrites = storage.writes.length; + + try { + manager.seedContextRead("channel-1", 100); + manager.fetchOwnBlobBeforePublish = async () => {}; + + await manager.publish(); + + assert.equal(timers.size, 0); + assert.equal(storage.writes.length - baselineWrites, 3); + } finally { + manager.destroy(); + restore(); + } +}); + +test("destroyed manager cannot persist after an in-flight fetch resolves", async () => { + const storage = makeLocalStorage(); + globalThis.window.localStorage = storage; + const { timers, restore } = withFakeTimers(); + const pubkey = "6".repeat(64); + let resolveFetch; + const fetchPending = new Promise((resolve) => { + resolveFetch = resolve; + }); + const staleManager = new ReadStateManager(pubkey, { + ...makeFakeRelay(), + fetchEvents: () => fetchPending, + }); + + try { + const initialization = staleManager.initialize(); + staleManager.seedContextRead("channel-1", 100); + staleManager.destroy(); + + const replacementManager = new ReadStateManager(pubkey, makeFakeRelay()); + replacementManager.seedContextRead("channel-1", 200); + timers.advanceBy(1_000); + const writesAfterReplacement = storage.writes.length; + + resolveFetch([]); + await initialization; + timers.runAll(); + + assert.equal( + storage.writes.length, + writesAfterReplacement, + "the stale manager must not schedule persistence after destruction", + ); + const contexts = JSON.parse( + storage.getItem(`buzz.channel-read-state.v2:${pubkey}`), + ); + assert.equal( + contexts["channel-1"], + new Date(200_000).toISOString(), + "the stale manager must not overwrite the replacement manager", + ); + replacementManager.destroy(); + } finally { + staleManager.destroy(); + restore(); + } +}); + test("resolveEffectiveTimestamp returns own value when context has no parent", () => { const effectiveState = new Map([[channelKey, 200]]); const result = resolveEffectiveTimestamp({ diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts index 382a60f20ec..3ba382dc61e 100644 --- a/desktop/src/features/channels/readState/readStateManager.ts +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -23,7 +23,8 @@ import { truncatePubkey } from "@/shared/lib/pubkey"; const CLIENT_ID_KEY_PREFIX = "buzz.nip-rs.client-id"; const SLOT_ID_KEY_PREFIX = "buzz.nip-rs.slot-id"; -const DEBOUNCE_MS = 5_000; +const PUBLISH_DEBOUNCE_MS = 5_000; +const LOCAL_PERSIST_MAX_WAIT_MS = 1_000; function generateHex(bytes: number): string { const arr = new Uint8Array(bytes); @@ -312,6 +313,7 @@ export class ReadStateManager { private publishableContextIds = new Set(); private lastPublishedContexts: Record = {}; private debounceTimer: number | null = null; + private localPersistTimer: number | null = null; private listeners = new Set<() => void>(); private unsubscribeLive: (() => void) | null = null; private initialized = false; @@ -331,6 +333,8 @@ export class ReadStateManager { generateHex(16), ); this.extraSlotIds = loadExtraSlotIds(pubkey); + window.addEventListener("pagehide", this.flushLocalState); + document.addEventListener("visibilitychange", this.handleVisibilityChange); } async initialize(): Promise { @@ -377,6 +381,7 @@ export class ReadStateManager { unixTimestamp: number, options: { publishable: boolean }, ): void { + if (this.destroyed) return; const current = this.effectiveState.get(contextId) ?? 0; if (unixTimestamp <= current) { if (!options.publishable || this.publishableContextIds.has(contextId)) { @@ -438,7 +443,14 @@ export class ReadStateManager { destroy(): void { this.destroyed = true; - // Flush any pending writes immediately + window.removeEventListener("pagehide", this.flushLocalState); + document.removeEventListener( + "visibilitychange", + this.handleVisibilityChange, + ); + this.flushLocalState(); + + // Flush any pending relay publish immediately if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); this.debounceTimer = null; @@ -475,6 +487,7 @@ export class ReadStateManager { } private async mergeEvents(events: RelayEvent[]): Promise { + if (this.destroyed) return; // Collect all own blobs (keyed by slot d-tag) to union them all. // NIP-RS: multiple own-slot blobs must be max-merged, not winner-takes-all. const ownBlobsBySlot = new Map< @@ -484,6 +497,7 @@ export class ReadStateManager { for (const event of events) { const parsed = await parseReadStateEvent(event, this.pubkey); + if (this.destroyed) return; if (!parsed) continue; this.maxFetchedCreatedAt = Math.max( @@ -520,6 +534,7 @@ export class ReadStateManager { // d-tag coordinate. If so, rotate our slotId to avoid clobbering. for (const event of events) { const parsed = await parseReadStateEvent(event, this.pubkey); + if (this.destroyed) return; if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue; if (parsed.blob.client_id !== this.clientId) { this.slotId = generateHex(16); @@ -572,14 +587,13 @@ export class ReadStateManager { } private async handleIncomingEvent(event: RelayEvent): Promise { - if (event.pubkey !== this.pubkey) return; - if (this.destroyed) return; + if (this.destroyed || event.pubkey !== this.pubkey) return; console.debug( `[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`, ); const parsed = await parseReadStateEvent(event, this.pubkey); - if (!parsed) return; + if (!parsed || this.destroyed) return; this.maxFetchedCreatedAt = Math.max( this.maxFetchedCreatedAt, @@ -622,18 +636,21 @@ export class ReadStateManager { } private schedulePublish(): void { + if (this.destroyed) return; if (this.debounceTimer !== null) { window.clearTimeout(this.debounceTimer); } this.debounceTimer = window.setTimeout(() => { this.debounceTimer = null; void this.publish(); - }, DEBOUNCE_MS); + }, PUBLISH_DEBOUNCE_MS); } private async publish(): Promise { console.debug(`[ReadStateManager] publish starting slotId=${this.slotId}`); await this.fetchOwnBlobBeforePublish(); + if (this.destroyed) return; + this.flushLocalState(); // Build blob from contexts this client is allowed to publish. const contexts = this.currentContexts(); @@ -927,10 +944,33 @@ export class ReadStateManager { for (const [contextId, createdAt] of stored.contextSourceCreatedAt) { this.contextSourceCreatedAt.set(contextId, createdAt); } - this.persistLocalState(); + this.writeLocalState(); } private persistLocalState(): void { + if (this.destroyed || this.localPersistTimer !== null) return; + + this.localPersistTimer = window.setTimeout(() => { + this.localPersistTimer = null; + this.writeLocalState(); + }, LOCAL_PERSIST_MAX_WAIT_MS); + } + + private readonly flushLocalState = (): void => { + if (this.localPersistTimer === null) return; + + window.clearTimeout(this.localPersistTimer); + this.localPersistTimer = null; + this.writeLocalState(); + }; + + private readonly handleVisibilityChange = (): void => { + if (document.visibilityState === "hidden") { + this.flushLocalState(); + } + }; + + private writeLocalState(): void { writeStoredReadState( this.pubkey, this.effectiveState, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 410b05a2ccd..28f3f8aae7a 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -25,7 +25,6 @@ import { import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; -import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; @@ -73,7 +72,6 @@ export const ChannelPane = React.memo(function ChannelPane({ autoSendDraftKey = null, onAutoSendComplete = null, botTypingEntries, - channelFind, channelManagementOpen = false, currentPubkey, editTarget = null, @@ -127,6 +125,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onResetThreadPanelWidth, onSelectThreadReplyTarget, onSendMessage, + onSendToChannel, onSendVideoReviewComment, onSendThreadReply, onThreadScrollTargetResolved, @@ -265,9 +264,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onEdit(target); return true; }, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]); - const timeoutState = useTimeoutState(); - // A moderation DM (1:1 with the relay identity) is read-only for the member; // only DMs pay for the NIP-11 `self` lookup. Fails open: no `relaySelf` → // ordinary DM, composer enabled. @@ -558,19 +555,6 @@ export const ChannelPane = React.memo(function ChannelPane({ } > {isHuddleTranscript ? null : header} - {channelFind.isOpen ? ( -
- -
- ) : null} resolveScrollTarget()} onScrollTargetSettled={resolveScrollTarget} onToggleReaction={onToggleReaction} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 763b8bf3797..030229dc24f 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -7,7 +7,7 @@ import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channel import type { TimelineMessage } from "@/features/messages/types"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import type { useChannelFind } from "@/features/search/useChannelFind"; +import type { DraftMentionRef } from "@/features/messages/lib/useDrafts"; import type { ProfilePanelTab, ProfilePanelView, @@ -34,7 +34,6 @@ export type ChannelPaneProps = { */ onAutoSendComplete?: (() => void) | null; botTypingEntries: TypingIndicatorEntry[]; - channelFind: ReturnType; channelManagementOpen?: boolean; currentPubkey?: string; editTarget?: { @@ -42,6 +41,7 @@ export type ChannelPaneProps = { body: string; id: string; imetaMedia?: ImetaMedia[]; + mentionRefs?: DraftMentionRef[]; } | null; fetchOlder?: () => Promise; header?: React.ReactNode; @@ -107,6 +107,11 @@ export type ChannelPaneProps = { mediaTags?: string[][], channelId?: string | null, ) => Promise; + onSendToChannel: ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => Promise; onSendVideoReviewComment?: ( message: TimelineMessage, content: string, diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 86663949384..8150f6df7de 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { useAppShell } from "@/app/AppShellContext"; -import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; @@ -42,9 +41,9 @@ import { useSendMessageMutation, useToggleReactionMutation, } from "@/features/messages/hooks"; +import { buildMessageComposerEditTarget } from "@/features/messages/lib/draftMentionRefs"; import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { DeleteMessageConfirmDialog } from "@/features/messages/ui/DeleteMessageConfirmDialog"; -import { imetaMediaFromTags } from "@/features/messages/lib/imetaMediaMarkdown"; import { getThreadReference } from "@/features/messages/lib/threading"; import { resolveTimelineLoadingLatch, @@ -57,8 +56,7 @@ import { useChannelTyping } from "@/features/messages/useChannelTyping"; import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; -import type { RelayEvent, RespondToMode, SearchHit } from "@/shared/api/types"; -import { useChannelFind } from "@/features/search/useChannelFind"; +import type { RelayEvent, RespondToMode } from "@/shared/api/types"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; import { useHuddleChannelMessages, @@ -84,8 +82,7 @@ import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; import type { ChannelScreenProps } from "./ChannelScreen.types"; -const HEADER_ACTIONS_COMPACT_BREAKPOINT_PX = 760, - EMPTY_RELAY_EVENTS: RelayEvent[] = []; +const EMPTY_RELAY_EVENTS: RelayEvent[] = []; export function ChannelScreen({ activeChannel, autoSendDraftKey, @@ -204,9 +201,8 @@ export function ChannelScreen({ const messages = messagesQuery.data; if (!messages) return null; for (let index = messages.length - 1; index >= 0; index -= 1) { - if (getThreadReference(messages[index].tags).parentId === null) { + if (getThreadReference(messages[index].tags).parentId === null) return messages[index]; - } } return null; }, [messagesQuery.data]); @@ -245,14 +241,8 @@ export function ChannelScreen({ const deleteMessageMutation = useDeleteMessageMutation(activeChannel); const editMessageMutation = useEditMessageMutation(activeChannel); const joinChannelMutation = useJoinChannelMutation(activeChannelId); - const [findEvents, setFindEvents] = React.useState([]); - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - React.useEffect(() => { - setFindEvents([]); - }, [activeChannelId]); const { resolvedMessages, threadSummaries } = useHuddleChannelMessages({ activeChannel, - findEvents, isHuddleTranscript, messages: messagesQuery.data ?? EMPTY_RELAY_EVENTS, targetMessageEvents, @@ -420,19 +410,6 @@ export function ChannelScreen({ resolvedMessages, ], ); - const handleFindSearchHit = React.useCallback((hit: SearchHit) => { - const event = cacheSearchHitEvent(hit); - setFindEvents((currentEvents) => - currentEvents.some((currentEvent) => currentEvent.id === event.id) - ? currentEvents - : [...currentEvents, event], - ); - }, []); - const channelFind = useChannelFind({ - channelId: activeChannelId, - messages: timelineMessages, - onSearchHit: handleFindSearchHit, - }); const threadPanelData = useIndependentThreadPanel({ activeChannel, channelEvents: resolvedMessages, @@ -495,6 +472,7 @@ export function ChannelScreen({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, @@ -506,6 +484,7 @@ export function ChannelScreen({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + profiles: messageProfiles, recordThreadInteraction, openThreadHeadId: effectiveOpenThreadHeadId, onOptimisticOpenThreadHeadIdChange: setOptimisticOpenThreadHeadId, @@ -713,7 +692,7 @@ export function ChannelScreen({ const shouldCompactHeaderActions = hasAuxiliaryPanel && channelContentWidthPx > 0 && - channelContentWidthPx < HEADER_ACTIONS_COMPACT_BREAKPOINT_PX; + channelContentWidthPx < 760; const channelHeaderChromeRef = useMeasuredCssVariable({ targetRef: mainInsetRef, ...channelContentTopPaddingMeasurement, @@ -859,7 +838,6 @@ export function ChannelScreen({ autoSendDraftKey={autoSendDraftKey} onAutoSendComplete={clearAutoSend} botTypingEntries={botTypingEntries} - channelFind={channelFind} channelManagementOpen={channelManagementOpen} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} @@ -879,14 +857,13 @@ export function ChannelScreen({ welcomeKickoffSettingUp={welcomeKickoffSettingUp} editTarget={ editTargetMessage - ? { - author: editTargetMessage.author, - body: editTargetMessage.body, - id: editTargetMessage.id, - imetaMedia: imetaMediaFromTags( - editTargetMessage.tags, - ), - } + ? buildMessageComposerEditTarget( + editTargetMessage, + messageProfiles, + (pubkey) => + knownAgentPubkeys.has(pubkey) || + !!messageProfiles?.[pubkey]?.isAgent, + ) : null } followThreadById={followThread} @@ -940,6 +917,7 @@ export function ChannelScreen({ onOpenThread={handleOpenThreadAndCloseAgentSession} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} onSendMessage={handleSendMessage} + onSendToChannel={handleSendToChannel} onSendVideoReviewComment={effectiveSendVideoReviewComment} onSendThreadReply={handleSendThreadReply} onThreadScrollTargetResolved={ diff --git a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts index acbf831dd2a..2a90971ddec 100644 --- a/desktop/src/features/channels/ui/useHuddleChannelMessages.ts +++ b/desktop/src/features/channels/ui/useHuddleChannelMessages.ts @@ -19,7 +19,6 @@ export function useIsHuddleTranscript(activeChannelId: string | null) { type HuddleChannelMessagesOptions = { activeChannel: Channel | null; - findEvents: RelayEvent[]; isHuddleTranscript: boolean; messages: RelayEvent[]; targetMessageEvents: RelayEvent[]; @@ -28,17 +27,16 @@ type HuddleChannelMessagesOptions = { export function useHuddleChannelMessages({ activeChannel, - findEvents, isHuddleTranscript, messages, targetMessageEvents, windowStore, }: HuddleChannelMessagesOptions) { const resolvedChannelMessages = React.useMemo(() => { - const extraEvents = [...targetMessageEvents, ...findEvents]; + const extraEvents = targetMessageEvents; if (!activeChannel || extraEvents.length === 0) return messages; return extraEvents.reduce(mergeMessages, messages); - }, [activeChannel, findEvents, messages, targetMessageEvents]); + }, [activeChannel, messages, targetMessageEvents]); const threadSummaries = React.useMemo( () => (windowStore ? channelWindowThreadSummaries(windowStore) : new Map()), diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 7e78d9601f1..d7b2e5a6fd7 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -7,7 +7,10 @@ import type { useToggleReactionMutation, } from "@/features/messages/hooks"; import { resolveThreadReplyTarget } from "@/features/messages/hooks"; +import { getSendToChannelSemantics } from "@/features/messages/lib/sendToChannelSemantics"; +import { summarizeThreadRoot } from "@/features/messages/lib/sentFromThread"; import type { TimelineMessage } from "@/features/messages/types"; +import type { UserProfileLookup } from "@/features/profile/lib/identity"; /** * Stable callback references for ChannelPane so that keystroke-driven @@ -25,6 +28,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage, getReplyDescendantIdsForMessage, markRevealedRepliesRead, + profiles, recordThreadInteraction, onOptimisticOpenThreadHeadIdChange, onRequestEmptyEditDelete, @@ -45,6 +49,7 @@ export function useChannelPaneHandlers({ getFirstReplyIdForMessage: (messageId: string) => string | null; getReplyDescendantIdsForMessage: (messageId: string) => string[]; markRevealedRepliesRead: (messageId: string) => void; + profiles: UserProfileLookup | undefined; recordThreadInteraction: (rootId: string) => void; onOptimisticOpenThreadHeadIdChange: React.Dispatch< React.SetStateAction @@ -73,6 +78,9 @@ export function useChannelPaneHandlers({ const expandedThreadReplyIdsRef = React.useRef(expandedThreadReplyIds); expandedThreadReplyIdsRef.current = expandedThreadReplyIds; + const profilesRef = React.useRef(profiles); + profilesRef.current = profiles; + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); sendMutateRef.current = sendMessageMutation.mutateAsync; @@ -287,6 +295,28 @@ export function useChannelPaneHandlers({ [], ); + const handleSendToChannel = React.useCallback( + async ( + message: TimelineMessage, + threadRoot: TimelineMessage, + channelId: string, + ) => { + const { mentionPubkeys, semanticTags } = getSendToChannelSemantics( + message, + profilesRef.current, + ); + await sendMutateRef.current({ + channelId, + content: message.body, + mediaTags: semanticTags, + mentionPubkeys, + sentFromThreadRootExcerpt: summarizeThreadRoot(threadRoot.body), + sentFromThreadRootId: threadRoot.id, + }); + }, + [], + ); + const handleSendThreadReply = React.useCallback( async ( content: string, @@ -376,6 +406,7 @@ export function useChannelPaneHandlers({ handleExpandThreadReplies, handleOpenThread, handleSendMessage, + handleSendToChannel, handleSendThreadReply, handleSelectThreadReplyTarget, handleToggleReaction, diff --git a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx index d861fae802e..a2056b6d37c 100644 --- a/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx +++ b/desktop/src/features/community-members/ui/CommunityMembersSettingsCard.tsx @@ -154,7 +154,10 @@ function RelayMemberRow({ ) : null} -
+
{member.role}