From 433da4f0267fd7e7b05ffaf3d24a4f1e702e26f7 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Wed, 8 Apr 2026 16:06:17 -0400 Subject: [PATCH 01/18] meld init --- Cargo.lock | 1744 +++++++++++++++++++++++++++++++++-- Cargo.toml | 2 + packages/meld/Cargo.toml | 20 + packages/meld/src/host.rs | 348 +++++++ packages/meld/src/main.rs | 150 +++ packages/meld/src/viewer.rs | 165 ++++ 6 files changed, 2351 insertions(+), 78 deletions(-) create mode 100644 packages/meld/Cargo.toml create mode 100644 packages/meld/src/host.rs create mode 100644 packages/meld/src/main.rs create mode 100644 packages/meld/src/viewer.rs diff --git a/Cargo.lock b/Cargo.lock index 60aca16..48f36bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -152,6 +187,28 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version", +] + [[package]] name = "atk" version = "0.18.2" @@ -193,12 +250,33 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "url", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -295,6 +373,23 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base32" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" + [[package]] name = "base64" version = "0.21.7" @@ -364,7 +459,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -390,6 +485,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96eb4cdd6cf1b31d671e9efe75c5d1ec614776856cefbe109ca373554a6d514f" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -623,6 +727,16 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.5.57" @@ -663,6 +777,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -728,6 +851,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -759,6 +888,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "cordyceps" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "688d7fbb8092b8de775ef2536f36c8c31f2bc4006ece2e8d8ad2d17d00ce0a2a" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -832,6 +971,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -918,6 +1063,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -978,14 +1132,76 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f9200d1d13637f15a6acb71e758f64624048d85b31a5fdbfd8eca1e2687d0b7" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest 0.11.0-rc.10", + "fiat-crypto", + "rand_core 0.9.5", + "rustc_version", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", ] [[package]] @@ -1001,13 +1217,24 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.114", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.114", ] @@ -1030,8 +1257,19 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1045,6 +1283,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -1078,20 +1347,38 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.114", + "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa94b64bfc6549e6e4b5a3216f22593224174083da7a90db47e951c4fb31725" +dependencies = [ + "block-buffer 0.11.0", + "const-oid 0.10.2", + "crypto-common 0.2.1", +] + [[package]] name = "dirs" version = "5.0.1" @@ -1157,6 +1444,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "dlopen2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09b4f5f101177ff01b8ec4ecc81eead416a8aa42819a2869311b3420fa114ffa" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -1252,6 +1550,33 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "3.0.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" +dependencies = [ + "pkcs8 0.11.0-rc.11", + "serde", + "signature 3.0.0-rc.10", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad207ed88a133091f83224265eac21109930db09bedcad05d5252f2af2de20a1" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "signature 3.0.0-rc.10", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1281,6 +1606,18 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + [[package]] name = "encode_unicode" version = "1.0.0" @@ -1296,6 +1633,29 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "enum-assoc" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed8956bd5c1f0415200516e78ff07ec9e16415ade83c056c230d7b7ea0d55b7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1360,10 +1720,22 @@ dependencies = [ ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "fastbloom" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" +dependencies = [ + "getrandom 0.3.4", + "libm", + "rand 0.9.2", + "siphasher 1.0.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "fdeflate" @@ -1374,6 +1746,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "field-offset" version = "0.3.6" @@ -1456,7 +1834,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1547,6 +1925,19 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.0", +] + [[package]] name = "futures-channel" version = "0.3.31" @@ -1591,6 +1982,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -1740,6 +2144,21 @@ dependencies = [ "x11", ] +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link 0.2.1", + "windows-result 0.4.1", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1795,10 +2214,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1899,6 +2330,18 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gobject-sys" version = "0.18.0" @@ -1992,6 +2435,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2061,6 +2513,20 @@ dependencies = [ "http", ] +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.8", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2082,6 +2548,59 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "http", + "idna", + "ipnet", + "once_cell", + "rand 0.9.2", + "ring", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.2", + "resolv-conf", + "rustls", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2097,7 +2616,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2195,6 +2714,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2385,6 +2913,12 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "identity-hash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfdd7caa900436d8f13b2346fe10257e0c05c1f1f9e351f4f5d57c03bd5f45da" + [[package]] name = "idna" version = "1.1.0" @@ -2406,6 +2940,27 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97" +dependencies = [ + "async-trait", + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.9.2", + "tokio", + "url", + "xmltree", +] + [[package]] name = "ignore" version = "0.4.25" @@ -2499,6 +3054,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "insta" version = "1.46.3" @@ -2517,7 +3081,7 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -2542,6 +3106,19 @@ dependencies = [ "libc", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result 0.4.1", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2558,6 +3135,168 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb56e7e4b0ec7fba7efa6a236b016a52b5d927d50244aceb9e20566159b1a32" +dependencies = [ + "backon", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "ed25519-dalek", + "futures-util", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "pkarr", + "pkcs8 0.11.0-rc.11", + "portable-atomic", + "portmapper", + "rand 0.9.2", + "reqwest 0.12.28", + "rustc-hash", + "rustls", + "rustls-pki-types", + "rustls-webpki", + "serde", + "smallvec", + "strum 0.28.0", + "sync_wrapper", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", + "webpki-roots 1.0.6", +] + +[[package]] +name = "iroh-base" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55a354e3396b62c14717ee807dfee9a7f43f6dad47e4ac0fd1d49f1ffad14ef0" +dependencies = [ + "curve25519-dalek", + "data-encoding", + "derive_more 2.1.1", + "digest 0.11.0-rc.10", + "ed25519-dalek", + "n0-error", + "rand_core 0.9.5", + "serde", + "sha2 0.11.0-rc.2", + "url", + "zeroize", + "zeroize_derive", +] + +[[package]] +name = "iroh-metrics" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "761b45ba046134b11eb3e432fa501616b45c4bf3a30c21717578bc07aa6461dd" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "postcard", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab063c2bfd6c3d5a33a913d4fdb5252f140db29ec67c704f20f3da7e8f92dbf" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "iroh-relay" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d786b260cadfe82ae0b6a9e372e8c78949096a06c857d1c3521355cefced0f55" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more 2.1.1", + "getrandom 0.3.4", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "pkarr", + "postcard", + "rand 0.9.2", + "reqwest 0.12.28", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum 0.28.0", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "vergen-gitcl", + "webpki-roots 1.0.6", + "ws_stream_wasm", + "z32", +] + +[[package]] +name = "iroh-tickets" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab64bac4bb573b9cfd2142bd2876ed65ca792efbc4398361a4ee51a0f9afbed6" +dependencies = [ + "data-encoding", + "derive_more 2.1.1", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2741,7 +3480,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2860,6 +3599,19 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + [[package]] name = "lru" version = "0.16.3" @@ -2881,6 +3633,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mac_address" version = "1.1.8" @@ -2979,7 +3737,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "meld" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "iroh", + "iroh-tickets", + "ratatui", + "tokio", + "tracing", + "tracing-subscriber", + "vt100", + "workshop", ] [[package]] @@ -3056,6 +3830,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "muda" version = "0.17.1" @@ -3078,33 +3869,192 @@ dependencies = [ ] [[package]] -name = "ndk" -version = "0.9.0" +name = "n0-error" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4782b4baf92d686d161c15460c83d16ebcfd215918763903e9619842665cae" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03755949235714b2b307e5ae89dd8c1c2531fb127d9b8b7b4adf9c876cd3ed18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more 2.1.1", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38795f7932e6e9d1c6e989270ef5b3ff24ebb910e2c9d4bed2d28d8bae3007dc" +dependencies = [ + "derive_more 2.1.1", + "n0-error", + "n0-future", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.10.0", + "jni-sys", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "netdev" +version = "0.40.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b0a0096d9613ee878dba89bbe595f079d373e3f1960d882e4f2f78ff9c30a0a" +dependencies = [ + "block2", + "dispatch2", + "dlopen2 0.5.0", + "ipnet", + "libc", + "mac-addr", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2-core-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.59.0", +] + +[[package]] +name = "netlink-packet-core" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4" +dependencies = [ + "paste", +] + +[[package]] +name = "netlink-packet-route" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9854ea6ad14e3f4698a7f03b65bce0833dd2d81d594a0e4a984170537146b6" +dependencies = [ + "bitflags 2.10.0", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +dependencies = [ + "bytes", + "futures", + "log", + "netlink-packet-core", + "netlink-sys", + "thiserror 2.0.18", +] + +[[package]] +name = "netlink-sys" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ - "bitflags 2.10.0", - "jni-sys", + "bytes", + "futures-util", + "libc", "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", + "tokio", ] [[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" +name = "netwatch" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +checksum = "3b1b27babe89ef9f2237bc6c028bea24fa84163a1b6f8f17ff93573ebd7d861f" dependencies = [ - "jni-sys", + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more 2.1.1", + "js-sys", + "libc", + "n0-error", + "n0-future", + "n0-watcher", + "netdev", + "netlink-packet-core", + "netlink-packet-route", + "netlink-proto", + "netlink-sys", + "noq-udp", + "objc2-core-foundation", + "objc2-system-configuration", + "pin-project-lite", + "serde", + "socket2", + "time", + "tokio", + "tokio-util", + "tracing", + "web-sys", + "windows 0.62.2", + "windows-result 0.4.1", + "wmi", ] [[package]] @@ -3156,6 +4106,67 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "noq" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df966fb44ac763bc86da97fa6c811c54ae82ef656575949f93c6dae0c9f09bf" +dependencies = [ + "bytes", + "cfg_aliases", + "noq-proto", + "noq-udp", + "pin-project-lite", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "web-time", +] + +[[package]] +name = "noq-proto" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c61b72abd670eebc05b5cf720e077b04a3ef3354bc7bc19f1c3524cb424db7b" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more 2.1.1", + "enum-assoc", + "fastbloom", + "getrandom 0.3.4", + "identity-hash", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb9be4fedd6b98f3ba82ccd3506f4d0219fb723c3f97c67e12fe1494aa020e44" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "notify" version = "7.0.0" @@ -3184,6 +4195,21 @@ dependencies = [ "instant", ] +[[package]] +name = "ntimestamp" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c50f94c405726d3e0095e89e72f75ce7f6587b94a8bd8dc8054b73f65c0fd68c" +dependencies = [ + "base32", + "document-features", + "getrandom 0.2.17", + "httpdate", + "js-sys", + "once_cell", + "serde", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3317,7 +4343,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", + "block2", "dispatch2", + "libc", "objc2", ] @@ -3384,6 +4412,31 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "bitflags 2.10.0", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -3415,6 +4468,10 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -3422,6 +4479,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -3484,6 +4547,22 @@ dependencies = [ "system-deps", ] +[[package]] +name = "papaya" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "997ee03cd38c01469a7046643714f0ad28880bcb9e6679ff0666e24817ca19b7" +dependencies = [ + "equivalent", + "seize", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3562,6 +4641,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3608,7 +4696,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", - "sha2", + "sha2 0.10.9", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version", ] [[package]] @@ -3798,6 +4896,26 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3810,15 +4928,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkarr" +version = "5.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7bfb9143bbba379f246211eb68074d78db9cc048e4c5701f3b0e6cb1ec67ca2" +dependencies = [ + "base32", + "bytes", + "cfg_aliases", + "document-features", + "ed25519-dalek", + "getrandom 0.4.1", + "ntimestamp", + "self_cell", + "serde", + "simple-dns", + "thiserror 2.0.18", +] + [[package]] name = "pkcs1" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", ] [[package]] @@ -3827,8 +4964,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12922b6296c06eb741b02d7b5161e3aaa22864af38dfa025a1a3ba3f68c84577" +dependencies = [ + "der 0.8.0", + "spki 0.8.0", ] [[package]] @@ -3863,11 +5010,26 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +dependencies = [ + "serde", +] [[package]] name = "portable-pty" @@ -3890,6 +5052,61 @@ dependencies = [ "winreg 0.10.1", ] +[[package]] +name = "portmapper" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74748bc706fa6b6aebac6bbe0bbe0de806b384cb5c557ea974f771360a4e3858" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more 2.1.1", + "futures-lite", + "futures-util", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "netwatch", + "num_enum", + "rand 0.9.2", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "postcard-derive", + "serde", +] + +[[package]] +name = "postcard-derive" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0232bd009a197ceec9cc881ba46f727fcd8060a2d8d6a9dde7a69030a6fe2bb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -4239,7 +5456,7 @@ dependencies = [ "itertools", "kasuari", "lru", - "strum", + "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -4291,7 +5508,7 @@ dependencies = [ "itertools", "line-clipping", "ratatui-core", - "strum", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width", @@ -4425,12 +5642,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams 0.4.2", "web-sys", "webpki-roots 1.0.6", ] @@ -4465,10 +5684,16 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -4500,16 +5725,16 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -4556,7 +5781,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", - "sha2", + "sha2 0.10.9", "walkdir", ] @@ -4696,12 +5921,28 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seize" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "selectors" version = "0.24.0" @@ -4739,6 +5980,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -4749,6 +5996,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -4771,6 +6024,16 @@ dependencies = [ "typeid", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -4893,7 +6156,7 @@ version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.114", @@ -4990,7 +6253,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -5001,7 +6264,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0-rc.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1e3878ab0f98e35b2df35fe53201d088299b41a6bb63e3e34dada2ac4abd924" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.0-rc.10", ] [[package]] @@ -5103,22 +6377,43 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0-rc.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f1880df446116126965eeec169136b2e0251dba37c6223bcc819569550edea3" + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple-dns" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df350943049174c4ae8ced56c604e28270258faec12a6a48637a7655287c9ce0" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "siphasher" version = "0.3.11" @@ -5175,6 +6470,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sorted-index-buffer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea06cc588e43c632923a55450401b8f25e628131571d4e1baea1bdfdb2b5ed06" + [[package]] name = "soup3" version = "0.5.0" @@ -5201,6 +6502,17 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "spin" version = "0.9.8" @@ -5210,6 +6522,12 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spki" version = "0.7.3" @@ -5217,7 +6535,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.0", ] [[package]] @@ -5273,7 +6601,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlformat", "thiserror 1.0.69", @@ -5312,7 +6640,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -5336,7 +6664,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -5357,7 +6685,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -5397,7 +6725,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -5522,7 +6850,16 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros", + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", ] [[package]] @@ -5537,6 +6874,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5623,6 +6972,12 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tao" version = "0.34.6" @@ -5635,7 +6990,7 @@ dependencies = [ "core-graphics", "crossbeam-channel", "dispatch2", - "dlopen2", + "dlopen2 0.8.2", "dpi", "gdkwayland-sys", "gdkx11-sys", @@ -5655,7 +7010,7 @@ dependencies = [ "tao-macros", "unicode-segmentation", "url", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", @@ -5726,7 +7081,7 @@ dependencies = [ "webkit2gtk", "webview2-com", "window-vibrancy", - "windows", + "windows 0.61.3", ] [[package]] @@ -5768,7 +7123,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "syn 2.0.114", "tauri-utils", "thiserror 2.0.18", @@ -5867,7 +7222,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", ] [[package]] @@ -5892,7 +7247,7 @@ dependencies = [ "url", "webkit2gtk", "webview2-com", - "windows", + "windows 0.61.3", "wry", ] @@ -6024,7 +7379,7 @@ dependencies = [ "pest", "pest_derive", "phf 0.11.3", - "sha2", + "sha2 0.10.9", "signal-hook", "siphasher 1.0.2", "terminfo", @@ -6098,6 +7453,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "libc", "num-conv", "num_threads", @@ -6195,6 +7551,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -6241,10 +7598,33 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1b6348ebfaaecd771cecb69e832961d277f59845d4220a584701f72728152b7" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.3.4", + "http", + "httparse", + "rand 0.9.2", + "ring", + "rustls-pki-types", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + [[package]] name = "toml" version = "0.8.2" @@ -6853,6 +8233,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6949,6 +8339,54 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vergen" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "vergen-lib 9.1.0", +] + +[[package]] +name = "vergen-gitcl" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dfc1de6eb2e08a4ddf152f1b179529638bedc0ea95e6d667c014506377aefe" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", + "time", + "vergen", + "vergen-lib 0.1.6", +] + +[[package]] +name = "vergen-lib" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b07e6010c0f3e59fcb164e0163834597da68d1f864e2b8ca49f74de01e9c166" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + +[[package]] +name = "vergen-lib" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569" +dependencies = [ + "anyhow", + "derive_builder", + "rustversion", +] + [[package]] name = "version-compare" version = "0.2.1" @@ -7158,6 +8596,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -7285,7 +8736,7 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-implement", "windows-interface", @@ -7309,7 +8760,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" dependencies = [ "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-core 0.61.2", ] @@ -7331,7 +8782,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -7395,6 +8846,12 @@ dependencies = [ "wasite", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -7447,11 +8904,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -7463,6 +8932,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -7497,7 +8975,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -7544,6 +9033,27 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7706,6 +9216,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -8020,6 +9539,21 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wmi" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c81b85c57a57500e56669586496bf2abd5cf082b9d32995251185d105208b64" +dependencies = [ + "chrono", + "futures", + "log", + "serde", + "thiserror 2.0.18", + "windows 0.61.3", + "windows-core 0.62.2", +] + [[package]] name = "workshop" version = "0.45.1" @@ -8143,7 +9677,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", "thiserror 2.0.18", @@ -8151,12 +9685,31 @@ dependencies = [ "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows", + "windows 0.61.3", "windows-core 0.61.2", "windows-version", "x11-dl", ] +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "x11" version = "2.21.0" @@ -8178,6 +9731,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d8a75eaf6557bb84a65ace8609883db44a29951042ada9b393151532e41fcb" +dependencies = [ + "xml-rs", +] + [[package]] name = "yansi" version = "1.0.1" @@ -8207,6 +9775,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "z32" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2164e798d9e3d84ee2c91139ace54638059a3b23e361f5c11781c2c6459bde0f" + [[package]] name = "zerocopy" version = "0.8.39" @@ -8253,6 +9827,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 7aaeb14..44e2cc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "packages/pty_manager", "packages/tty_wrapper", "packages/virtual_terminal", + "packages/meld", ] # workshop_ui/crate requires WORKSHOP_UI_PATH env var pointing to SvelteKit build output. # workshop_desktop requires Tauri system dependencies (WebKit, etc.). @@ -18,6 +19,7 @@ default-members = [ "packages/pty_manager", "packages/tty_wrapper", "packages/virtual_terminal", + "packages/meld", ] resolver = "2" diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml new file mode 100644 index 0000000..c8c5b10 --- /dev/null +++ b/packages/meld/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "meld" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "meld" +path = "src/main.rs" + +[dependencies] +workshop = { path = "../workshop" } +iroh = "0.97" +iroh-tickets = "0.4" +ratatui = "0.30" +vt100 = "0.16" +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs new file mode 100644 index 0000000..282f361 --- /dev/null +++ b/packages/meld/src/host.rs @@ -0,0 +1,348 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use anyhow::{Context, Result}; +use iroh::Endpoint; +use iroh::endpoint::presets; +use iroh_tickets::endpoint::EndpointTicket; +use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use tokio::sync::{RwLock, broadcast, mpsc, oneshot}; +use tracing::{info, warn}; + +use workshop::instance_actor::{InstanceHandle, SpawnOptions, create_instance}; +use workshop::instance_manager::InstanceKind; +use workshop::process_driver::ShellDriver; +use workshop::virtual_terminal::ClientType; + +pub const ALPN: &[u8] = b"meld/term/0"; +const SCROLLBACK: usize = 10_000; + +pub async fn run(command: Vec) -> Result<()> { + let (cmd, args) = resolve_command(command); + let cwd = std::env::current_dir()?.to_string_lossy().to_string(); + + let handle = create_instance(SpawnOptions { + name: "meld".into(), + display_command: cmd.clone(), + actual_command: cmd.clone(), + args: args.clone(), + working_dir: cwd, + kind: InstanceKind::Unstructured { + label: Some("meld".into()), + }, + max_buffer_bytes: 1024 * 1024, + scrollback_lines: SCROLLBACK, + vt_record_dir: None, + driver: Box::new(ShellDriver), + state_broadcast_tx: None, + lifecycle_tx: None, + claimed_sessions: Arc::new(RwLock::new(HashMap::new())), + first_input_data: Arc::new(RwLock::new(HashMap::new())), + pending_attributions: Arc::new(RwLock::new(HashMap::new())), + repository: None, + }) + .await + .context("failed to spawn instance")?; + + let mut output_rx = handle.subscribe_output().await?; + + let endpoint = Endpoint::builder(presets::N0) + .alpns(vec![ALPN.to_vec()]) + .bind() + .await + .context("failed to bind iroh endpoint")?; + endpoint.online().await; + + let ticket = EndpointTicket::new(endpoint.addr()); + eprintln!(); + eprintln!(" viewers can connect with:"); + eprintln!(); + eprintln!(" meld view {ticket}"); + eprintln!(); + eprint!(" press enter to start session..."); + let _ = std::io::stdin().read_line(&mut String::new()); + + let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; + let mut pty_rows = rows.saturating_sub(1).max(1); + + handle + .update_viewport_and_resize("host", pty_rows, cols, ClientType::Terminal) + .await?; + + let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + let mut scroll_offset: usize = 0; + + let mut terminal = ratatui::init(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::EnableMouseCapture + )?; + + // Crossterm event reader thread — replaces stdin thread + SIGWINCH + let (event_tx, mut event_rx) = mpsc::channel::(64); + std::thread::spawn(move || { + loop { + match event::read() { + Ok(ev) => { + if event_tx.blocking_send(ev).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + // Viewer accept loop + let viewer_count = Arc::new(AtomicUsize::new(0)); + let (viewer_changed_tx, mut viewer_changed_rx) = mpsc::channel::<()>(4); + let vc = viewer_count.clone(); + let vtx = viewer_changed_tx.clone(); + let viewer_handle = handle.clone(); + let viewer_endpoint = endpoint.clone(); + tokio::spawn(async move { + accept_viewers(viewer_endpoint, viewer_handle, vc, vtx).await; + }); + drop(viewer_changed_tx); + + // PTY exit detection + let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); + let exit_handle = handle.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if exit_handle.get_pid().await.is_none() { + let _ = shutdown_tx.send(()); + return; + } + } + }); + + loop { + // Set scrollback viewport for rendering + if scroll_offset > 0 { + vt_parser.screen_mut().set_scrollback(scroll_offset); + scroll_offset = vt_parser.screen().scrollback(); + } + + let n = viewer_count.load(Ordering::Relaxed); + let status = if scroll_offset > 0 { + format!("(meld) ↑ {} lines — type to return", scroll_offset) + } else { + host_status(n) + }; + let screen = vt_parser.screen(); + let cursor_pos = screen.cursor_position(); + let hide_cursor = screen.hide_cursor(); + + terminal.draw(|frame| { + let [content, status_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) + .areas(frame.area()); + + frame.render_widget(crate::PtyWidget { screen }, content); + frame.render_widget( + Paragraph::new(status.as_str()).style(Style::default().dim()), + status_area, + ); + + if scroll_offset == 0 && !hide_cursor { + let (row, col) = cursor_pos; + frame.set_cursor_position((content.x + col, content.y + row)); + } + })?; + + // Restore scrollback viewport + if scroll_offset > 0 { + vt_parser.screen_mut().set_scrollback(0); + } + + tokio::select! { + result = output_rx.recv() => { + match result { + Ok(output) => { + vt_parser.process(&output.data); + drain_output(&mut output_rx, &mut vt_parser); + scroll_offset = 0; + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + Some(ev) = event_rx.recv() => { + match ev { + Event::Key(key) => { + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + if shift && key.code == KeyCode::PageUp { + scroll_offset += pty_rows as usize / 2; + } else if shift && key.code == KeyCode::PageDown { + scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); + } else if let Some(bytes) = crate::key_to_bytes(&key) { + let text = String::from_utf8_lossy(&bytes); + let _ = handle.write_input(&text).await; + scroll_offset = 0; + } + } + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => scroll_offset += 3, + MouseEventKind::ScrollDown => { + scroll_offset = scroll_offset.saturating_sub(3); + } + _ => {} + }, + Event::Resize(new_cols, new_rows) => { + cols = new_cols; + rows = new_rows; + pty_rows = rows.saturating_sub(1).max(1); + vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + let _ = handle.update_viewport_and_resize( + "host", pty_rows, cols, ClientType::Terminal, + ).await; + scroll_offset = 0; + } + _ => {} + } + } + _ = viewer_changed_rx.recv() => {} + _ = &mut shutdown_rx => break, + } + } + + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::DisableMouseCapture + )?; + ratatui::restore(); + let _ = handle.stop().await; + endpoint.close().await; + Ok(()) +} + +fn drain_output( + rx: &mut broadcast::Receiver, + parser: &mut vt100::Parser, +) { + loop { + match rx.try_recv() { + Ok(output) => parser.process(&output.data), + Err(broadcast::error::TryRecvError::Lagged(_)) => {} + _ => break, + } + } +} + +fn host_status(viewers: usize) -> String { + format!( + "(meld) hosting [{} viewer{}]", + viewers, + if viewers == 1 { "" } else { "s" } + ) +} + +async fn accept_viewers( + endpoint: Endpoint, + handle: InstanceHandle, + viewer_count: Arc, + viewer_changed_tx: mpsc::Sender<()>, +) { + while let Some(incoming) = endpoint.accept().await { + let conn = match incoming.await { + Ok(conn) => conn, + Err(e) => { + warn!("failed to accept connection: {e}"); + continue; + } + }; + let h = handle.clone(); + let vc = viewer_count.clone(); + let vtx = viewer_changed_tx.clone(); + tokio::spawn(async move { + if let Err(e) = handle_viewer(conn, h, vc, vtx).await { + info!("viewer disconnected: {e}"); + } + }); + } +} + +async fn handle_viewer( + conn: iroh::endpoint::Connection, + handle: InstanceHandle, + viewer_count: Arc, + viewer_changed_tx: mpsc::Sender<()>, +) -> Result<()> { + let conn_id = conn.remote_id().to_string(); + info!("viewer connected: {}", &conn_id[..8]); + viewer_count.fetch_add(1, Ordering::Relaxed); + let _ = viewer_changed_tx.send(()).await; + + let result = serve_viewer(&conn, &handle, &conn_id).await; + + viewer_count.fetch_sub(1, Ordering::Relaxed); + let _ = viewer_changed_tx.send(()).await; + let _ = handle.remove_client_and_resize(&conn_id).await; + info!("viewer disconnected: {}", &conn_id[..8]); + result +} + +async fn serve_viewer( + conn: &iroh::endpoint::Connection, + handle: &InstanceHandle, + conn_id: &str, +) -> Result<()> { + let (mut send, mut recv) = conn.accept_bi().await?; + + let mut buf = [0u8; 4]; + recv.read_exact(&mut buf).await?; + let rows = u16::from_be_bytes([buf[0], buf[1]]).max(1); + let cols = u16::from_be_bytes([buf[2], buf[3]]).max(1); + + handle + .update_viewport_and_resize(conn_id, rows, cols, ClientType::Terminal) + .await?; + + let replay = handle.get_recent_output(64 * 1024, rows).await; + for chunk in &replay { + send.write_all(chunk.as_bytes()).await?; + } + + let mut output_rx = handle.subscribe_output().await?; + + loop { + tokio::select! { + result = output_rx.recv() => { + match result { + Ok(output) => send.write_all(&output.data).await?, + Err(broadcast::error::RecvError::Lagged(n)) => { + warn!("viewer {} lagged by {n}", &conn_id[..8]); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + result = recv.read_exact(&mut buf) => { + match result { + Ok(()) => { + let rows = u16::from_be_bytes([buf[0], buf[1]]).max(1); + let cols = u16::from_be_bytes([buf[2], buf[3]]).max(1); + let _ = handle.update_viewport_and_resize(conn_id, rows, cols, ClientType::Terminal).await; + } + Err(_) => break, + } + } + } + } + + Ok(()) +} + +fn resolve_command(command: Vec) -> (String, Vec) { + if command.is_empty() { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".into()); + (shell, vec![]) + } else { + let cmd = command[0].clone(); + let args = command[1..].to_vec(); + (cmd, args) + } +} diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs new file mode 100644 index 0000000..98bfb31 --- /dev/null +++ b/packages/meld/src/main.rs @@ -0,0 +1,150 @@ +pub mod host; +mod viewer; + +use anyhow::Result; +use clap::{Parser, Subcommand}; +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::prelude::*; +use ratatui::widgets::Widget; +use workshop::virtual_terminal::walk_row; + +#[derive(Parser)] +#[command(name = "meld", about = "P2P terminal sharing over iroh")] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// Share a terminal session + Host { + /// Command to run (default: $SHELL or /bin/bash) + #[arg(trailing_var_arg = true)] + command: Vec, + }, + /// View a shared terminal session (readonly) + View { + /// iroh ticket from the host + ticket: String, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "off".parse().unwrap()), + ) + .with_writer(std::io::stderr) + .init(); + + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + ratatui::restore(); + original_hook(info); + })); + + let cli = Cli::parse(); + match cli.command { + Command::Host { command } => host::run(command).await, + Command::View { ticket } => viewer::run(&ticket).await, + } +} + +// --- Shared widgets --- + +pub struct PtyWidget<'a> { + pub screen: &'a vt100::Screen, +} + +impl Widget for PtyWidget<'_> { + fn render(self, area: Rect, buf: &mut Buffer) { + for row in 0..area.height { + for cell in walk_row(self.screen, row, area.width) { + let mut style = Style::default() + .fg(vt100_color_to_ratatui(cell.fg)) + .bg(vt100_color_to_ratatui(cell.bg)); + let mut modifiers = Modifier::empty(); + if cell.bold { + modifiers |= Modifier::BOLD; + } + if cell.italic { + modifiers |= Modifier::ITALIC; + } + if cell.underline { + modifiers |= Modifier::UNDERLINED; + } + if cell.inverse { + modifiers |= Modifier::REVERSED; + } + style = style.add_modifier(modifiers); + let x = area.x + cell.col; + let y = area.y + row; + if x < area.right() && y < area.bottom() { + buf[(x, y)].set_symbol(cell.contents).set_style(style); + } + } + } + } +} + +fn vt100_color_to_ratatui(color: vt100::Color) -> Color { + match color { + vt100::Color::Default => Color::Reset, + vt100::Color::Idx(n) => Color::Indexed(n), + vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b), + } +} + +/// Convert a crossterm KeyEvent to the byte sequence a PTY expects. +pub fn key_to_bytes(key: &KeyEvent) -> Option> { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Char(c) if ctrl => match c { + 'a'..='z' => Some(vec![(c as u8) - b'a' + 1]), + '4'..='7' => Some(vec![(c as u8) - b'4' + 0x1C]), + _ => None, + }, + KeyCode::Char(c) => { + let mut bytes = [0u8; 4]; + let s = c.encode_utf8(&mut bytes); + Some(s.as_bytes().to_vec()) + } + KeyCode::Enter => Some(b"\r".to_vec()), + KeyCode::Backspace => Some(vec![0x7f]), + KeyCode::Esc => Some(b"\x1b".to_vec()), + KeyCode::Tab => Some(b"\t".to_vec()), + KeyCode::BackTab => Some(b"\x1b[Z".to_vec()), + KeyCode::Up => Some(b"\x1b[A".to_vec()), + KeyCode::Down => Some(b"\x1b[B".to_vec()), + KeyCode::Right => Some(b"\x1b[C".to_vec()), + KeyCode::Left => Some(b"\x1b[D".to_vec()), + KeyCode::Home => Some(b"\x1b[H".to_vec()), + KeyCode::End => Some(b"\x1b[F".to_vec()), + KeyCode::PageUp => Some(b"\x1b[5~".to_vec()), + KeyCode::PageDown => Some(b"\x1b[6~".to_vec()), + KeyCode::Insert => Some(b"\x1b[2~".to_vec()), + KeyCode::Delete => Some(b"\x1b[3~".to_vec()), + KeyCode::F(n @ 1..=12) => { + let seq: &[u8] = match n { + 1 => b"\x1bOP", + 2 => b"\x1bOQ", + 3 => b"\x1bOR", + 4 => b"\x1bOS", + 5 => b"\x1b[15~", + 6 => b"\x1b[17~", + 7 => b"\x1b[18~", + 8 => b"\x1b[19~", + 9 => b"\x1b[20~", + 10 => b"\x1b[21~", + 11 => b"\x1b[23~", + 12 => b"\x1b[24~", + _ => return None, + }; + Some(seq.to_vec()) + } + _ => None, + } +} diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs new file mode 100644 index 0000000..4a5d54f --- /dev/null +++ b/packages/meld/src/viewer.rs @@ -0,0 +1,165 @@ +use anyhow::{Context, Result}; +use iroh::Endpoint; +use iroh::endpoint::presets; +use iroh_tickets::endpoint::EndpointTicket; +use ratatui::crossterm::event::{self, Event, KeyCode, MouseEventKind}; +use ratatui::prelude::*; +use ratatui::widgets::Paragraph; +use tokio::sync::mpsc; +use tracing::info; + +use crate::host::ALPN; + +const STATUS: &str = "(meld) viewing [readonly] press q to exit"; +const SCROLLBACK: usize = 10_000; + +pub async fn run(ticket_str: &str) -> Result<()> { + let ticket: EndpointTicket = ticket_str + .parse() + .map_err(|e| anyhow::anyhow!("invalid ticket: {e}"))?; + + info!("connecting..."); + + let endpoint = Endpoint::bind(presets::N0) + .await + .context("failed to bind iroh endpoint")?; + + let conn = endpoint + .connect(ticket.endpoint_addr().clone(), ALPN) + .await + .context("failed to connect to host")?; + + info!("connected"); + + let (mut send, mut recv) = conn.open_bi().await?; + + let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; + let mut pty_rows = rows.saturating_sub(1).max(1); + + send_viewport(&mut send, pty_rows, cols).await?; + + let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + let mut scroll_offset: usize = 0; + + let mut terminal = ratatui::init(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::EnableMouseCapture + )?; + + // Crossterm event reader thread + let (event_tx, mut event_rx) = mpsc::channel::(64); + std::thread::spawn(move || { + loop { + match event::read() { + Ok(ev) => { + if event_tx.blocking_send(ev).is_err() { + break; + } + } + Err(_) => break, + } + } + }); + + let mut buf = vec![0u8; 4096]; + + loop { + if scroll_offset > 0 { + vt_parser.screen_mut().set_scrollback(scroll_offset); + scroll_offset = vt_parser.screen().scrollback(); + } + + let status = if scroll_offset > 0 { + format!("(meld) ↑ {} lines — scroll down to return", scroll_offset) + } else { + STATUS.to_string() + }; + let screen = vt_parser.screen(); + let cursor_pos = screen.cursor_position(); + let hide_cursor = screen.hide_cursor(); + + terminal.draw(|frame| { + let [content, status_area] = + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) + .areas(frame.area()); + + frame.render_widget(crate::PtyWidget { screen }, content); + frame.render_widget( + Paragraph::new(status.as_str()).style(Style::default().dim()), + status_area, + ); + + if scroll_offset == 0 && !hide_cursor { + let (row, col) = cursor_pos; + frame.set_cursor_position((content.x + col, content.y + row)); + } + })?; + + if scroll_offset > 0 { + vt_parser.screen_mut().set_scrollback(0); + } + + tokio::select! { + result = recv.read(&mut buf) => { + match result { + Ok(Some(n)) => { + vt_parser.process(&buf[..n]); + scroll_offset = 0; + } + Ok(None) => break, + Err(_) => break, + } + } + Some(ev) = event_rx.recv() => { + match ev { + Event::Key(key) => match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') => break, + KeyCode::PageUp => { + scroll_offset += pty_rows as usize / 2; + } + KeyCode::PageDown => { + scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); + } + _ => {} + }, + Event::Mouse(mouse) => match mouse.kind { + MouseEventKind::ScrollUp => scroll_offset += 3, + MouseEventKind::ScrollDown => { + scroll_offset = scroll_offset.saturating_sub(3); + } + _ => {} + }, + Event::Resize(new_cols, new_rows) => { + cols = new_cols; + rows = new_rows; + pty_rows = rows.saturating_sub(1).max(1); + vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + let _ = send_viewport(&mut send, pty_rows, cols).await; + scroll_offset = 0; + } + _ => {} + } + } + } + } + + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::DisableMouseCapture + )?; + ratatui::restore(); + conn.close(0u32.into(), b"done"); + Ok(()) +} + +async fn send_viewport(send: &mut iroh::endpoint::SendStream, rows: u16, cols: u16) -> Result<()> { + let buf = [ + (rows >> 8) as u8, + rows as u8, + (cols >> 8) as u8, + cols as u8, + ]; + send.write_all(&buf).await?; + Ok(()) +} From d3b1597c6356e4ee8bc2006833fcf09fc6e725ce Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 9 Apr 2026 19:43:47 -0400 Subject: [PATCH 02/18] refactor meld to avoid heavy workshop dependency --- Cargo.lock | 3 +- Cargo.toml | 3 + packages/meld/Cargo.toml | 3 +- packages/meld/src/host.rs | 109 +++++---------- packages/meld/src/main.rs | 3 +- packages/meld/src/session.rs | 264 +++++++++++++++++++++++++++++++++++ packages/meld/src/viewer.rs | 21 +-- 7 files changed, 316 insertions(+), 90 deletions(-) create mode 100644 packages/meld/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index 48f36bd..d88ca05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3748,12 +3748,13 @@ dependencies = [ "clap", "iroh", "iroh-tickets", + "pty_manager", "ratatui", "tokio", "tracing", "tracing-subscriber", + "virtual_terminal", "vt100", - "workshop", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 44e2cc8..f473405 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,3 +48,6 @@ tower = "0.5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } uuid = { version = "1.13.2", features = ["v4"] } +workshop = { path = "packages/workshop" } +pty_manager = { path = "packages/pty_manager" } +virtual_terminal = { path = "packages/virtual_terminal" } diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml index c8c5b10..98d9180 100644 --- a/packages/meld/Cargo.toml +++ b/packages/meld/Cargo.toml @@ -8,7 +8,8 @@ name = "meld" path = "src/main.rs" [dependencies] -workshop = { path = "../workshop" } +pty_manager = { workspace = true } +virtual_terminal = { workspace = true } iroh = "0.97" iroh-tickets = "0.4" ratatui = "0.30" diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index 282f361..b44384f 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -1,6 +1,6 @@ -use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + use anyhow::{Context, Result}; use iroh::Endpoint; use iroh::endpoint::presets; @@ -8,13 +8,10 @@ use iroh_tickets::endpoint::EndpointTicket; use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind}; use ratatui::prelude::*; use ratatui::widgets::Paragraph; -use tokio::sync::{RwLock, broadcast, mpsc, oneshot}; +use tokio::sync::{broadcast, mpsc, oneshot}; use tracing::{info, warn}; -use workshop::instance_actor::{InstanceHandle, SpawnOptions, create_instance}; -use workshop::instance_manager::InstanceKind; -use workshop::process_driver::ShellDriver; -use workshop::virtual_terminal::ClientType; +use crate::session::{Output, Session}; pub const ALPN: &[u8] = b"meld/term/0"; const SCROLLBACK: usize = 10_000; @@ -23,31 +20,7 @@ pub async fn run(command: Vec) -> Result<()> { let (cmd, args) = resolve_command(command); let cwd = std::env::current_dir()?.to_string_lossy().to_string(); - let handle = create_instance(SpawnOptions { - name: "meld".into(), - display_command: cmd.clone(), - actual_command: cmd.clone(), - args: args.clone(), - working_dir: cwd, - kind: InstanceKind::Unstructured { - label: Some("meld".into()), - }, - max_buffer_bytes: 1024 * 1024, - scrollback_lines: SCROLLBACK, - vt_record_dir: None, - driver: Box::new(ShellDriver), - state_broadcast_tx: None, - lifecycle_tx: None, - claimed_sessions: Arc::new(RwLock::new(HashMap::new())), - first_input_data: Arc::new(RwLock::new(HashMap::new())), - pending_attributions: Arc::new(RwLock::new(HashMap::new())), - repository: None, - }) - .await - .context("failed to spawn instance")?; - - let mut output_rx = handle.subscribe_output().await?; - + // Set up iroh endpoint before spawning PTY (so we can show ticket first) let endpoint = Endpoint::builder(presets::N0) .alpns(vec![ALPN.to_vec()]) .bind() @@ -67,8 +40,15 @@ pub async fn run(command: Vec) -> Result<()> { let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); - handle - .update_viewport_and_resize("host", pty_rows, cols, ClientType::Terminal) + // Spawn PTY session + let session = Session::spawn(&cmd, &args, &cwd, pty_rows, cols, SCROLLBACK) + .context("failed to spawn session")?; + + let mut output_rx = session.subscribe_output().await?; + + // Register host's terminal as a viewport + session + .update_viewport_and_resize("host", pty_rows, cols) .await?; let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); @@ -80,17 +60,12 @@ pub async fn run(command: Vec) -> Result<()> { ratatui::crossterm::event::EnableMouseCapture )?; - // Crossterm event reader thread — replaces stdin thread + SIGWINCH + // Crossterm event reader thread let (event_tx, mut event_rx) = mpsc::channel::(64); std::thread::spawn(move || { - loop { - match event::read() { - Ok(ev) => { - if event_tx.blocking_send(ev).is_err() { - break; - } - } - Err(_) => break, + while let Ok(ev) = event::read() { + if event_tx.blocking_send(ev).is_err() { + break; } } }); @@ -100,20 +75,20 @@ pub async fn run(command: Vec) -> Result<()> { let (viewer_changed_tx, mut viewer_changed_rx) = mpsc::channel::<()>(4); let vc = viewer_count.clone(); let vtx = viewer_changed_tx.clone(); - let viewer_handle = handle.clone(); + let viewer_session = session.clone(); let viewer_endpoint = endpoint.clone(); tokio::spawn(async move { - accept_viewers(viewer_endpoint, viewer_handle, vc, vtx).await; + accept_viewers(viewer_endpoint, viewer_session, vc, vtx).await; }); drop(viewer_changed_tx); // PTY exit detection let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); - let exit_handle = handle.clone(); + let exit_session = session.clone(); tokio::spawn(async move { loop { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - if exit_handle.get_pid().await.is_none() { + if exit_session.get_pid().await.is_none() { let _ = shutdown_tx.send(()); return; } @@ -121,7 +96,6 @@ pub async fn run(command: Vec) -> Result<()> { }); loop { - // Set scrollback viewport for rendering if scroll_offset > 0 { vt_parser.screen_mut().set_scrollback(scroll_offset); scroll_offset = vt_parser.screen().scrollback(); @@ -139,8 +113,7 @@ pub async fn run(command: Vec) -> Result<()> { terminal.draw(|frame| { let [content, status_area] = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) - .areas(frame.area()); + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); frame.render_widget(crate::PtyWidget { screen }, content); frame.render_widget( @@ -154,7 +127,6 @@ pub async fn run(command: Vec) -> Result<()> { } })?; - // Restore scrollback viewport if scroll_offset > 0 { vt_parser.screen_mut().set_scrollback(0); } @@ -181,7 +153,7 @@ pub async fn run(command: Vec) -> Result<()> { scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); } else if let Some(bytes) = crate::key_to_bytes(&key) { let text = String::from_utf8_lossy(&bytes); - let _ = handle.write_input(&text).await; + let _ = session.write_input(&text).await; scroll_offset = 0; } } @@ -197,9 +169,7 @@ pub async fn run(command: Vec) -> Result<()> { rows = new_rows; pty_rows = rows.saturating_sub(1).max(1); vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); - let _ = handle.update_viewport_and_resize( - "host", pty_rows, cols, ClientType::Terminal, - ).await; + let _ = session.update_viewport_and_resize("host", pty_rows, cols).await; scroll_offset = 0; } _ => {} @@ -215,15 +185,12 @@ pub async fn run(command: Vec) -> Result<()> { ratatui::crossterm::event::DisableMouseCapture )?; ratatui::restore(); - let _ = handle.stop().await; + let _ = session.stop().await; endpoint.close().await; Ok(()) } -fn drain_output( - rx: &mut broadcast::Receiver, - parser: &mut vt100::Parser, -) { +fn drain_output(rx: &mut broadcast::Receiver, parser: &mut vt100::Parser) { loop { match rx.try_recv() { Ok(output) => parser.process(&output.data), @@ -243,7 +210,7 @@ fn host_status(viewers: usize) -> String { async fn accept_viewers( endpoint: Endpoint, - handle: InstanceHandle, + session: Session, viewer_count: Arc, viewer_changed_tx: mpsc::Sender<()>, ) { @@ -255,11 +222,11 @@ async fn accept_viewers( continue; } }; - let h = handle.clone(); + let s = session.clone(); let vc = viewer_count.clone(); let vtx = viewer_changed_tx.clone(); tokio::spawn(async move { - if let Err(e) = handle_viewer(conn, h, vc, vtx).await { + if let Err(e) = handle_viewer(conn, s, vc, vtx).await { info!("viewer disconnected: {e}"); } }); @@ -268,7 +235,7 @@ async fn accept_viewers( async fn handle_viewer( conn: iroh::endpoint::Connection, - handle: InstanceHandle, + session: Session, viewer_count: Arc, viewer_changed_tx: mpsc::Sender<()>, ) -> Result<()> { @@ -277,18 +244,18 @@ async fn handle_viewer( viewer_count.fetch_add(1, Ordering::Relaxed); let _ = viewer_changed_tx.send(()).await; - let result = serve_viewer(&conn, &handle, &conn_id).await; + let result = serve_viewer(&conn, &session, &conn_id).await; viewer_count.fetch_sub(1, Ordering::Relaxed); let _ = viewer_changed_tx.send(()).await; - let _ = handle.remove_client_and_resize(&conn_id).await; + let _ = session.remove_client_and_resize(&conn_id).await; info!("viewer disconnected: {}", &conn_id[..8]); result } async fn serve_viewer( conn: &iroh::endpoint::Connection, - handle: &InstanceHandle, + session: &Session, conn_id: &str, ) -> Result<()> { let (mut send, mut recv) = conn.accept_bi().await?; @@ -298,16 +265,16 @@ async fn serve_viewer( let rows = u16::from_be_bytes([buf[0], buf[1]]).max(1); let cols = u16::from_be_bytes([buf[2], buf[3]]).max(1); - handle - .update_viewport_and_resize(conn_id, rows, cols, ClientType::Terminal) + session + .update_viewport_and_resize(conn_id, rows, cols) .await?; - let replay = handle.get_recent_output(64 * 1024, rows).await; + let replay = session.get_recent_output(64 * 1024, rows).await; for chunk in &replay { send.write_all(chunk.as_bytes()).await?; } - let mut output_rx = handle.subscribe_output().await?; + let mut output_rx = session.subscribe_output().await?; loop { tokio::select! { @@ -325,7 +292,7 @@ async fn serve_viewer( Ok(()) => { let rows = u16::from_be_bytes([buf[0], buf[1]]).max(1); let cols = u16::from_be_bytes([buf[2], buf[3]]).max(1); - let _ = handle.update_viewport_and_resize(conn_id, rows, cols, ClientType::Terminal).await; + let _ = session.update_viewport_and_resize(conn_id, rows, cols).await; } Err(_) => break, } diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index 98bfb31..b616a1d 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -1,4 +1,5 @@ pub mod host; +pub mod session; mod viewer; use anyhow::Result; @@ -6,7 +7,7 @@ use clap::{Parser, Subcommand}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::prelude::*; use ratatui::widgets::Widget; -use workshop::virtual_terminal::walk_row; +use virtual_terminal::walk_row; #[derive(Parser)] #[command(name = "meld", about = "P2P terminal sharing over iroh")] diff --git a/packages/meld/src/session.rs b/packages/meld/src/session.rs new file mode 100644 index 0000000..a5f9bfa --- /dev/null +++ b/packages/meld/src/session.rs @@ -0,0 +1,264 @@ +use anyhow::Result; +use pty_manager::{PtyConfig, PtyHandle, PtyOutput}; +use tokio::sync::{broadcast, mpsc, oneshot}; +use virtual_terminal::{ClientType, VirtualTerminal}; + +const MAX_DELTA_BYTES: usize = 1024 * 1024; + +/// PTY output enriched with cursor position from the VirtualTerminal. +#[derive(Debug, Clone)] +pub struct Output { + pub data: Vec, + pub cursor: (u16, u16), +} + +enum Command { + WriteInput { + text: String, + respond_to: oneshot::Sender>, + }, + Resize { + rows: u16, + cols: u16, + respond_to: oneshot::Sender>, + }, + UpdateViewport { + id: String, + rows: u16, + cols: u16, + respond_to: oneshot::Sender>, + }, + RemoveClient { + id: String, + respond_to: oneshot::Sender>, + }, + GetRecentOutput { + max_bytes: usize, + client_rows: u16, + respond_to: oneshot::Sender>, + }, + SubscribeOutput { + respond_to: oneshot::Sender>, + }, + GetPid { + respond_to: oneshot::Sender>, + }, + Stop { + respond_to: oneshot::Sender>, + }, +} + +/// Handle to a running PTY session. Cheap to clone. +#[derive(Clone)] +pub struct Session { + tx: mpsc::Sender, +} + +impl Session { + /// Spawn a new PTY session. + pub fn spawn( + command: &str, + args: &[String], + working_dir: &str, + rows: u16, + cols: u16, + scrollback_lines: usize, + ) -> Result { + let config = PtyConfig { + command: command.to_string(), + args: args.to_vec(), + working_dir: Some(working_dir.to_string()), + env: Vec::new(), + rows, + cols, + }; + let pty = pty_manager::pty::PtyActor::spawn(config)?; + let pty_output_rx = pty.subscribe(); + let (output_tx, _) = broadcast::channel::(64); + let vt = VirtualTerminal::new(rows, cols, MAX_DELTA_BYTES, scrollback_lines); + let (cmd_tx, cmd_rx) = mpsc::channel(32); + + tokio::spawn(actor_loop(pty, pty_output_rx, vt, output_tx, cmd_rx)); + + Ok(Self { tx: cmd_tx }) + } + + pub async fn write_input(&self, text: &str) -> Result { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Command::WriteInput { + text: text.to_string(), + respond_to: tx, + }) + .await?; + Ok(rx.await??) + } + + pub async fn subscribe_output(&self) -> Result> { + let (tx, rx) = oneshot::channel(); + self.tx + .send(Command::SubscribeOutput { respond_to: tx }) + .await?; + Ok(rx.await?) + } + + pub async fn update_viewport(&self, id: &str, rows: u16, cols: u16) -> Option<(u16, u16)> { + let (tx, rx) = oneshot::channel(); + let _ = self + .tx + .send(Command::UpdateViewport { + id: id.to_string(), + rows, + cols, + respond_to: tx, + }) + .await; + rx.await.ok().flatten() + } + + pub async fn remove_client(&self, id: &str) -> Option<(u16, u16)> { + let (tx, rx) = oneshot::channel(); + let _ = self + .tx + .send(Command::RemoveClient { + id: id.to_string(), + respond_to: tx, + }) + .await; + rx.await.ok().flatten() + } + + /// Update viewport and resize the PTY if effective dims changed. + pub async fn update_viewport_and_resize(&self, id: &str, rows: u16, cols: u16) -> Result<()> { + if let Some((eff_rows, eff_cols)) = self.update_viewport(id, rows, cols).await { + self.resize(eff_rows, eff_cols).await?; + } + Ok(()) + } + + pub async fn remove_client_and_resize(&self, id: &str) -> Result<()> { + if let Some((eff_rows, eff_cols)) = self.remove_client(id).await { + self.resize(eff_rows, eff_cols).await?; + } + Ok(()) + } + + pub async fn get_recent_output(&self, max_bytes: usize, client_rows: u16) -> Vec { + let (tx, rx) = oneshot::channel(); + let _ = self + .tx + .send(Command::GetRecentOutput { + max_bytes, + client_rows, + respond_to: tx, + }) + .await; + rx.await.unwrap_or_default() + } + + pub async fn get_pid(&self) -> Option { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(Command::GetPid { respond_to: tx }).await; + rx.await.ok().flatten() + } + + pub async fn stop(&self) -> Result<()> { + let (tx, rx) = oneshot::channel(); + let _ = self.tx.send(Command::Stop { respond_to: tx }).await; + rx.await??; + Ok(()) + } + + async fn resize(&self, rows: u16, cols: u16) -> Result<()> { + let (tx, rx) = oneshot::channel(); + let _ = self + .tx + .send(Command::Resize { + rows, + cols, + respond_to: tx, + }) + .await; + rx.await??; + Ok(()) + } +} + +async fn actor_loop( + pty: PtyHandle, + mut pty_output_rx: broadcast::Receiver, + mut vt: VirtualTerminal, + output_tx: broadcast::Sender, + mut cmd_rx: mpsc::Receiver, +) { + loop { + tokio::select! { + result = pty_output_rx.recv() => { + match result { + Ok(pty_output) => { + vt.process_output(&pty_output.data); + let cursor = vt.cursor_position(); + let _ = output_tx.send(Output { + data: pty_output.data, + cursor, + }); + } + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + cmd = cmd_rx.recv() => { + let Some(cmd) = cmd else { break }; + match cmd { + Command::WriteInput { text, respond_to } => { + let _ = respond_to.send(pty.write_str(&text).await); + } + Command::Resize { rows, cols, respond_to } => { + let result = pty.resize(rows, cols).await; + vt.resize(rows, cols); + let _ = respond_to.send(result); + } + Command::UpdateViewport { id, rows, cols, respond_to } => { + let result = vt.update_viewport(&id, rows, cols, ClientType::Terminal); + let _ = respond_to.send(result); + } + Command::RemoveClient { id, respond_to } => { + let result = vt.remove_client(&id); + let _ = respond_to.send(result); + } + Command::GetRecentOutput { max_bytes, client_rows, respond_to } => { + let replay = vt.replay(client_rows); + let text = String::from_utf8_lossy(&replay); + // Trim to max_bytes respecting UTF-8 boundaries + let trimmed = if text.len() > max_bytes { + let mut end = max_bytes; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] + } else { + &text + }; + let _ = respond_to.send(vec![trimmed.to_string()]); + } + Command::SubscribeOutput { respond_to } => { + let _ = respond_to.send(output_tx.subscribe()); + } + Command::GetPid { respond_to } => { + let pid = match pty.state().await { + Ok(state) => state.pid, + Err(_) => None, + }; + let _ = respond_to.send(pid); + } + Command::Stop { respond_to } => { + let result = pty.kill(None).await + .map_err(|e| anyhow::anyhow!(e)); + let _ = respond_to.send(result); + break; + } + } + } + } + } +} diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 4a5d54f..281ca4d 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -50,14 +50,9 @@ pub async fn run(ticket_str: &str) -> Result<()> { // Crossterm event reader thread let (event_tx, mut event_rx) = mpsc::channel::(64); std::thread::spawn(move || { - loop { - match event::read() { - Ok(ev) => { - if event_tx.blocking_send(ev).is_err() { - break; - } - } - Err(_) => break, + while let Ok(ev) = event::read() { + if event_tx.blocking_send(ev).is_err() { + break; } } }); @@ -81,8 +76,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { terminal.draw(|frame| { let [content, status_area] = - Layout::vertical([Constraint::Min(1), Constraint::Length(1)]) - .areas(frame.area()); + Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); frame.render_widget(crate::PtyWidget { screen }, content); frame.render_widget( @@ -154,12 +148,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { } async fn send_viewport(send: &mut iroh::endpoint::SendStream, rows: u16, cols: u16) -> Result<()> { - let buf = [ - (rows >> 8) as u8, - rows as u8, - (cols >> 8) as u8, - cols as u8, - ]; + let buf = [(rows >> 8) as u8, rows as u8, (cols >> 8) as u8, cols as u8]; send.write_all(&buf).await?; Ok(()) } From cf104441b0160bb69e9ec6cd86a2d0d5da66904a Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 10 Apr 2026 17:06:09 -0400 Subject: [PATCH 03/18] feat(meld): implement turn requests --- packages/meld/src/host.rs | 209 +++++++++++++++++++++++++++------- packages/meld/src/main.rs | 1 + packages/meld/src/protocol.rs | 42 +++++++ packages/meld/src/viewer.rs | 124 +++++++++++++++----- 4 files changed, 303 insertions(+), 73 deletions(-) create mode 100644 packages/meld/src/protocol.rs diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index b44384f..2fbe69a 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -8,19 +8,34 @@ use iroh_tickets::endpoint::EndpointTicket; use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind}; use ratatui::prelude::*; use ratatui::widgets::Paragraph; -use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; use tracing::{info, warn}; +use crate::protocol::{self, host as htag, viewer as vtag}; use crate::session::{Output, Session}; pub const ALPN: &[u8] = b"meld/term/0"; const SCROLLBACK: usize = 10_000; +/// Turn state broadcast from host main loop to viewer tasks. +#[derive(Clone, Default)] +struct TurnState { + holder: Option, + requester: Option, +} + +/// Events from viewer tasks to host main loop. +enum TurnEvent { + ViewerConnected, + ViewerDisconnected, + TurnRequested { conn_id: String }, + TurnReleased { conn_id: String }, +} + pub async fn run(command: Vec) -> Result<()> { let (cmd, args) = resolve_command(command); let cwd = std::env::current_dir()?.to_string_lossy().to_string(); - // Set up iroh endpoint before spawning PTY (so we can show ticket first) let endpoint = Endpoint::builder(presets::N0) .alpns(vec![ALPN.to_vec()]) .bind() @@ -40,13 +55,10 @@ pub async fn run(command: Vec) -> Result<()> { let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); - // Spawn PTY session let session = Session::spawn(&cmd, &args, &cwd, pty_rows, cols, SCROLLBACK) .context("failed to spawn session")?; let mut output_rx = session.subscribe_output().await?; - - // Register host's terminal as a viewport session .update_viewport_and_resize("host", pty_rows, cols) .await?; @@ -70,17 +82,19 @@ pub async fn run(command: Vec) -> Result<()> { } }); - // Viewer accept loop + // Viewer management let viewer_count = Arc::new(AtomicUsize::new(0)); - let (viewer_changed_tx, mut viewer_changed_rx) = mpsc::channel::<()>(4); + let (turn_tx, turn_rx) = watch::channel(TurnState::default()); + let (event_notify_tx, mut event_notify_rx) = mpsc::channel::(16); let vc = viewer_count.clone(); - let vtx = viewer_changed_tx.clone(); + let etx = event_notify_tx.clone(); let viewer_session = session.clone(); let viewer_endpoint = endpoint.clone(); + let viewer_turn_rx = turn_rx.clone(); tokio::spawn(async move { - accept_viewers(viewer_endpoint, viewer_session, vc, vtx).await; + accept_viewers(viewer_endpoint, viewer_session, vc, etx, viewer_turn_rx).await; }); - drop(viewer_changed_tx); + drop(event_notify_tx); // PTY exit detection let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); @@ -95,6 +109,8 @@ pub async fn run(command: Vec) -> Result<()> { } }); + let mut turn_state = TurnState::default(); + loop { if scroll_offset > 0 { vt_parser.screen_mut().set_scrollback(scroll_offset); @@ -102,11 +118,7 @@ pub async fn run(command: Vec) -> Result<()> { } let n = viewer_count.load(Ordering::Relaxed); - let status = if scroll_offset > 0 { - format!("(meld) ↑ {} lines — type to return", scroll_offset) - } else { - host_status(n) - }; + let status = build_status(n, &turn_state, scroll_offset); let screen = vt_parser.screen(); let cursor_pos = screen.cursor_position(); let hide_cursor = screen.hide_cursor(); @@ -151,6 +163,23 @@ pub async fn run(command: Vec) -> Result<()> { scroll_offset += pty_rows as usize / 2; } else if shift && key.code == KeyCode::PageDown { scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); + } else if key.code == KeyCode::F(9) { + // F9: accept request or revoke turn + if turn_state.requester.is_some() { + // Grant: move requester → holder + turn_state.holder = turn_state.requester.take(); + let _ = turn_tx.send(turn_state.clone()); + } else if turn_state.holder.is_some() { + // Revoke + turn_state.holder = None; + let _ = turn_tx.send(turn_state.clone()); + } + } else if key.code == KeyCode::F(10) { + // F10: deny request + if turn_state.requester.is_some() { + turn_state.requester = None; + let _ = turn_tx.send(turn_state.clone()); + } } else if let Some(bytes) = crate::key_to_bytes(&key) { let text = String::from_utf8_lossy(&bytes); let _ = session.write_input(&text).await; @@ -175,7 +204,27 @@ pub async fn run(command: Vec) -> Result<()> { _ => {} } } - _ = viewer_changed_rx.recv() => {} + Some(event) = event_notify_rx.recv() => { + match event { + TurnEvent::ViewerConnected | TurnEvent::ViewerDisconnected => {} + TurnEvent::TurnRequested { conn_id } => { + // Only allow one requester at a time + if turn_state.holder.is_none() && turn_state.requester.is_none() { + turn_state.requester = Some(conn_id); + } + // If someone else already has the turn or is requesting, ignore + } + TurnEvent::TurnReleased { conn_id } => { + if turn_state.holder.as_deref() == Some(&conn_id) { + turn_state.holder = None; + let _ = turn_tx.send(turn_state.clone()); + } + if turn_state.requester.as_deref() == Some(&conn_id) { + turn_state.requester = None; + } + } + } + } _ = &mut shutdown_rx => break, } } @@ -190,6 +239,25 @@ pub async fn run(command: Vec) -> Result<()> { Ok(()) } +fn build_status(viewers: usize, turn: &TurnState, scroll_offset: usize) -> String { + if scroll_offset > 0 { + return format!("(meld) ↑ {} lines — type to return", scroll_offset); + } + if let Some(ref id) = turn.requester { + let short = &id[..8.min(id.len())]; + return format!("(meld) {short} requesting edit · F9 accept · F10 deny"); + } + if let Some(ref id) = turn.holder { + let short = &id[..8.min(id.len())]; + return format!("(meld) {short} editing · F9 revoke"); + } + format!( + "(meld) hosting [{} viewer{}]", + viewers, + if viewers == 1 { "" } else { "s" } + ) +} + fn drain_output(rx: &mut broadcast::Receiver, parser: &mut vt100::Parser) { loop { match rx.try_recv() { @@ -200,19 +268,12 @@ fn drain_output(rx: &mut broadcast::Receiver, parser: &mut vt100::Parser } } -fn host_status(viewers: usize) -> String { - format!( - "(meld) hosting [{} viewer{}]", - viewers, - if viewers == 1 { "" } else { "s" } - ) -} - async fn accept_viewers( endpoint: Endpoint, session: Session, viewer_count: Arc, - viewer_changed_tx: mpsc::Sender<()>, + event_tx: mpsc::Sender, + turn_rx: watch::Receiver, ) { while let Some(incoming) = endpoint.accept().await { let conn = match incoming.await { @@ -224,9 +285,10 @@ async fn accept_viewers( }; let s = session.clone(); let vc = viewer_count.clone(); - let vtx = viewer_changed_tx.clone(); + let etx = event_tx.clone(); + let trx = turn_rx.clone(); tokio::spawn(async move { - if let Err(e) = handle_viewer(conn, s, vc, vtx).await { + if let Err(e) = handle_viewer(conn, s, vc, etx, trx).await { info!("viewer disconnected: {e}"); } }); @@ -237,17 +299,24 @@ async fn handle_viewer( conn: iroh::endpoint::Connection, session: Session, viewer_count: Arc, - viewer_changed_tx: mpsc::Sender<()>, + event_tx: mpsc::Sender, + turn_rx: watch::Receiver, ) -> Result<()> { let conn_id = conn.remote_id().to_string(); info!("viewer connected: {}", &conn_id[..8]); viewer_count.fetch_add(1, Ordering::Relaxed); - let _ = viewer_changed_tx.send(()).await; + let _ = event_tx.send(TurnEvent::ViewerConnected).await; - let result = serve_viewer(&conn, &session, &conn_id).await; + let result = serve_viewer(&conn, &session, &conn_id, &event_tx, turn_rx).await; + // Clean up turn state if this viewer held or requested the turn + let _ = event_tx + .send(TurnEvent::TurnReleased { + conn_id: conn_id.clone(), + }) + .await; viewer_count.fetch_sub(1, Ordering::Relaxed); - let _ = viewer_changed_tx.send(()).await; + let _ = event_tx.send(TurnEvent::ViewerDisconnected).await; let _ = session.remove_client_and_resize(&conn_id).await; info!("viewer disconnected: {}", &conn_id[..8]); result @@ -257,44 +326,96 @@ async fn serve_viewer( conn: &iroh::endpoint::Connection, session: &Session, conn_id: &str, + event_tx: &mpsc::Sender, + mut turn_rx: watch::Receiver, ) -> Result<()> { let (mut send, mut recv) = conn.accept_bi().await?; - let mut buf = [0u8; 4]; - recv.read_exact(&mut buf).await?; - let rows = u16::from_be_bytes([buf[0], buf[1]]).max(1); - let cols = u16::from_be_bytes([buf[2], buf[3]]).max(1); + // Read initial viewport (framed) + let (tag, payload) = protocol::read_msg(&mut recv).await?; + anyhow::ensure!( + tag == vtag::VIEWPORT && payload.len() == 4, + "expected viewport" + ); + let rows = u16::from_be_bytes([payload[0], payload[1]]).max(1); + let cols = u16::from_be_bytes([payload[2], payload[3]]).max(1); session .update_viewport_and_resize(conn_id, rows, cols) .await?; + // Send replay let replay = session.get_recent_output(64 * 1024, rows).await; for chunk in &replay { - send.write_all(chunk.as_bytes()).await?; + protocol::write_msg(&mut send, htag::OUTPUT, chunk.as_bytes()).await?; } let mut output_rx = session.subscribe_output().await?; + // Track what this viewer's last-known turn state was, to send grant/revoke only on transitions + let mut was_holder = false; + let mut was_denied = false; + loop { tokio::select! { result = output_rx.recv() => { match result { - Ok(output) => send.write_all(&output.data).await?, + Ok(output) => { + protocol::write_msg(&mut send, htag::OUTPUT, &output.data).await?; + } Err(broadcast::error::RecvError::Lagged(n)) => { warn!("viewer {} lagged by {n}", &conn_id[..8]); } Err(broadcast::error::RecvError::Closed) => break, } } - result = recv.read_exact(&mut buf) => { - match result { - Ok(()) => { - let rows = u16::from_be_bytes([buf[0], buf[1]]).max(1); - let cols = u16::from_be_bytes([buf[2], buf[3]]).max(1); - let _ = session.update_viewport_and_resize(conn_id, rows, cols).await; + result = protocol::read_msg(&mut recv) => { + let (tag, payload) = result?; + match tag { + vtag::VIEWPORT => { + if payload.len() == 4 { + let rows = u16::from_be_bytes([payload[0], payload[1]]).max(1); + let cols = u16::from_be_bytes([payload[2], payload[3]]).max(1); + let _ = session.update_viewport_and_resize(conn_id, rows, cols).await; + } } - Err(_) => break, + vtag::REQUEST_TURN => { + let _ = event_tx.send(TurnEvent::TurnRequested { + conn_id: conn_id.to_string(), + }).await; + } + vtag::INPUT => { + // Only forward if this viewer holds the turn + let state = turn_rx.borrow().clone(); + if state.holder.as_deref() == Some(conn_id) { + let text = String::from_utf8_lossy(&payload); + let _ = session.write_input(&text).await; + } + } + vtag::RELEASE_TURN => { + let _ = event_tx.send(TurnEvent::TurnReleased { + conn_id: conn_id.to_string(), + }).await; + } + _ => {} + } + } + Ok(()) = turn_rx.changed() => { + let state = turn_rx.borrow().clone(); + let is_holder = state.holder.as_deref() == Some(conn_id); + let is_requester = state.requester.as_deref() == Some(conn_id); + + if is_holder && !was_holder { + protocol::write_msg(&mut send, htag::TURN_GRANTED, &[]).await?; + was_holder = true; + was_denied = false; + } else if !is_holder && was_holder { + protocol::write_msg(&mut send, htag::TURN_REVOKED, &[]).await?; + was_holder = false; + } else if !is_requester && !is_holder && !was_denied && !was_holder { + // Was requesting but now cleared (denied) + protocol::write_msg(&mut send, htag::TURN_DENIED, &[]).await?; + was_denied = true; } } } diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index b616a1d..5008bab 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -1,4 +1,5 @@ pub mod host; +pub mod protocol; pub mod session; mod viewer; diff --git a/packages/meld/src/protocol.rs b/packages/meld/src/protocol.rs new file mode 100644 index 0000000..498831c --- /dev/null +++ b/packages/meld/src/protocol.rs @@ -0,0 +1,42 @@ +use anyhow::Result; +use iroh::endpoint::{RecvStream, SendStream}; + +/// Tags for viewer -> host messages. +pub mod viewer { + pub const VIEWPORT: u8 = 0x01; + pub const REQUEST_TURN: u8 = 0x02; + pub const INPUT: u8 = 0x03; + pub const RELEASE_TURN: u8 = 0x04; +} + +/// Tags for host -> viewer messages. +pub mod host { + pub const OUTPUT: u8 = 0x01; + pub const TURN_GRANTED: u8 = 0x02; + pub const TURN_REVOKED: u8 = 0x03; + pub const TURN_DENIED: u8 = 0x04; +} + +/// Write a framed message: `[tag: u8][len: u16 BE][payload]`. +pub async fn write_msg(send: &mut SendStream, tag: u8, payload: &[u8]) -> Result<()> { + let len = payload.len() as u16; + let header = [tag, (len >> 8) as u8, len as u8]; + send.write_all(&header).await?; + if !payload.is_empty() { + send.write_all(payload).await?; + } + Ok(()) +} + +/// Read a framed message. Returns `(tag, payload)`. +pub async fn read_msg(recv: &mut RecvStream) -> Result<(u8, Vec)> { + let mut header = [0u8; 3]; + recv.read_exact(&mut header).await?; + let tag = header[0]; + let len = u16::from_be_bytes([header[1], header[2]]) as usize; + let mut payload = vec![0u8; len]; + if len > 0 { + recv.read_exact(&mut payload).await?; + } + Ok((tag, payload)) +} diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 281ca4d..835f47d 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -9,10 +9,17 @@ use tokio::sync::mpsc; use tracing::info; use crate::host::ALPN; +use crate::protocol::{self, host as htag, viewer as vtag}; -const STATUS: &str = "(meld) viewing [readonly] press q to exit"; const SCROLLBACK: usize = 10_000; +#[derive(Clone, Copy, PartialEq)] +enum Mode { + ReadOnly, + Requesting, + Editing, +} + pub async fn run(ticket_str: &str) -> Result<()> { let ticket: EndpointTicket = ticket_str .parse() @@ -36,10 +43,13 @@ pub async fn run(ticket_str: &str) -> Result<()> { let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); - send_viewport(&mut send, pty_rows, cols).await?; + // Send initial viewport (framed) + let vp = viewport_bytes(pty_rows, cols); + protocol::write_msg(&mut send, vtag::VIEWPORT, &vp).await?; let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); let mut scroll_offset: usize = 0; + let mut mode = Mode::ReadOnly; let mut terminal = ratatui::init(); ratatui::crossterm::execute!( @@ -57,19 +67,13 @@ pub async fn run(ticket_str: &str) -> Result<()> { } }); - let mut buf = vec![0u8; 4096]; - loop { if scroll_offset > 0 { vt_parser.screen_mut().set_scrollback(scroll_offset); scroll_offset = vt_parser.screen().scrollback(); } - let status = if scroll_offset > 0 { - format!("(meld) ↑ {} lines — scroll down to return", scroll_offset) - } else { - STATUS.to_string() - }; + let status = build_status(mode, scroll_offset); let screen = vt_parser.screen(); let cursor_pos = screen.cursor_position(); let hide_cursor = screen.hide_cursor(); @@ -95,28 +99,80 @@ pub async fn run(ticket_str: &str) -> Result<()> { } tokio::select! { - result = recv.read(&mut buf) => { - match result { - Ok(Some(n)) => { - vt_parser.process(&buf[..n]); + result = protocol::read_msg(&mut recv) => { + let (tag, payload) = match result { + Ok(msg) => msg, + Err(_) => break, // host closed connection + }; + match tag { + htag::OUTPUT => { + vt_parser.process(&payload); scroll_offset = 0; } - Ok(None) => break, - Err(_) => break, + htag::TURN_GRANTED => { + mode = Mode::Editing; + } + htag::TURN_REVOKED | htag::TURN_DENIED => { + mode = Mode::ReadOnly; + } + _ => {} } } Some(ev) = event_rx.recv() => { match ev { - Event::Key(key) => match key.code { - KeyCode::Char('q') | KeyCode::Char('Q') => break, - KeyCode::PageUp => { - scroll_offset += pty_rows as usize / 2; + Event::Key(key) => { + match mode { + Mode::ReadOnly => match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') => break, + KeyCode::Char('e') | KeyCode::Char('E') => { + protocol::write_msg(&mut send, vtag::REQUEST_TURN, &[]).await?; + mode = Mode::Requesting; + } + KeyCode::PageUp => { + scroll_offset += pty_rows as usize / 2; + } + KeyCode::PageDown => { + scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); + } + _ => {} + }, + Mode::Requesting => match key.code { + KeyCode::Char('q') | KeyCode::Char('Q') => { + protocol::write_msg(&mut send, vtag::RELEASE_TURN, &[]).await?; + break; + } + KeyCode::Esc => { + protocol::write_msg(&mut send, vtag::RELEASE_TURN, &[]).await?; + mode = Mode::ReadOnly; + } + KeyCode::PageUp => { + scroll_offset += pty_rows as usize / 2; + } + KeyCode::PageDown => { + scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); + } + _ => {} + }, + Mode::Editing => match key.code { + KeyCode::Esc => { + protocol::write_msg(&mut send, vtag::RELEASE_TURN, &[]).await?; + mode = Mode::ReadOnly; + } + KeyCode::PageUp => { + scroll_offset += pty_rows as usize / 2; + } + KeyCode::PageDown => { + scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); + } + _ => { + if let Some(bytes) = crate::key_to_bytes(&key) { + protocol::write_msg(&mut send, vtag::INPUT, &bytes).await?; + scroll_offset = 0; + } + } + }, } - KeyCode::PageDown => { - scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); - } - _ => {} - }, + } Event::Mouse(mouse) => match mouse.kind { MouseEventKind::ScrollUp => scroll_offset += 3, MouseEventKind::ScrollDown => { @@ -129,7 +185,8 @@ pub async fn run(ticket_str: &str) -> Result<()> { rows = new_rows; pty_rows = rows.saturating_sub(1).max(1); vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); - let _ = send_viewport(&mut send, pty_rows, cols).await; + let vp = viewport_bytes(pty_rows, cols); + protocol::write_msg(&mut send, vtag::VIEWPORT, &vp).await?; scroll_offset = 0; } _ => {} @@ -147,8 +204,17 @@ pub async fn run(ticket_str: &str) -> Result<()> { Ok(()) } -async fn send_viewport(send: &mut iroh::endpoint::SendStream, rows: u16, cols: u16) -> Result<()> { - let buf = [(rows >> 8) as u8, rows as u8, (cols >> 8) as u8, cols as u8]; - send.write_all(&buf).await?; - Ok(()) +fn build_status(mode: Mode, scroll_offset: usize) -> String { + if scroll_offset > 0 { + return format!("(meld) ↑ {} lines — scroll down to return", scroll_offset); + } + match mode { + Mode::ReadOnly => "(meld) viewing [readonly] e to request edit · q to exit".into(), + Mode::Requesting => "(meld) requesting edit access...".into(), + Mode::Editing => "(meld) editing · Esc to release".into(), + } +} + +fn viewport_bytes(rows: u16, cols: u16) -> [u8; 4] { + [(rows >> 8) as u8, rows as u8, (cols >> 8) as u8, cols as u8] } From dad8bea1302bfabb53decd7192cfe2faf3b36cb5 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Apr 2026 14:04:54 -0400 Subject: [PATCH 04/18] feat(meld): abortable connecting screen --- packages/meld/src/viewer.rs | 108 ++++++++++++++++++++++++------------ 1 file changed, 72 insertions(+), 36 deletions(-) diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 835f47d..3f52854 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -6,7 +6,6 @@ use ratatui::crossterm::event::{self, Event, KeyCode, MouseEventKind}; use ratatui::prelude::*; use ratatui::widgets::Paragraph; use tokio::sync::mpsc; -use tracing::info; use crate::host::ALPN; use crate::protocol::{self, host as htag, viewer as vtag}; @@ -25,47 +24,72 @@ pub async fn run(ticket_str: &str) -> Result<()> { .parse() .map_err(|e| anyhow::anyhow!("invalid ticket: {e}"))?; - info!("connecting..."); + let mut terminal = ratatui::init(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::EnableMouseCapture + )?; + + let cleanup = || { + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::DisableMouseCapture + ) + .ok(); + ratatui::restore(); + }; + + let (event_tx, mut event_rx) = mpsc::channel::(64); + std::thread::spawn(move || { + while let Ok(ev) = event::read() { + if event_tx.blocking_send(ev).is_err() { + break; + } + } + }); + + draw_message(&mut terminal, "(meld) connecting to host... q to quit")?; let endpoint = Endpoint::bind(presets::N0) .await .context("failed to bind iroh endpoint")?; - let conn = endpoint - .connect(ticket.endpoint_addr().clone(), ALPN) - .await - .context("failed to connect to host")?; - - info!("connected"); - - let (mut send, mut recv) = conn.open_bi().await?; + let (conn, mut send, mut recv) = { + let mut connect_fut = std::pin::pin!(async { + let conn = endpoint + .connect(ticket.endpoint_addr().clone(), ALPN) + .await?; + let streams = conn.open_bi().await?; + Ok::<_, anyhow::Error>((conn, streams.0, streams.1)) + }); + loop { + tokio::select! { + result = &mut connect_fut => { + match result { + Ok(val) => break val, + Err(e) => { cleanup(); return Err(e); } + } + } + Some(ev) = event_rx.recv() => { + if matches!(ev, Event::Key(key) if matches!(key.code, KeyCode::Char('q') | KeyCode::Char('Q'))) { + cleanup(); + return Ok(()); + } + } + } + } + }; let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); - // Send initial viewport (framed) let vp = viewport_bytes(pty_rows, cols); protocol::write_msg(&mut send, vtag::VIEWPORT, &vp).await?; let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); let mut scroll_offset: usize = 0; let mut mode = Mode::ReadOnly; - - let mut terminal = ratatui::init(); - ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::event::EnableMouseCapture - )?; - - // Crossterm event reader thread - let (event_tx, mut event_rx) = mpsc::channel::(64); - std::thread::spawn(move || { - while let Ok(ev) = event::read() { - if event_tx.blocking_send(ev).is_err() { - break; - } - } - }); + let mut got_output = false; loop { if scroll_offset > 0 { @@ -73,7 +97,11 @@ pub async fn run(ticket_str: &str) -> Result<()> { scroll_offset = vt_parser.screen().scrollback(); } - let status = build_status(mode, scroll_offset); + let status = if !got_output { + "(meld) connecting to host... q to quit".to_string() + } else { + build_status(mode, scroll_offset) + }; let screen = vt_parser.screen(); let cursor_pos = screen.cursor_position(); let hide_cursor = screen.hide_cursor(); @@ -88,7 +116,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { status_area, ); - if scroll_offset == 0 && !hide_cursor { + if got_output && scroll_offset == 0 && !hide_cursor { let (row, col) = cursor_pos; frame.set_cursor_position((content.x + col, content.y + row)); } @@ -102,12 +130,13 @@ pub async fn run(ticket_str: &str) -> Result<()> { result = protocol::read_msg(&mut recv) => { let (tag, payload) = match result { Ok(msg) => msg, - Err(_) => break, // host closed connection + Err(_) => break, }; match tag { htag::OUTPUT => { vt_parser.process(&payload); scroll_offset = 0; + got_output = true; } htag::TURN_GRANTED => { mode = Mode::Editing; @@ -124,7 +153,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { match mode { Mode::ReadOnly => match key.code { KeyCode::Char('q') | KeyCode::Char('Q') => break, - KeyCode::Char('e') | KeyCode::Char('E') => { + KeyCode::Char('e') | KeyCode::Char('E') if got_output => { protocol::write_msg(&mut send, vtag::REQUEST_TURN, &[]).await?; mode = Mode::Requesting; } @@ -195,15 +224,22 @@ pub async fn run(ticket_str: &str) -> Result<()> { } } - ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::event::DisableMouseCapture - )?; - ratatui::restore(); + cleanup(); conn.close(0u32.into(), b"done"); Ok(()) } +fn draw_message(terminal: &mut ratatui::DefaultTerminal, msg: &str) -> Result<()> { + terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget( + Paragraph::new(msg).style(Style::default().dim()), + Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1), + ); + })?; + Ok(()) +} + fn build_status(mode: Mode, scroll_offset: usize) -> String { if scroll_offset > 0 { return format!("(meld) ↑ {} lines — scroll down to return", scroll_offset); From 4825fa71104616fc7ac518268171326b63b26668 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Apr 2026 15:40:52 -0400 Subject: [PATCH 05/18] feat(meld): copy cmd to clipboard, show starting message --- Cargo.lock | 63 ++++++++++++++++++++++++++++++++++- packages/meld/Cargo.toml | 1 + packages/meld/src/host.rs | 66 ++++++++++++++++++++++--------------- packages/meld/src/main.rs | 12 +++++++ packages/meld/src/viewer.rs | 13 +------- 5 files changed, 115 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d88ca05..6108f7a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,23 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "log", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -777,6 +794,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cobs" version = "0.3.0" @@ -1683,6 +1709,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "etcetera" version = "0.8.0" @@ -2169,6 +2201,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -3745,6 +3787,7 @@ name = "meld" version = "0.1.0" dependencies = [ "anyhow", + "arboard", "clap", "iroh", "iroh-tickets", @@ -4334,6 +4377,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -9551,7 +9595,7 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.61.3", + "windows 0.62.2", "windows-core 0.62.2", ] @@ -9732,6 +9776,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "xml-rs" version = "0.8.28" diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml index 98d9180..4a0be75 100644 --- a/packages/meld/Cargo.toml +++ b/packages/meld/Cargo.toml @@ -14,6 +14,7 @@ iroh = "0.97" iroh-tickets = "0.4" ratatui = "0.30" vt100 = "0.16" +arboard = { version = "3", default-features = false } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index 2fbe69a..27dbdec 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -36,6 +36,23 @@ pub async fn run(command: Vec) -> Result<()> { let (cmd, args) = resolve_command(command); let cwd = std::env::current_dir()?.to_string_lossy().to_string(); + let mut terminal = ratatui::init(); + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::EnableMouseCapture + )?; + + let cleanup = || { + ratatui::crossterm::execute!( + std::io::stdout(), + ratatui::crossterm::event::DisableMouseCapture + ) + .ok(); + ratatui::restore(); + }; + + crate::draw_message(&mut terminal, "(meld) starting...")?; + let endpoint = Endpoint::builder(presets::N0) .alpns(vec![ALPN.to_vec()]) .bind() @@ -44,13 +61,10 @@ pub async fn run(command: Vec) -> Result<()> { endpoint.online().await; let ticket = EndpointTicket::new(endpoint.addr()); - eprintln!(); - eprintln!(" viewers can connect with:"); - eprintln!(); - eprintln!(" meld view {ticket}"); - eprintln!(); - eprint!(" press enter to start session..."); - let _ = std::io::stdin().read_line(&mut String::new()); + let view_cmd = format!("meld view {ticket}"); + let copied = arboard::Clipboard::new() + .and_then(|mut cb| cb.set_text(&view_cmd)) + .is_ok(); let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); @@ -66,12 +80,6 @@ pub async fn run(command: Vec) -> Result<()> { let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); let mut scroll_offset: usize = 0; - let mut terminal = ratatui::init(); - ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::event::EnableMouseCapture - )?; - // Crossterm event reader thread let (event_tx, mut event_rx) = mpsc::channel::(64); std::thread::spawn(move || { @@ -82,7 +90,6 @@ pub async fn run(command: Vec) -> Result<()> { } }); - // Viewer management let viewer_count = Arc::new(AtomicUsize::new(0)); let (turn_tx, turn_rx) = watch::channel(TurnState::default()); let (event_notify_tx, mut event_notify_rx) = mpsc::channel::(16); @@ -110,6 +117,11 @@ pub async fn run(command: Vec) -> Result<()> { }); let mut turn_state = TurnState::default(); + let banner_until = if copied { + Some(tokio::time::Instant::now() + std::time::Duration::from_secs(5)) + } else { + None + }; loop { if scroll_offset > 0 { @@ -119,6 +131,7 @@ pub async fn run(command: Vec) -> Result<()> { let n = viewer_count.load(Ordering::Relaxed); let status = build_status(n, &turn_state, scroll_offset); + let show_banner = banner_until.is_some_and(|t| tokio::time::Instant::now() < t); let screen = vt_parser.screen(); let cursor_pos = screen.cursor_position(); let hide_cursor = screen.hide_cursor(); @@ -133,6 +146,17 @@ pub async fn run(command: Vec) -> Result<()> { status_area, ); + if show_banner { + let msg = " viewer command copied to clipboard "; + let w = msg.len() as u16; + let x = content.right().saturating_sub(w); + let banner_area = Rect::new(x, content.y, w, 1); + frame.render_widget( + Paragraph::new(msg).style(Style::default().dim()), + banner_area, + ); + } + if scroll_offset == 0 && !hide_cursor { let (row, col) = cursor_pos; frame.set_cursor_position((content.x + col, content.y + row)); @@ -164,18 +188,14 @@ pub async fn run(command: Vec) -> Result<()> { } else if shift && key.code == KeyCode::PageDown { scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); } else if key.code == KeyCode::F(9) { - // F9: accept request or revoke turn if turn_state.requester.is_some() { - // Grant: move requester → holder turn_state.holder = turn_state.requester.take(); let _ = turn_tx.send(turn_state.clone()); } else if turn_state.holder.is_some() { - // Revoke turn_state.holder = None; let _ = turn_tx.send(turn_state.clone()); } } else if key.code == KeyCode::F(10) { - // F10: deny request if turn_state.requester.is_some() { turn_state.requester = None; let _ = turn_tx.send(turn_state.clone()); @@ -208,11 +228,9 @@ pub async fn run(command: Vec) -> Result<()> { match event { TurnEvent::ViewerConnected | TurnEvent::ViewerDisconnected => {} TurnEvent::TurnRequested { conn_id } => { - // Only allow one requester at a time if turn_state.holder.is_none() && turn_state.requester.is_none() { turn_state.requester = Some(conn_id); } - // If someone else already has the turn or is requesting, ignore } TurnEvent::TurnReleased { conn_id } => { if turn_state.holder.as_deref() == Some(&conn_id) { @@ -229,11 +247,7 @@ pub async fn run(command: Vec) -> Result<()> { } } - ratatui::crossterm::execute!( - std::io::stdout(), - ratatui::crossterm::event::DisableMouseCapture - )?; - ratatui::restore(); + cleanup(); let _ = session.stop().await; endpoint.close().await; Ok(()) @@ -309,7 +323,6 @@ async fn handle_viewer( let result = serve_viewer(&conn, &session, &conn_id, &event_tx, turn_rx).await; - // Clean up turn state if this viewer held or requested the turn let _ = event_tx .send(TurnEvent::TurnReleased { conn_id: conn_id.clone(), @@ -331,7 +344,6 @@ async fn serve_viewer( ) -> Result<()> { let (mut send, mut recv) = conn.accept_bi().await?; - // Read initial viewport (framed) let (tag, payload) = protocol::read_msg(&mut recv).await?; anyhow::ensure!( tag == vtag::VIEWPORT && payload.len() == 4, diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index 5008bab..da7037e 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -100,6 +100,18 @@ fn vt100_color_to_ratatui(color: vt100::Color) -> Color { } } +pub fn draw_message(terminal: &mut ratatui::DefaultTerminal, msg: &str) -> anyhow::Result<()> { + use ratatui::widgets::Paragraph; + terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget( + Paragraph::new(msg).style(Style::default().dim()), + Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1), + ); + })?; + Ok(()) +} + /// Convert a crossterm KeyEvent to the byte sequence a PTY expects. pub fn key_to_bytes(key: &KeyEvent) -> Option> { let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 3f52854..d90665d 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -48,7 +48,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { } }); - draw_message(&mut terminal, "(meld) connecting to host... q to quit")?; + crate::draw_message(&mut terminal, "(meld) connecting to host... q to quit")?; let endpoint = Endpoint::bind(presets::N0) .await @@ -229,17 +229,6 @@ pub async fn run(ticket_str: &str) -> Result<()> { Ok(()) } -fn draw_message(terminal: &mut ratatui::DefaultTerminal, msg: &str) -> Result<()> { - terminal.draw(|frame| { - let area = frame.area(); - frame.render_widget( - Paragraph::new(msg).style(Style::default().dim()), - Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1), - ); - })?; - Ok(()) -} - fn build_status(mode: Mode, scroll_offset: usize) -> String { if scroll_offset > 0 { return format!("(meld) ↑ {} lines — scroll down to return", scroll_offset); From e53229215c22fa5ead99e2f0e799e0a6a6a10109 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Mon, 13 Apr 2026 15:50:32 -0400 Subject: [PATCH 06/18] feat(meld): improve styling --- packages/meld/src/host.rs | 21 +++++++++------------ packages/meld/src/main.rs | 9 ++++++++- packages/meld/src/viewer.rs | 22 +++++++++++----------- 3 files changed, 28 insertions(+), 24 deletions(-) diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index 27dbdec..89383ed 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -51,7 +51,7 @@ pub async fn run(command: Vec) -> Result<()> { ratatui::restore(); }; - crate::draw_message(&mut terminal, "(meld) starting...")?; + crate::draw_message(&mut terminal, "starting...")?; let endpoint = Endpoint::builder(presets::N0) .alpns(vec![ALPN.to_vec()]) @@ -141,10 +141,7 @@ pub async fn run(command: Vec) -> Result<()> { Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); frame.render_widget(crate::PtyWidget { screen }, content); - frame.render_widget( - Paragraph::new(status.as_str()).style(Style::default().dim()), - status_area, - ); + frame.render_widget(Paragraph::new(status.clone()), status_area); if show_banner { let msg = " viewer command copied to clipboard "; @@ -253,23 +250,23 @@ pub async fn run(command: Vec) -> Result<()> { Ok(()) } -fn build_status(viewers: usize, turn: &TurnState, scroll_offset: usize) -> String { +fn build_status(viewers: usize, turn: &TurnState, scroll_offset: usize) -> Line<'static> { if scroll_offset > 0 { - return format!("(meld) ↑ {} lines — type to return", scroll_offset); + return crate::status_line(&format!("↑ {} lines — type to return", scroll_offset)); } if let Some(ref id) = turn.requester { let short = &id[..8.min(id.len())]; - return format!("(meld) {short} requesting edit · F9 accept · F10 deny"); + return crate::status_line(&format!("{short} requesting edit · F9 accept · F10 deny")); } if let Some(ref id) = turn.holder { let short = &id[..8.min(id.len())]; - return format!("(meld) {short} editing · F9 revoke"); + return crate::status_line(&format!("{short} editing · F9 revoke")); } - format!( - "(meld) hosting [{} viewer{}]", + crate::status_line(&format!( + "hosting [{} viewer{}]", viewers, if viewers == 1 { "" } else { "s" } - ) + )) } fn drain_output(rx: &mut broadcast::Receiver, parser: &mut vt100::Parser) { diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index da7037e..29008d5 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -100,12 +100,19 @@ fn vt100_color_to_ratatui(color: vt100::Color) -> Color { } } +pub fn status_line(rest: &str) -> Line<'static> { + Line::from(vec![ + Span::styled("(meld) ", Style::default().fg(Color::Magenta)), + Span::styled(rest.to_string(), Style::default().dim()), + ]) +} + pub fn draw_message(terminal: &mut ratatui::DefaultTerminal, msg: &str) -> anyhow::Result<()> { use ratatui::widgets::Paragraph; terminal.draw(|frame| { let area = frame.area(); frame.render_widget( - Paragraph::new(msg).style(Style::default().dim()), + Paragraph::new(status_line(msg)), Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1), ); })?; diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index d90665d..1776c6e 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -48,7 +48,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { } }); - crate::draw_message(&mut terminal, "(meld) connecting to host... q to quit")?; + crate::draw_message(&mut terminal, "connecting to host... q to quit")?; let endpoint = Endpoint::bind(presets::N0) .await @@ -98,7 +98,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { } let status = if !got_output { - "(meld) connecting to host... q to quit".to_string() + crate::status_line("connecting to host... q to quit") } else { build_status(mode, scroll_offset) }; @@ -111,10 +111,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).areas(frame.area()); frame.render_widget(crate::PtyWidget { screen }, content); - frame.render_widget( - Paragraph::new(status.as_str()).style(Style::default().dim()), - status_area, - ); + frame.render_widget(Paragraph::new(status.clone()), status_area); if got_output && scroll_offset == 0 && !hide_cursor { let (row, col) = cursor_pos; @@ -229,14 +226,17 @@ pub async fn run(ticket_str: &str) -> Result<()> { Ok(()) } -fn build_status(mode: Mode, scroll_offset: usize) -> String { +fn build_status(mode: Mode, scroll_offset: usize) -> Line<'static> { if scroll_offset > 0 { - return format!("(meld) ↑ {} lines — scroll down to return", scroll_offset); + return crate::status_line(&format!( + "↑ {} lines — scroll down to return", + scroll_offset + )); } match mode { - Mode::ReadOnly => "(meld) viewing [readonly] e to request edit · q to exit".into(), - Mode::Requesting => "(meld) requesting edit access...".into(), - Mode::Editing => "(meld) editing · Esc to release".into(), + Mode::ReadOnly => crate::status_line("viewing [readonly] e to request edit · q to exit"), + Mode::Requesting => crate::status_line("requesting edit access..."), + Mode::Editing => crate::status_line("editing · Esc to release"), } } From 24c72e3a35703ebfd83a4f4c656934248d87014e Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 14 Apr 2026 13:15:13 -0400 Subject: [PATCH 07/18] feat(vt): reexport vt100 and expose mutable screen fn --- Cargo.lock | 3 +-- packages/virtual_terminal/Cargo.toml | 2 +- packages/virtual_terminal/src/lib.rs | 6 ++++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6108f7a..0f27025 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3797,7 +3797,6 @@ dependencies = [ "tracing", "tracing-subscriber", "virtual_terminal", - "vt100", ] [[package]] @@ -8446,7 +8445,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "virtual_terminal" -version = "0.2.0" +version = "0.2.1" dependencies = [ "ciborium", "insta", diff --git a/packages/virtual_terminal/Cargo.toml b/packages/virtual_terminal/Cargo.toml index 9f4699a..f2242d1 100644 --- a/packages/virtual_terminal/Cargo.toml +++ b/packages/virtual_terminal/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "virtual_terminal" -version = "0.2.0" +version = "0.2.1" edition = "2024" description = "Virtual terminal with vt100 screen buffer, keyframe/delta replay, and viewport negotiation" diff --git a/packages/virtual_terminal/src/lib.rs b/packages/virtual_terminal/src/lib.rs index 2cded86..3110cb3 100644 --- a/packages/virtual_terminal/src/lib.rs +++ b/packages/virtual_terminal/src/lib.rs @@ -4,6 +4,7 @@ //! generates keyframe snapshots, stores deltas (raw PTY output since last //! keyframe), and negotiates dimensions across multiple clients. +pub use vt100; pub mod recorder; pub use recorder::{VtEvent, VtRecorder, VtRecording, VtRecordingHeader}; @@ -128,6 +129,11 @@ impl VirtualTerminal { self.parser.screen() } + /// Mutable access to the vt100 screen. + pub fn screen_mut(&mut self) -> &mut vt100::Screen { + self.parser.screen_mut() + } + /// Current cursor position (row, col) — 0-indexed. pub fn cursor_position(&self) -> (u16, u16) { self.parser.screen().cursor_position() From e97e51627b92e6c3e1b9e44ff43bff8de889e08b Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Tue, 14 Apr 2026 13:18:29 -0400 Subject: [PATCH 08/18] feat(meld): improve resize communication --- packages/meld/Cargo.toml | 1 - packages/meld/src/host.rs | 80 +++++++++++++++++++++++++++-------- packages/meld/src/main.rs | 2 +- packages/meld/src/protocol.rs | 1 + packages/meld/src/session.rs | 26 ++++++++++-- packages/meld/src/viewer.rs | 51 ++++++++++++++++------ 6 files changed, 125 insertions(+), 36 deletions(-) diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml index 4a0be75..2b26921 100644 --- a/packages/meld/Cargo.toml +++ b/packages/meld/Cargo.toml @@ -13,7 +13,6 @@ virtual_terminal = { workspace = true } iroh = "0.97" iroh-tickets = "0.4" ratatui = "0.30" -vt100 = "0.16" arboard = { version = "3", default-features = false } anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index 89383ed..04748d6 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -73,11 +73,14 @@ pub async fn run(command: Vec) -> Result<()> { .context("failed to spawn session")?; let mut output_rx = session.subscribe_output().await?; + let mut dims_rx = session.subscribe_dims(); session .update_viewport_and_resize("host", pty_rows, cols) .await?; - let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + let (eff_rows, eff_cols) = *dims_rx.borrow(); + let mut vt = + virtual_terminal::VirtualTerminal::new(eff_rows, eff_cols, 1024 * 1024, SCROLLBACK); let mut scroll_offset: usize = 0; // Crossterm event reader thread @@ -98,8 +101,17 @@ pub async fn run(command: Vec) -> Result<()> { let viewer_session = session.clone(); let viewer_endpoint = endpoint.clone(); let viewer_turn_rx = turn_rx.clone(); + let viewer_dims_rx = dims_rx.clone(); tokio::spawn(async move { - accept_viewers(viewer_endpoint, viewer_session, vc, etx, viewer_turn_rx).await; + accept_viewers( + viewer_endpoint, + viewer_session, + vc, + etx, + viewer_turn_rx, + viewer_dims_rx, + ) + .await; }); drop(event_notify_tx); @@ -125,14 +137,15 @@ pub async fn run(command: Vec) -> Result<()> { loop { if scroll_offset > 0 { - vt_parser.screen_mut().set_scrollback(scroll_offset); - scroll_offset = vt_parser.screen().scrollback(); + vt.screen_mut().set_scrollback(scroll_offset); + scroll_offset = vt.screen().scrollback(); } let n = viewer_count.load(Ordering::Relaxed); - let status = build_status(n, &turn_state, scroll_offset); + let (eff_r, eff_c) = *dims_rx.borrow(); + let status = build_status(n, &turn_state, scroll_offset, pty_rows, cols, eff_r, eff_c); let show_banner = banner_until.is_some_and(|t| tokio::time::Instant::now() < t); - let screen = vt_parser.screen(); + let screen = vt.screen(); let cursor_pos = screen.cursor_position(); let hide_cursor = screen.hide_cursor(); @@ -161,15 +174,15 @@ pub async fn run(command: Vec) -> Result<()> { })?; if scroll_offset > 0 { - vt_parser.screen_mut().set_scrollback(0); + vt.screen_mut().set_scrollback(0); } tokio::select! { result = output_rx.recv() => { match result { Ok(output) => { - vt_parser.process(&output.data); - drain_output(&mut output_rx, &mut vt_parser); + vt.process_output(&output.data); + drain_output(&mut output_rx, &mut vt); scroll_offset = 0; } Err(broadcast::error::RecvError::Lagged(_)) => {} @@ -214,13 +227,20 @@ pub async fn run(command: Vec) -> Result<()> { cols = new_cols; rows = new_rows; pty_rows = rows.saturating_sub(1).max(1); - vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); let _ = session.update_viewport_and_resize("host", pty_rows, cols).await; scroll_offset = 0; } _ => {} } } + Ok(()) = dims_rx.changed() => { + let (new_r, new_c) = *dims_rx.borrow(); + let (cur_r, cur_c) = vt.screen().size(); + if new_r != cur_r || new_c != cur_c { + vt.resize(new_r, new_c); + scroll_offset = 0; + } + } Some(event) = event_notify_rx.recv() => { match event { TurnEvent::ViewerConnected | TurnEvent::ViewerDisconnected => {} @@ -250,29 +270,44 @@ pub async fn run(command: Vec) -> Result<()> { Ok(()) } -fn build_status(viewers: usize, turn: &TurnState, scroll_offset: usize) -> Line<'static> { +fn build_status( + viewers: usize, + turn: &TurnState, + scroll_offset: usize, + local_rows: u16, + local_cols: u16, + eff_rows: u16, + eff_cols: u16, +) -> Line<'static> { if scroll_offset > 0 { return crate::status_line(&format!("↑ {} lines — type to return", scroll_offset)); } + let dims_note = if eff_rows != local_rows || eff_cols != local_cols { + format!(" · {}×{}", eff_cols, eff_rows) + } else { + String::new() + }; if let Some(ref id) = turn.requester { let short = &id[..8.min(id.len())]; - return crate::status_line(&format!("{short} requesting edit · F9 accept · F10 deny")); + return crate::status_line(&format!( + "{short} requesting edit · F9 accept · F10 deny{dims_note}" + )); } if let Some(ref id) = turn.holder { let short = &id[..8.min(id.len())]; - return crate::status_line(&format!("{short} editing · F9 revoke")); + return crate::status_line(&format!("{short} editing · F9 revoke{dims_note}")); } crate::status_line(&format!( - "hosting [{} viewer{}]", + "hosting [{} viewer{}]{dims_note}", viewers, if viewers == 1 { "" } else { "s" } )) } -fn drain_output(rx: &mut broadcast::Receiver, parser: &mut vt100::Parser) { +fn drain_output(rx: &mut broadcast::Receiver, vt: &mut virtual_terminal::VirtualTerminal) { loop { match rx.try_recv() { - Ok(output) => parser.process(&output.data), + Ok(output) => vt.process_output(&output.data), Err(broadcast::error::TryRecvError::Lagged(_)) => {} _ => break, } @@ -285,6 +320,7 @@ async fn accept_viewers( viewer_count: Arc, event_tx: mpsc::Sender, turn_rx: watch::Receiver, + dims_rx: watch::Receiver<(u16, u16)>, ) { while let Some(incoming) = endpoint.accept().await { let conn = match incoming.await { @@ -298,8 +334,9 @@ async fn accept_viewers( let vc = viewer_count.clone(); let etx = event_tx.clone(); let trx = turn_rx.clone(); + let drx = dims_rx.clone(); tokio::spawn(async move { - if let Err(e) = handle_viewer(conn, s, vc, etx, trx).await { + if let Err(e) = handle_viewer(conn, s, vc, etx, trx, drx).await { info!("viewer disconnected: {e}"); } }); @@ -312,13 +349,14 @@ async fn handle_viewer( viewer_count: Arc, event_tx: mpsc::Sender, turn_rx: watch::Receiver, + dims_rx: watch::Receiver<(u16, u16)>, ) -> Result<()> { let conn_id = conn.remote_id().to_string(); info!("viewer connected: {}", &conn_id[..8]); viewer_count.fetch_add(1, Ordering::Relaxed); let _ = event_tx.send(TurnEvent::ViewerConnected).await; - let result = serve_viewer(&conn, &session, &conn_id, &event_tx, turn_rx).await; + let result = serve_viewer(&conn, &session, &conn_id, &event_tx, turn_rx, dims_rx).await; let _ = event_tx .send(TurnEvent::TurnReleased { @@ -338,6 +376,7 @@ async fn serve_viewer( conn_id: &str, event_tx: &mpsc::Sender, mut turn_rx: watch::Receiver, + mut dims_rx: watch::Receiver<(u16, u16)>, ) -> Result<()> { let (mut send, mut recv) = conn.accept_bi().await?; @@ -409,6 +448,11 @@ async fn serve_viewer( _ => {} } } + Ok(()) = dims_rx.changed() => { + let (r, c) = *dims_rx.borrow(); + let payload = [(r >> 8) as u8, r as u8, (c >> 8) as u8, c as u8]; + protocol::write_msg(&mut send, htag::DIMS_CHANGED, &payload).await?; + } Ok(()) = turn_rx.changed() => { let state = turn_rx.borrow().clone(); let is_holder = state.holder.as_deref() == Some(conn_id); diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index 29008d5..5cca865 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -8,7 +8,7 @@ use clap::{Parser, Subcommand}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::prelude::*; use ratatui::widgets::Widget; -use virtual_terminal::walk_row; +use virtual_terminal::{vt100, walk_row}; #[derive(Parser)] #[command(name = "meld", about = "P2P terminal sharing over iroh")] diff --git a/packages/meld/src/protocol.rs b/packages/meld/src/protocol.rs index 498831c..9d01aff 100644 --- a/packages/meld/src/protocol.rs +++ b/packages/meld/src/protocol.rs @@ -15,6 +15,7 @@ pub mod host { pub const TURN_GRANTED: u8 = 0x02; pub const TURN_REVOKED: u8 = 0x03; pub const TURN_DENIED: u8 = 0x04; + pub const DIMS_CHANGED: u8 = 0x05; } /// Write a framed message: `[tag: u8][len: u16 BE][payload]`. diff --git a/packages/meld/src/session.rs b/packages/meld/src/session.rs index a5f9bfa..165af0d 100644 --- a/packages/meld/src/session.rs +++ b/packages/meld/src/session.rs @@ -1,6 +1,6 @@ use anyhow::Result; use pty_manager::{PtyConfig, PtyHandle, PtyOutput}; -use tokio::sync::{broadcast, mpsc, oneshot}; +use tokio::sync::{broadcast, mpsc, oneshot, watch}; use virtual_terminal::{ClientType, VirtualTerminal}; const MAX_DELTA_BYTES: usize = 1024 * 1024; @@ -52,6 +52,7 @@ enum Command { #[derive(Clone)] pub struct Session { tx: mpsc::Sender, + effective_dims: watch::Receiver<(u16, u16)>, } impl Session { @@ -77,10 +78,21 @@ impl Session { let (output_tx, _) = broadcast::channel::(64); let vt = VirtualTerminal::new(rows, cols, MAX_DELTA_BYTES, scrollback_lines); let (cmd_tx, cmd_rx) = mpsc::channel(32); + let (dims_tx, dims_rx) = watch::channel((rows, cols)); - tokio::spawn(actor_loop(pty, pty_output_rx, vt, output_tx, cmd_rx)); + tokio::spawn(actor_loop( + pty, + pty_output_rx, + vt, + output_tx, + cmd_rx, + dims_tx, + )); - Ok(Self { tx: cmd_tx }) + Ok(Self { + tx: cmd_tx, + effective_dims: dims_rx, + }) } pub async fn write_input(&self, text: &str) -> Result { @@ -102,6 +114,11 @@ impl Session { Ok(rx.await?) } + /// Subscribe to effective PTY dimension changes. + pub fn subscribe_dims(&self) -> watch::Receiver<(u16, u16)> { + self.effective_dims.clone() + } + pub async fn update_viewport(&self, id: &str, rows: u16, cols: u16) -> Option<(u16, u16)> { let (tx, rx) = oneshot::channel(); let _ = self @@ -190,6 +207,7 @@ async fn actor_loop( mut vt: VirtualTerminal, output_tx: broadcast::Sender, mut cmd_rx: mpsc::Receiver, + dims_tx: watch::Sender<(u16, u16)>, ) { loop { tokio::select! { @@ -216,6 +234,7 @@ async fn actor_loop( Command::Resize { rows, cols, respond_to } => { let result = pty.resize(rows, cols).await; vt.resize(rows, cols); + let _ = dims_tx.send((rows, cols)); let _ = respond_to.send(result); } Command::UpdateViewport { id, rows, cols, respond_to } => { @@ -229,7 +248,6 @@ async fn actor_loop( Command::GetRecentOutput { max_bytes, client_rows, respond_to } => { let replay = vt.replay(client_rows); let text = String::from_utf8_lossy(&replay); - // Trim to max_bytes respecting UTF-8 boundaries let trimmed = if text.len() > max_bytes { let mut end = max_bytes; while end > 0 && !text.is_char_boundary(end) { diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 1776c6e..e2a55e7 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -86,23 +86,26 @@ pub async fn run(ticket_str: &str) -> Result<()> { let vp = viewport_bytes(pty_rows, cols); protocol::write_msg(&mut send, vtag::VIEWPORT, &vp).await?; - let mut vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + let mut eff_rows = pty_rows; + let mut eff_cols = cols; + let mut vt = + virtual_terminal::VirtualTerminal::new(eff_rows, eff_cols, 1024 * 1024, SCROLLBACK); let mut scroll_offset: usize = 0; let mut mode = Mode::ReadOnly; let mut got_output = false; loop { if scroll_offset > 0 { - vt_parser.screen_mut().set_scrollback(scroll_offset); - scroll_offset = vt_parser.screen().scrollback(); + vt.screen_mut().set_scrollback(scroll_offset); + scroll_offset = vt.screen().scrollback(); } let status = if !got_output { crate::status_line("connecting to host... q to quit") } else { - build_status(mode, scroll_offset) + build_status(mode, scroll_offset, pty_rows, cols, eff_rows, eff_cols) }; - let screen = vt_parser.screen(); + let screen = vt.screen(); let cursor_pos = screen.cursor_position(); let hide_cursor = screen.hide_cursor(); @@ -120,7 +123,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { })?; if scroll_offset > 0 { - vt_parser.screen_mut().set_scrollback(0); + vt.screen_mut().set_scrollback(0); } tokio::select! { @@ -131,7 +134,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { }; match tag { htag::OUTPUT => { - vt_parser.process(&payload); + vt.process_output(&payload); scroll_offset = 0; got_output = true; } @@ -141,6 +144,16 @@ pub async fn run(ticket_str: &str) -> Result<()> { htag::TURN_REVOKED | htag::TURN_DENIED => { mode = Mode::ReadOnly; } + htag::DIMS_CHANGED if payload.len() == 4 => { + let new_r = u16::from_be_bytes([payload[0], payload[1]]); + let new_c = u16::from_be_bytes([payload[2], payload[3]]); + if new_r != eff_rows || new_c != eff_cols { + eff_rows = new_r; + eff_cols = new_c; + vt.resize(eff_rows, eff_cols); + scroll_offset = 0; + } + } _ => {} } } @@ -210,7 +223,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { cols = new_cols; rows = new_rows; pty_rows = rows.saturating_sub(1).max(1); - vt_parser = vt100::Parser::new(pty_rows, cols, SCROLLBACK); + vt.resize(pty_rows, cols); let vp = viewport_bytes(pty_rows, cols); protocol::write_msg(&mut send, vtag::VIEWPORT, &vp).await?; scroll_offset = 0; @@ -226,17 +239,31 @@ pub async fn run(ticket_str: &str) -> Result<()> { Ok(()) } -fn build_status(mode: Mode, scroll_offset: usize) -> Line<'static> { +fn build_status( + mode: Mode, + scroll_offset: usize, + local_rows: u16, + local_cols: u16, + eff_rows: u16, + eff_cols: u16, +) -> Line<'static> { if scroll_offset > 0 { return crate::status_line(&format!( "↑ {} lines — scroll down to return", scroll_offset )); } + let dims_note = if eff_rows != local_rows || eff_cols != local_cols { + format!(" · {}×{}", eff_cols, eff_rows) + } else { + String::new() + }; match mode { - Mode::ReadOnly => crate::status_line("viewing [readonly] e to request edit · q to exit"), - Mode::Requesting => crate::status_line("requesting edit access..."), - Mode::Editing => crate::status_line("editing · Esc to release"), + Mode::ReadOnly => crate::status_line(&format!( + "viewing [readonly] e to request edit · q to exit{dims_note}" + )), + Mode::Requesting => crate::status_line(&format!("requesting edit access...{dims_note}")), + Mode::Editing => crate::status_line(&format!("editing · Esc to release{dims_note}")), } } From 5998749ec0eda10031d9bca6e5afde4fa97665fd Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Wed, 15 Apr 2026 14:58:41 -0400 Subject: [PATCH 09/18] fix(meld): various fixes --- packages/meld/src/host.rs | 35 ++++++++++++++++++----------------- packages/meld/src/viewer.rs | 17 ++++++++--------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index 04748d6..e14b770 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -210,10 +210,12 @@ pub async fn run(command: Vec) -> Result<()> { turn_state.requester = None; let _ = turn_tx.send(turn_state.clone()); } - } else if let Some(bytes) = crate::key_to_bytes(&key) { - let text = String::from_utf8_lossy(&bytes); - let _ = session.write_input(&text).await; - scroll_offset = 0; + } else if turn_state.holder.is_none() { + if let Some(bytes) = crate::key_to_bytes(&key) { + let text = String::from_utf8_lossy(&bytes); + let _ = session.write_input(&text).await; + scroll_offset = 0; + } } } Event::Mouse(mouse) => match mouse.kind { @@ -287,21 +289,20 @@ fn build_status( } else { String::new() }; - if let Some(ref id) = turn.requester { + let msg = if let Some(ref id) = turn.requester { let short = &id[..8.min(id.len())]; - return crate::status_line(&format!( - "{short} requesting edit · F9 accept · F10 deny{dims_note}" - )); - } - if let Some(ref id) = turn.holder { + format!("{short} requesting edit · F9 accept · F10 deny") + } else if let Some(ref id) = turn.holder { let short = &id[..8.min(id.len())]; - return crate::status_line(&format!("{short} editing · F9 revoke{dims_note}")); - } - crate::status_line(&format!( - "hosting [{} viewer{}]{dims_note}", - viewers, - if viewers == 1 { "" } else { "s" } - )) + format!("{short} editing · F9 revoke") + } else { + format!( + "hosting [{} viewer{}]", + viewers, + if viewers == 1 { "" } else { "s" } + ) + }; + crate::status_line(&format!("{msg}{dims_note}")) } fn drain_output(rx: &mut broadcast::Receiver, vt: &mut virtual_terminal::VirtualTerminal) { diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index e2a55e7..364980f 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -180,7 +180,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { protocol::write_msg(&mut send, vtag::RELEASE_TURN, &[]).await?; break; } - KeyCode::Esc => { + KeyCode::F(9) => { protocol::write_msg(&mut send, vtag::RELEASE_TURN, &[]).await?; mode = Mode::ReadOnly; } @@ -193,7 +193,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { _ => {} }, Mode::Editing => match key.code { - KeyCode::Esc => { + KeyCode::F(9) => { protocol::write_msg(&mut send, vtag::RELEASE_TURN, &[]).await?; mode = Mode::ReadOnly; } @@ -258,13 +258,12 @@ fn build_status( } else { String::new() }; - match mode { - Mode::ReadOnly => crate::status_line(&format!( - "viewing [readonly] e to request edit · q to exit{dims_note}" - )), - Mode::Requesting => crate::status_line(&format!("requesting edit access...{dims_note}")), - Mode::Editing => crate::status_line(&format!("editing · Esc to release{dims_note}")), - } + let msg = match mode { + Mode::ReadOnly => "viewing [readonly] e to request edit · q to exit", + Mode::Requesting => "requesting edit access... F9 to cancel", + Mode::Editing => "editing · F9 to release", + }; + crate::status_line(&format!("{msg}{dims_note}")) } fn viewport_bytes(rows: u16, cols: u16) -> [u8; 4] { From 0cd97565421d273da06d1c4975292346053775fa Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Wed, 15 Apr 2026 15:32:42 -0400 Subject: [PATCH 10/18] feat(meld): names + config file --- Cargo.lock | 2 + packages/meld/Cargo.toml | 2 + packages/meld/src/config.rs | 87 +++++++++++++++++++++++++++++++++++ packages/meld/src/host.rs | 64 +++++++++++++++++++++----- packages/meld/src/main.rs | 1 + packages/meld/src/protocol.rs | 1 + packages/meld/src/viewer.rs | 3 ++ 7 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 packages/meld/src/config.rs diff --git a/Cargo.lock b/Cargo.lock index 0f27025..a8c25c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3793,7 +3793,9 @@ dependencies = [ "iroh-tickets", "pty_manager", "ratatui", + "serde", "tokio", + "toml 0.8.2", "tracing", "tracing-subscriber", "virtual_terminal", diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml index 2b26921..eed4819 100644 --- a/packages/meld/Cargo.toml +++ b/packages/meld/Cargo.toml @@ -14,6 +14,8 @@ iroh = "0.97" iroh-tickets = "0.4" ratatui = "0.30" arboard = { version = "3", default-features = false } +serde = { workspace = true } +toml = "0.8" anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } diff --git a/packages/meld/src/config.rs b/packages/meld/src/config.rs new file mode 100644 index 0000000..fd8d5b8 --- /dev/null +++ b/packages/meld/src/config.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; + +use anyhow::Result; +use ratatui::crossterm::event::{self, Event, KeyCode}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Default)] +pub struct Config { + #[serde(default)] + pub name: String, +} + +fn config_path() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home).join(".meld").join("config.toml") +} + +impl Config { + pub fn load() -> Option { + let content = std::fs::read_to_string(config_path()).ok()?; + let config: Config = toml::from_str(&content).ok()?; + if config.name.is_empty() { + None + } else { + Some(config) + } + } + + pub fn save(&self) -> Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, toml::to_string_pretty(self)?)?; + Ok(()) + } +} + +pub fn ensure_name(terminal: &mut ratatui::DefaultTerminal) -> Result { + let default = Config::load() + .map(|c| c.name) + .unwrap_or_else(|| std::env::var("USER").unwrap_or_default()); + let mut input = default; + let mut cursor = input.len(); + + loop { + let prompt = format!("enter your name: {}", &input); + terminal.draw(|frame| { + let area = frame.area(); + let status_area = + ratatui::prelude::Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1); + frame.render_widget( + ratatui::widgets::Paragraph::new(crate::status_line(&prompt)), + status_area, + ); + let cursor_x = status_area.x + + "(meld) ".len() as u16 + + "enter your name: ".len() as u16 + + cursor as u16; + frame.set_cursor_position((cursor_x, status_area.y)); + })?; + + match event::read()? { + Event::Key(key) => match key.code { + KeyCode::Enter if !input.is_empty() => { + let config = Config { + name: input.clone(), + }; + config.save()?; + return Ok(input); + } + KeyCode::Char(c) => { + input.insert(cursor, c); + cursor += 1; + } + KeyCode::Backspace if cursor > 0 => { + cursor -= 1; + input.remove(cursor); + } + KeyCode::Left if cursor > 0 => cursor -= 1, + KeyCode::Right if cursor < input.len() => cursor += 1, + _ => {} + }, + _ => {} + } + } +} diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index e14b770..d4e31ef 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -26,8 +26,8 @@ struct TurnState { /// Events from viewer tasks to host main loop. enum TurnEvent { - ViewerConnected, - ViewerDisconnected, + ViewerConnected { conn_id: String, name: String }, + ViewerDisconnected { conn_id: String }, TurnRequested { conn_id: String }, TurnReleased { conn_id: String }, } @@ -51,6 +51,8 @@ pub async fn run(command: Vec) -> Result<()> { ratatui::restore(); }; + let _host_name = crate::config::ensure_name(&mut terminal)?; + crate::draw_message(&mut terminal, "starting...")?; let endpoint = Endpoint::builder(presets::N0) @@ -129,6 +131,8 @@ pub async fn run(command: Vec) -> Result<()> { }); let mut turn_state = TurnState::default(); + let mut viewer_names: std::collections::HashMap = + std::collections::HashMap::new(); let banner_until = if copied { Some(tokio::time::Instant::now() + std::time::Duration::from_secs(5)) } else { @@ -143,7 +147,16 @@ pub async fn run(command: Vec) -> Result<()> { let n = viewer_count.load(Ordering::Relaxed); let (eff_r, eff_c) = *dims_rx.borrow(); - let status = build_status(n, &turn_state, scroll_offset, pty_rows, cols, eff_r, eff_c); + let status = build_status( + n, + &turn_state, + &viewer_names, + scroll_offset, + pty_rows, + cols, + eff_r, + eff_c, + ); let show_banner = banner_until.is_some_and(|t| tokio::time::Instant::now() < t); let screen = vt.screen(); let cursor_pos = screen.cursor_position(); @@ -245,7 +258,12 @@ pub async fn run(command: Vec) -> Result<()> { } Some(event) = event_notify_rx.recv() => { match event { - TurnEvent::ViewerConnected | TurnEvent::ViewerDisconnected => {} + TurnEvent::ViewerConnected { conn_id, name } => { + viewer_names.insert(conn_id, name); + } + TurnEvent::ViewerDisconnected { conn_id } => { + viewer_names.remove(&conn_id); + } TurnEvent::TurnRequested { conn_id } => { if turn_state.holder.is_none() && turn_state.requester.is_none() { turn_state.requester = Some(conn_id); @@ -275,6 +293,7 @@ pub async fn run(command: Vec) -> Result<()> { fn build_status( viewers: usize, turn: &TurnState, + names: &std::collections::HashMap, scroll_offset: usize, local_rows: u16, local_cols: u16, @@ -289,12 +308,19 @@ fn build_status( } else { String::new() }; + let display_name = |conn_id: &str| -> String { + names + .get(conn_id) + .cloned() + .unwrap_or_else(|| conn_id[..8.min(conn_id.len())].to_string()) + }; let msg = if let Some(ref id) = turn.requester { - let short = &id[..8.min(id.len())]; - format!("{short} requesting edit · F9 accept · F10 deny") + format!( + "{} requesting edit · F9 accept · F10 deny", + display_name(id) + ) } else if let Some(ref id) = turn.holder { - let short = &id[..8.min(id.len())]; - format!("{short} editing · F9 revoke") + format!("{} editing · F9 revoke", display_name(id)) } else { format!( "hosting [{} viewer{}]", @@ -353,9 +379,7 @@ async fn handle_viewer( dims_rx: watch::Receiver<(u16, u16)>, ) -> Result<()> { let conn_id = conn.remote_id().to_string(); - info!("viewer connected: {}", &conn_id[..8]); viewer_count.fetch_add(1, Ordering::Relaxed); - let _ = event_tx.send(TurnEvent::ViewerConnected).await; let result = serve_viewer(&conn, &session, &conn_id, &event_tx, turn_rx, dims_rx).await; @@ -365,7 +389,11 @@ async fn handle_viewer( }) .await; viewer_count.fetch_sub(1, Ordering::Relaxed); - let _ = event_tx.send(TurnEvent::ViewerDisconnected).await; + let _ = event_tx + .send(TurnEvent::ViewerDisconnected { + conn_id: conn_id.clone(), + }) + .await; let _ = session.remove_client_and_resize(&conn_id).await; info!("viewer disconnected: {}", &conn_id[..8]); result @@ -381,6 +409,20 @@ async fn serve_viewer( ) -> Result<()> { let (mut send, mut recv) = conn.accept_bi().await?; + // Read Hello (viewer name) + let (tag, payload) = protocol::read_msg(&mut recv).await?; + anyhow::ensure!(tag == vtag::HELLO, "expected hello"); + let viewer_name = String::from_utf8_lossy(&payload).to_string(); + info!("viewer connected: {viewer_name}"); + + let _ = event_tx + .send(TurnEvent::ViewerConnected { + conn_id: conn_id.to_string(), + name: viewer_name, + }) + .await; + + // Read viewport let (tag, payload) = protocol::read_msg(&mut recv).await?; anyhow::ensure!( tag == vtag::VIEWPORT && payload.len() == 4, diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index 5cca865..d3b1b8e 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -1,3 +1,4 @@ +pub mod config; pub mod host; pub mod protocol; pub mod session; diff --git a/packages/meld/src/protocol.rs b/packages/meld/src/protocol.rs index 9d01aff..d598c19 100644 --- a/packages/meld/src/protocol.rs +++ b/packages/meld/src/protocol.rs @@ -7,6 +7,7 @@ pub mod viewer { pub const REQUEST_TURN: u8 = 0x02; pub const INPUT: u8 = 0x03; pub const RELEASE_TURN: u8 = 0x04; + pub const HELLO: u8 = 0x05; } /// Tags for host -> viewer messages. diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 364980f..722a85b 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -39,6 +39,8 @@ pub async fn run(ticket_str: &str) -> Result<()> { ratatui::restore(); }; + let viewer_name = crate::config::ensure_name(&mut terminal)?; + let (event_tx, mut event_rx) = mpsc::channel::(64); std::thread::spawn(move || { while let Ok(ev) = event::read() { @@ -83,6 +85,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); + protocol::write_msg(&mut send, vtag::HELLO, viewer_name.as_bytes()).await?; let vp = viewport_bytes(pty_rows, cols); protocol::write_msg(&mut send, vtag::VIEWPORT, &vp).await?; From 105a1703b04abeeeeacd7fca842be4141b7f3a99 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 10:51:32 -0400 Subject: [PATCH 11/18] feat(meld): send disconnect message on disconnect --- packages/meld/src/host.rs | 9 ++++++--- packages/meld/src/protocol.rs | 1 + packages/meld/src/viewer.rs | 6 +++++- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index d4e31ef..a9a6d56 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -484,10 +484,13 @@ async fn serve_viewer( } } vtag::RELEASE_TURN => { - let _ = event_tx.send(TurnEvent::TurnReleased { - conn_id: conn_id.to_string(), - }).await; + let _ = event_tx + .send(TurnEvent::TurnReleased { + conn_id: conn_id.to_string(), + }) + .await; } + vtag::GOODBYE => break, _ => {} } } diff --git a/packages/meld/src/protocol.rs b/packages/meld/src/protocol.rs index d598c19..7bfcabe 100644 --- a/packages/meld/src/protocol.rs +++ b/packages/meld/src/protocol.rs @@ -8,6 +8,7 @@ pub mod viewer { pub const INPUT: u8 = 0x03; pub const RELEASE_TURN: u8 = 0x04; pub const HELLO: u8 = 0x05; + pub const GOODBYE: u8 = 0x06; } /// Tags for host -> viewer messages. diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index 722a85b..d0051b7 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -237,8 +237,12 @@ pub async fn run(ticket_str: &str) -> Result<()> { } } - cleanup(); + let _ = protocol::write_msg(&mut send, vtag::GOODBYE, &[]).await; + let _ = send.finish(); + drop(send); + drop(recv); conn.close(0u32.into(), b"done"); + cleanup(); Ok(()) } From 8bb2807649dbeaab791a4c3fd3ce0dc5e08f2f89 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 14:10:32 -0400 Subject: [PATCH 12/18] feat(meld): misc fixes and improvements --- Cargo.lock | 1 + packages/meld/Cargo.toml | 1 + packages/meld/src/host.rs | 434 +++++++++++++++++++++++++------ packages/meld/src/main.rs | 369 +++++++++++++++++++++++--- packages/meld/src/protocol.rs | 69 ++++- packages/meld/src/session.rs | 65 +++-- packages/meld/src/session_dir.rs | 32 +++ packages/meld/src/viewer.rs | 4 +- 8 files changed, 801 insertions(+), 174 deletions(-) create mode 100644 packages/meld/src/session_dir.rs diff --git a/Cargo.lock b/Cargo.lock index a8c25c6..8552f03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3798,6 +3798,7 @@ dependencies = [ "toml 0.8.2", "tracing", "tracing-subscriber", + "uuid", "virtual_terminal", ] diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml index eed4819..72ed687 100644 --- a/packages/meld/Cargo.toml +++ b/packages/meld/Cargo.toml @@ -21,3 +21,4 @@ clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } tracing-subscriber = { workspace = true } +uuid = { workspace = true } diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index a9a6d56..b0f2c1b 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -8,17 +9,17 @@ use iroh_tickets::endpoint::EndpointTicket; use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers, MouseEventKind}; use ratatui::prelude::*; use ratatui::widgets::Paragraph; -use tokio::sync::{broadcast, mpsc, oneshot, watch}; +use tokio::sync::{broadcast, mpsc, watch}; use tracing::{info, warn}; use crate::protocol::{self, host as htag, viewer as vtag}; -use crate::session::{Output, Session}; +use crate::session::Session; pub const ALPN: &[u8] = b"meld/term/0"; const SCROLLBACK: usize = 10_000; /// Turn state broadcast from host main loop to viewer tasks. -#[derive(Clone, Default)] +#[derive(Clone, Default, Debug, PartialEq)] struct TurnState { holder: Option, requester: Option, @@ -32,6 +33,118 @@ enum TurnEvent { TurnReleased { conn_id: String }, } +/// Side-effects emitted by `HostState` transitions. +#[derive(Debug, PartialEq)] +enum Effect { + BroadcastTurn, + BroadcastDenied(String), + UpdateActiveUser, +} + +/// Authoritative turn + viewer state. All methods are pure: they mutate +/// the struct and return the side-effects the caller must perform. +struct HostState { + turn: TurnState, + viewer_names: HashMap, + host_name: String, + session_id: String, +} + +impl HostState { + fn new(host_name: String, session_id: String) -> Self { + Self { + turn: TurnState::default(), + viewer_names: HashMap::new(), + host_name, + session_id, + } + } + + /// F9: promote requester → holder, or revoke current holder. + fn accept_or_revoke(&mut self) -> Vec { + if self.turn.requester.is_some() { + self.turn.holder = self.turn.requester.take(); + vec![Effect::BroadcastTurn, Effect::UpdateActiveUser] + } else if self.turn.holder.is_some() { + self.turn.holder = None; + vec![Effect::BroadcastTurn, Effect::UpdateActiveUser] + } else { + vec![] + } + } + + /// F10: deny the current requester. + fn deny(&mut self) -> Vec { + if let Some(req) = self.turn.requester.take() { + vec![Effect::BroadcastDenied(req), Effect::BroadcastTurn] + } else { + vec![] + } + } + + fn viewer_connected(&mut self, conn_id: String, name: String) { + self.viewer_names.insert(conn_id, name); + } + + fn viewer_disconnected(&mut self, conn_id: &str) { + self.viewer_names.remove(conn_id); + } + + /// A viewer requests the turn. Ignored if anyone already holds or is queued. + fn turn_requested(&mut self, conn_id: String) { + if self.turn.holder.is_none() && self.turn.requester.is_none() { + self.turn.requester = Some(conn_id); + } + } + + /// A viewer released the turn (explicit release, or connection dropped). + fn turn_released(&mut self, conn_id: &str) -> Vec { + let mut effects = vec![]; + if self.turn.holder.as_deref() == Some(conn_id) { + self.turn.holder = None; + effects.push(Effect::BroadcastTurn); + effects.push(Effect::UpdateActiveUser); + } + if self.turn.requester.as_deref() == Some(conn_id) { + self.turn.requester = None; + } + effects + } + + /// Name of whoever currently drives the session (for `active_user` and UI). + fn active_user(&self) -> &str { + match &self.turn.holder { + Some(id) => self + .viewer_names + .get(id) + .map(String::as_str) + .unwrap_or(&self.host_name), + None => &self.host_name, + } + } +} + +fn apply_effects( + state: &HostState, + effects: Vec, + turn_tx: &watch::Sender, + denied_tx: &broadcast::Sender, +) { + for eff in effects { + match eff { + Effect::BroadcastTurn => { + let _ = turn_tx.send(state.turn.clone()); + } + Effect::BroadcastDenied(id) => { + let _ = denied_tx.send(id); + } + Effect::UpdateActiveUser => { + crate::session_dir::write_active_user(&state.session_id, state.active_user()); + } + } + } +} + pub async fn run(command: Vec) -> Result<()> { let (cmd, args) = resolve_command(command); let cwd = std::env::current_dir()?.to_string_lossy().to_string(); @@ -51,7 +164,7 @@ pub async fn run(command: Vec) -> Result<()> { ratatui::restore(); }; - let _host_name = crate::config::ensure_name(&mut terminal)?; + let host_name = crate::config::ensure_name(&mut terminal)?; crate::draw_message(&mut terminal, "starting...")?; @@ -71,7 +184,10 @@ pub async fn run(command: Vec) -> Result<()> { let (mut cols, mut rows) = ratatui::crossterm::terminal::size()?; let mut pty_rows = rows.saturating_sub(1).max(1); - let session = Session::spawn(&cmd, &args, &cwd, pty_rows, cols, SCROLLBACK) + let session_id = uuid::Uuid::new_v4().to_string(); + crate::session_dir::write_active_user(&session_id, &host_name); + + let session = Session::spawn(&cmd, &args, &cwd, pty_rows, cols, SCROLLBACK, &session_id) .context("failed to spawn session")?; let mut output_rx = session.subscribe_output().await?; @@ -97,6 +213,7 @@ pub async fn run(command: Vec) -> Result<()> { let viewer_count = Arc::new(AtomicUsize::new(0)); let (turn_tx, turn_rx) = watch::channel(TurnState::default()); + let (denied_tx, _) = broadcast::channel::(16); let (event_notify_tx, mut event_notify_rx) = mpsc::channel::(16); let vc = viewer_count.clone(); let etx = event_notify_tx.clone(); @@ -104,6 +221,7 @@ pub async fn run(command: Vec) -> Result<()> { let viewer_endpoint = endpoint.clone(); let viewer_turn_rx = turn_rx.clone(); let viewer_dims_rx = dims_rx.clone(); + let viewer_denied_tx = denied_tx.clone(); tokio::spawn(async move { accept_viewers( viewer_endpoint, @@ -112,27 +230,13 @@ pub async fn run(command: Vec) -> Result<()> { etx, viewer_turn_rx, viewer_dims_rx, + viewer_denied_tx, ) .await; }); drop(event_notify_tx); - // PTY exit detection - let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); - let exit_session = session.clone(); - tokio::spawn(async move { - loop { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - if exit_session.get_pid().await.is_none() { - let _ = shutdown_tx.send(()); - return; - } - } - }); - - let mut turn_state = TurnState::default(); - let mut viewer_names: std::collections::HashMap = - std::collections::HashMap::new(); + let mut state = HostState::new(host_name, session_id); let banner_until = if copied { Some(tokio::time::Instant::now() + std::time::Duration::from_secs(5)) } else { @@ -147,16 +251,7 @@ pub async fn run(command: Vec) -> Result<()> { let n = viewer_count.load(Ordering::Relaxed); let (eff_r, eff_c) = *dims_rx.borrow(); - let status = build_status( - n, - &turn_state, - &viewer_names, - scroll_offset, - pty_rows, - cols, - eff_r, - eff_c, - ); + let status = build_status(n, &state, scroll_offset, pty_rows, cols, eff_r, eff_c); let show_banner = banner_until.is_some_and(|t| tokio::time::Instant::now() < t); let screen = vt.screen(); let cursor_pos = screen.cursor_position(); @@ -194,7 +289,7 @@ pub async fn run(command: Vec) -> Result<()> { result = output_rx.recv() => { match result { Ok(output) => { - vt.process_output(&output.data); + vt.process_output(&output); drain_output(&mut output_rx, &mut vt); scroll_offset = 0; } @@ -211,19 +306,12 @@ pub async fn run(command: Vec) -> Result<()> { } else if shift && key.code == KeyCode::PageDown { scroll_offset = scroll_offset.saturating_sub(pty_rows as usize / 2); } else if key.code == KeyCode::F(9) { - if turn_state.requester.is_some() { - turn_state.holder = turn_state.requester.take(); - let _ = turn_tx.send(turn_state.clone()); - } else if turn_state.holder.is_some() { - turn_state.holder = None; - let _ = turn_tx.send(turn_state.clone()); - } + let effects = state.accept_or_revoke(); + apply_effects(&state, effects, &turn_tx, &denied_tx); } else if key.code == KeyCode::F(10) { - if turn_state.requester.is_some() { - turn_state.requester = None; - let _ = turn_tx.send(turn_state.clone()); - } - } else if turn_state.holder.is_none() { + let effects = state.deny(); + apply_effects(&state, effects, &turn_tx, &denied_tx); + } else if state.turn.holder.is_none() { if let Some(bytes) = crate::key_to_bytes(&key) { let text = String::from_utf8_lossy(&bytes); let _ = session.write_input(&text).await; @@ -259,41 +347,33 @@ pub async fn run(command: Vec) -> Result<()> { Some(event) = event_notify_rx.recv() => { match event { TurnEvent::ViewerConnected { conn_id, name } => { - viewer_names.insert(conn_id, name); + state.viewer_connected(conn_id, name); } TurnEvent::ViewerDisconnected { conn_id } => { - viewer_names.remove(&conn_id); + state.viewer_disconnected(&conn_id); } TurnEvent::TurnRequested { conn_id } => { - if turn_state.holder.is_none() && turn_state.requester.is_none() { - turn_state.requester = Some(conn_id); - } + state.turn_requested(conn_id); } TurnEvent::TurnReleased { conn_id } => { - if turn_state.holder.as_deref() == Some(&conn_id) { - turn_state.holder = None; - let _ = turn_tx.send(turn_state.clone()); - } - if turn_state.requester.as_deref() == Some(&conn_id) { - turn_state.requester = None; - } + let effects = state.turn_released(&conn_id); + apply_effects(&state, effects, &turn_tx, &denied_tx); } } } - _ = &mut shutdown_rx => break, } } cleanup(); let _ = session.stop().await; endpoint.close().await; + crate::session_dir::cleanup_session(&state.session_id); Ok(()) } fn build_status( viewers: usize, - turn: &TurnState, - names: &std::collections::HashMap, + state: &HostState, scroll_offset: usize, local_rows: u16, local_cols: u16, @@ -309,17 +389,18 @@ fn build_status( String::new() }; let display_name = |conn_id: &str| -> String { - names + state + .viewer_names .get(conn_id) .cloned() .unwrap_or_else(|| conn_id[..8.min(conn_id.len())].to_string()) }; - let msg = if let Some(ref id) = turn.requester { + let msg = if let Some(ref id) = state.turn.requester { format!( "{} requesting edit · F9 accept · F10 deny", display_name(id) ) - } else if let Some(ref id) = turn.holder { + } else if let Some(ref id) = state.turn.holder { format!("{} editing · F9 revoke", display_name(id)) } else { format!( @@ -331,10 +412,10 @@ fn build_status( crate::status_line(&format!("{msg}{dims_note}")) } -fn drain_output(rx: &mut broadcast::Receiver, vt: &mut virtual_terminal::VirtualTerminal) { +fn drain_output(rx: &mut broadcast::Receiver>, vt: &mut virtual_terminal::VirtualTerminal) { loop { match rx.try_recv() { - Ok(output) => vt.process_output(&output.data), + Ok(output) => vt.process_output(&output), Err(broadcast::error::TryRecvError::Lagged(_)) => {} _ => break, } @@ -348,6 +429,7 @@ async fn accept_viewers( event_tx: mpsc::Sender, turn_rx: watch::Receiver, dims_rx: watch::Receiver<(u16, u16)>, + denied_tx: broadcast::Sender, ) { while let Some(incoming) = endpoint.accept().await { let conn = match incoming.await { @@ -362,8 +444,9 @@ async fn accept_viewers( let etx = event_tx.clone(); let trx = turn_rx.clone(); let drx = dims_rx.clone(); + let denied_rx = denied_tx.subscribe(); tokio::spawn(async move { - if let Err(e) = handle_viewer(conn, s, vc, etx, trx, drx).await { + if let Err(e) = handle_viewer(conn, s, vc, etx, trx, drx, denied_rx).await { info!("viewer disconnected: {e}"); } }); @@ -377,11 +460,15 @@ async fn handle_viewer( event_tx: mpsc::Sender, turn_rx: watch::Receiver, dims_rx: watch::Receiver<(u16, u16)>, + denied_rx: broadcast::Receiver, ) -> Result<()> { let conn_id = conn.remote_id().to_string(); viewer_count.fetch_add(1, Ordering::Relaxed); - let result = serve_viewer(&conn, &session, &conn_id, &event_tx, turn_rx, dims_rx).await; + let result = serve_viewer( + &conn, &session, &conn_id, &event_tx, turn_rx, dims_rx, denied_rx, + ) + .await; let _ = event_tx .send(TurnEvent::TurnReleased { @@ -406,6 +493,7 @@ async fn serve_viewer( event_tx: &mpsc::Sender, mut turn_rx: watch::Receiver, mut dims_rx: watch::Receiver<(u16, u16)>, + mut denied_rx: broadcast::Receiver, ) -> Result<()> { let (mut send, mut recv) = conn.accept_bi().await?; @@ -436,23 +524,18 @@ async fn serve_viewer( .await?; // Send replay - let replay = session.get_recent_output(64 * 1024, rows).await; - for chunk in &replay { - protocol::write_msg(&mut send, htag::OUTPUT, chunk.as_bytes()).await?; - } + let replay = session.get_replay(rows).await; + protocol::write_msg(&mut send, htag::OUTPUT, &replay).await?; let mut output_rx = session.subscribe_output().await?; - - // Track what this viewer's last-known turn state was, to send grant/revoke only on transitions let mut was_holder = false; - let mut was_denied = false; loop { tokio::select! { result = output_rx.recv() => { match result { Ok(output) => { - protocol::write_msg(&mut send, htag::OUTPUT, &output.data).await?; + protocol::write_msg(&mut send, htag::OUTPUT, &output).await?; } Err(broadcast::error::RecvError::Lagged(n)) => { warn!("viewer {} lagged by {n}", &conn_id[..8]); @@ -476,7 +559,6 @@ async fn serve_viewer( }).await; } vtag::INPUT => { - // Only forward if this viewer holds the turn let state = turn_rx.borrow().clone(); if state.holder.as_deref() == Some(conn_id) { let text = String::from_utf8_lossy(&payload); @@ -500,21 +582,18 @@ async fn serve_viewer( protocol::write_msg(&mut send, htag::DIMS_CHANGED, &payload).await?; } Ok(()) = turn_rx.changed() => { - let state = turn_rx.borrow().clone(); - let is_holder = state.holder.as_deref() == Some(conn_id); - let is_requester = state.requester.as_deref() == Some(conn_id); - + let is_holder = turn_rx.borrow().holder.as_deref() == Some(conn_id); if is_holder && !was_holder { protocol::write_msg(&mut send, htag::TURN_GRANTED, &[]).await?; was_holder = true; - was_denied = false; } else if !is_holder && was_holder { protocol::write_msg(&mut send, htag::TURN_REVOKED, &[]).await?; was_holder = false; - } else if !is_requester && !is_holder && !was_denied && !was_holder { - // Was requesting but now cleared (denied) + } + } + Ok(denied_id) = denied_rx.recv() => { + if denied_id == conn_id { protocol::write_msg(&mut send, htag::TURN_DENIED, &[]).await?; - was_denied = true; } } } @@ -533,3 +612,192 @@ fn resolve_command(command: Vec) -> (String, Vec) { (cmd, args) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn state() -> HostState { + HostState::new("host".into(), "sess".into()) + } + + #[test] + fn initial_state_is_empty() { + let s = state(); + assert_eq!(s.turn, TurnState::default()); + assert!(s.viewer_names.is_empty()); + assert_eq!(s.active_user(), "host"); + } + + #[test] + fn turn_requested_sets_requester_when_idle() { + let mut s = state(); + s.turn_requested("alice".into()); + assert_eq!(s.turn.requester.as_deref(), Some("alice")); + } + + #[test] + fn turn_requested_ignored_when_holder_exists() { + let mut s = state(); + s.turn.holder = Some("bob".into()); + s.turn_requested("alice".into()); + assert_eq!(s.turn.requester, None); + } + + #[test] + fn turn_requested_ignored_when_requester_already_queued() { + let mut s = state(); + s.turn.requester = Some("bob".into()); + s.turn_requested("alice".into()); + assert_eq!(s.turn.requester.as_deref(), Some("bob")); + } + + #[test] + fn f9_promotes_requester_to_holder() { + let mut s = state(); + s.viewer_connected("alice".into(), "Alice".into()); + s.turn_requested("alice".into()); + let effects = s.accept_or_revoke(); + assert_eq!(s.turn.holder.as_deref(), Some("alice")); + assert_eq!(s.turn.requester, None); + assert_eq!( + effects, + vec![Effect::BroadcastTurn, Effect::UpdateActiveUser] + ); + assert_eq!(s.active_user(), "Alice"); + } + + #[test] + fn f9_revokes_holder_when_no_requester() { + let mut s = state(); + s.viewer_connected("alice".into(), "Alice".into()); + s.turn.holder = Some("alice".into()); + let effects = s.accept_or_revoke(); + assert_eq!(s.turn.holder, None); + assert_eq!( + effects, + vec![Effect::BroadcastTurn, Effect::UpdateActiveUser] + ); + assert_eq!(s.active_user(), "host"); + } + + #[test] + fn f9_is_noop_when_idle() { + let mut s = state(); + let effects = s.accept_or_revoke(); + assert!(effects.is_empty()); + assert_eq!(s.turn, TurnState::default()); + } + + #[test] + fn f10_denies_requester_and_broadcasts_identity() { + let mut s = state(); + s.turn_requested("alice".into()); + let effects = s.deny(); + assert_eq!(s.turn.requester, None); + assert_eq!( + effects, + vec![ + Effect::BroadcastDenied("alice".into()), + Effect::BroadcastTurn, + ], + ); + } + + #[test] + fn f10_is_noop_when_no_requester() { + let mut s = state(); + let effects = s.deny(); + assert!(effects.is_empty()); + } + + #[test] + fn f10_does_not_affect_holder() { + let mut s = state(); + s.turn.holder = Some("alice".into()); + let effects = s.deny(); + assert!(effects.is_empty()); + assert_eq!(s.turn.holder.as_deref(), Some("alice")); + } + + #[test] + fn turn_released_clears_holder_and_broadcasts() { + let mut s = state(); + s.viewer_connected("alice".into(), "Alice".into()); + s.turn.holder = Some("alice".into()); + let effects = s.turn_released("alice"); + assert_eq!(s.turn.holder, None); + assert_eq!( + effects, + vec![Effect::BroadcastTurn, Effect::UpdateActiveUser] + ); + } + + #[test] + fn turn_released_by_requester_clears_silently() { + let mut s = state(); + s.turn.requester = Some("alice".into()); + let effects = s.turn_released("alice"); + // Requester change is not broadcast — the next turn_tx send will reflect it, + // but releasing the queue slot alone is not a turn-state change viewers care about. + assert_eq!(s.turn.requester, None); + assert!(effects.is_empty()); + } + + #[test] + fn turn_released_by_stranger_is_noop() { + let mut s = state(); + s.turn.holder = Some("alice".into()); + let effects = s.turn_released("eve"); + assert!(effects.is_empty()); + assert_eq!(s.turn.holder.as_deref(), Some("alice")); + } + + #[test] + fn turn_released_clears_both_slots_if_same_conn() { + let mut s = state(); + s.turn.holder = Some("alice".into()); + s.turn.requester = Some("alice".into()); + let effects = s.turn_released("alice"); + assert_eq!(s.turn, TurnState::default()); + assert_eq!( + effects, + vec![Effect::BroadcastTurn, Effect::UpdateActiveUser] + ); + } + + #[test] + fn viewer_disconnect_removes_name() { + let mut s = state(); + s.viewer_connected("alice".into(), "Alice".into()); + s.viewer_disconnected("alice"); + assert!(s.viewer_names.is_empty()); + } + + #[test] + fn active_user_falls_back_to_host_if_holder_name_missing() { + // If a viewer held the turn and then disconnected without the main + // loop seeing the release first, the name map might not have them. + let mut s = state(); + s.turn.holder = Some("ghost".into()); + assert_eq!(s.active_user(), "host"); + } + + #[test] + fn request_grant_revoke_round_trip() { + let mut s = state(); + s.viewer_connected("alice".into(), "Alice".into()); + + s.turn_requested("alice".into()); + assert_eq!(s.turn.requester.as_deref(), Some("alice")); + + let _ = s.accept_or_revoke(); + assert_eq!(s.turn.holder.as_deref(), Some("alice")); + assert_eq!(s.turn.requester, None); + assert_eq!(s.active_user(), "Alice"); + + let _ = s.accept_or_revoke(); + assert_eq!(s.turn.holder, None); + assert_eq!(s.active_user(), "host"); + } +} diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index d3b1b8e..342c03b 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -2,6 +2,7 @@ pub mod config; pub mod host; pub mod protocol; pub mod session; +pub mod session_dir; mod viewer; use anyhow::Result; @@ -120,53 +121,341 @@ pub fn draw_message(terminal: &mut ratatui::DefaultTerminal, msg: &str) -> anyho Ok(()) } +/// xterm modifier encoding: 1 + shift + alt*2 + ctrl*4 (range 1..=8, where 1 = no mods). +fn modifier_code(m: KeyModifiers) -> u8 { + let mut code = 1; + if m.contains(KeyModifiers::SHIFT) { + code += 1; + } + if m.contains(KeyModifiers::ALT) { + code += 2; + } + if m.contains(KeyModifiers::CONTROL) { + code += 4; + } + code +} + +/// CSI-encoded sequence for keys that support xterm modifier encoding +/// (arrows, Home/End, page/insert/delete, F1-F12). Returns `None` for +/// other keycodes so the caller can handle them (chars, ctrl-letters, etc). +fn csi_key(code: KeyCode, mod_code: u8) -> Option> { + if let Some(letter) = match code { + KeyCode::Up => Some('A'), + KeyCode::Down => Some('B'), + KeyCode::Right => Some('C'), + KeyCode::Left => Some('D'), + KeyCode::Home => Some('H'), + KeyCode::End => Some('F'), + _ => None, + } { + return Some(if mod_code == 1 { + format!("\x1b[{letter}").into_bytes() + } else { + format!("\x1b[1;{mod_code}{letter}").into_bytes() + }); + } + + if let Some(letter) = match code { + KeyCode::F(1) => Some('P'), + KeyCode::F(2) => Some('Q'), + KeyCode::F(3) => Some('R'), + KeyCode::F(4) => Some('S'), + _ => None, + } { + return Some(if mod_code == 1 { + format!("\x1bO{letter}").into_bytes() + } else { + format!("\x1b[1;{mod_code}{letter}").into_bytes() + }); + } + + let num: Option = match code { + KeyCode::Insert => Some(2), + KeyCode::Delete => Some(3), + KeyCode::PageUp => Some(5), + KeyCode::PageDown => Some(6), + KeyCode::F(5) => Some(15), + KeyCode::F(6) => Some(17), + KeyCode::F(7) => Some(18), + KeyCode::F(8) => Some(19), + KeyCode::F(9) => Some(20), + KeyCode::F(10) => Some(21), + KeyCode::F(11) => Some(23), + KeyCode::F(12) => Some(24), + _ => None, + }; + num.map(|n| { + if mod_code == 1 { + format!("\x1b[{n}~").into_bytes() + } else { + format!("\x1b[{n};{mod_code}~").into_bytes() + } + }) +} + /// Convert a crossterm KeyEvent to the byte sequence a PTY expects. pub fn key_to_bytes(key: &KeyEvent) -> Option> { + let mod_code = modifier_code(key.modifiers); + if let Some(bytes) = csi_key(key.code, mod_code) { + return Some(bytes); + } + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - match key.code { + let alt = key.modifiers.contains(KeyModifiers::ALT); + let mut bytes = match key.code { + KeyCode::Char(' ') if ctrl => vec![0x00], KeyCode::Char(c) if ctrl => match c { - 'a'..='z' => Some(vec![(c as u8) - b'a' + 1]), - '4'..='7' => Some(vec![(c as u8) - b'4' + 0x1C]), - _ => None, + 'a'..='z' => vec![(c as u8) - b'a' + 1], + '4'..='7' => vec![(c as u8) - b'4' + 0x1C], + _ => return None, }, KeyCode::Char(c) => { - let mut bytes = [0u8; 4]; - let s = c.encode_utf8(&mut bytes); - Some(s.as_bytes().to_vec()) + let mut buf = [0u8; 4]; + c.encode_utf8(&mut buf).as_bytes().to_vec() } - KeyCode::Enter => Some(b"\r".to_vec()), - KeyCode::Backspace => Some(vec![0x7f]), - KeyCode::Esc => Some(b"\x1b".to_vec()), - KeyCode::Tab => Some(b"\t".to_vec()), - KeyCode::BackTab => Some(b"\x1b[Z".to_vec()), - KeyCode::Up => Some(b"\x1b[A".to_vec()), - KeyCode::Down => Some(b"\x1b[B".to_vec()), - KeyCode::Right => Some(b"\x1b[C".to_vec()), - KeyCode::Left => Some(b"\x1b[D".to_vec()), - KeyCode::Home => Some(b"\x1b[H".to_vec()), - KeyCode::End => Some(b"\x1b[F".to_vec()), - KeyCode::PageUp => Some(b"\x1b[5~".to_vec()), - KeyCode::PageDown => Some(b"\x1b[6~".to_vec()), - KeyCode::Insert => Some(b"\x1b[2~".to_vec()), - KeyCode::Delete => Some(b"\x1b[3~".to_vec()), - KeyCode::F(n @ 1..=12) => { - let seq: &[u8] = match n { - 1 => b"\x1bOP", - 2 => b"\x1bOQ", - 3 => b"\x1bOR", - 4 => b"\x1bOS", - 5 => b"\x1b[15~", - 6 => b"\x1b[17~", - 7 => b"\x1b[18~", - 8 => b"\x1b[19~", - 9 => b"\x1b[20~", - 10 => b"\x1b[21~", - 11 => b"\x1b[23~", - 12 => b"\x1b[24~", - _ => return None, - }; - Some(seq.to_vec()) + KeyCode::Enter => b"\r".to_vec(), + KeyCode::Backspace => vec![0x7f], + KeyCode::Esc => b"\x1b".to_vec(), + KeyCode::Tab => b"\t".to_vec(), + KeyCode::BackTab => b"\x1b[Z".to_vec(), + _ => return None, + }; + if alt { + bytes.insert(0, 0x1b); + } + Some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::KeyEventKind; + + fn k(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { + KeyEvent::new_with_kind(code, modifiers, KeyEventKind::Press) + } + + #[test] + fn plain_ascii() { + assert_eq!( + key_to_bytes(&k(KeyCode::Char('a'), KeyModifiers::NONE)), + Some(b"a".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Char('Z'), KeyModifiers::SHIFT)), + Some(b"Z".to_vec()) + ); + } + + #[test] + fn ctrl_letters() { + for (c, expected) in [('a', 0x01), ('m', 0x0d), ('z', 0x1a)] { + assert_eq!( + key_to_bytes(&k(KeyCode::Char(c), KeyModifiers::CONTROL)), + Some(vec![expected]), + ); } - _ => None, + } + + #[test] + fn ctrl_space_is_nul() { + assert_eq!( + key_to_bytes(&k(KeyCode::Char(' '), KeyModifiers::CONTROL)), + Some(vec![0x00]), + ); + } + + #[test] + fn ctrl_digit_range() { + // 4/5/6/7 → 0x1C..0x1F (FS/GS/RS/US, the xterm mapping for C-\ C-] C-^ C-_). + assert_eq!( + key_to_bytes(&k(KeyCode::Char('4'), KeyModifiers::CONTROL)), + Some(vec![0x1C]) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Char('7'), KeyModifiers::CONTROL)), + Some(vec![0x1F]) + ); + } + + #[test] + fn alt_prepends_escape() { + assert_eq!( + key_to_bytes(&k(KeyCode::Char('a'), KeyModifiers::ALT)), + Some(vec![0x1b, b'a']), + ); + } + + #[test] + fn ctrl_alt_composes() { + assert_eq!( + key_to_bytes(&k( + KeyCode::Char('a'), + KeyModifiers::CONTROL | KeyModifiers::ALT + )), + Some(vec![0x1b, 0x01]), + ); + } + + #[test] + fn arrows() { + assert_eq!( + key_to_bytes(&k(KeyCode::Up, KeyModifiers::NONE)), + Some(b"\x1b[A".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Down, KeyModifiers::NONE)), + Some(b"\x1b[B".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Right, KeyModifiers::NONE)), + Some(b"\x1b[C".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Left, KeyModifiers::NONE)), + Some(b"\x1b[D".to_vec()) + ); + } + + #[test] + fn function_keys() { + assert_eq!( + key_to_bytes(&k(KeyCode::F(1), KeyModifiers::NONE)), + Some(b"\x1bOP".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::F(5), KeyModifiers::NONE)), + Some(b"\x1b[15~".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::F(12), KeyModifiers::NONE)), + Some(b"\x1b[24~".to_vec()) + ); + } + + #[test] + fn enter_backspace_esc_tab() { + assert_eq!( + key_to_bytes(&k(KeyCode::Enter, KeyModifiers::NONE)), + Some(b"\r".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Backspace, KeyModifiers::NONE)), + Some(vec![0x7f]) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Esc, KeyModifiers::NONE)), + Some(b"\x1b".to_vec()) + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Tab, KeyModifiers::NONE)), + Some(b"\t".to_vec()) + ); + } + + #[test] + fn modifier_code_table() { + assert_eq!(modifier_code(KeyModifiers::NONE), 1); + assert_eq!(modifier_code(KeyModifiers::SHIFT), 2); + assert_eq!(modifier_code(KeyModifiers::ALT), 3); + assert_eq!(modifier_code(KeyModifiers::SHIFT | KeyModifiers::ALT), 4); + assert_eq!(modifier_code(KeyModifiers::CONTROL), 5); + assert_eq!( + modifier_code(KeyModifiers::CONTROL | KeyModifiers::SHIFT), + 6 + ); + assert_eq!(modifier_code(KeyModifiers::CONTROL | KeyModifiers::ALT), 7); + assert_eq!( + modifier_code(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT), + 8 + ); + } + + #[test] + fn alt_arrow() { + assert_eq!( + key_to_bytes(&k(KeyCode::Up, KeyModifiers::ALT)), + Some(b"\x1b[1;3A".to_vec()), + ); + } + + #[test] + fn ctrl_arrow() { + assert_eq!( + key_to_bytes(&k(KeyCode::Right, KeyModifiers::CONTROL)), + Some(b"\x1b[1;5C".to_vec()), + ); + } + + #[test] + fn shift_arrow() { + assert_eq!( + key_to_bytes(&k(KeyCode::Left, KeyModifiers::SHIFT)), + Some(b"\x1b[1;2D".to_vec()), + ); + } + + #[test] + fn ctrl_alt_arrow() { + assert_eq!( + key_to_bytes(&k(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::ALT)), + Some(b"\x1b[1;7B".to_vec()), + ); + } + + #[test] + fn modified_home_end() { + assert_eq!( + key_to_bytes(&k(KeyCode::Home, KeyModifiers::ALT)), + Some(b"\x1b[1;3H".to_vec()), + ); + assert_eq!( + key_to_bytes(&k(KeyCode::End, KeyModifiers::SHIFT)), + Some(b"\x1b[1;2F".to_vec()), + ); + } + + #[test] + fn modified_f1_through_f4() { + assert_eq!( + key_to_bytes(&k(KeyCode::F(1), KeyModifiers::SHIFT)), + Some(b"\x1b[1;2P".to_vec()), + ); + assert_eq!( + key_to_bytes(&k(KeyCode::F(4), KeyModifiers::CONTROL)), + Some(b"\x1b[1;5S".to_vec()), + ); + } + + #[test] + fn shift_f5() { + assert_eq!( + key_to_bytes(&k(KeyCode::F(5), KeyModifiers::SHIFT)), + Some(b"\x1b[15;2~".to_vec()), + ); + } + + #[test] + fn ctrl_shift_f12() { + assert_eq!( + key_to_bytes(&k( + KeyCode::F(12), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )), + Some(b"\x1b[24;6~".to_vec()), + ); + } + + #[test] + fn modified_pageup_delete() { + assert_eq!( + key_to_bytes(&k(KeyCode::PageUp, KeyModifiers::CONTROL)), + Some(b"\x1b[5;5~".to_vec()), + ); + assert_eq!( + key_to_bytes(&k(KeyCode::Delete, KeyModifiers::ALT)), + Some(b"\x1b[3;3~".to_vec()), + ); } } diff --git a/packages/meld/src/protocol.rs b/packages/meld/src/protocol.rs index 7bfcabe..896af85 100644 --- a/packages/meld/src/protocol.rs +++ b/packages/meld/src/protocol.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use iroh::endpoint::{RecvStream, SendStream}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; /// Tags for viewer -> host messages. pub mod viewer { @@ -20,26 +20,69 @@ pub mod host { pub const DIMS_CHANGED: u8 = 0x05; } -/// Write a framed message: `[tag: u8][len: u16 BE][payload]`. -pub async fn write_msg(send: &mut SendStream, tag: u8, payload: &[u8]) -> Result<()> { - let len = payload.len() as u16; - let header = [tag, (len >> 8) as u8, len as u8]; - send.write_all(&header).await?; +/// Frame layout: `[tag: u8][len: u32 BE][payload]`. +pub async fn write_msg(w: &mut W, tag: u8, payload: &[u8]) -> Result<()> { + let len = payload.len() as u32; + let mut header = [0u8; 5]; + header[0] = tag; + header[1..5].copy_from_slice(&len.to_be_bytes()); + w.write_all(&header).await?; if !payload.is_empty() { - send.write_all(payload).await?; + w.write_all(payload).await?; } Ok(()) } -/// Read a framed message. Returns `(tag, payload)`. -pub async fn read_msg(recv: &mut RecvStream) -> Result<(u8, Vec)> { - let mut header = [0u8; 3]; - recv.read_exact(&mut header).await?; +pub async fn read_msg(r: &mut R) -> Result<(u8, Vec)> { + let mut header = [0u8; 5]; + r.read_exact(&mut header).await?; let tag = header[0]; - let len = u16::from_be_bytes([header[1], header[2]]) as usize; + let len = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize; let mut payload = vec![0u8; len]; if len > 0 { - recv.read_exact(&mut payload).await?; + r.read_exact(&mut payload).await?; } Ok((tag, payload)) } + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::duplex; + + async fn roundtrip(tag: u8, payload: Vec) { + let (mut a, mut b) = duplex(1 << 20); + let expected = payload.clone(); + let writer = tokio::spawn(async move { + write_msg(&mut a, tag, &payload).await.unwrap(); + }); + let (got_tag, got_payload) = read_msg(&mut b).await.unwrap(); + writer.await.unwrap(); + assert_eq!(got_tag, tag); + assert_eq!(got_payload, expected); + } + + #[tokio::test] + async fn empty_payload() { + roundtrip(0x42, vec![]).await; + } + + #[tokio::test] + async fn small_payload() { + roundtrip(0x01, b"hello world".to_vec()).await; + } + + #[tokio::test] + async fn payload_above_u16_limit() { + // Regression: would have truncated under the old u16 length header. + let payload = vec![0xABu8; 128 * 1024]; + roundtrip(0x01, payload).await; + } + + #[tokio::test] + async fn preserves_all_tag_values() { + for tag in [0u8, 1, 127, 128, 255] { + roundtrip(tag, vec![tag; 7]).await; + } + } +} diff --git a/packages/meld/src/session.rs b/packages/meld/src/session.rs index 165af0d..ad3e1ea 100644 --- a/packages/meld/src/session.rs +++ b/packages/meld/src/session.rs @@ -5,13 +5,6 @@ use virtual_terminal::{ClientType, VirtualTerminal}; const MAX_DELTA_BYTES: usize = 1024 * 1024; -/// PTY output enriched with cursor position from the VirtualTerminal. -#[derive(Debug, Clone)] -pub struct Output { - pub data: Vec, - pub cursor: (u16, u16), -} - enum Command { WriteInput { text: String, @@ -32,13 +25,12 @@ enum Command { id: String, respond_to: oneshot::Sender>, }, - GetRecentOutput { - max_bytes: usize, + GetReplay { client_rows: u16, - respond_to: oneshot::Sender>, + respond_to: oneshot::Sender>, }, SubscribeOutput { - respond_to: oneshot::Sender>, + respond_to: oneshot::Sender>>, }, GetPid { respond_to: oneshot::Sender>, @@ -64,18 +56,19 @@ impl Session { rows: u16, cols: u16, scrollback_lines: usize, + session_id: &str, ) -> Result { let config = PtyConfig { command: command.to_string(), args: args.to_vec(), working_dir: Some(working_dir.to_string()), - env: Vec::new(), + env: vec![("MELD_SESSION_ID".into(), session_id.to_string())], rows, cols, }; let pty = pty_manager::pty::PtyActor::spawn(config)?; let pty_output_rx = pty.subscribe(); - let (output_tx, _) = broadcast::channel::(64); + let (output_tx, _) = broadcast::channel::>(64); let vt = VirtualTerminal::new(rows, cols, MAX_DELTA_BYTES, scrollback_lines); let (cmd_tx, cmd_rx) = mpsc::channel(32); let (dims_tx, dims_rx) = watch::channel((rows, cols)); @@ -106,7 +99,7 @@ impl Session { Ok(rx.await??) } - pub async fn subscribe_output(&self) -> Result> { + pub async fn subscribe_output(&self) -> Result>> { let (tx, rx) = oneshot::channel(); self.tx .send(Command::SubscribeOutput { respond_to: tx }) @@ -160,12 +153,11 @@ impl Session { Ok(()) } - pub async fn get_recent_output(&self, max_bytes: usize, client_rows: u16) -> Vec { + pub async fn get_replay(&self, client_rows: u16) -> Vec { let (tx, rx) = oneshot::channel(); let _ = self .tx - .send(Command::GetRecentOutput { - max_bytes, + .send(Command::GetReplay { client_rows, respond_to: tx, }) @@ -205,26 +197,38 @@ async fn actor_loop( pty: PtyHandle, mut pty_output_rx: broadcast::Receiver, mut vt: VirtualTerminal, - output_tx: broadcast::Sender, + output_tx: broadcast::Sender>, mut cmd_rx: mpsc::Receiver, dims_tx: watch::Sender<(u16, u16)>, ) { + // pty_manager's PtyHandle holds an output_tx clone, so pty_output_rx never + // closes on its own when the child exits — only the reader thread's clone + // drops. And the pty actor only re-checks child exit after processing a + // message, and replies with cached state *before* that check. So the first + // tick after exit still reports running=true; detection needs ~2 ticks — + // keep the interval tight so the exit latency stays imperceptible. + let mut exit_check = tokio::time::interval(std::time::Duration::from_millis(100)); + exit_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { tokio::select! { result = pty_output_rx.recv() => { match result { Ok(pty_output) => { vt.process_output(&pty_output.data); - let cursor = vt.cursor_position(); - let _ = output_tx.send(Output { - data: pty_output.data, - cursor, - }); + let _ = output_tx.send(pty_output.data); } Err(broadcast::error::RecvError::Lagged(_)) => {} Err(broadcast::error::RecvError::Closed) => break, } } + _ = exit_check.tick() => { + match pty.state().await { + Ok(state) if !state.running => break, + Err(_) => break, + _ => {} + } + } cmd = cmd_rx.recv() => { let Some(cmd) = cmd else { break }; match cmd { @@ -245,19 +249,8 @@ async fn actor_loop( let result = vt.remove_client(&id); let _ = respond_to.send(result); } - Command::GetRecentOutput { max_bytes, client_rows, respond_to } => { - let replay = vt.replay(client_rows); - let text = String::from_utf8_lossy(&replay); - let trimmed = if text.len() > max_bytes { - let mut end = max_bytes; - while end > 0 && !text.is_char_boundary(end) { - end -= 1; - } - &text[..end] - } else { - &text - }; - let _ = respond_to.send(vec![trimmed.to_string()]); + Command::GetReplay { client_rows, respond_to } => { + let _ = respond_to.send(vt.replay(client_rows)); } Command::SubscribeOutput { respond_to } => { let _ = respond_to.send(output_tx.subscribe()); diff --git a/packages/meld/src/session_dir.rs b/packages/meld/src/session_dir.rs new file mode 100644 index 0000000..f0d593b --- /dev/null +++ b/packages/meld/src/session_dir.rs @@ -0,0 +1,32 @@ +//! Per-session scratch directory under `~/.meld/sessions//`. +//! +//! The session id is also exported to the PTY child as `MELD_SESSION_ID`, so +//! subprocesses can plug in and attribute actions to whoever currently holds +//! the edit turn by reading `active_user`. + +use std::path::PathBuf; + +fn dir(session_id: &str) -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".into()); + PathBuf::from(home) + .join(".meld") + .join("sessions") + .join(session_id) +} + +/// Atomically publish the name of the user currently driving the session. +pub fn write_active_user(session_id: &str, name: &str) { + let dir = dir(session_id); + if std::fs::create_dir_all(&dir).is_err() { + return; + } + let path = dir.join("active_user"); + let tmp = dir.join("active_user.tmp"); + if std::fs::write(&tmp, name).is_ok() { + let _ = std::fs::rename(&tmp, &path); + } +} + +pub fn cleanup_session(session_id: &str) { + let _ = std::fs::remove_dir_all(dir(session_id)); +} diff --git a/packages/meld/src/viewer.rs b/packages/meld/src/viewer.rs index d0051b7..d549c1e 100644 --- a/packages/meld/src/viewer.rs +++ b/packages/meld/src/viewer.rs @@ -166,7 +166,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { match mode { Mode::ReadOnly => match key.code { KeyCode::Char('q') | KeyCode::Char('Q') => break, - KeyCode::Char('e') | KeyCode::Char('E') if got_output => { + KeyCode::F(9) if got_output => { protocol::write_msg(&mut send, vtag::REQUEST_TURN, &[]).await?; mode = Mode::Requesting; } @@ -266,7 +266,7 @@ fn build_status( String::new() }; let msg = match mode { - Mode::ReadOnly => "viewing [readonly] e to request edit · q to exit", + Mode::ReadOnly => "viewing [readonly] F9 to request edit · q to exit", Mode::Requesting => "requesting edit access... F9 to cancel", Mode::Editing => "editing · F9 to release", }; From 0c61f05c3239ed64f553e92f877bce9a75c9d130 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 14:24:24 -0400 Subject: [PATCH 13/18] docs(meld): add readme and update Cargo.toml --- Cargo.lock | 83 +++++++++++++++++++++++++--------------- packages/meld/Cargo.toml | 10 ++++- packages/meld/README.md | 82 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 142 insertions(+), 33 deletions(-) create mode 100644 packages/meld/README.md diff --git a/Cargo.lock b/Cargo.lock index 8552f03..d7f5bc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3782,26 +3782,6 @@ dependencies = [ "digest 0.10.7", ] -[[package]] -name = "meld" -version = "0.1.0" -dependencies = [ - "anyhow", - "arboard", - "clap", - "iroh", - "iroh-tickets", - "pty_manager", - "ratatui", - "serde", - "tokio", - "toml 0.8.2", - "tracing", - "tracing-subscriber", - "uuid", - "virtual_terminal", -] - [[package]] name = "memchr" version = "2.8.0" @@ -3848,6 +3828,26 @@ dependencies = [ "unicase", ] +[[package]] +name = "mindmeld" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "clap", + "iroh", + "iroh-tickets", + "pty_manager", + "ratatui", + "serde", + "tokio", + "toml 1.1.2+spec-1.1.0", + "tracing", + "tracing-subscriber", + "uuid", + "virtual_terminal", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -6159,9 +6159,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -7692,13 +7692,28 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.0.4", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow 0.7.14", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.13.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.1", +] + [[package]] name = "toml_datetime" version = "0.6.3" @@ -7719,9 +7734,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -7757,25 +7772,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 0.7.14", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 0.7.14", + "winnow 1.0.1", ] [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "toolpath" @@ -9479,6 +9494,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" + [[package]] name = "winreg" version = "0.10.1" diff --git a/packages/meld/Cargo.toml b/packages/meld/Cargo.toml index 72ed687..d3e932f 100644 --- a/packages/meld/Cargo.toml +++ b/packages/meld/Cargo.toml @@ -1,7 +1,13 @@ [package] -name = "meld" +name = "mindmeld" version = "0.1.0" edition = "2024" +description = "Peer-to-peer terminal sharing over iroh. Host a shell, share a ticket, pair-program with turn-based edit control." +license = "Apache-2.0" +repository = "https://github.com/empathic/workshop" +readme = "README.md" +keywords = ["terminal", "iroh", "p2p", "pair-programming", "tmux"] +categories = ["command-line-utilities", "network-programming", "development-tools"] [[bin]] name = "meld" @@ -15,7 +21,7 @@ iroh-tickets = "0.4" ratatui = "0.30" arboard = { version = "3", default-features = false } serde = { workspace = true } -toml = "0.8" +toml = "1" anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } tokio = { workspace = true, features = ["full"] } diff --git a/packages/meld/README.md b/packages/meld/README.md new file mode 100644 index 0000000..a487ad4 --- /dev/null +++ b/packages/meld/README.md @@ -0,0 +1,82 @@ +# mindmeld + +Peer-to-peer terminal sharing over [iroh](https://iroh.computer). One person hosts a shell; others connect read-only and can request edit access. No relay servers to run, no accounts, no ports to open — connections are established directly between peers via iroh's QUIC transport. + +## Install + +```sh +cargo install mindmeld +``` + +This installs the `meld` binary. + +## Usage + +**Host a session** (shares your `$SHELL` by default; runs the given command otherwise): + +```sh +meld host # shares $SHELL +meld host claude # shares a specific command +``` + +On startup, the viewer command is copied to your clipboard: + +``` +meld view +``` + +Send that to whoever should join. + +**Join a session:** + +```sh +meld view +``` + +Viewers start read-only. + +## Keybindings + +### Host + +| Key | Action | +| -------------- | ----------------------------------------------- | +| F9 | Accept a pending edit request / revoke the turn | +| F10 | Deny a pending edit request | +| Shift+PageUp | Scroll back | +| Shift+PageDown | Scroll forward | + +While no viewer holds the turn, the host types into the shared shell normally. + +### Viewer + +| Key | Action | +| -------- | ------------------------------------------------ | +| q | Quit | +| F9 | Request edit access / cancel request / release | +| PageUp | Scroll back | +| PageDown | Scroll forward | + +Once the host grants the turn, the viewer's keystrokes flow into the shared shell. Host F9 revokes. + +## Config + +`~/.meld/config.toml` stores your display name, prompted for on first run: + +```toml +name = "alice" +``` + +## Subprocess integration + +Meld exports two hooks so subprocesses inside the shared shell can attribute actions to the user currently driving the session. + +**`$MELD_SESSION_ID`** is set in the child environment to a per-session UUID. Presence of this variable tells a subprocess it's running inside a meld-shared shell. + +**`~/.meld/sessions/$MELD_SESSION_ID/active_user`** is a plain-text file holding the display name of whoever currently holds the edit turn (the host, or the viewer who was granted by F9). It's rewritten atomically on every turn change and deleted when the session ends. + +## How it works + +The host spawns a PTY and binds an iroh endpoint with the `meld/term/0` ALPN. Viewers dial the host's ticket, open a bidirectional QUIC stream, and receive a keyframe replay of the current screen followed by a live stream of PTY output. A single viewer at a time may hold the edit turn; their keystrokes are forwarded to the host's PTY. The PTY is resized to the smallest connected viewport so everyone sees the same layout. + +Wire protocol: `[tag: u8][len: u32 BE][payload]`. See `src/protocol.rs`. From e2b4cbb13558db56c21f0e35a95eae930bd0d234 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 14:33:30 -0400 Subject: [PATCH 14/18] refactor(meld): rename view command to join --- packages/meld/README.md | 12 ++---------- packages/meld/src/host.rs | 2 +- packages/meld/src/main.rs | 6 +++--- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/packages/meld/README.md b/packages/meld/README.md index a487ad4..024df57 100644 --- a/packages/meld/README.md +++ b/packages/meld/README.md @@ -19,22 +19,14 @@ meld host # shares $SHELL meld host claude # shares a specific command ``` -On startup, the viewer command is copied to your clipboard: +On startup, the join command is copied to your clipboard: ``` -meld view +meld join ``` Send that to whoever should join. -**Join a session:** - -```sh -meld view -``` - -Viewers start read-only. - ## Keybindings ### Host diff --git a/packages/meld/src/host.rs b/packages/meld/src/host.rs index b0f2c1b..02b8fc5 100644 --- a/packages/meld/src/host.rs +++ b/packages/meld/src/host.rs @@ -176,7 +176,7 @@ pub async fn run(command: Vec) -> Result<()> { endpoint.online().await; let ticket = EndpointTicket::new(endpoint.addr()); - let view_cmd = format!("meld view {ticket}"); + let view_cmd = format!("meld join {ticket}"); let copied = arboard::Clipboard::new() .and_then(|mut cb| cb.set_text(&view_cmd)) .is_ok(); diff --git a/packages/meld/src/main.rs b/packages/meld/src/main.rs index 342c03b..4441e62 100644 --- a/packages/meld/src/main.rs +++ b/packages/meld/src/main.rs @@ -27,8 +27,8 @@ enum Command { #[arg(trailing_var_arg = true)] command: Vec, }, - /// View a shared terminal session (readonly) - View { + /// Join a shared terminal session (readonly by default) + Join { /// iroh ticket from the host ticket: String, }, @@ -53,7 +53,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Command::Host { command } => host::run(command).await, - Command::View { ticket } => viewer::run(&ticket).await, + Command::Join { ticket } => viewer::run(&ticket).await, } } From be326b400c4773335f23ee38a48e37eddcef3bb9 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 14:35:15 -0400 Subject: [PATCH 15/18] chore(meld): rename meld dir to mindmeld --- packages/{meld => mindmeld}/Cargo.toml | 0 packages/{meld => mindmeld}/README.md | 0 packages/{meld => mindmeld}/src/config.rs | 0 packages/{meld => mindmeld}/src/host.rs | 0 packages/{meld => mindmeld}/src/main.rs | 0 packages/{meld => mindmeld}/src/protocol.rs | 0 packages/{meld => mindmeld}/src/session.rs | 0 packages/{meld => mindmeld}/src/session_dir.rs | 0 packages/{meld => mindmeld}/src/viewer.rs | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename packages/{meld => mindmeld}/Cargo.toml (100%) rename packages/{meld => mindmeld}/README.md (100%) rename packages/{meld => mindmeld}/src/config.rs (100%) rename packages/{meld => mindmeld}/src/host.rs (100%) rename packages/{meld => mindmeld}/src/main.rs (100%) rename packages/{meld => mindmeld}/src/protocol.rs (100%) rename packages/{meld => mindmeld}/src/session.rs (100%) rename packages/{meld => mindmeld}/src/session_dir.rs (100%) rename packages/{meld => mindmeld}/src/viewer.rs (100%) diff --git a/packages/meld/Cargo.toml b/packages/mindmeld/Cargo.toml similarity index 100% rename from packages/meld/Cargo.toml rename to packages/mindmeld/Cargo.toml diff --git a/packages/meld/README.md b/packages/mindmeld/README.md similarity index 100% rename from packages/meld/README.md rename to packages/mindmeld/README.md diff --git a/packages/meld/src/config.rs b/packages/mindmeld/src/config.rs similarity index 100% rename from packages/meld/src/config.rs rename to packages/mindmeld/src/config.rs diff --git a/packages/meld/src/host.rs b/packages/mindmeld/src/host.rs similarity index 100% rename from packages/meld/src/host.rs rename to packages/mindmeld/src/host.rs diff --git a/packages/meld/src/main.rs b/packages/mindmeld/src/main.rs similarity index 100% rename from packages/meld/src/main.rs rename to packages/mindmeld/src/main.rs diff --git a/packages/meld/src/protocol.rs b/packages/mindmeld/src/protocol.rs similarity index 100% rename from packages/meld/src/protocol.rs rename to packages/mindmeld/src/protocol.rs diff --git a/packages/meld/src/session.rs b/packages/mindmeld/src/session.rs similarity index 100% rename from packages/meld/src/session.rs rename to packages/mindmeld/src/session.rs diff --git a/packages/meld/src/session_dir.rs b/packages/mindmeld/src/session_dir.rs similarity index 100% rename from packages/meld/src/session_dir.rs rename to packages/mindmeld/src/session_dir.rs diff --git a/packages/meld/src/viewer.rs b/packages/mindmeld/src/viewer.rs similarity index 100% rename from packages/meld/src/viewer.rs rename to packages/mindmeld/src/viewer.rs From 212ac4493dfd03dabfa4285fd43de6c78ab95bb3 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 14:38:22 -0400 Subject: [PATCH 16/18] chore(meld): rename in workspace --- Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f473405..cd8e3fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ "packages/pty_manager", "packages/tty_wrapper", "packages/virtual_terminal", - "packages/meld", + "packages/mindmeld", ] # workshop_ui/crate requires WORKSHOP_UI_PATH env var pointing to SvelteKit build output. # workshop_desktop requires Tauri system dependencies (WebKit, etc.). @@ -19,7 +19,7 @@ default-members = [ "packages/pty_manager", "packages/tty_wrapper", "packages/virtual_terminal", - "packages/meld", + "packages/mindmeld", ] resolver = "2" From 282ec6149f745160133a14c4a8b8e6034b7a1d44 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 15:32:24 -0400 Subject: [PATCH 17/18] fix(meld): denial state machine improvements --- packages/mindmeld/src/host.rs | 43 ++++++++++++++++++++++----------- packages/mindmeld/src/main.rs | 17 ++++++++++--- packages/mindmeld/src/viewer.rs | 21 +++++++++++++++- 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/mindmeld/src/host.rs b/packages/mindmeld/src/host.rs index 02b8fc5..603d923 100644 --- a/packages/mindmeld/src/host.rs +++ b/packages/mindmeld/src/host.rs @@ -90,10 +90,13 @@ impl HostState { self.viewer_names.remove(conn_id); } - /// A viewer requests the turn. Ignored if anyone already holds or is queued. - fn turn_requested(&mut self, conn_id: String) { + /// A viewer requests the turn. Denied if anyone already holds or is queued. + fn turn_requested(&mut self, conn_id: String) -> Vec { if self.turn.holder.is_none() && self.turn.requester.is_none() { self.turn.requester = Some(conn_id); + vec![Effect::BroadcastTurn] + } else { + vec![Effect::BroadcastDenied(conn_id)] } } @@ -353,7 +356,8 @@ pub async fn run(command: Vec) -> Result<()> { state.viewer_disconnected(&conn_id); } TurnEvent::TurnRequested { conn_id } => { - state.turn_requested(conn_id); + let effects = state.turn_requested(conn_id); + apply_effects(&state, effects, &turn_tx, &denied_tx); } TurnEvent::TurnReleased { conn_id } => { let effects = state.turn_released(&conn_id); @@ -395,12 +399,20 @@ fn build_status( .cloned() .unwrap_or_else(|| conn_id[..8.min(conn_id.len())].to_string()) }; - let msg = if let Some(ref id) = state.turn.requester { - format!( - "{} requesting edit · F9 accept · F10 deny", - display_name(id) - ) - } else if let Some(ref id) = state.turn.holder { + if let Some(ref id) = state.turn.requester { + return Line::from(vec![ + crate::meld_prefix(), + crate::fg( + format!( + "{} requesting edit · F9 accept · F10 deny", + display_name(id) + ), + Color::White, + ), + crate::dim(dims_note.clone()), + ]); + } + let msg = if let Some(ref id) = state.turn.holder { format!("{} editing · F9 revoke", display_name(id)) } else { format!( @@ -632,24 +644,27 @@ mod tests { #[test] fn turn_requested_sets_requester_when_idle() { let mut s = state(); - s.turn_requested("alice".into()); + let effects = s.turn_requested("alice".into()); assert_eq!(s.turn.requester.as_deref(), Some("alice")); + assert_eq!(effects, vec![Effect::BroadcastTurn]); } #[test] - fn turn_requested_ignored_when_holder_exists() { + fn turn_requested_denied_when_holder_exists() { let mut s = state(); s.turn.holder = Some("bob".into()); - s.turn_requested("alice".into()); + let effects = s.turn_requested("alice".into()); assert_eq!(s.turn.requester, None); + assert_eq!(effects, vec![Effect::BroadcastDenied("alice".into())]); } #[test] - fn turn_requested_ignored_when_requester_already_queued() { + fn turn_requested_denied_when_requester_already_queued() { let mut s = state(); s.turn.requester = Some("bob".into()); - s.turn_requested("alice".into()); + let effects = s.turn_requested("alice".into()); assert_eq!(s.turn.requester.as_deref(), Some("bob")); + assert_eq!(effects, vec![Effect::BroadcastDenied("alice".into())]); } #[test] diff --git a/packages/mindmeld/src/main.rs b/packages/mindmeld/src/main.rs index 4441e62..5d5c285 100644 --- a/packages/mindmeld/src/main.rs +++ b/packages/mindmeld/src/main.rs @@ -102,11 +102,20 @@ fn vt100_color_to_ratatui(color: vt100::Color) -> Color { } } +pub fn meld_prefix() -> Span<'static> { + Span::styled("(meld) ", Style::default().fg(Color::Magenta)) +} + +pub fn dim(text: impl Into) -> Span<'static> { + Span::styled(text.into(), Style::default().dim()) +} + +pub fn fg(text: impl Into, color: Color) -> Span<'static> { + Span::styled(text.into(), Style::default().fg(color)) +} + pub fn status_line(rest: &str) -> Line<'static> { - Line::from(vec![ - Span::styled("(meld) ", Style::default().fg(Color::Magenta)), - Span::styled(rest.to_string(), Style::default().dim()), - ]) + Line::from(vec![meld_prefix(), dim(rest)]) } pub fn draw_message(terminal: &mut ratatui::DefaultTerminal, msg: &str) -> anyhow::Result<()> { diff --git a/packages/mindmeld/src/viewer.rs b/packages/mindmeld/src/viewer.rs index d549c1e..bfbcf23 100644 --- a/packages/mindmeld/src/viewer.rs +++ b/packages/mindmeld/src/viewer.rs @@ -96,6 +96,7 @@ pub async fn run(ticket_str: &str) -> Result<()> { let mut scroll_offset: usize = 0; let mut mode = Mode::ReadOnly; let mut got_output = false; + let mut denied_until: Option = None; loop { if scroll_offset > 0 { @@ -103,8 +104,14 @@ pub async fn run(ticket_str: &str) -> Result<()> { scroll_offset = vt.screen().scrollback(); } + let show_denied = denied_until.is_some_and(|t| tokio::time::Instant::now() < t); let status = if !got_output { crate::status_line("connecting to host... q to quit") + } else if show_denied { + Line::from(vec![ + crate::meld_prefix(), + crate::fg("edit request denied", Color::White), + ]) } else { build_status(mode, scroll_offset, pty_rows, cols, eff_rows, eff_cols) }; @@ -129,7 +136,13 @@ pub async fn run(ticket_str: &str) -> Result<()> { vt.screen_mut().set_scrollback(0); } + let banner_deadline = denied_until + .unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(86400)); + tokio::select! { + _ = tokio::time::sleep_until(banner_deadline), if denied_until.is_some() => { + denied_until = None; + } result = protocol::read_msg(&mut recv) => { let (tag, payload) = match result { Ok(msg) => msg, @@ -144,8 +157,14 @@ pub async fn run(ticket_str: &str) -> Result<()> { htag::TURN_GRANTED => { mode = Mode::Editing; } - htag::TURN_REVOKED | htag::TURN_DENIED => { + htag::TURN_REVOKED => { + mode = Mode::ReadOnly; + } + htag::TURN_DENIED => { mode = Mode::ReadOnly; + denied_until = Some( + tokio::time::Instant::now() + std::time::Duration::from_secs(3), + ); } htag::DIMS_CHANGED if payload.len() == 4 => { let new_r = u16::from_be_bytes([payload[0], payload[1]]); From 59333df50b7ae4c2c7c300c325e9168038b7949e Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 16 Apr 2026 15:39:13 -0400 Subject: [PATCH 18/18] chore: integrate meld into bazel and fix clippy lints --- MODULE.bazel | 13 +++++++++++++ packages/mindmeld/BUILD.bazel | 30 ++++++++++++++++++++++++++++++ packages/mindmeld/src/config.rs | 7 +++---- packages/mindmeld/src/host.rs | 12 ++++++------ 4 files changed, 52 insertions(+), 10 deletions(-) create mode 100644 packages/mindmeld/BUILD.bazel diff --git a/MODULE.bazel b/MODULE.bazel index 4f50050..8b3433b 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -278,6 +278,19 @@ crate_index.spec( package = "ratatui", version = "0.30", ) +crate_index.spec( + package = "iroh", + version = "0.97", +) +crate_index.spec( + package = "iroh-tickets", + version = "0.4", +) +crate_index.spec( + default_features = False, + package = "arboard", + version = "3", +) # File / directory utilities crate_index.spec( diff --git a/packages/mindmeld/BUILD.bazel b/packages/mindmeld/BUILD.bazel new file mode 100644 index 0000000..b039b78 --- /dev/null +++ b/packages/mindmeld/BUILD.bazel @@ -0,0 +1,30 @@ +load("@rules_rust//rust:defs.bzl", "rust_binary", "rust_test") + +package(default_visibility = ["//visibility:public"]) + +rust_binary( + name = "meld", + srcs = glob(["src/**/*.rs"]), + edition = "2024", + deps = [ + "//packages/pty_manager", + "//packages/virtual_terminal", + "@crate_index//:anyhow", + "@crate_index//:arboard", + "@crate_index//:clap", + "@crate_index//:iroh", + "@crate_index//:iroh-tickets", + "@crate_index//:ratatui", + "@crate_index//:serde", + "@crate_index//:tokio", + "@crate_index//:toml", + "@crate_index//:tracing", + "@crate_index//:tracing-subscriber", + "@crate_index//:uuid", + ], +) + +rust_test( + name = "meld_test", + crate = ":meld", +) diff --git a/packages/mindmeld/src/config.rs b/packages/mindmeld/src/config.rs index fd8d5b8..5dfaddf 100644 --- a/packages/mindmeld/src/config.rs +++ b/packages/mindmeld/src/config.rs @@ -60,8 +60,8 @@ pub fn ensure_name(terminal: &mut ratatui::DefaultTerminal) -> Result { frame.set_cursor_position((cursor_x, status_area.y)); })?; - match event::read()? { - Event::Key(key) => match key.code { + if let Event::Key(key) = event::read()? { + match key.code { KeyCode::Enter if !input.is_empty() => { let config = Config { name: input.clone(), @@ -80,8 +80,7 @@ pub fn ensure_name(terminal: &mut ratatui::DefaultTerminal) -> Result { KeyCode::Left if cursor > 0 => cursor -= 1, KeyCode::Right if cursor < input.len() => cursor += 1, _ => {} - }, - _ => {} + } } } } diff --git a/packages/mindmeld/src/host.rs b/packages/mindmeld/src/host.rs index 603d923..e983fb6 100644 --- a/packages/mindmeld/src/host.rs +++ b/packages/mindmeld/src/host.rs @@ -314,12 +314,12 @@ pub async fn run(command: Vec) -> Result<()> { } else if key.code == KeyCode::F(10) { let effects = state.deny(); apply_effects(&state, effects, &turn_tx, &denied_tx); - } else if state.turn.holder.is_none() { - if let Some(bytes) = crate::key_to_bytes(&key) { - let text = String::from_utf8_lossy(&bytes); - let _ = session.write_input(&text).await; - scroll_offset = 0; - } + } else if state.turn.holder.is_none() + && let Some(bytes) = crate::key_to_bytes(&key) + { + let text = String::from_utf8_lossy(&bytes); + let _ = session.write_input(&text).await; + scroll_offset = 0; } } Event::Mouse(mouse) => match mouse.kind {