diff --git a/Cargo.lock b/Cargo.lock index 12c34ba40..4a16e22ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,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.6", + "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 0.2.17", +] + +[[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" @@ -118,6 +153,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "ariadne" version = "0.6.0" @@ -134,6 +178,18 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "async-channel" version = "2.5.0" @@ -175,6 +231,17 @@ dependencies = [ "syn", ] +[[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 = "asynchronous-codec" version = "0.7.0" @@ -188,12 +255,33 @@ dependencies = [ "pin-project-lite", ] +[[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 = "auto_impl" version = "1.3.0" @@ -214,8 +302,7 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "automerge" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b78abcbba93428b9465b26cb2816a5b4654cce507f099a84a8c1b311cb3633" +source = "git+https://github.com/quarto-dev/automerge?rev=152d98cb3db771e8ecf8ac7465f2b79ed64cca07#152d98cb3db771e8ecf8ac7465f2b79ed64cca07" dependencies = [ "cfg-if", "flate2", @@ -365,12 +452,29 @@ dependencies = [ "syn", ] +[[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 = "base16ct" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.21.7" @@ -416,6 +520,20 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -434,6 +552,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "borsh" version = "1.6.1" @@ -512,6 +639,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfb" version = "0.7.3" @@ -531,9 +664,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -587,6 +720,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.6", + "inout", +] + [[package]] name = "clap" version = "4.6.1" @@ -744,6 +887,12 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -764,6 +913,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -955,6 +1114,21 @@ 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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1051,6 +1225,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1076,6 +1251,15 @@ dependencies = [ "syn", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1095,8 +1279,26 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "digest 0.11.2", + "fiat-crypto 0.3.0", + "rand_core 0.10.1", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -1141,9 +1343,29 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn", +] [[package]] name = "der" @@ -1152,7 +1374,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1196,8 +1429,15 @@ dependencies = [ "quote", "rustc_version", "syn", + "unicode-xid", ] +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + [[package]] name = "diff" version = "0.1.13" @@ -1249,6 +1489,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1260,6 +1512,17 @@ dependencies = [ "syn", ] +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "libc", + "once_cell", + "winapi", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1302,12 +1565,12 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest 0.10.7", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", ] [[package]] @@ -1316,8 +1579,19 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8 0.11.0", + "serdect", + "signature 3.0.0", ] [[package]] @@ -1326,14 +1600,30 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", "serde", "sha2 0.10.9", "subtle", "zeroize", ] +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", + "rand_core 0.10.1", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "ego-tree" version = "0.11.0" @@ -1352,15 +1642,15 @@ version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "base16ct", + "base16ct 0.2.0", "crypto-bigint", "digest 0.10.7", "ff", "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", "sec1", "subtle", @@ -1400,6 +1690,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5320ae4c3782150d900b79807611a59a99fc9a1d61d686faafc24b93fc8d7ca" +[[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", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1476,6 +1777,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "filetime" version = "0.2.29" @@ -1591,6 +1898,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.1", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1630,7 +1950,10 @@ 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", ] @@ -1689,6 +2012,21 @@ dependencies = [ "thread_local", ] +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + [[package]] name = "generic-array" version = "0.14.9" @@ -1743,11 +2081,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "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]] @@ -1780,6 +2130,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 = "grass" version = "0.13.4" @@ -1847,7 +2209,16 @@ dependencies = [ ] [[package]] -name = "hashbrown" +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.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" @@ -1880,6 +2251,11 @@ name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1915,6 +2291,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.5.0" @@ -1936,13 +2326,89 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hexane" version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f4ecba0bb4e14df997df7cab6d1b584c6432d8865cf2adfced814706d715a7b" +source = "git+https://github.com/quarto-dev/automerge?rev=152d98cb3db771e8ecf8ac7465f2b79ed64cca07#152d98cb3db771e8ecf8ac7465f2b79ed64cca07" dependencies = [ "leb128", "thiserror 2.0.18", ] +[[package]] +name = "hickory-net" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2295ed2f9c31e471e1428a8f88a3f0e1f4b27c15049592138d1eebe9c35b183" +dependencies = [ + "async-trait", + "bytes", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "h2", + "hickory-proto", + "http", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.1", + "rustls", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tokio-rustls", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bab31817bfb44672a252e97fe81cd0c18d1b2cf892108922f6818820df8c643" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni 0.22.4", + "once_cell", + "prefix-trie", + "rand 0.10.1", + "ring", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d58d28879ceecde6607729660c2667a081ccdc082e082675042793960f178c" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni 0.22.4", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.1", + "resolv-conf", + "rustls", + "smallvec", + "system-configuration", + "thiserror 2.0.18", + "tokio", + "tokio-rustls", + "tracing", +] + [[package]] name = "hkdf" version = "0.12.4" @@ -2224,6 +2690,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[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" @@ -2245,6 +2717,26 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "igd-next" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de7238d487a9aff61f81b5ab41c0a841532a115a398b5fa92a2fadd0885e2581" +dependencies = [ + "attohttpc", + "bytes", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "rand 0.10.1", + "tokio", + "url", + "xmltree", +] + [[package]] name = "imagesize" version = "0.13.0" @@ -2311,6 +2803,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 = "inquire" version = "0.9.4" @@ -2340,11 +2841,27 @@ dependencies = [ "tempfile", ] +[[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", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -2356,6 +2873,186 @@ dependencies = [ "serde", ] +[[package]] +name = "iroh" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72" +dependencies = [ + "backon", + "blake3", + "bytes", + "cfg_aliases", + "ctutils", + "data-encoding", + "derive_more", + "ed25519-dalek 3.0.0", + "futures-util", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "ipnet", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "iroh-relay", + "n0-error", + "n0-future", + "n0-watcher", + "netwatch", + "noq", + "noq-proto", + "noq-udp", + "papaya", + "pin-project", + "portable-atomic", + "portmapper", + "rand 0.10.1", + "reqwest 0.13.3", + "rustc-hash", + "rustls", + "rustls-pki-types", + "serde", + "smallvec", + "strum", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "iroh-base" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8" +dependencies = [ + "curve25519-dalek 5.0.0", + "data-encoding", + "data-encoding-macro", + "derive_more", + "ed25519-dalek 3.0.0", + "getrandom 0.4.2", + "n0-error", + "rand 0.10.1", + "serde", + "url", + "zeroize", +] + +[[package]] +name = "iroh-dns" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349" +dependencies = [ + "arc-swap", + "cfg_aliases", + "derive_more", + "hickory-resolver", + "iroh-base", + "n0-error", + "n0-future", + "ndk-context", + "portable-atomic", + "rand 0.10.1", + "rustls", + "simple-dns", + "strum", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "iroh-metrics" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291065721ad7c477b972e581bbc528df031dc8eb5e39fe1ff3300ae5dfb157ef" +dependencies = [ + "iroh-metrics-derive", + "itoa", + "n0-error", + "portable-atomic", + "ryu", + "serde", + "tracing", +] + +[[package]] +name = "iroh-metrics-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae5f0c4405d1fbc9fb16ff422ca40620e93dc36c30ecaba0c2aee3992b7bd48" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "iroh-relay" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193" +dependencies = [ + "blake3", + "bytes", + "cfg_aliases", + "data-encoding", + "derive_more", + "getrandom 0.4.2", + "hickory-resolver", + "http", + "http-body-util", + "hyper", + "hyper-util", + "iroh-base", + "iroh-dns", + "iroh-metrics", + "lru", + "n0-error", + "n0-future", + "noq", + "noq-proto", + "num_enum", + "pin-project", + "postcard", + "rand 0.10.1", + "reqwest 0.13.3", + "rustls", + "rustls-pki-types", + "serde", + "serde_bytes", + "strum", + "tokio", + "tokio-rustls", + "tokio-util", + "tokio-websockets", + "tracing", + "url", + "webpki-roots", + "ws_stream_wasm", +] + +[[package]] +name = "iroh-tickets" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53233419ca36bf521ed45683b7748366f9b233032891eefc2d70567a84ac54" +dependencies = [ + "data-encoding", + "derive_more", + "iroh-base", + "n0-error", + "postcard", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2402,6 +3099,22 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -2411,7 +3124,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys", + "jni-sys 0.4.1", "log", "simd_cesu8", "thiserror 2.0.18", @@ -2432,6 +3145,15 @@ dependencies = [ "syn", ] +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + [[package]] name = "jni-sys" version = "0.4.1" @@ -2478,7 +3200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ "base64 0.22.1", - "ed25519-dalek", + "ed25519-dalek 2.2.0", "getrandom 0.2.17", "hmac 0.12.1", "js-sys", @@ -2490,7 +3212,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "signature", + "signature 2.2.0", "simple_asn1", ] @@ -2544,7 +3266,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2613,6 +3335,28 @@ 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.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.0", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2649,6 +3393,12 @@ dependencies = [ "which", ] +[[package]] +name = "mac-addr" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" + [[package]] name = "mach2" version = "0.4.3" @@ -2791,6 +3541,76 @@ dependencies = [ "pkg-config", ] +[[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 = "n0-error" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c37e81176a83a77d2514528b91bdafc70ef88aab428f0e1b91aebb8d99888895" +dependencies = [ + "n0-error-macros", + "spez", +] + +[[package]] +name = "n0-error-macros" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2acd8b070213b0299282f884b4beba4e7b52d624fdcd504a3ad3665390c11e1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "web-time", +] + +[[package]] +name = "n0-watcher" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc618745ad0b7414b149d0517ad8b5573b2fb4d4e2717add3d2446ce1fdd826" +dependencies = [ + "derive_more", + "n0-error", + "n0-future", +] + [[package]] name = "native-tls" version = "0.2.18" @@ -2809,10 +3629,191 @@ dependencies = [ ] [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "netdev" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" +dependencies = [ + "block2", + "dispatch2", + "dlopen2", + "ipnet", + "jni 0.21.1", + "libc", + "mac-addr", + "ndk-context", + "netlink-packet-core", + "netlink-packet-route", + "netlink-sys", + "objc2", + "objc2-core-foundation", + "objc2-core-wlan", + "objc2-foundation", + "objc2-system-configuration", + "once_cell", + "plist", + "windows-sys 0.61.2", +] + +[[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.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" +dependencies = [ + "bitflags 2.11.1", + "libc", + "log", + "netlink-packet-core", +] + +[[package]] +name = "netlink-proto" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "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 = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" +dependencies = [ + "bytes", + "futures-util", + "libc", + "log", + "tokio", +] + +[[package]] +name = "netwatch" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d9cbe01741347ef750d743d6690603f5eed8341e679fb51c8e629337aa11976" +dependencies = [ + "atomic-waker", + "bytes", + "cfg_aliases", + "derive_more", + "ipnet", + "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", + "windows-result", + "wmi", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "noq" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e4bb6601fa543c110d8957813267d5a8d775a0f8fbaccf1f615d06ba9b10da" +dependencies = [ + "bytes", + "cfg_aliases", + "derive_more", + "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 = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa7b5ccd819a9c68a0d955e67a881032d09b1a17219b1f90b0997a0888e1a15" +dependencies = [ + "aes-gcm", + "bytes", + "derive_more", + "enum-assoc", + "getrandom 0.4.2", + "identity-hash", + "lru-slab", + "rand 0.10.1", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "sorted-index-buffer", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "noq-udp" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "02bba20e097a5a16cd0ad14ec882fae1e80a092a124e9422fc4dddd92e96a647" +dependencies = [ + "cfg_aliases", + "libc", + "socket2", + "tracing", + "windows-sys 0.61.2", +] [[package]] name = "notify" @@ -2924,6 +3925,118 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-wlan" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-security", + "objc2-security-foundation", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-security-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" +dependencies = [ + "objc2", + "objc2-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.11.1", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-security", +] + [[package]] name = "object" version = "0.37.3" @@ -2941,6 +4054,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -2954,6 +4071,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[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.5" @@ -3107,6 +4230,16 @@ dependencies = [ "yaml-rust2", ] +[[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" @@ -3167,6 +4300,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" @@ -3226,6 +4368,16 @@ dependencies = [ "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]] name = "phf" version = "0.11.3" @@ -3353,9 +4505,9 @@ 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]] @@ -3364,8 +4516,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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -3374,6 +4536,19 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml 0.41.0", + "serde", + "time", +] + [[package]] name = "polling" version = "3.11.0" @@ -3394,6 +4569,56 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +dependencies = [ + "serde", +] + +[[package]] +name = "portmapper" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3713e4977408279158444a18c1a01ac9bf2e7eaf1fbfd1a19ac9cd18d90721" +dependencies = [ + "base64 0.22.1", + "bytes", + "derive_more", + "hyper-util", + "igd-next", + "iroh-metrics", + "libc", + "n0-error", + "n0-future", + "netwatch", + "num_enum", + "rand 0.10.1", + "serde", + "smallvec", + "socket2", + "time", + "tokio", + "tokio-util", + "tower-layer", + "tracing", + "url", +] + [[package]] name = "postcard" version = "1.1.3" @@ -3403,9 +4628,22 @@ 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", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -3436,6 +4674,17 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3465,6 +4714,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3555,6 +4813,7 @@ dependencies = [ "quarto-hub-provider", "quarto-lsp", "quarto-mcp-launcher", + "quarto-p2p", "quarto-preview", "quarto-project-create", "quarto-publish", @@ -3944,6 +5203,24 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "quarto-p2p" +version = "0.14.0" +dependencies = [ + "axum", + "futures", + "iroh", + "iroh-tickets", + "postcard", + "rand 0.9.4", + "serde", + "subtle", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite 0.29.0", + "tracing", +] + [[package]] name = "quarto-pandoc-types" version = "0.0.0" @@ -3985,6 +5262,7 @@ dependencies = [ "quarto-core", "quarto-error-reporting", "quarto-hub", + "quarto-p2p", "quarto-pandoc-types", "quarto-source-map", "quarto-system-runtime", @@ -3999,6 +5277,7 @@ dependencies = [ "tokio", "tower 0.5.3", "tracing", + "url", ] [[package]] @@ -4171,7 +5450,7 @@ dependencies = [ "insta", "quarto-error-reporting", "quarto-source-map", - "quick-xml", + "quick-xml 0.39.3", "thiserror 2.0.18", ] @@ -4202,6 +5481,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4355,6 +5643,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -4558,15 +5855,23 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", + "tokio-util", "tower 0.5.3", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "wasm-streams", "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "rfc6979" version = "0.4.0" @@ -4603,10 +5908,10 @@ dependencies = [ "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", ] @@ -4743,7 +6048,7 @@ checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", - "jni", + "jni 0.22.4", "log", "once_cell", "rustls", @@ -4899,6 +6204,12 @@ dependencies = [ "syn", ] +[[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" @@ -4935,10 +6246,10 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "base16ct", - "der", + "base16ct 0.2.0", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "subtle", "zeroize", ] @@ -4966,6 +6277,16 @@ dependencies = [ "libc", ] +[[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.36.1" @@ -4991,6 +6312,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[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" @@ -5011,6 +6338,16 @@ dependencies = [ "serde", ] +[[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" @@ -5103,6 +6440,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct 1.0.0", + "serde", +] + [[package]] name = "servo_arc" version = "0.4.3" @@ -5227,6 +6574,15 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5264,6 +6620,15 @@ dependencies = [ "bstr", ] +[[package]] +name = "simple-dns" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a75cbde1bf934313596a004973e462f9a82caa814dcf1a5f507bdf51597eeb4" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "simple_asn1" version = "0.6.4" @@ -5317,11 +6682,37 @@ 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 = "spez" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87e960f4dca2788eeb86bbdde8dd246be8948790b7618d656e68f9b720a86e8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -5330,7 +6721,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.1", ] [[package]] @@ -5375,6 +6776,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5439,6 +6861,12 @@ dependencies = [ "libc", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + [[package]] name = "tar" version = "0.4.46" @@ -5642,6 +7070,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.27.0" @@ -5678,10 +7118,64 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "getrandom 0.4.2", + "http", + "httparse", + "rand 0.10.1", + "ring", + "rustls-pki-types", + "sha1_smol", + "simdutf8", + "tokio", + "tokio-rustls", + "tokio-util", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -6111,6 +7605,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[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.6", + "subtle", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6427,6 +7931,19 @@ dependencies = [ name = "wasm-printf-fmt" version = "0.14.0" +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.236.1" @@ -6740,6 +8257,12 @@ dependencies = [ "libc", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "win_uds" version = "0.2.2" @@ -6802,6 +8325,27 @@ dependencies = [ "wasmtime-internal-math", ] +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6815,6 +8359,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6843,6 +8398,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6872,6 +8437,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -6899,6 +8473,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -6932,6 +8521,21 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6944,6 +8548,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6956,6 +8566,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6980,6 +8596,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6992,6 +8614,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7004,6 +8632,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7016,6 +8650,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -7028,6 +8668,15 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -7122,12 +8771,46 @@ dependencies = [ "wasmparser 0.244.0", ] +[[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", + "windows-core", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[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 0.4.58 (registry+https://github.com/rust-lang/crates.io-index)", + "web-sys", +] + [[package]] name = "xattr" version = "1.6.1" @@ -7138,6 +8821,21 @@ dependencies = [ "rustix", ] +[[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 = "xtask" version = "0.1.0" @@ -7237,9 +8935,23 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zeromq" diff --git a/Cargo.toml b/Cargo.toml index 66acb0515..0391661f8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -174,6 +174,9 @@ path = "./crates/quarto-hub" [workspace.dependencies.quarto-navigation] path = "./crates/quarto-navigation" +[workspace.dependencies.quarto-p2p] +path = "./crates/quarto-p2p" + [workspace.dependencies.quarto-publish] path = "./crates/quarto-publish" @@ -278,6 +281,13 @@ verbose_bit_mask = "warn" verbose_file_reads = "warn" [patch.crates-io] +# automerge#1327 (MissingOps panic: fork_at's dependency traversal can +# schedule a dep twice in some DAG shapes; the duplicate change metadata +# makes the change collector return MissingOps and its unwrap panics — +# hit by quarto-hub's sync_document every-5s fork_at, aborting preview +# via samod's WithDocGuard). 0.10.0 tag + the #1366 fix cherry-picked +# (bd-4062e8tn). Remove once upstream merges #1366 and ships a release. +automerge = { git = "https://github.com/quarto-dev/automerge", rev = "152d98cb3db771e8ecf8ac7465f2b79ed64cca07" } lua-src = { path = "crates/lua-src-wasm" } # Neutralize tree-sitter-language's upstream wasm32 stdio/stdlib/string C # stubs so wasm-c-shim is the single source of truth for C stdlib symbols diff --git a/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md new file mode 100644 index 000000000..0df497de3 --- /dev/null +++ b/claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md @@ -0,0 +1,1443 @@ +--- +title: "q2 preview live share over iroh (`--share` / `--join`)" +date: 2026-08-03 +status: planned +branch: feature/preview-live-share (integration line; sub-tasks on braid/-) +braid: bd-yyoyvx91 +design-input: user design doc "Creating q2 preview with iroh" (2026-08-03 session) +verified: "file:line claims checked against ../iroh @ v1.0.3, ../iroh-tickets @ 1.0.0, ../samod, and this tree" +--- + +# q2 preview live share over iroh + +Goal: "VS Code Live Share, but built into `q2`." A host runs +`q2 preview --share` and gets a single join string; a guest runs +`q2 preview --join ` on another machine and gets a browser tab showing +the same live preview session — automerge sync, presence, and engine captures +included — over an end-to-end-encrypted iroh P2P connection (relay fallback +when hole-punching fails). + +## Explicit decision reversal + +The preview epic (`claude-notes/plans/2026-05-11-q2-preview-epic.md:578-580`) +declared "multi-user collaborative preview" out of scope ("real collaboration +belongs in `quarto hub`, not `quarto preview`"). This plan reverses that, per +the design doc: ephemeral, zero-setup collaboration is exactly the +niche `q2 preview` fills that a persistent hub does not. The epic's +never-implemented Phase E `--share ` idea (broadcast to a remote sync +server; an unnumbered bullet at :313-315 — there is no "E.3" label) is +superseded: our `--share` takes no value and hosts P2P; joining a hosted hub +is Phase 5 of this plan, from the other direction (`--join `). + +## Architecture decision: authenticated TCP-over-iroh tunnel + +**The joiner is a dumb, token-authenticated TCP proxy. The host's existing +preview HTTP server serves everything — SPA assets, `/health`, `/ws`, +`/api/preview/*`, project artifacts — through iroh QUIC streams.** + +``` +HOST GUEST +q2 preview --share q2 preview --join +┌────────────────────────────┐ ┌──────────────────────────┐ +│ preview hub (axum) │ │ TcpListener 127.0.0.1:N │ +│ 127.0.0.1:PORT │ │ 1 TCP conn = 1 QUIC │ +│ /health /ws /api /assets │ │ bi-stream, token- │ +│ ▲ │ │ prefixed │ +│ TunnelHost │ TcpStream per │ iroh (QUIC, │ ▲ │ +│ (iroh │ bi-stream │◄──E2E-encrypted,──►│ TunnelClient │ +│ Router) │ │ hole-punch or │ (iroh Endpoint) │ +└────────────┴───────────────┘ n0 relay) └────────────┴─────────────┘ + ▲ + browser: http://127.0.0.1:N/ +``` + +Why this shape wins (each alternative was seriously considered): + +1. **The preview SPA hard-assumes same-origin.** `deriveWsUrl()` derives + `ws(s)://{location.host}/ws` (`q2-preview-spa/src/PreviewApp.tsx:539-542`) + and the index doc id comes from same-origin `GET /health` + (`PreviewApp.tsx:499-514`). A local proxy makes both true by construction — + zero SPA changes for the MVP. +2. **The boot/reconnect supervisor keeps working.** `bootController.ts`'s + contract is "HTTP `/health` decides liveness; the WebSocket gets patience" + (`q2-preview-spa/src/bootController.ts:1-20`). With a tunnel, `/health` + through the local port genuinely probes the remote host: tunnel dies → + health strikes → banner → HTTP-only polling → recovery. For free. +3. **Rendering is already client-side.** The SPA renders via WASM in the + guest's browser; after the one-time asset download, tunnel traffic is just + automerge sync frames, presence ephemerals, and occasional artifact + fetches. A "native" samod-over-iroh transport would not move rendering + anywhere better. +4. **No version skew.** The guest's browser runs the host's SPA build, so SPA + ↔ sync-protocol ↔ schema versions always match. A joiner that served its + own SPA would need a compatibility story. +5. **Engine captures, artifacts, re-execute all work.** `/.quarto/project-artifacts/*` + and `/api/preview/re-execute` read host-local state that automerge does not + carry; proxying HTTP is the only design in which these work without new + protocol surface. +6. **No TCP-over-TCP pathology.** The tunnel is a *terminated splice*, not + encapsulation: the TCP legs are loopback on each end and only QUIC runs on + the WAN, so exactly one congestion controller governs the wide-area path. + +**Scope note: multiple concurrent guests are in scope for v1.** Each guest is +an independent iroh connection into the same Router accept loop, all sharing +the one session token; per-peer distinction exists only in host logs +(`remote_id()`), not in permissions (risk #4). Nothing in the design is +single-guest-shaped; Gate 0 Q1 carries an optional multi-guest sanity check, +and Phase 3's end-to-end check joins two guests at least once. + +**Alternative rejected (for v1): samod-native iroh transport.** samod's +`Transport::from_tokio_io` accepts any `AsyncRead + AsyncWrite` (samod +checkout `samod/src/transport.rs:54-81`), `HubContext::acceptor()` is public +(`crates/quarto-hub/src/context.rs:441`), and `BearerDialer` +(`crates/quarto-hub-provider/src/dialer.rs`) is precedent for a custom +`samod::Dialer` — so a first-class `IrohDialer`/iroh acceptor is quite +feasible. The decisive count: **the guest needs five +surfaces from the host — SPA assets, `/health` (index doc id), +`/api/preview/config` (allow-edit), `/ws` (sync + presence), and +`/.quarto/project-artifacts/*` + `/api/preview/re-execute` — and a native +samod transport replaces exactly one of them.** The HTTP tunnel must exist +regardless, so "native transport" means a *second* transport, auth +checkpoint, and reconnect lifecycle alongside it, to save only WebSocket +framing on the smallest, most latency-tolerant traffic class (sync frames; +the heavy traffic — assets, WASM, artifacts — is HTTP either way). Auth +bypass (checks live in the axum `ws_handler`, +`crates/quarto-hub/src/server.rs:1602-1648`; the samod `AccessPolicy` impl — +concrete type `AuditAccessPolicy` — is allow-all, with the unconditional +`true` at `access_policy.rs:62`) is the *cheapest* objection to fix with a new auth +endpoint — the load-bearing ones are the same-origin assumptions, the +no-version-skew guarantee, and the artifacts/re-execute surface, which an +auth endpoint does not touch. For accuracy: the "guest as intermediate samod +peer" variant would *not* break presence — samod gossips ephemerals through +intermediate peers (verified: `samod-core/src/actors/document/document_actor.rs:319-333` +forwards ephemeral messages to all other connections) — but it would break +the boot supervisor's "HTTP `/health` decides liveness" contract (a local +`/health` always answers even when the host is gone) and grow the "separate +auth endpoint" into a full sidecar API for doc id / allow-edit / artifacts / +re-execute. Keep as future work for native-peer use cases (e.g. +`q2 provide-hub` over iroh), where samod-over-iroh replaces the *entire* +surface, not a fifth of it. + +## iroh facts this plan depends on (verified against ../iroh @ v1.0.3) + +The local checkout is **iroh 1.0.3** — a hard API break from pre-1.0 +tutorials. Published on crates.io; MIT OR Apache-2.0; MSRV 1.91 (we're on +nightly-2026-04-28 / 1.97 — fine); edition 2024 (we already use it). + +- `Endpoint::builder(preset)` / `Endpoint::bind(preset)` — the preset is + **mandatory** (`iroh/src/endpoint.rs:952,957`). `presets::N0` = n0 pkarr + publish/resolve + DNS lookup + default relays + ring crypto + (`iroh/src/endpoint/presets.rs:113`). Tests use `presets::Minimal` (crypto + only) + `RelayMode::Disabled` + explicit loopback addrs for hermeticity + (Minimal-built endpoints already default to `RelayMode::Disabled`; the + explicit setting is documentation). +- Names are `EndpointId` / `EndpointAddr` / `TransportAddr::{Relay,Ip,Custom}` + (`iroh-base/src/endpoint_addr.rs:41-54`). `NodeId`/`NodeAddr`/ + `discovery_n0()` no longer exist. +- `Router::builder(ep).accept(ALPN, handler).spawn()` registers ALPNs itself + (`iroh/src/protocol.rs:502-511`); `ProtocolHandler::accept(Connection)` runs + on its own task and may be long-lived (`protocol.rs:262-268`). Router is + abort-on-drop: store it, await `shutdown()`. +- `open_bi`/`accept_bi` yield `(SendStream, RecvStream)`; `RecvStream: AsyncRead`, + `SendStream: AsyncWrite`, and `SendStream::poll_shutdown` calls `finish()` — + a QUIC FIN, exactly the half-close mapping the splice needs (via `noq` + 1.1.0, n0's quinn fork; `send_stream.rs:345-347`). Use + `tokio::io::join(recv, send)` + `tokio::io::copy_bidirectional` for splicing. +- **Stream laziness:** `open_bi()` does not wake the peer's `accept_bi()` + until the opener writes bytes (`iroh/src/lib.rs:150-158`). Our per-stream + token prefix satisfies this by construction. +- `endpoint.online().await` **pends forever with no relay reachable**; wrap in + `tokio::time::timeout(Duration::from_secs(10), …)` with our own constant. + **Do not use `iroh::NET_REPORT_TIMEOUT`** — that re-export is + `net_report::TIMEOUT`, a bare `u64 = 5` intended for docs (not a `Duration`, + not 10 s; passing it to `timeout()` is a compile error). The 10 s budget we + want matches iroh's *private* `defaults.rs:129` constant. + Only after it resolves does `endpoint.addr()` contain a relay URL. +- Tickets live in a separate crate, **`iroh-tickets` 1.0.0** (verified against + ../iroh-tickets). Trait `Ticket` requires `KIND` + + `encode_bytes`/`decode_bytes` (body format is implementer-supplied; postcard + is the recommendation we follow); the provided `encode_string`/ + `decode_string` produce lowercase-`KIND`-prefix + BASE32_NOPAD and reject + foreign kinds. We implement `Ticket` for our own struct rather than + shipping a bare `EndpointTicket` (KIND `"endpoint"`), because the join + string must also carry the session token. +- Graceful close: `router.shutdown().await?` alone suffices — it already + calls `Endpoint::close` (`protocol.rs:421-423`) and returns + `Result<(), JoinError>` which must be handled (≈3 s worst case); a trailing + `endpoint.close().await` is an idempotent no-op. `Connection::close()` is + sync and only queues. +- Default n0 relays need no auth; behind NAT the relay path is the designed + fallback (worst case: relayed throughput, not failure). +- No in-tree TCP↔QUIC bridge exists; we write the ~30-line splice ourselves. + `iroh/examples/echo-no-router.rs`, `auth-hook.rs`, `search.rs` are the + patterns to crib from. + +## Security model + +- **Join string = capability.** `q2preview…` ticket carries the host's + `EndpointAddr` **and a random 256-bit session token**. QUIC gives + confidentiality + host authentication (the joiner dials the pinned + `EndpointId` from the ticket — MITM-proof); the token authenticates the + *guest* to the host. +- **Per-stream token prefix.** Every bi-stream starts with the raw 32-byte + token; the host `read_exact`s it under a timeout and compares with + `subtle::ConstantTimeEq` before connecting the backing `TcpStream`. + Stateless, no extra RTT (bytes pipeline behind the token), and it satisfies + iroh's write-first rule. Mismatch → stream reset + connection close + log. +- **The local HTTP port stays loopback-bound.** `--share` adds no listening + TCP surface; the only remote path is the authenticated tunnel. (This + sidesteps the never-implemented `--insecure-allow-network` posture from + epic Q7, `2026-05-11-q2-preview-epic.md:416-431`.) +- **What the token grants — must be printed at share time:** guests can view + the project, trigger `POST /api/preview/re-execute` (engine execution on the + host = code execution by design), and, iff the host passed `--allow-edit`, + write edits that persist to the host's disk. Per-peer permissions are + explicitly out of scope for v1 (`--allow-edit` is a process-wide + `OnceLock` + one `DiskWritePolicy`; see follow-ups). +- Log each joining peer's `EndpointId` (`conn.remote_id().fmt_short()`) on the + host for auditability. +- Privacy note: `presets::N0` publishes the host's addresses (including LAN + IPs) to n0's pkarr/DNS. Acceptable for v1; `AddrFilter::relay_only()` is the + knob if we want to change that (follow-up). + +## CLI surface (decided) + +| Flag | Meaning | +|---|---| +| `q2 preview --share` | Host: normal preview + iroh tunnel host; prints ticket + ready-to-paste `q2 preview --join …` line | +| `q2 preview --join ` | Guest: local loopback proxy + browser; no local project, no hub, no SPA of its own | +| `q2 preview --ui ` | Which embedded frontend the server serves. `viewer` (default): the read-only preview SPA; `editor`: the full hub-client editor. Orthogonal to `--allow-edit` — UI choice never changes the disk write policy | + +- **`--ui` replaces the earlier `--with-editor` proposal.** + An enum because the flag *substitutes* which embedded dist `spa_handler` + serves (it is not additive); `viewer` rather than `preview` because + `q2 preview --ui preview` is a tautology — "preview" names the session, not + the frontend. Crucially, **`--ui editor` does not imply `--allow-edit`**: + UI and write policy are a real 2×2 — the viewer already has an allow-edit + inline-edit surface (`PreviewApp.tsx:523-532`), and + `DiskWritePolicy::ReadOnly` already models "session edits sync live, disk + stays authoritative" (`crates/quarto-hub/src/sync.rs:35-57`). So + `--share --ui editor` *without* `--allow-edit` is a deliberate sandbox + mode: guests get the full editor, their edits drive everyone's live + session, and nothing persists to the host's disk. (Caveat to document: + under `ReadOnly` a session edit survives only until the next host-side + filesystem change converges that doc back to disk content — `sync.rs:41-45`; + binary docs revert immediately.) Running `--ui editor` + without `--allow-edit` prints: "session edits are ephemeral — pass + `--allow-edit` to persist to disk". Symmetrically, `--share` does **not** + imply `--ui editor` (that would couple Phase 2 to Phase 4 and make the + heavy editor bundle the default relay-path boot experience); if usage + later wants editor-by-default when sharing, the mechanism is an + overridable `--ui auto` default — explicitly a non-goal for v1. +- **`--host` is unavailable for hosting semantics** — it is already the bind + interface (`crates/quarto/src/main.rs:212-213`). It keeps that meaning + (and, for `--join`, binds the local proxy listener; default `127.0.0.1`). +- Compositions: `--share --ui editor` (guests get the full editor, served by + the host; sandbox unless `--allow-edit`); `--share --ui editor --allow-edit` + (full collaborative editing, persists to host disk); `--share --allow-edit` + (viewer with inline-edit write-back); `--join --port N`; `--join --no-browser`. +- Conflicts (clap `conflicts_with`): `--join` × {`path`, `--share`, + `--no-project`, `--allow-edit`, `--ui`, `--data-dir`, + `--preview-dir`}. +- ALPN: `b"q2/preview-tunnel/0"`. Ticket KIND: `"q2preview"` (KIND string is + the protocol version tag; breaking change ⇒ new KIND). + +## New crate: `crates/quarto-p2p` + +Encapsulates all iroh usage; `quarto-preview` and `crates/quarto` see only: + +```rust +pub struct PreviewShareTicket { pub addr: EndpointAddr, pub token: [u8; 32] } + // impl iroh_tickets::Ticket (KIND "q2preview"), Display/FromStr, Debug redacts token + +pub struct TunnelHost; // TunnelHost::spawn(cfg, target: SocketAddr) -> (PreviewShareTicket, TunnelHostHandle) +pub struct TunnelClient; // TunnelClient::bind(ticket, local: SocketAddr) -> (SocketAddr, TunnelClientHandle) + // handles: async shutdown(); client: status watch (Connected/Reconnecting) for CLI messaging +``` + +Native-only (not in the WASM closure — `wasm-quarto-hub-client` depends on +`quarto-core`/`pampa`, not on `quarto-preview`/`quarto-p2p`). Deps: `iroh` +(default features), `iroh-tickets`, `subtle`, `rand`, `tokio` — with +**explicit `io-util` + `net` features**: `io::join`/`copy_bidirectional` are +`io-util`-gated, the workspace default set (`rt-multi-thread`, `macros`) +lacks it, and today it only unifies on via quarto-hub's `full` — `tracing`, +`thiserror`. (Lockfile check: `subtle`, `rand`, and the Phase 1 +WS-test dev-dep `tokio-tungstenite` 0.29 are already in `Cargo.lock` +transitively; the genuinely new tree is iroh + iroh-tickets + postcard.) Host side re-dial-free (Router accept loop); client side owns a +re-dial loop with backoff (existing TCP conns die on connection loss; new ones +use the fresh connection; the SPA's health supervisor papers over the gap). +Client registers the ticket's `EndpointAddr` in a `MemoryLookup` +(`iroh/src/address_lookup/memory.rs:75`) so re-dials re-resolve without n0 +infra. + +--- + +# Phases + +Sequencing: **Gate 0 first — nothing else starts until it returns "go."** +Then 0 → 1 → 2 → 3 are strictly ordered. **Phase 4 (`--ui editor`) is +independent of iroh entirely** and can proceed in parallel with Phase 0/1 +once the gate passes — it waits for the gate like everything else, because a +no-go kills the epic and P4 only exists here in service of the share story. +Phase 5 is a spike, gated on 3 + 4. + +## Gate 0 — Feasibility spike (go/no-go) + +**Nothing below this section starts until this gate returns "go."** The plan +commits to a specific architecture (dumb token-authenticated TCP proxy over +iroh QUIC) and a heavy new dependency before any of it has run end-to-end. +This gate front-loads the bets that would kill or reshape the plan, at +throwaway-spike cost, before we pay for Phase 1's full TDD surface and five +phases of implementation. + +**Mechanics.** Time-box: ~2 working days. Spike code lives on the gate +strand's branch (`braid/bd-l4j4ky8k-live-share-feasibility-gate`) and is +**throwaway — it is never merged**; what lands on the integration line is +this section's findings, the measurements, and the recorded decision. +**TDD exemption:** this is an investigation, not a feature — the repo's +TDD rule resumes at Phase 1, which re-implements from scratch under tests +(salvage knowledge, not code). Spike shortcuts allowed: `unwrap()`s, no +re-dial loop, no graceful shutdown, a debug-printed `EndpointAddr` + hex +token instead of the ticket format. Shortcuts NOT allowed: a real +`q2 preview` server as the tunnel target, a real browser as the client, +the real n0 relay for the cross-network leg, and the 32-byte token prefix +on each stream (it doubles as iroh's write-first requirement, so skipping +it would make the spike unrepresentative). + +**Feasibility questions (spike):** + +- [x] **Q1 — Does the SPA work through the tunnel at all?** One machine: + `q2 preview` on a fixture project, hand-rolled tunnel host in front of + it, hand-rolled tunnel client on another port, browser pointed at the + client port. Pass: SPA boots, `/health` returns the index doc id, the + document renders (assets + WASM fetched through the tunnel), and a + host-side file edit propagates live to the browser — i.e. the splice + carries both plain HTTP and a long-lived WebSocket. Run in **Chrome + and Firefox** — browser-specific WS behavior has bitten this SPA + before (`2026-06-11-firefox-ws-peer-timeout-fix.md`), and a + browser-specific failure is cheaper to learn here than in Phase 2. + Optional (non-blocking): point a second tunnel client + browser at + the same host as a multi-guest sanity check — v1 scope is N + concurrent guests (see the architecture scope note). +- [x] **Q2 — Does it survive a real session?** Keep the Q1 session open + ≥10 minutes including ≥2 minutes fully idle, then edit again. Pass: + the edit still propagates. This checks the QUIC keep-alive vs. + browser-connection-pooling interaction (`endpoint/quic.rs:155-161`, + 5 s keep-alive vs. 30 s idle) in reality, not just in source. Run + the idle leg in Safari as well (free on this machine) — + idle-connection handling is exactly where browsers differ. + Opportunistic observation, not pass/fail: close the host laptop's + lid mid-session and note what the guest sees on wake — sleep/wake is + the most common real-world connection death and the observation + informs Phase 1's re-dial design (the reconnect story itself is + Phase 1 scope). +- [x] **Q3 — Does the relay path work and feel usable?** Host and guest on + different networks, real n0 relay. Logistics: network namespaces are + Linux-only and the dev host is macOS, so this leg is **two physical + machines** (e.g. one on a phone hotspot); a headless cloud VM guest + cannot measure browser time-to-first-render. (If a test network + blocks UDP entirely, iroh falls back to relay-over-TCP-443 — that is + the designed worst case, not a failure to debug.) Pass: join + succeeds. Record **three numbers**, not one: (a) time-to-first-render + on the guest, (b) bytes transferred + effective throughput for the + asset boot, (c) edit→propagation latency once the session is warm — + (c) is the steady-state "feels usable" number (sync frames are + small, so it should be ~relay RTT; confirm). **Known confounder for + (a):** the preview server serves everything uncompressed (verified: + no CompressionLayer anywhere in the preview/hub stack — + quarto-hub's tower-http features are only `trace`/`cors`/ + `set-header`), so the ~38 MB WASM travels at full size; (b) exists + to make a breach attributable to tunnel vs. payload. Soft threshold: + < 30 s time-to-first-render on a residential-class connection; + worse ⇒ conditional go, mitigation ladder in order of cost: + (1) HTTP compression on the preview server + (`tower_http::CompressionLayer` or a precompressed `.wasm.br` — + wasm compresses ~3–5×, no version-skew cost), (2) guest-side asset + serving (risk #5) promoted from follow-up into Phase 1 scope. +- [x] **Q4 — Is the dependency weight acceptable?** Measure and record + here: `q2` release binary size and clean-build wall time, before vs. + after the iroh dep tree. Methodology (so Phase 0's re-measure + compares like with like): release build of `--bin q2`, `cargo clean` + before each timed run, baseline and after measured back-to-back on + the same machine — the bd-xvdop controlled-measurement style — and + record the exact iroh/iroh-tickets versions measured. Soft + thresholds: ≤ 15 MB binary growth and ≤ 25 % clean-build slowdown; + worse ⇒ conditional go, feature-gating decision required before + Phase 0 starts. + +**Static checks (no spike code needed, but gate-blocking):** + +- [x] Licensing: iroh is MIT OR Apache-2.0 + one BSD-3 notice + (`../iroh/iroh/LICENSE-BSD3`) to carry in attribution — confirm + nothing else in the transitive tree (noq, netwatch, portmapper, + hickory, reqwest) is problematic. This repo has + **no cargo-deny infrastructure** (no `deny.toml`, no CI step), so + this is a one-off inventory on the spike branch (ad-hoc + `cargo deny check licenses` with a scratch config, or + `cargo license`), not an existing gate to pass +- [x] n0 usage policy: `--share` defaults to n0's hosted relays and pkarr + publishing. Licensing covers the code; this covers the **service**: + confirm n0's terms for third-party production use of the public + relay/DNS infrastructure (fair-use expectations, rate limits, any + "run your own relay for shipped products" guidance). Same class of + due diligence as the license inventory — cheap to check now, + expensive to discover post-ship +- [x] Windows: the spike crate compiles for Windows (iroh supports it; + watch `netwatch`/`portmapper`). **Test-suite CI + has no Windows leg** (`.github/workflows/test-suite.yml` matrix is + ubuntu + macos; only `release.yml` builds Windows). A manual check + on a real Windows machine (`cargo xtask test` per + `claude-notes/instructions/windows-dev.md`) works if one is handy; + **do not** burn spike time on + `cargo check --target x86_64-pc-windows-msvc` from macOS — it dies + in ring's C/asm build script (no MSVC-targeting toolchain) before + saying anything about iroh. The reliable cheap signal is a + **throwaway `workflow_dispatch` GH Actions job** on the spike branch + running `cargo check` on `windows-latest` (release.yml already + proves those runners build this tree); it is not a PR-CI signal +- [x] WASM closure unaffected: `cargo tree -i iroh` from + `wasm-quarto-hub-client` must fail + +**Decision (the actual gate):** + +- [x] Findings recorded above — exact invocations, output snippets, and + numbers; the repo's end-to-end evidence policy applies to the spike + too ("output inspected" notes, no success-by-absence-of-errors) + *(see "Gate 0 findings (spike executed 2026-08-04)" below)* +- [x] Verdict posted as a `braid comment` on the epic (bd-yyoyvx91); + **user sign-off required** — go / conditional go / no-go is the + user's call, informed by this data *(findings posted 2026-08-04; + **user signed off: GO**, 2026-08-04 — recorded on the epic; gate + strand bd-l4j4ky8k closed; Phase 0 and Phase 4 unblock)* + +| Verdict | Meaning | +|---|---| +| **Go** | Q1–Q4 pass, static checks green → Phase 0 unblocks | +| **Conditional go** | Q3 or Q4 breached a soft threshold → user picks the mitigation (feature-gate iroh; add HTTP compression to the preview server; promote guest-side asset serving; LAN-only v1 scope) and this plan is amended before Phase 0 starts | +| **No-go** | Q1/Q2 fail unfixably (the splice can't carry the SPA), or licensing/Windows are hard blockers → the epic closes, or the architecture section is redone from the rejected-alternatives list (guest-side SPA + samod-native transport is the fallback to re-evaluate); Phases 0–5 do not start | + +### Gate 0 findings (spike executed 2026-08-04) + +**Spike artifacts.** Branch `braid/bd-l4j4ky8k-live-share-feasibility-gate` +(throwaway — never merged), commits `e80cebf8` (tunnel host+client crate +`crates/q2-p2p-spike`) and `9067b008` (Q4 wiring + Windows workflow). +Dependencies measured: **iroh 1.0.3 + iroh-tickets 1.0.0 from crates.io** +(spike client's `--relay-only` uses a custom `PathSelector` behind iroh's +`unstable-custom-transports` feature — zero extra deps). Browser legs driven +by Playwright (Chromium 1223, Firefox 1522, WebKit 2287) with a scratchpad +driver script; one non-obvious detail for Phase 2/3 e2e work: **the preview +SPA renders the document inside an iframe**, so text assertions must scan +`page.frames()`, not the top page. + +**Q1 — PASS (Chromium + Firefox, two concurrent guests).** +Invocations: `q2 preview --no-browser` (port 49583, 2-page fixture +project) ← `spike-tunnel-host 127.0.0.1:49583` ← two +`spike-tunnel-client 9280|9281` ← browsers at +`http://127.0.0.1:928x/?page=index.qmd`. +- `curl http://127.0.0.1:9280/health` through the tunnel returned the + **identical** payload to direct, including + `"index_document_id":"4ZLBFnLKivaVWXc9HdF2SCACTn2U"`. +- SPA booted and rendered in both browsers; **~47.5 MB fetched through the + tunnel per guest** (uncompressed, 38.4 MB of it the WASM — the plan's + no-compression confounder confirmed); first render 1.7 s (Chromium) / + 1.9 s (Firefox) on the direct path. +- Host-side `sed` edit (`MARKER-0`→`MARKER-1`) propagated live to **both + guests simultaneously** in **554 ms (Chromium) / 587 ms (Firefox)** — + multi-guest sanity check passed in the same run. Before/after screenshots + inspected: rendered document shows the new marker text in both browsers. +- Wrong-token check: client with a zeroed token → host logs + `BAD TOKEN - dropping stream`, curl fails, zero bytes reach the target. +- Splice carries HTTP + the long-lived `/ws` WebSocket by construction + (sync frames are what propagated the edits). + +**Q2 — PASS (12 min session, fully idle, Chromium + WebKit).** +Fresh sessions on both tunnel clients, then **720 s fully idle** (only the +SPA's own background polling), then another host edit +(`MARKER-1`→`MARKER-2`): propagated in **1.07 s (Chromium) / 1.18 s +(WebKit)**. Post-soak screenshots inspected — rendered `MARKER-2` visible. +The QUIC keep-alive (5 s) vs browser-connection-pooling (30 s idle timeout) +interaction is a non-issue in practice. Caveats: WebKit (Playwright) stands +in for Safari — same engine, not the Safari app; the lid-close observation +was not performed (no way to close the lid programmatically) — fold it into +the real cross-network session below. + +**Q3 — PASS. Two measurements: (i) single-machine relay-pinned +approximation, (ii) real cross-network leg via a GH Actions guest (below).** +The spike client's `--relay-only` flag pins path selection to relay paths +(verified: **zero** DIRECT selections in the client log for the whole leg; +selected path stayed `euc1-1.relay.n0.iroh.link`, rtt ~31 ms warm). Fresh +Chromium boot through the relay-pinned tunnel: +- (a) time-to-first-render: **4.07 s** (soft threshold was < 30 s) +- (b) bytes: ~47.5 MB → ≥ **11.7 MB/s** effective through the real n0 relay + (lower bound; includes render time) +- (c) edit→propagation warm: **1.00 s** (vs ~0.55 s direct — consistent with + "+relay RTT") +Honest limitation: both endpoints shared this machine's (fast) connection, +so (a)/(b) are not a residential-guest measurement — but the traffic did +transit the real n0 relay, so protocol behavior and rate-limiting posture +are exercised. Also observed (informs Phase 1 status messaging): on loopback +the first selected path after connect is RELAY, upgrading to DIRECT within +~3–6 s — initial connect in 162 ms. + +**Q3 cross-network leg — executed and PASSED (2026-08-04, user-approved +push).** The plan assumed a cloud VM guest "cannot measure browser +time-to-first-render" — true for eyeballs, false for a Playwright driver +measuring it in-process, so the two-machine leg was automated: live host on +the dev machine (`q2 preview` + `spike-tunnel-host`, plus a loop bumping a +numbered marker every 20 s with ms timestamps), guest = `ubuntu-latest` +GH runner (Azure network) that builds the spike client, joins via the +ticket (passed through ephemeral repo secrets, deleted after the run), +and boots headless Chromium through the tunnel. Workflow +`spike-q3-guest.yml`, run 30897199010; boot + final screenshots for both +legs downloaded from the artifact and inspected (rendered document at the +last observed marker). Results: +- connect: **0.42 s** cross-network. **Both legs — default and + relay-pinned — stayed on the relay for their whole run**: hole-punching + Azure↔residential NAT never yielded a selected direct path, so this + measured exactly the relay-fallback scenario Q3 exists for. + Guest→relay rtt ~130 ms (`euc1-1` from Azure); relays probe at + 200/OK in 0.08–0.5 s from the runner. +- (a) time-to-first-render: **13.2 s (default) / 13.3 s (relay-pinned)** — + soft threshold < 30 s, PASS. +- (b) ~47.5 MB per boot → **~3.7 MB/s sustained through the relay** + (uncompressed WASM dominates; the HTTP-compression mitigation would cut + the payload ~3–5×). +- (c) edit→observed propagation over 6 marker bumps: **0.81–2.34 s, + median ~1.1 s** (includes the driver's 150 ms poll grain and host↔runner + NTP clock skew). +- Caveats: runner egress is datacenter-class, not residential; the + Safari-app and lid-close observations still need a human-driven session. +- Reliability note worth keeping: the **first** guest run (30896594167's + sibling, run 30896593541) failed at `ep.connect` with a bare timeout — + the host saw no inbound attempt — and was unreproducible 8 minutes later + (identical code + ticket connected in 0.42 s). A transient + n0 discovery/relay hiccup of exactly the class Phase 1's re-dial/backoff + and Phase 3's error UX must absorb (the spike client retries nothing by + design). + +**Q4 — dependency weight (controlled, bd-xvdop style: `cargo clean` before +each timed run, back-to-back on the same machine, release `--bin q2`, +iroh 1.0.3 wired reachably into the binary via an env-gated hook).** +- baseline (branch point, no iroh): **94.9 s** wall, binary **81,814,464 B + (78.0 MiB)** +- after (iroh dep tree linked): **110.6 s** wall, binary **95,602,544 B + (91.2 MiB)** +- deltas: **+13.79 MB binary** (threshold ≤ 15 MB — within) and **+16.6 % + clean-build wall time** (threshold ≤ 25 % — within) → **PASS, no + feature-gating decision forced** +- reachability verified end-to-end: the measured release binary itself ran + the tunnel host (`Q2_SPIKE_TUNNEL_TARGET=127.0.0.1:49583 + ./target/release/q2` printed a live TICKET/TOKEN) — the delta is not a + dead-code artifact + +**Static checks.** +- **Licensing — PASS.** One-off inventory of the 141 packages the iroh tree + adds to `Cargo.lock` (`cargo tree --format "{p}|{l}"` + `cargo metadata` + for target-specific deps): all permissive. MIT/Apache-2.0 dual dominates; + BSD-3-Clause: `curve25519-dalek`, `ed25519-dalek` (attribution notices to + carry, matching iroh's own `LICENSE-BSD3`); BSD-2-Clause: `arrayref`, + `spez`; **one MPL-2.0: `attohttpc`** (via the portmapper stack; file-level + weak copyleft — fine as an unmodified linked dep); 3× Unlicense + (`async_io_stream`, `pharos`, `ws_stream_wasm` — wasm-target-only); + 1× Zlib-or; no GPL/LGPL/AGPL/SSPL anywhere. +- **n0 usage policy — PASS with eyes open.** No dedicated ToS for the preset + infra exists (itself a finding). DNS-discovery docs explicitly say + *"You're more than welcome to run production systems using the public + relays if you find performance acceptable"* — but relays are rate-limited + (numbers unpublished, *"can change at any time"*, iroh 1.0 post), carry + *"no guaranteed uptime"*, and the add-a-relay doc says *"production + deployments should run their own."* Iroh Services ToS (May 2025) reserves + unilateral termination, caps liability at US$50. Paid offering exists + (Iroh Services; dedicated relays ~$197/mo, free tier 10 concurrent + endpoints). Public-relay sunsets are per-protocol-version on an announced + schedule (v1.0 relays "until End of Life"). Verdict: ephemeral preview + sharing is well inside tolerated use — relays are handshake/fallback + paths, not the primary data path — but **make relay/DNS endpoints + user-configurable** (follow-up strand at epic close) and track the + per-version relay sunset schedule at iroh upgrades. +- **Windows — PASS.** Throwaway workflow on the spike branch + (`.github/workflows/spike-windows-check.yml`) pushed with user approval + as `spike/bd-l4j4ky8k-live-share-feasibility-gate` and run on + `windows-latest`: `cargo check -p q2-p2p-spike -p quarto` (the iroh + tunnel crate + the fully wired q2 closure) succeeded in 5m36s — GH run + 30894960520, 2026-08-04. Gotcha for posterity: `workflow_dispatch` only + registers from the default branch (404 on dispatch), so the workflow + fires `on: push` to `spike/**` instead. +- **WASM closure — PASS.** From `crates/wasm-quarto-hub-client` (spike + branch, iroh in the workspace lockfile): + `cargo tree -i iroh` → `error: package ID specification 'iroh' did not + match any packages`. +- Bonus datapoint: the spike's bare `EndpointTicket` printed at 151 chars + (+ 64 hex token chars) — consistent with the plan's 173–235-char estimate + for the combined `q2preview` ticket. + +**Runbook — optional residential-class Q3 re-run (user + one extra +machine/network; the cross-network leg itself already passed via the GH +Actions guest above — this remains only if a residential-uplink +time-to-first-render number is wanted):** +1. On the host machine (this repo, spike branch worktree): + `cargo run --bin q2 -- preview --no-browser` (note the port), + then `./target/debug/spike-tunnel-host 127.0.0.1:` (from + `.worktrees/bd-l4j4ky8k-live-share-feasibility-gate`); copy TICKET + + TOKEN. +2. On a second physical machine on a different network (e.g. phone + hotspot), build the spike client (`cargo build -p q2-p2p-spike` on the + spike branch) and run + `spike-tunnel-client 9280 --relay-only` (relay-pinned; + drop `--relay-only` for a hole-punching run — the client logs which path + is selected). +3. Browser at `http://127.0.0.1:9280/?page=index.qmd`; stopwatch + time-to-first-render; edit a file host-side and eyeball propagation. + Optionally close the host's lid mid-session and note what the guest sees + on wake (informs Phase 1 re-dial UX). + +**Verdict — GO, signed off by the user 2026-08-04** (all four questions +and all four static checks passed; no soft threshold breached). Gate +strand bd-l4j4ky8k closed; Phase 0 (bd-9gam4jqe) and Phase 4 +(bd-jt1etjbn) unblock. Optional extras left open: a residential-class Q3 +re-run (runbook above) and a human-driven session for the +Safari-app/lid-close observations. Housekeeping done: remote `spike/…` +branch deleted 2026-08-04 (throwaway; existed to run the Windows + +cross-network CI legs), ephemeral SPIKE_* secrets deleted, session token +dead with the host process; the local spike worktree +(`.worktrees/bd-l4j4ky8k-live-share-feasibility-gate`) is kept for +reference until Phase 1 re-implements under tests — its code is still +never to merge. + +## Phase 0 — Scaffold + +No behavior; keep it short. Dependency due-diligence (size/build-time +measurement, licensing, Windows, WASM closure) lives in Gate 0 — Phase 0 +only re-establishes those facts on the real crate wiring, since the gate +proved them on a throwaway branch that never merges. + +- [x] Add `crates/quarto-p2p` (lib) to the workspace; deps as above; empty + public API stubs behind `todo!()` are fine at this point + *(done 2026-08-04: `PreviewShareTicket`, `TunnelHost`/`TunnelHostHandle`, + `TunnelClient`/`TunnelClientHandle`, `TunnelStatus`, `TunnelError`; + all method bodies `todo!("Phase 1 (bd-v8mwzpmi)")`; deps iroh 1.0.3 + (default features), iroh-tickets 1.0, subtle 2, rand 0.9, + tokio +io-util+net, tracing, thiserror; `[workspace.dependencies.quarto-p2p]` + entry added for Phase 2's consumer)* +- [x] `cargo build --workspace` green; confirm `cargo xtask verify + --skip-hub-build` unaffected *(done 2026-08-04: workspace build + green; `cargo xtask verify --skip-hub-build` → "All verification + steps passed!", output inspected)* +- [x] Re-confirm the Gate 0 static checks on the real scaffold: + `cargo tree -i iroh` from `wasm-quarto-hub-client` fails; a Windows + build compiles the crate (manual or release-workflow leg — + test-suite CI has no Windows matrix entry); the dep set matches what + Gate 0 measured (if it drifted — e.g. feature changes — re-measure + Q4 and update the gate section's numbers) + *(2026-08-04: **WASM closure PASS** — `cargo tree -i iroh` + from `crates/wasm-quarto-hub-client` → "package ID specification + 'iroh' did not match any packages"; **dep set PASS** — Cargo.lock + additions are name+version-identical to the gate branch's lockfile + (141 external packages; iroh 1.0.3, iroh-tickets 1.0.0; only + symmetric diff is `quarto-p2p` vs `q2-p2p-spike`), so no Q4 + re-measure; **Windows: gate coverage accepted** — user decision + 2026-08-04: no dedicated Windows leg for Phase 0; the gate proved + the identical dep set on windows-latest (run 30894960520, + 2026-08-04) and only the ~90-line stub crate is new, so the + Windows signal rides the release workflow / later CI instead)* + +## Phase 1 — `quarto-p2p` core (TDD) + +Tests live in `crates/quarto-p2p/tests/integration/` per the integration-test +layout rule (single `main.rs` binary). All tests hermetic: `presets::Minimal`, +`RelayMode::Disabled`, explicit loopback `TransportAddr::Ip` addrs — **no n0 +infrastructure in CI**. + +**Test specs (write these first, watch them fail):** + +*(all landed failing-first in `c146ca6d` — 10/10 FAIL via `todo!()` stubs, +output inspected — then went green with the implementation, 2026-08-05; +suite lives in `crates/quarto-p2p/tests/integration/{ticket,tunnel}.rs`)* + +- [x] `ticket::roundtrip` — ticket with relay + ip addrs + token → + `to_string()` (starts with `q2preview`) → `parse()` → equal +- [x] `ticket::rejects_garbage_and_foreign_kinds` — empty string, random + base32, a bare iroh `EndpointTicket` string (`endpoint…`) all fail with + a typed error +- [x] `ticket::debug_redacts_token` — `format!("{ticket:?}")` does not contain + the token bytes/hex *(also asserts Debug does not embed the full join + string, which would leak the token via base32)* +- [x] `tunnel::http_roundtrip_loopback` — tiny axum server as target; host + endpoint + `TunnelHost::spawn`; client endpoint + `TunnelClient::bind`; + raw HTTP/1.1 GET through the client's local port returns the body; + repeat over ≥8 **concurrent** connections (concurrent QUIC streams) +- [x] `tunnel::websocket_frames_survive` — target is an axum `/ws` echo; + `tokio-tungstenite` client through the local port; upgrade + a few + frames round-trip (proves the splice handles long-lived duplex traffic) +- [x] `tunnel::wrong_token_rejected` — stream with a wrong/short token is + reset; the target server sees **zero** TCP connections (count accepts) +- [x] `tunnel::client_redials_after_connection_loss` — drop the host-side + connection; next local TCP conn succeeds after client re-dial + *(host restarted with fixed secret key + token + UDP port so the + unchanged ticket stays valid; asserts the status watch flips to + `Reconnecting` and back to `Connected`)* +- [x] `tunnel::half_close_propagates` — guest-side TCP write-half shutdown + reaches the target as read-EOF (and the reverse direction), while the + other direction keeps flowing; guards the splice's EOF ↔ + `SendStream::finish()` mapping, which `websocket_frames_survive`'s + symmetric traffic does not exercise +- [x] `tunnel::clean_shutdown` — `shutdown()` on both handles completes + without hangs and unbinds the local port + +**Implementation:** + +*(implementation complete 2026-08-05; verification: 10/10 crate tests +green, `cargo nextest run --workspace` 10873 passed, `cargo xtask verify +--skip-hub-build` "All verification steps passed!", `cargo tree -i iroh` +from `wasm-quarto-hub-client` still fails — output inspected for all)* + +- [x] `ticket.rs` — struct + `iroh_tickets::Ticket` impl + `FromStr`/`Display` + *(postcard wire format follows iroh-tickets' versioned-enum convention: + `TicketWireFormat::Variant1 { id, addrs, token }`; manual `Debug` + redacts the token)* +- [x] `host.rs` — `Endpoint` (preset injectable for tests) + `Router` with a + `ProtocolHandler` whose `accept()` loops on `accept_bi()`, spawning per + stream: `read_exact` 32-byte token under a 10 s timeout → constant-time + compare → `TcpStream::connect(target)` → + `copy_bidirectional(&mut tokio::io::join(recv, send), &mut tcp)`; + log `remote_id().fmt_short()` per connection. **Half-close:** + `copy_bidirectional` propagates read-EOF as `poll_shutdown` on the + opposite writer; via `tokio::io::join` that must land as + `SendStream::finish()` (QUIC FIN), and a stream FIN must become TCP + write-shutdown — verify with `tunnel::half_close_propagates` rather + than assuming the adapter chain does it + *(done; preset injection is `TunnelHostConfig { preset, secret_key, + token, bind_addr }` — the last three exist for the hermetic + restart-same-identity re-dial test. N0 spawn wraps `online()` in the + plan's 10 s timeout and warns + degrades to direct/LAN-only on miss)* +- [x] **QUIC keep-alive vs. browser connection pooling:** browsers hold idle + pooled HTTP/1.1 connections open for minutes; if the iroh connection's + idle timeout fires in between, the next request on a pooled TCP conn + fails before the client re-dials. iroh's defaults already cover this — + keep-alive 5 s vs. 30 s connection idle timeout + (`iroh/src/endpoint/quic.rs:155-161`, `socket.rs:109`; connection idle + is the noq default 30 s) — so this item is a verifying test, not new + config. If we ever do override: the type is `QuicTransportConfig` / + `QuicTransportConfigBuilder` set via `Builder::transport_config` + (`endpoint.rs:669`) — **not** `TransportConfig`, which in iroh v1 is an + unrelated internal socket-transport enum. Do not rely on the SPA's + health polling to keep the tunnel warm + *(verified by `tunnel::idle_pooled_conn_survives_quic_keepalive`: + 35 s fully-idle pooled HTTP/1.1 conn through default-config hermetic + endpoints, then a second request on the same conn succeeds — ~40 s + runtime by design, the slowest test in the crate)* +- [x] `client.rs` — endpoint + `MemoryLookup` seeded from the ticket; + `TcpListener` accept loop; per conn: `open_bi()` on the current + connection (re-dial with expo backoff on failure), write token, splice; + status watch channel for CLI messaging + *(re-dial is owned by a supervisor task parked on `conn.closed()` — + backoff 250 ms → 5 s cap, per-attempt 10 s connect timeout; per-conn + handlers wait on the status watch, budget 30 s, then drop the TCP + conn so browser/health-supervisor retries stay cheap. Initial dial + failure is a `TunnelClient::bind` error by design — Phase 3 wants + "host unreachable" at join time, not a silent background retry)* +- [x] Shutdown plumbing: `router.shutdown().await?` on the host (it closes + the endpoint itself; handle the returned `JoinError`); abort accept + loop + close endpoint on the client + *(client shutdown awaits the aborted accept-loop task so the local + port is provably unbound before returning — asserted by + `tunnel::clean_shutdown`)* + +## Phase 2 — `q2 preview --share` (host) + +*(implemented 2026-08-05, bd-jhvkwosw; tests landed first and were +observed failing — CLI tests via E0026 missing-field compile errors (the +expected failure mode for a structural clap addition), share-glue tests +4/4 FAIL at runtime on `todo!()` stubs, output inspected — then went +green with the implementation)* + +**Tests first:** + +- [x] CLI: `--share` parses; `--share --join x` rejected. **New tests, not an + extension** — `crates/quarto` has no clap parse tests today + (`preview.rs:625-647` are boot-URL formatting tests; the one existing + exclusion is a runtime bail at `preview.rs:70`), so build the small + `try_parse_from` harness this plan's conflict matrices need + *(`cli_parse_tests` in `crates/quarto/src/main.rs`: parses, defaults + off, composes with `--allow-edit`, conflicts with `--join` — the + conflict asserted as `ErrorKind::ArgumentConflict`, so it pins a real + `conflicts_with`, not an unknown-arg rejection)* +- [x] `quarto-preview` unit: share glue produces a ticket whose tunnel target + is `127.0.0.1:{config.port}` (the port is resolved CLI-side before the + server starts, `preview.rs:114-117` — `on_ready` does *not* carry it); + ticket line printed via an injected writer/callback — do not scrape + stdout + *(`crates/quarto-preview/tests/integration/share.rs`, hermetic-iroh: + the glue test fetches a marker through a `TunnelClient` bound to the + minted ticket, proving the target; the banner arrives via an injected + closure. Extra banner tests: capability wording per `--allow-edit`, + direct/LAN-only notice when the ticket has no relay addr, join line + always last (copy-paste contract))* + +**Implementation:** + +- [x] `PreviewArgs::share` + clap flag (`crates/quarto/src/main.rs` Preview + variant) → `PreviewConfig::share` (`crates/quarto-preview/src/lib.rs`) + *(also declared `--join ` in clap now — hidden + (`hide = true`) with `conflicts_with = "share"` and a runtime + "not implemented yet (Phase 3)" bail — because the conflict test + needs the arg to exist; Phase 3 unhides it, implements the guest + path, and adds the full conflict matrix)* +- [x] `quarto-preview` → `quarto-p2p` dep; when sharing: generate token, bind + endpoint (`presets::N0`), `timeout(Duration::from_secs(10), online())` + (on timeout: proceed, warn "relay unreachable — direct/LAN connections + only"), `TunnelHost::spawn` targeting `config.port`. **Print timing:** + the `on_ready` callback receives only `Arc` and fires + *before* the listener binds (`server.rs:1915` vs. `:1933`) — so a + ticket printed there precedes accept. That is the same property as the + existing CLI boot-URL print (`preview.rs:136-141`) and is acceptable + (a too-fast guest just retries via its health supervisor), but the + print does not need `on_ready` at all — the ticket's only inputs + (`config.port`, token, endpoint addr) exist before the server starts: + + ``` + Sharing this preview session (end-to-end encrypted via iroh). + Anyone with this string can VIEW the project and RE-RUN its code: + [and EDIT files on this machine — only if --allow-edit] + + q2 preview --join q2preview<...> + ``` + + The ticket string will be long — **measured** with a + scratch impl against crates.io iroh-base/iroh-tickets 1.0 (postcard + of `{id, addrs, token}`, KIND `q2preview`, real n0 relay hostname): + 173 chars relay-only, 235 chars typical (relay + 2×IPv4 + IPv6), + 336 chars with 5×IPv6 — i.e. 3–5 wrapped lines at 80 columns. The + same experiment validated that the planned wire shape round-trips + through `encode_string`/`decode_string` exactly as the trait docs + promise. Print the `q2 preview --join …` line on its own line with + nothing after it, so a triple-click / drag copy survives terminal + wrapping; the end-to-end check below must include copy-pasting the + wrapped line from a real terminal + *(done: `crates/quarto-preview/src/share.rs` — + `start_share_session(TunnelHostConfig, host, port, allow_edit, + announce)` → `ShareSession { ticket, handle }` + + `format_share_banner`; called from `run_with_on_ready` before the + server starts (with a `port != 0` guard — library callers must + pre-resolve like the CLI does). Two deviations-with-reasons from the + sketch: (1) the banner's relay-unreachable warning is driven by + inspecting the minted ticket via a new + `PreviewShareTicket::has_relay_addr()` — quarto-p2p's + `tracing::warn!` is invisible at the CLI's default `quarto=warn` + filter, so the banner is the user-visible signal; (2) the tunnel + target is `share_target(host, port)` rather than hardcoded + `127.0.0.1` so `--share` still works when `--host` binds a concrete + non-loopback interface — unspecified binds (`0.0.0.0`/`::`) and + hostnames still map to loopback)* +- [x] Ctrl-C: tunnel shutdown joined into the existing graceful-shutdown path + (before the `TempDir` drop) + *(in `run_with_on_ready`: `ShareSession::shutdown()` runs after + `run_server_with` returns — i.e. after the hub's final filesystem + sync — and before control returns to the CLI where the ephemeral + `TempDir` drops; verified live in the e2e below: SIGINT → graceful + shutdown logs → `EXIT=0`)* +- [x] **End-to-end (mandatory, record invocation + output here):** two + terminals on one machine — host `--share` in a fixture project, guest + `--join`; `curl http://127.0.0.1:/health` shows the host's + `index_document_id`; browser on the guest port renders the document; + live edit on host propagates + *(executed 2026-08-05 — see "Phase 2 end-to-end record" below; the + guest side used `cargo run -p quarto-p2p --example tunnel-client` + since `--join` itself is Phase 3)* + +### Phase 2 end-to-end record (2026-08-05) + +All output inspected; the guest was the new `tunnel-client` example +(`crates/quarto-p2p/examples/tunnel-client.rs`) because the real `--join` +lands in Phase 3 — the tunnel path exercised (ticket parse → +`TunnelClient::bind` → local proxy) is exactly what Phase 3 will wrap. + +- **Host** (fixture project: `_quarto.yml` + `index.qmd` + `about.qmd`): + `q2 preview --share --no-browser --port 9377` printed the + boot URL, then the banner: + + ``` + Sharing this preview session (end-to-end encrypted via iroh). + Anyone with the join string below can VIEW the project and RE-RUN its + code on this machine: + + q2 preview --join q2previewadtdnwynvuwfau3ybfdlxih7yyexrw5xms6ew6dnoix7g3y… + ``` + + (224-char ticket, within the plan's 173–235 estimate; no direct/LAN + warning — the n0 relay was reachable.) +- **Guest:** `tunnel-client 9280` → "joined shared preview + session: http://127.0.0.1:9280/", status `Connected`. The ticket was + copy-pasted from the host's captured stdout and parsed round-trip; + the interactive triple-click-on-a-wrapped-terminal-line check still + wants a human eyeball (noted for the Phase 3 e2e, which a user drives). +- **`/health` through the tunnel** returned the byte-identical payload to + direct, including `"index_document_id":"2GQkn7ADdeaLnaME5mQBo6eFnJvi"` + and `"qmd_file_count":2`. +- **Browser (Playwright Chromium 1223** at `http://127.0.0.1:9280/?page=index.qmd`, + frames scanned per the Gate 0 iframe finding): document rendered + through the tunnel in **1.47 s**; screenshot inspected (`MARKER-0` + visible). +- **Live edit:** host-side `MARKER-0`→`MARKER-1` write propagated to the + guest browser in **1.07 s**; post-edit screenshot inspected (rendered + text shows `MARKER-1`). +- **Ctrl-C:** SIGINT to the host → "Received Ctrl-C, initiating graceful + shutdown…" → final filesystem sync (3 docs, 0 errors) → process + `EXIT=0`; the guest's status watch flipped `Connected → Reconnecting`. + +## Phase 3 — `q2 preview --join ` (guest) + +*(implemented 2026-08-06, bd-6y0p1bne; tests landed first and were +observed failing — 5/6 new CLI conflict tests FAIL (only the Phase 2 +`--share` conflict pre-existed), the two new quarto-p2p status tests +fail as E0432/E0599 compile errors (the expected failure mode for the +`TunnelStatus` shape change, matching Phase 2's E0026 precedent) — +then went green with the implementation)* + +**Tests first:** + +- [x] CLI conflict matrix: `--join` × each of {path, `--share`, + `--no-project`, `--allow-edit`, `--ui editor`, `--data-dir`, + `--preview-dir`} rejected; × {`--port`, `--no-browser`, `--host`} accepted + *(done except `--ui editor`: the `--ui` flag itself is Phase 4 + (bd-jt1etjbn, not yet implemented), so its `--join` conflict lands + there with the flag — noted in the Phase 4 items below. Conflicts + asserted as `ErrorKind::ArgumentConflict`; the accepted set is + pinned by `preview_join_composes_with_guest_flags`)* +- [x] **The money test** (integration, `crates/quarto-preview` or `quarto-p2p` + with a dev-dep on `quarto-hub`): start a real preview hub in-process on + a fixture project (`run_server_with`), `TunnelHost` in front of it, + `TunnelClient` on a random port (all hermetic-iroh); then through the + guest port: (a) `GET /health` → 200 with the host's + `index_document_id`; (b) `repo.dial_websocket("ws://127.0.0.1:{guest}/ws")` + with an in-memory samod client → load `IndexDocument` → files map + matches the fixture. This proves automerge-sync-over-tunnel without a + browser. Patterns: `dial_websocket` per + `crates/quarto-hub-provider/tests/integration/relay_sync.rs:54` (works + here because preview's `/ws` takes no credentials — **verified**: + preview sets `auth_config: None`, + `quarto-preview/src/lib.rs:407-410`, and `ws_handler` skips + credential *and* Origin checks entirely when no auth config is set, + `quarto-hub/src/server.rs:1607,1642-1644`); in-memory repo + + `IndexDocument::load` per `join.rs:30-56` — but note `join.rs` itself + dials via `repo.dial(BackoffConfig, BearerDialer)` because + `dial_websocket` cannot set auth headers (that distinction matters + again in Phase 5) + *(landed as `crates/quarto-preview/tests/integration/join_tunnel.rs::guest_syncs_project_through_tunnel`, + via `quarto_preview::run` (which wraps `run_server_with`) — no new + dev-dep needed beyond `url` (samod + quarto-hub are already regular + deps; samod's `tungstenite` feature unifies in via quarto-hub). + Composition-of-tested-parts, so it passed on first run — that is + the expected outcome for this proof, unlike the fail-first items + above. Also asserts tunnel `/health` == direct `/health`)* + +**Implementation:** + +- [x] `--join ` arg; guest path in `commands/preview.rs::run` that + bypasses project resolution/TempDir/hub entirely: parse ticket, + `TunnelClient::bind(("127.0.0.1"|--host, --port or probed))`, print URL + *(clap arg unhidden + `conflicts_with_all`; `run_join` in + `commands/preview.rs`; `--host` resolves via `tokio::net::lookup_host` + so `localhost` works; explicit `--port` gets the friendly + `validate_explicit_port` check, otherwise the OS assigns and + `TunnelClient::bind` reports the bound port back — no pre-probe + needed since nothing must print before a second server starts)* +- [x] Browser-open readiness = first successful `GET /health` **through the + tunnel** (extend `wait_until_accepting`, `preview.rs:294-327` — a local + TCP accept alone would lie when the tunnel is dead) + *(new `wait_until_healthy` + `health_get_ok` (hand-rolled HTTP/1.1 + GET, no new HTTP-client dep): same backoff shape and + open-anyway-on-timeout floor as `wait_until_accepting`, 15 s budget, + 5 s per-attempt cap; unit tests cover 200 / non-200-keeps-polling / + nothing-listening)* +- [x] Status messaging from the client watch channel: "connected via + ", "reconnecting…". API pinned: + `Connection::paths()` returns a `PathList` whose `Path` entries + expose `is_selected()` / `is_relay()` / `is_ip()` / `rtt()` + (`../iroh/iroh/src/socket/remote_map/remote_state/path_watcher.rs:446-494`); + `paths_stream()` / `path_events()` give live snapshots for the + watch channel (`endpoint/connection.rs:1144-1176`). The selected + path's `is_relay()` is the direct-vs-relay discriminator. + (`Endpoint::remote_info(EndpointId)` also exists, `endpoint.rs:1623`, + but the per-connection API is the right one here) + *(API change in quarto-p2p: `TunnelStatus::Connected` now carries a + `PathKind` ({Direct, Relay, Unknown}, Display-able), fed by a + per-connection `paths_stream()` watcher task (used over + `path_events()` because it yields the current snapshot on first + poll — no missed initial selection). A `conn_generation` counter + guards against a dying connection's straggler snapshot overwriting + the re-dialed connection's kind. `futures` promoted from dev-dep to + dep for `StreamExt`. Pinned by `tunnel::status_reports_direct_path_kind`)* +- [x] Ctrl-C teardown; clear error UX for: malformed ticket, host unreachable, + token rejected (host rotated/session ended) + *(malformed → parse error naming `q2 preview --share` + the + wrapped-line copy warning, exit 1; unreachable → bounded 10 s dial + timeout then "could not reach the share host", exit 1; token + rejected → second quarto-p2p API change: the client supervisor + inspects `conn.closed()` and maps an `ApplicationClosed` carrying + the host's `ERROR_CODE_UNAUTHORIZED` (1, now a crate-shared const) + to a **terminal** `TunnelStatus::Rejected` — no re-dial spin, since + the same token can never succeed — which the CLI turns into "the + host rejected this join string… ask for a fresh `--share` string" + and a non-zero exit. Pinned hermetically by + `tunnel::rejected_token_flips_status_terminal` (client side; the + target-sees-zero-TCP-conns half was already pinned by Phase 1's + `wrong_token_rejected`). Ctrl-C: select on `ctrl_c()` vs the + status reporter, then `TunnelClientHandle::shutdown()`; measured + 29 ms exit when Connected, 3.0 s when Reconnecting (iroh's + documented graceful-close budget with a dial in flight — + accepted))* +- [x] **End-to-end (mandatory, record here):** cross-machine host/guest run + with the real n0 relay path (netns is Linux-only — same logistics as + Gate 0 Q3: two physical machines); inspect rendered output in the + guest browser; note "verified in browser". Join **two guests + concurrently** at least once (v1 scope is N guests — architecture + scope note) + *(both legs done: single-machine 2026-08-06 with two concurrent + guests, cross-machine 2026-08-06 via a GH-Actions guest — see the + records below. The only remaining human-eyeball nicety is the + wrapped-ticket triple-click copy check in a real terminal — + non-blocking, same status as Phase 2's note)* + +### Phase 3 end-to-end record (single-machine legs, 2026-08-06) + +All output inspected. Binary: `target/debug/q2` at the Phase 3 tree +(guests use the real `q2 preview --join` — no example shims). Fixture: +`_quarto.yml` + `index.qmd` (`MARKER-0`) + `about.qmd` in a scratchpad +dir. + +- **Host:** `q2 preview --share --no-browser --port 9377` + printed the boot URL, then the banner ending in a bare + `q2 preview --join q2previewadwfpfxpcncsiqj2qf6…` line (203-char + ticket; relay reachable, so no direct/LAN warning). +- **Two concurrent guests:** `q2 preview --join --no-browser + --port 9280|9281` → each printed + `→ http://127.0.0.1:928x/` and `● connected via direct connection`. +- **`/health` through both guest ports** returned **byte-identical** + payloads to direct (`diff` clean), including + `"index_document_id":"4VQ27RYYrpZ5W3D5Fph6NE5tmYoy"` and + `"qmd_file_count":2`. +- **Browser (Playwright Chromium** at `…/?page=index.qmd`, frames + scanned per the Gate 0 iframe finding): document rendered through the + tunnel on **both** guests in **1.46 s**; screenshots inspected + (`MARKER-0` visible). Note: guests ran `--no-browser` and Playwright + drove the pages, so the auto-open itself wasn't exercised — its + gating helper (`wait_until_healthy`) is unit-tested and the opener is + the same `open_browser_or_log` host mode uses. +- **Live edit:** host-side `MARKER-0`→`MARKER-1` propagated to the + already-open guest page in **~0.6 s** (marker visible 2.2 s after the + edit including a second page's fresh boot + both screenshots); a + fresh guest boot after the edit rendered `MARKER-1` in 1.45 s. + Post-edit screenshots inspected on both guests. +- **Error UX, all observed:** `--join not-a-ticket` → + `invalid join string (wrong prefix, expected q2preview)` + guidance, + `EXIT=1`; SIGINT of the host flipped the surviving guest to + `○ connection lost — reconnecting…`; joining the dead host's ticket + failed after the bounded 10.0 s dial timeout with + `could not reach the share host …`, `EXIT=1`. Token-rejected is + covered hermetically (`rejected_token_flips_status_terminal`) — not + reproducible from the CLI without hand-crafting a wrong-token ticket. +- **Ctrl-C teardown:** guest exit measured **29 ms** in the Connected + state (port unbound, `Received Ctrl-C, leaving the shared session…`); + **3.0 s** in the Reconnecting state (iroh close with a dial in + flight). Host Ctrl-C behavior unchanged from Phase 2's record. +- **Verification at this tree:** `cargo build --workspace` green; + `cargo nextest run --workspace` **10897 passed**; `cargo xtask verify + --skip-hub-build` → "All verification steps passed!"; `cargo tree -i + iroh` from `wasm-quarto-hub-client` still fails (closure clean). + +### Phase 3 end-to-end record (cross-machine n0-relay leg, 2026-08-06) + +Executed via a **GH-Actions guest**, same logistics as Gate 0 Q3: live +host on the dev machine (`q2 preview --share --no-browser` +— fixture with `MARKER-0`, plus a loop bumping `MARKER-N` every 20 s +and logging host bump timestamps in epoch-ms), guest = `ubuntu-latest` +runner (Azure network) that builds **the real `q2` binary** and joins +with **the real `q2 preview --join`** — no spike shims anywhere. +Throwaway workflow `spike-p3-join-guest.yml` + driver +`spike/p3-guest-driver.mjs` on branch `spike/bd-6y0p1bne-p3-cross-e2e` +(commit 07d945a1, never merged); ticket passed through the ephemeral +`SPIKE_P3_TICKET` repo secret. **GH run 31092359776** (job green in +12m13s, most of it the q2 build). All output and both screenshots +downloaded from the `p3-guest-evidence` artifact and inspected. + +- **CLI status surface:** each guest printed exactly one + `● connected via relay` — the Azure↔residential pair never + hole-punched a direct path (exactly the relay-fallback scenario this + leg exists for), and zero `reconnecting`/`rejected` events over the + whole session. This is the leg that exercises the + `PathKind::Relay` rendering (local runs only ever show `direct + connection`). +- **`/health` through the tunnel** (both guests, guest2 concurrent + with guest1): correct payload with the host's + `"index_document_id":"4JGt98WMiAbWRuCfaDwmp3NmPuga"`, + `"qmd_file_count":2`. +- **Browser (headless Chromium on the runner):** first render through + the real n0 relay in **12.7 s**, ~**47.5 MB** fetched (uncompressed + WASM dominates — same payload confounder as Gate 0; its + HTTP-compression mitigation remains the first lever). Boot + screenshot shows `MARKER-33` rendered; final shows `MARKER-37`. +- **Live-edit propagation over 4 marker bumps** (runner-observed ts − + host bump ts; includes the driver's 150 ms poll grain and + host↔runner NTP skew): **899 / 979 / 1043 / 1563 ms, median + ~1.0 s** — consistent with Gate 0's 0.81–2.34 s and the local leg. +- **n0 relays from the runner:** euc1-1 and use1-1 probed 200/OK in + 0.57 s / 0.32 s. +- Caveats unchanged from Gate 0: runner egress is datacenter-class, + not residential; a human-driven Safari-app/lid-close session remains + an optional extra. +- **Housekeeping done 2026-08-06:** `SPIKE_P3_TICKET` secret deleted, + remote `spike/bd-6y0p1bne-p3-cross-e2e` branch deleted (workflow was + throwaway, never merged; local branch kept for reference), host + process stopped — the session token died with it. + +## Phase 4 — `q2 preview --ui editor` (independent track) + +*(implemented 2026-08-06, bd-jt1etjbn; tests landed first and were +observed failing — 20+ E0432/E0425/E0560 compile errors on the +not-yet-existing API (`PreviewUi`, `PreviewUiArg`, `EMBEDDED_EDITOR`, +`lookup_embedded`, `build_editor_boot_url`, `pick_editor_file`, +`editor_ephemeral_note`, `PreviewConfig::ui`), the expected failure +mode for a structural addition per the Phase 2 E0026 precedent — +then went green with the implementation, first against the editor +placeholder, then against the real embed)* + +Serve the **full hub-client editor** from the preview server instead of the +read-only SPA. Zero hub-client *source* changes expected — we reuse the +`#/share/` route with a relative `server` (hub-client resolves relative sync +URLs against the page origin: `hub-client/src/utils/routing.ts:51-57`; share +params parsed at `routing.ts:226-239`, all three of `server`/`file`/`name` +required: `App.tsx:418-423`). + +**Tests first:** + +- [x] CLI: `--ui viewer` / `--ui editor` parse (clap `ValueEnum`, default + `viewer`); an unknown value (`--ui monaco`) is rejected with the list + of valid values; **`--ui` × `--join` rejected** (the one Phase 3 + conflict-matrix entry deferred here because the flag didn't exist + yet — extend `--join`'s `conflicts_with_all` in `main.rs` and add + the parse test alongside Phase 3's `assert_join_conflict` helper) + *(6 tests in `cli_parse_tests`, incl. the `--share --ui editor + --allow-edit` composition; the conflict pinned as + `ErrorKind::ArgumentConflict`)* +- [x] Rust unit: `--ui editor` boot URL builder emits + `http://{host}:{port}/#/share/{indexDocId}?server=%2Fws&file={rel}&name={project}` + (doc id **without** the `automerge:` prefix — `routing.ts:420`; `file` + falls back to the first `.qmd` when no initial page was resolved; note + params ride the **hash fragment**, not the URL query) + *(`build_editor_boot_url` + `pick_editor_file` tests in + `commands/preview.rs`; the fallback takes the lexicographic min of + the index's `.qmd` paths so the unordered files map can't make the + boot nondeterministic; param values go through a new + `percent_encode_component` — also encodes `/`, matching what + `URLSearchParams.toString()` emits on the client)* +- [x] Rust unit: `--ui editor` leaves the write policy alone: with + `--allow-edit` → `DiskWritePolicy::WriteBack`; without → + `DiskWritePolicy::ReadOnly` **and** the ephemeral-session note is + emitted ("session edits are ephemeral — pass `--allow-edit` to + persist to disk") — assert via an injected writer/callback, not + stdout scraping (same style as Phase 2's ticket-line test) + *(`ui_choice_never_changes_disk_write_policy` sweeps the full 2×2 + through the real `build_hub_config`; the note is the pure function + `editor_ephemeral_note` (tested directly — the string logic, not + stdout), printed by the CLI when editor && !allow-edit)* +- [x] Build-time: embed-dir placeholder fallback works on a tree without the + editor dist (mirror the existing `QUARTO_PREVIEW_EMBED_DIR` placeholder + test story, `crates/quarto-preview/build.rs:19-43,60-107`) + *(the embed tests ran green in both tree states — placeholder + (before `dist-preview-embed/` existed) and real embed; the editor + placeholder carries `
` like the viewer's and names + `cargo xtask build-hub-client-embed`. Deliberate deviation: no + `cargo:warning` for the missing editor dist — unlike the viewer + it is opt-in, and warning on every build would nag everyone who + never uses `--ui editor`; the placeholder page is the signal. + Plus 4 embed-contract unit tests in `lib.rs` (editor index served + from the editor embed; viewer never reads the editor embed; + shared assets fall back to the viewer's bytes; **no file in the + editor embed is byte-identical to the viewer embed at the same + path** — the strip contract) and a server-tier integration test + `editor_ui.rs` pinning config → OnceLock → handler plumbing)* +- [x] `cd hub-client && npm run build:all` still green (CRITICAL per + CLAUDE.md); new embed build produces a servable dist + *(covered by the full `cargo xtask verify` recorded below; the + embed dist was served for real in the e2e session)* + +**Implementation:** + +- [x] hub-client: `build:preview-embed` npm script — no `VITE_GOOGLE_CLIENT_ID` + (auth UI off: `App.tsx:104`), `VITE_DEFAULT_SYNC_SERVER=/ws`, outDir + `dist-preview-embed/` via `vite build --outDir`. Note: hub-client has + **no** alternate-config/outDir precedent to copy — the only prior art is + env-var-at-build-time (`build:local-prod`, + `scripts/build-local-prod.sh:11`); the script must keep the 4 HTML + entry points (main, debug, q2-debug, q2-preview) and run after the + `build:wasm` + `build:sandboxed` pre-steps. **hub-client change ⇒ + two-commit changelog rule** + *(done; same vite.config.ts, so all 4 entries + the public/ copies + survive. One addition beyond the plan: `VITE_DISABLE_PWA=1` + a + matching conditional in vite.config.ts — the PWA service worker + would precache the whole ~67 MB bundle (WASM included) into Cache + Storage for every ephemeral `q2 preview` origin (each random port + is its own origin), so the embed build disables it; normal builds + are untouched. `dist-preview-embed` added to hub-client/.gitignore + — the bare `dist` entry doesn't match it)* +- [x] xtask: `build-hub-client-embed` (sibling of `build_q2_preview_spa.rs`) +- [x] `quarto-preview/build.rs`: second build-script-**emitted** env + (`cargo:rustc-env=QUARTO_HUB_CLIENT_EMBED_DIR=…` — mirroring how + `QUARTO_PREVIEW_EMBED_DIR` actually works: build.rs emits it at :30-33, + nothing reads it from the environment) + `include_dir!` + placeholder + fallback; runtime: `--ui editor` flips which dir `spa_handler` + (`lib.rs:490-509`) serves + *(done; the emitted dir is an OUT_DIR **filtered copy** of + `dist-preview-embed/` — see the dedupe item. Runtime seam is + `lookup_embedded(ui, rel)`: viewer mode reads only `EMBEDDED_SPA` + (Phase-A behavior byte for byte); editor mode reads + `EMBEDDED_EDITOR` then falls back to `EMBEDDED_SPA`. A + `--preview-dir` disk override keeps taking precedence over both + embeds regardless of `--ui`, unchanged semantics)* +- [x] `--ui` flag (clap `ValueEnum` `PreviewUi { Viewer, Editor }`, default + `Viewer`) → boot URL in share-route form when `editor`. **Structural + change required:** today the CLI builds *and prints* the boot URL and + captures it in the browser-open task (`preview.rs:136-141`, :158-180) + before `quarto_preview::run` is even called (:224), while the index doc + id only exists server-side (`ctx.index().document_id()`, + `quarto-hub/src/index.rs:138` — bare id; the share route wants it + bare), reachable in `on_ready`. So move boot-URL construction into the + library, or channel the doc id from `on_ready` back to the CLI's + print + browser-open path — "available in `on_ready`" is not enough by + itself. No write-policy coupling — without `--allow-edit`, emit the + ephemeral-session note instead of flipping `DiskWritePolicy` + *(done via the channel-back option: the enum lives in quarto-preview + (`PreviewUi`, no clap dep) with a CLI-side `PreviewUiArg` ValueEnum + mirror; editor mode calls the already-public + `quarto_preview::run_with_on_ready` with a CLI closure that reads + `ctx.index().document_id()` + `get_all_files()`, builds the URL, + prints it, and gates the browser-open on the same + `wait_until_accepting` probe viewer mode uses. Degenerate case: + no `.qmd` in the index (e.g. `--no-project`) boots to the plain + editor project selector with a warning instead of a broken share + link. Viewer mode's print/open path is untouched)* +- [x] Dedupe the shared `wasm_quarto_hub_client_bg.wasm` across the two + embeds. **Decided from measured numbers:** the artifact + is 38,371,765 bytes and **byte-identical** in both dists (sha256 + `a075c962…`), and Vite's content hashing even gives it the same + hashed filename in both (`wasm_quarto_hub_client_bg-B4wtBy8i.wasm`) + since the hash is content-derived. hub-client dist ≈ 67 MB, + q2-preview-spa dist ≈ 45 MB — a naive double-embed adds ~67 MB to + `q2`, ~38 MB of it pure duplication. So: serve the `.wasm` from a + single shared embed; the identical content-hashed filename makes + "strip from one dist, route both asset paths to the shared copy" the + natural mechanism (exact design in this phase). Still record the + final binary delta after dedupe + *(done, generalized: build.rs strips **every** file byte-identical + to the real viewer dist at the same rel path (not just the wasm), + and the runtime editor→viewer fallback serves those paths from the + viewer embed. Measured on the real dists: 64 of 187 files stripped, + 45,705,880 of 69,580,949 bytes — the 38.4 MB main wasm, a 3.3 MB + sass chunk, the 2.7 MB automerge wasm, tree-sitter wasm, KaTeX + fonts — leaving 23.9 MB embedded. **Release `q2` binary: + 153,127,632 B without the editor dist → 175,353,280 B with it = + +22.2 MB** (vs ~+69.6 MB naive; ~47 MB saved). Safety property: + only byte-identical files are stripped, so two dists built from + *different* wasm artifacts embed both copies — fat but correct. + Operational note: `build:preview-embed` reruns `build:wasm`, and a + regenerated wasm can differ from the one the viewer dist carries + (observed: sha `a075c962…` → `6a265987…` with no Rust change in + the WASM closure) — if the hashes diverge, rebuild the viewer + (`cargo xtask build-q2-preview-spa`) so the strip fires again)* +- [x] Known warts to document in `--help` + here: hub-client persists a + ProjectEntry + IndexedDB automerge cache per ephemeral session (stale + entries accumulate across preview restarts — follow-up strand); + `--share --ui editor` means the *host* picks the UI for all guests + *(both in the `--ui` help text. One more wart found in the e2e: + on a fresh origin — which every `q2 preview` port is — the share + route's auto-added project entry trips hub-client's **project-set + migration screen** ("Upgrade: Synced Project List"); one click on + "Migrate Projects" proceeds, and the set syncs through the + preview's own `/ws`. App.tsx:240-252 already auto-establishes the + set silently for `join-collection` invites; extending that to + `share` routes would change production hub-client behavior, so it + goes to the follow-up strand with the IndexedDB cleanup, not into + this phase)* +- [x] End-to-end (mandatory): real browser session — editor loads, file + sidebar shows the project, Monaco edit persists to host disk (verify + file content on disk), preview pane updates + *(executed 2026-08-06 — see "Phase 4 end-to-end record" below)* + +### Phase 4 end-to-end record (2026-08-06) + +All output inspected. Binary: `target/debug/q2` at the Phase 4 tree with +the real embeds (viewer + editor rebuilt from the same wasm artifact). +Fixture: `_quarto.yml` + `index.qmd` (`MARKER-0 here.`) + `about.qmd` in +a scratchpad dir. Browser: Playwright Chromium, screenshots inspected +for both legs. + +- **Write-back leg:** `q2 preview --ui editor --allow-edit + --no-browser --port 9393` printed + `→ http://127.0.0.1:9393/#/share/269NF7Cd3ECERWph17EyzsTBmhuW?server=%2Fws&file=index.qmd&name=fixture` + (no ephemeral note — `--allow-edit` given). Browser: project-set + migration screen (fresh origin, the wart above) → one click → + **editor loaded (Monaco surface) in 0.48 s**; file sidebar lists + `_quarto.yml`, `about.qmd`, `index.qmd` + outline; preview pane + rendered `MARKER-0 here.`. Typed ` EDITED-BY-P4-E2E` at the end of + the marker line in Monaco → preview pane re-rendered live (checked in + child frames only — the iframe finding from Gate 0) → **the edit + landed in `index.qmd` on disk ~3 s later** (rides the hub's 5 s + periodic sync): `MARKER-0 here. EDITED-BY-P4-E2E`. Before/after + screenshots show Monaco + preview pane both carrying the edit. +- **Sandbox leg (the 2×2's other corner):** same fixture reset, `q2 + preview --ui editor --no-browser --port 9394` (no + `--allow-edit`) printed the note `session edits are ephemeral — pass + --allow-edit to persist edits to disk`, then the share-route URL. + Editor loaded (0.47 s), typed ` SANDBOX-EDIT-P4`, preview pane + re-rendered live, and **`index.qmd` on disk stayed byte-identical + across a 12 s window** (two periodic-sync intervals) — the + ReadOnly policy held while the live session carried the edit. +- **Editor-mode HTTP surface** (server-tier test + placeholder-tree + run): `GET /` serves the editor embed's `index.html`, unknown paths + fall back to it, `/health` + `/api/preview/config` keep answering + from the hub. On the placeholder tree the same invocation served the + "editor UI is not built" page naming the xtask (observed live at + port 9391 before the embed was built). +- **Verification at this tree:** `cargo build --workspace` green; + `cargo nextest run --workspace` **10914 passed** (incl. the 17 new + Phase 4 tests, run against both the placeholder and the real embed); + clippy clean on the three touched crates; **full `cargo xtask verify` + (hub-client legs included, per this phase's policy) → "All + verification steps passed!"**, output inspected. After verify's own + `build:wasm` re-run, the two dists' wasm stayed byte-identical + (sha `6a265987…` in both) — the dedupe precondition holds. + +## Phase 5 — Spike: `--join https://quarto-hub.com/#/share/…` + +Design-doc question: can `--join` also target a hosted hub share URL? The +honest answer is "auth is the hard part" — so this phase is a **spike + design +note**, not committed implementation. Deliverable: a +`claude-notes/plans/2026-XX-XX-join-hosted-hub.md` with a go/no-go and a +working prototype behind no flag stability promise. + +Sketch (what research says is feasible): + +- Parse the share URL (precedent: `parse_index_doc_id`, + `crates/quarto/src/commands/provide_hub.rs:63-72`; TS twin + `ts-packages/quarto-hub-mcp/src/share-url.ts`) +- Serve the Phase-4 hub-client embed locally; browser stays same-origin +- The blocker: hub cookie auth is same-origin with an Origin==Host check on + `/ws` (`crates/quarto-hub/src/server.rs:1631-1636`), and browsers can't set + WS headers. So: a local **reverse proxy** that terminates the browser's + same-origin `/ws` and dials `wss://quarto-hub.com/ws` injecting + `Authorization: Bearer ` — the hub already accepts Bearer + on `/ws`, and both the token acquisition (hub-mcp PKCE + keyring, + `ts-packages/quarto-hub-mcp/src/auth/*`) and the header-injecting dial + (`BearerDialer`, `crates/quarto-hub-provider/src/dialer.rs`) have working + precedent. `/auth/actor` (per-project actor id) needs the same proxying +- Open questions for the spike: bearer expiry mid-session (no refresh story on + `/ws` — the hub validates once at upgrade, `server.rs:1595-1601`); whether + q2 grows a browserless Google login flow or shells out to `q2`'s existing + hub auth; identity/presence when several guests proxy through one bearer + +- [ ] Spike + design note + go/no-go + +--- + +# Verification (repo policy) + +- Every phase: `cargo build --workspace`, `cargo nextest run --workspace` + (never through `tail`), `cargo xtask verify --skip-hub-build`; **full + `cargo xtask verify` for Phase 4** (hub-client + embed legs) +- TDD is non-negotiable: each phase's test list lands and **fails** before its + implementation items. **The one exemption is Gate 0**: it is an + investigation spike whose code is never merged; Phase 1 re-implements the + tunnel from scratch, tests-first +- Gate 0's verdict (with the user's sign-off) must be recorded in this file + and as a braid comment on the epic before any other phase's strand moves + to `in_progress` +- End-to-end verification per CLAUDE.md: the checked items marked + *(mandatory, record here)* must capture the exact invocation, an output + snippet, and an explicit "output inspected" note in this file before the + phase's strand closes. Unit tests alone do not close a phase +- No pushes without explicit user approval + +# Risks / open questions + +1. **Dependency weight.** iroh brings noq (quinn fork), netwatch, portmapper, + hickory, reqwest. Compile time + binary size measured in Gate 0 (Q4); + the go/conditional-go criterion is explicit there. + (`fast-apple-datapath` uses private Apple APIs — irrelevant unless we + ever ship via the Mac App Store.) +2. **n0 infrastructure dependency at runtime.** Relays + pkarr are n0-hosted + defaults. Ticket embeds direct+relay addrs, so LAN joins survive an n0 + outage; cross-NAT joins do not. Acceptable for v1; self-hosted relay is the + escape hatch. `IROH_FORCE_STAGING_RELAYS` exists for debugging. +3. **RCE surface honesty.** Sharing = letting guests run the project's code on + the host (that's what preview re-execution *is*). Mitigated by capability + ticket + loud print; not mitigable further without per-peer permissions. +4. **Per-peer permissions don't exist.** One `DiskWritePolicy` + one + process-wide `allowEdit` for all peers. Follow-up strand; likely needs the + samod fork's `AccessPolicy` hook (it exists for exactly this). +5. **Tunnel latency on the relay path.** Asset boot may be slow relayed; + sync frames are small. The preview server serves assets **uncompressed** + (verified: no CompressionLayer in the preview/hub stack; + quarto-hub's tower-http features are `trace`/`cors`/`set-header` only), + so the first lever if boot feels bad is HTTP compression + (`tower_http::CompressionLayer` or precompressed `.wasm.br` — wasm + compresses ~3–5×, no version-skew cost). Only after that: serving SPA + assets from a guest-side embed while proxying only `/health|/ws|/api|/.quarto` + — deliberately deferred (version-skew cost). +6. **Windows** for iroh-dependent tests — Gate 0 checks compile (note: + test-suite CI is ubuntu + macos only; Windows verification is manual per + `claude-notes/instructions/windows-dev.md`, or the `release.yml` windows + leg); if tests misbehave see that file's exclusion guidance. + +# Follow-up strands to file when the epic lands + +- Per-peer edit permissions (AccessPolicy-based) +- Guest-side asset serving (relay-path boot latency) +- `AddrFilter::relay_only()` privacy knob for `--share` +- hub-client ephemeral-session IndexedDB/ProjectEntry cleanup +- samod-native `IrohDialer` for native peers (`q2 provide-hub` over iroh) + +# Braid strands + +- Epic: **bd-yyoyvx91** +- Gate 0: bd-l4j4ky8k · Phase 0: bd-9gam4jqe · Phase 1: bd-v8mwzpmi · + Phase 2: bd-jhvkwosw · Phase 3: bd-6y0p1bne · Phase 4: bd-jt1etjbn · + Phase 5 (spike): bd-ckra329s +- Blocking chain: G0 → P0 → P1 → P2 → P3; P4 blocks only on G0 (parallel + track once the gate passes — the gate *is* the due diligence P4 waits + for); P5 blocks on P3 + P4. `braid ready` should show G0 and nothing + else until it closes (P0 and P4 unblock together when it does). The + skein matches this chain. + +# Reference index + +- Preview CLI + flow: `crates/quarto/src/main.rs:195-243`, + `crates/quarto/src/commands/preview.rs` (`run` :58-225, port probe :229-256, + browser wait :294-327, boot URL :446-471) +- Preview server: `crates/quarto-preview/src/lib.rs` (embed :38, config + routes :443-476, spa_handler :490-509, hub config :360-423, + allow-edit/DiskWritePolicy :414-421) +- Hub seams: `crates/quarto-hub/src/server.rs` (`run_server_with` :1890, + `extend_router`/`on_ready`/`on_file_changed` :1893-1895; `on_ready` fires + :1915 *before* the listener binds :1933, ws auth :1602-1648), + `context.rs` (`acceptor()` :441, repo build :293-316); index doc id via + `ctx.index().document_id()` (`index.rs:138`, bare id — the SPA prepends + `automerge:`, `PreviewApp.tsx:511-513`) +- samod transport seam: samod checkout `samod/src/transport.rs:19-81`, + `acceptor_handle.rs:74-82`; dialer precedent + `crates/quarto-hub-provider/src/dialer.rs`, joiner precedent `join.rs:30-56` +- SPA same-origin assumptions: `q2-preview-spa/src/PreviewApp.tsx` + (deriveWsUrl :539-542, fetchIndexDocId :499-514, allow-edit :523-532, + connect :818-824), `bootController.ts:1-20` +- hub-client share route: `hub-client/src/utils/routing.ts` (:35-57, :99-109, + :226-239, :347-354, :413-431), consumption `App.tsx:409-463` +- iroh (../iroh @ v1.0.3): `iroh/src/endpoint.rs` (builder :952, connect + :1052, accept :1165, online :1358, close :1706), + `endpoint/presets.rs:113`, `protocol.rs` (Router :406-511), + `endpoint/quic.rs` re-exports; `iroh-tickets` 1.0 (`Ticket` trait, + BASE32_NOPAD + postcard); examples `echo.rs`, `echo-no-router.rs`, + `auth-hook.rs`, `transfer.rs` (local-relay dev pattern) +- Prior plans: `2026-05-11-q2-preview-epic.md` (Phase E bullets :311-315 + (unnumbered — no "E.2/E.3" labels exist), out-of-scope + :572-580, Q7 :416-431), `2026-06-10-q2-preview-edit-writeback.md`, + `2026-06-11-firefox-ws-peer-timeout-fix.md` diff --git a/claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md b/claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md new file mode 100644 index 000000000..7e8d2b928 --- /dev/null +++ b/claude-notes/plans/2026-08-07-automerge-fork-at-missingops.md @@ -0,0 +1,115 @@ +# Diagnosis: preview hub abort — automerge `fork_at` MissingOps (upstream #1327) + +Strand: bd-4062e8tn + +## The crash (as reported) + +`q2 preview --allow-edit --share .` aborts after a few minutes: + +1. `automerge-0.10.0/src/op_set2/change/collector.rs:761:40`: + `called Result::unwrap() on an Err value: MissingOps` — inside + `run_periodic_sync` → `sync_document`'s `with_document` closure. +2. The unwind escapes the closure, so samod's + `WithDocGuard::drop` panics (`WithDocGuard dropped without + comitting`, document_actor.rs:721). +3. "panic in a destructor during cleanup" → non-unwinding panic → + **process abort**. + +## Root cause (verified) + +This is upstream [automerge#1327](https://github.com/automerge/automerge/issues/1327) +with the root-cause fix in open PR +[automerge#1366](https://github.com/automerge/automerge/pull/1366). + +`Automerge::fork_at` (automerge.rs:578-602 in 0.10.0) walks backwards +from the requested heads, marking a hash as seen only when it is +**popped** from the stack, not when it is **scheduled**. In some DAG +shapes the same dependency is scheduled twice before either copy is +popped; the duplicate produces two `BuildChangeMetadata` entries for +the same `(actor, seq, op range)`. The change collector +(`ChangeCollector::for_hashes` → `from_build_meta_inner`) fills one +builder, then finds the duplicate's slots empty → `Err(MissingOps)` → +the `unwrap()` at collector.rs:761 panics. + +The trigger in q2: `sync_document`'s every-5-seconds +`fork_at(last_sync_heads)` + `update_text` + `merge` cycle +([sync.rs:111](../../crates/quarto-hub/src/sync.rs)) interleaved with +splice-heavy browser edits arriving over sync — precisely the issue's +reported pattern ("file watcher uses `fork_at(save_heads)` to +reconcile external disk changes"). The checkpoint heads are multi-head +after any merge, which is what gives the walk its diamond shape. This +is why it takes "a few minutes": the panic needs the right topology, +not a specific duration. + +**Not** corruption: `save()`/`load()` round-trips fine (the export +path bypasses the collector). The bug is deterministic in the +traversal, so once the DAG has the shape, `fork_at` over those heads +panics every time — containment alone cannot restore sync for the +affected document. + +## Verification evidence + +- The issue's deterministic reproduction (char-at-a-time `splice_text` + from one peer, fork+merge from another, real `sync::State` message + exchange, `fork_at` over historical heads every 200 chars) panics on + our pinned automerge 0.10.0 with the **identical** signature + (`collector.rs:761:40: MissingOps`). Adapted as + `sync::tests::repro_fork_at_missing_ops` in sync.rs (currently + failing — TDD red). +- Applying #1366's one-hunk fix (mark dep hashes seen at schedule + time) to a scratch copy of 0.10.0 and `[patch.crates-io]`-ing it in: + the repro **passes**, and the full quarto-hub + quarto-preview + suites (553 tests) stay green. +- Earlier hypotheses eliminated empirically: pure fork/merge patterns + (500 rounds, linear or concurrent branches) don't trigger it — + real `sync::State` message exchange plus the multi-head checkpoint + walk is required; duplicate actor ids produce a *clean* + `Err(DuplicateSeqNumber)` from the batch apply (rejected before any + application), not MissingOps. + +## Fix tracks + +### A. Root cause: carry #1366 via a git-pinned automerge (recommended) + +`[patch.crates-io] automerge = { git = "…", rev = "…" }` pointing at a +fork with the #1366 commit rebased on the 0.10.0 tag. Precedent in +this workspace: the `runtimelib` git pin (cscheid/runtimed, by rev). +nteract carries the same patch rebased on 0.10.0 for the same reason. +The pin covers the whole graph (samod uses `automerge = { workspace = +true }`; a single automerge 0.10.0 is in the lockfile). Remove once +upstream merges #1366 and ships a release. + +Open question: where the fork lives (quarto-dev fork of automerge-rs +vs. pinning rgbkrk's branch by rev — his branch is main-based and was +force-pushed once, so rev-pinning is mandatory; a 0.10.0-based branch +is safer). + +### B. Blast radius: containment in `sync_document` (recommended as well) + +Even with A, the collector `unwrap` remains for other error classes +(PR #1360, error propagation, is still open upstream), and samod's +`WithDocGuard` turns **any** panic inside `with_document` into a +process abort. Wrap the `sync_document` closure body in +`catch_unwind(AssertUnwindSafe(..))`; on panic, heal the document in +place (`*doc = Automerge::load(&doc.save())` — rebuilds clean internal +indices, preserves heads and actor id) and return a sync error instead +of crashing. The collector panic paths are all read paths +(`fork_at`/`get_change_by_hash`/`generate_sync_message` build new +docs or read; the mutating `apply_changes` does not run the +collector), so the document is never half-mutated by the panic itself. + +### Explicitly rejected + +- **Avoiding `fork_at` / single-head checkpoints**: breaks + sync_document's merge semantics; the diamond lives inside ordinary + multi-head history anyway. +- **Containment only**: the traversal bug is deterministic per DAG + shape — the affected doc would panic every 5 s cycle forever + (caught, but sync dead for that doc). + +## State of the working tree + +- `crates/quarto-hub/src/sync.rs`: the deterministic repro test + (TDD red against unpatched automerge). +- Scratch under `target/repro-missingops/` (gitignored): patched + automerge copy, live-repro driver, fixture copy. diff --git a/claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md b/claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md new file mode 100644 index 000000000..bf23576e8 --- /dev/null +++ b/claude-notes/plans/2026-08-07-preview-editor-skip-project-setup.md @@ -0,0 +1,169 @@ +# Skip project-set setup for `q2 preview --ui editor` (ephemeral hub) + +Strand: bd-zf4ryvuq + +## Overview + +`q2 preview --ui editor` boots the browser to a hub-client share URL +(`#/share/?server=%2Fws&file=…&name=…`, built by +`build_editor_boot_url` in +[preview.rs](../../crates/quarto/src/commands/preview.rs)). Before the +share handler can land the user in the editor, `App.tsx` gates rendering +on `projectSetState.status`: a fresh browser profile (`needs-setup`) or +one with legacy IDB projects (`needs-migration`) gets the +`ProjectSetSetup` create/migrate page instead of the preview. Preview +binds an ephemeral port by default, so IndexedDB is almost always fresh +— every preview boot shows onboarding for a project set the user does +not need (the hub is a throwaway per-session server; see the sibling +plan +[2026-08-07-ephemeral-hub-secrets-for-preview.md](2026-08-07-ephemeral-hub-secrets-for-preview.md)). + +**Approach:** the preview server's boot URL carries `ephemeral=true` on +the share route. hub-client captures the flag once at mount (before the +share handler clears the URL), then: + +1. **Silently establishes the personal root set**, mirroring the + existing invite-first onboarding effect for `join-collection` + (`App.tsx`): `needs-setup` → `createProjectSet(DEFAULT_SYNC_SERVER)`; + `needs-migration` → `migrateProjects(DEFAULT_SYNC_SERVER)`. In the + preview-embed build `DEFAULT_SYNC_SERVER` is `/ws` (hub-client + `package.json` `build:preview-embed`) — the ephemeral hub itself. + Fire-once ref guard, same retry-loop rationale as join-collection. +2. **Bypasses the `ProjectSetSetup` gates** (`needs-setup` / + `needs-migration` / `error`) when the flag is set, so the user goes + straight to the connecting state and then the editor. + +Production is unaffected: `buildShareableUrl` / `buildHashRoute` never +emit the param, so only preview-generated boot URLs carry it. + +## Work Items + +### Phase 1 — Tests first (TDD) + +- [x] `crates/quarto/src/commands/preview.rs`: update + `build_editor_boot_url_emits_share_route_in_hash` and + `build_editor_boot_url_strips_automerge_prefix` to expect + `&ephemeral=true`; add a test asserting the flag is present and is the + last param. Confirm fail. (Confirmed: both failed pre-implementation.) +- [x] `hub-client/src/utils/routing.test.ts`: share-route parse tests — + `&ephemeral=true` → `ephemeral: true` on the route; absent param → + field absent (conditional spread, mirrors `anchor`); non-`true` value + → absent. Confirm fail. (Confirmed: parse test failed + pre-implementation.) + +### Phase 2 — Rust implementation + +- [x] `build_editor_boot_url`: append `&ephemeral=true`; extend the doc + comment (the param marks the serving hub as ephemeral; client skips + project-set onboarding). +- [x] `cargo nextest run -p quarto build_editor_boot_url` — 3 passed. + +### Phase 3 — hub-client implementation + +- [x] `routing.ts`: `ShareRoute` gains optional `ephemeral?: boolean` + (doc comment: only preview boot URLs set it; never emitted by + `buildHashRoute`); parse via conditional spread, mirroring the + `anchor` pattern. `routesEqual` unchanged (flag is not a location + discriminator). +- [x] `App.tsx`: + - Capture once at mount: `useState(() => { const r = + parseHashRoute(window.location.hash); return r.type === 'share' && + r.ephemeral === true; })` (mirrors the `authErrorReason` pattern). + - Silent-establish effect after the join-collection one, with a + fire-once ref (`ephemeralRootInitiatedRef`). + - Gate skips: `!ephemeralHub && …` on the needs-setup/needs-migration + gate and the error gate. +- [x] `npx vitest run src/utils/routing.test.ts` (85 passed); `tsc -b` + clean. + +### Phase 4 — End-to-end verification + +- [x] Rebuild the embedded editor: `cargo xtask build-hub-client-embed` + (runs hub-client `build:preview-embed`), then `cargo build --bin q2`. +- [x] Boot `target/debug/q2 preview examples/websites/01-minimal + --ui editor --no-browser`, confirm the printed URL carries + `ephemeral=true`. Observed: + + ``` + → http://127.0.0.1:53411/#/share/qe5rdRWvXH5eVGY5azrZ5bMZQtE?server=%2Fws&file=index.qmd&name=01-minimal&ephemeral=true + ``` + +- [x] Drive a real browser (Playwright script, fresh profile) against + the printed URL: assert the editor loads the file and the + ProjectSetSetup page ("Create New Project Set" / migration) never + appears. Evidence (script drove headless Chromium with a fresh + profile — no IDB/localStorage): + + ``` + PASS: .editor-container became visible + PASS: ProjectSetSetup never rendered + PASS: editor shows index.qmd + final url: http://127.0.0.1:53411/#/p//file/index.qmd + ``` + + Control run against the same server with the param removed (the + "before" behavior): `ProjectSetSetup visible: true`, editor not + visible. Two console errors appear in BOTH runs and are pre-existing, + unrelated to this change: `401 /auth/me` (expected in auth-less + builds — projectSetService.ts documents it always 401s without + VITE_GOOGLE_CLIENT_ID) and `WASM module not initialized` from + `disconnect()`'s `vfsClear` (automergeSync.ts:169) firing on the + share handler's URL-clearing route change before `connect()`'s + `await initWasm()` — present without the ephemeral flag too. +- [ ] Permanent Playwright spec spawning the q2 binary: deferred — the + e2e suite's globalSetup boots a hub on :3031 for all specs and + nothing in the suite builds/runs the q2 binary (the embed is baked at + cargo-build time), so a preview-boot spec needs its own harness. + Filed as follow-up on bd-zf4ryvuq. + +### Phase 5 — Full verification + +- [x] `cargo xtask verify` (full — hub-client changed): all 14 steps + passed. This covers `cargo build --workspace`, `cargo nextest run + --workspace`, the ts-packages builds, `cd hub-client && npm run + build:all` (the CLAUDE.md-required strict hub-client build), and + hub-client `test:ci`. The embedded editor bundle was rebuilt + separately via `cargo xtask build-hub-client-embed` (Phase 4) and + `cargo build --bin q2` re-embedded it. + +### Phase 6 — Bookkeeping + +- [x] Close bd-zf4ryvuq. + +(The hub-client changelog entry is waived for this change — user +decision, 2026-08-07. The CLAUDE.md two-commit changelog workflow does +not apply here.) + +## Details + +### Design decisions + +1. **Signal rides the boot URL, not `/api/preview/config`.** The + endpoint would need a new boot-time fetch in hub-client (404 in + production) and races with the render gates; the hash param travels + with the one URL only the preview server generates, alongside the + existing `server=%2Fws` signal, and is captured before the share + handler's SECURITY URL-clearing. +2. **Silent auto-setup, not a bare gate skip.** A bare skip leaves + `status` stuck at `needs-setup`: ProjectsHome would flash during the + share connect and degrade if the user navigates home. Silently + establishing the root (the join-collection pattern) makes the whole + app coherent — add-to-set, reconciliation, and ProjectsHome all work + — for the cost of one empty root doc per fresh origin. Default + preview ports are ephemeral, so each boot is a fresh origin with no + accumulation. +3. **`ephemeral?: boolean` via conditional spread** (omitted when + absent), matching `FileRoute.anchor`; existing parse-test objects + stay valid, `buildShareableUrl` needs no change. +4. **Skip the `error` gate too** in ephemeral mode: a project-set + failure must not block the preview — the share connect proceeds + without the set. + +### Explicitly out of scope + +- `--no-project` editor boots (no share URL; lands on the project + selector, still gated). Would need a different signal channel. +- IDB accumulation of dead per-session project entries on pinned-port + preview workflows (pre-existing; the silent root set actually absorbs + them via migration). +- `routesEqual` ignores `ephemeral` (not a location discriminator). diff --git a/claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md b/claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md new file mode 100644 index 000000000..6d43b9441 --- /dev/null +++ b/claude-notes/plans/2026-08-07-preview-join-editor-boot-url.md @@ -0,0 +1,214 @@ +# Editor boot URL for `q2 preview --join` guests (skip project-set setup) + +Strand: bd-7htq16rx + +## Overview + +`q2 preview --join ` against a host running `q2 preview --ui +editor --share` opens the browser at `http:///` — the +hub-client **root route**. Two failures follow: + +1. A fresh browser profile (the common case: the guest's proxy port is + ephemeral, so the origin is new) hits the `ProjectSetSetup` gate in + `App.tsx` (`needs-setup` / `needs-migration`). +2. Even past the gate, the app lands on `ProjectsHome`: the root route + carries no document coordinates, so the share handler + (`App.tsx:443`) never runs and the guest **never joins the + document**. + +The host's own boot URL is the share route with `ephemeral=true` +(`build_editor_boot_url`, bd-zf4ryvuq — see +[2026-08-07-preview-editor-skip-project-setup.md](2026-08-07-preview-editor-skip-project-setup.md)). +The guest needs the same URL shape, built against its local proxy +origin. + +**Approach:** + +1. **Host side** (`crates/quarto-preview`): the CLI's editor-mode + `on_ready` already computes the boot params (index doc id, file, + project name) for its own boot URL. It now also stashes them into a + `quarto_preview` OnceLock via `set_editor_boot(...)`, and + `GET /api/preview/config` gains an `editorBoot` field when stashed: + `{ "allowEdit": …, "editorBoot": { "indexDocId", "file", "name" } }`. + The stash happens in `on_ready`, which fires before the listener + binds, so no guest can fetch config before it is set. +2. **Guest side** (`crates/quarto/src/commands/preview.rs`, + `run_join`): after the tunnel binds, wait for `/health` through the + tunnel (the existing probe, now unconditional), then fetch + `/api/preview/config` once. If it carries `editorBoot`, build + `http:///#/share/?server=%2Fws&file=…&name=…&ephemeral=true` + (same route helper as the host builder) and print/open that; + otherwise fall back to `http:///` (viewer hosts, older hosts, + `--no-project` editor boots — all unchanged from today). + +The `ephemeral=true` flag makes the guest's hub-client capture the +flag at mount, silently establish the project-set root against `/ws` +(tunneled to the host), and skip the setup/migration/error gates — the +bd-zf4ryvuq machinery, now reached by guests too. No hub-client change +is needed; that support already shipped. + +**Why the config endpoint, not the ticket:** the ticket is minted +before the hub boots (so the banner can print ahead of the first +accept — `share.rs`), hence cannot carry the index doc id. The guest +already does HTTP probes through the tunnel for readiness, and +`/api/preview/config` is the established boot-time session-info +channel (bd-ov4gqk3m). Every `--share`-capable host already serves it, +and the doc id is already exposed to guests via `/health` — no new +trust surface. The viewer SPA reads only `allowEdit`; unknown fields +are ignored. + +## Work Items + +### Phase 1 — Tests first (TDD) + +- [x] `crates/quarto-preview/tests/integration/config_endpoint.rs`: + new test — server booted with `ui: PreviewUi::Editor`, on_ready + calls `quarto_preview::set_editor_boot(...)` (mirroring the CLI), + then `GET /api/preview/config` carries + `editorBoot { indexDocId, file, name }`. Also assert the field is + absent in the existing no-stash tests. Confirm fail (compile error — + the type/setter don't exist yet). Confirmed: + `error[E0425]: cannot find function set_editor_boot in crate + quarto_preview` + `E0422 EditorBootInfo`. +- [x] `crates/quarto/src/commands/preview.rs` tests: + - Guest URL builder: share-route shape, `ephemeral=true` last param, + `automerge:` prefix stripped, percent-encoding of file/name. + - `parse_editor_boot`: config body with `editorBoot` → `Some`; + without → `None`; malformed JSON → `None`; empty `indexDocId` / + `file` → `None`. + - Confirm fail. Confirmed: `E0425 build_guest_editor_url / + parse_editor_boot not found in this scope`. + +### Phase 2 — `quarto-preview` implementation + +- [x] `crates/quarto-preview/src/lib.rs`: public `EditorBootInfo` + (Serialize + Deserialize, camelCase wire names), crate OnceLock + + public `set_editor_boot` (first-writer-wins, same pattern as + `ALLOW_EDIT` / `PREVIEW_UI`), and `preview_config_handler` includes + `editorBoot` when stashed. +- [x] `cargo nextest run -p quarto-preview config_endpoint` — 3 passed + (including the new `config_reports_editor_boot_when_stashed`). + +### Phase 3 — CLI implementation (`crates/quarto/src/commands/preview.rs`) + +- [x] Extract `editor_share_route(index_doc_id, file, name)` (the hash + route, incl. `ephemeral=true`); `build_editor_boot_url` delegates so + host and guest share one route-shape source. +- [x] `build_guest_editor_url(SocketAddr, &EditorBootInfo)`. +- [x] `fetch_editor_boot(addr)` — hand-rolled one-shot + `GET /api/preview/config` (same no-new-deps style as + `health_get_ok`) + pure `parse_editor_boot(&[u8])`. +- [x] `run_join` restructure: probe `/health` unconditionally (15 s + budget, warn-on-timeout as today), then the config fetch; print the + final URL once; open the browser inline (the spawned probe task + goes away — the probe has already happened by print time). +- [x] Host editor-mode `on_ready`: stash `EditorBootInfo` when a share + file was picked (the `None` arm — `--no-project` — stashes nothing). +- [x] `cargo nextest run -p quarto preview` — 49 passed, including the + 5 new tests. + +### Phase 4 — Verification + +- [x] `cargo nextest run --workspace` (monorepo rule) — **10939 + passed, 0 failed**, 197 skipped. +- [x] `cargo xtask verify --skip-hub-build` (Rust-only change; + hub-client's ephemeral handling shipped with bd-zf4ryvuq) — all 14 + steps passed. +- [x] End-to-end through the real binaries — evidence below. + +#### End-to-end evidence + +Host (background, direct PID): + +``` +$ target/debug/q2 preview examples/websites/01-minimal --ui editor --share --no-browser + q2 preview — editor UI + session edits are ephemeral — pass --allow-edit to persist edits to disk + +Sharing this preview session (end-to-end encrypted via iroh). +… +q2 preview --join q2previewad762hujnc7b5xmlmlee6pysgdex65yzkkfq763sjch5wmfvgokaa… + + → http://127.0.0.1:51216/#/share/3gNGe3PqRHQ4EjyQFmNS2P94PMxC?server=%2Fws&file=index.qmd&name=01-minimal&ephemeral=true +``` + +Guest (same machine, `--no-browser`): + +``` +$ target/debug/q2 preview --join q2previewad762… --no-browser + + q2 preview — joining a shared session (end-to-end encrypted via iroh) + → http://127.0.0.1:51233/#/share/3gNGe3PqRHQ4EjyQFmNS2P94PMxC?server=%2Fws&file=index.qmd&name=01-minimal&ephemeral=true + + Press Ctrl-C to leave the session. + + ● connected via direct connection +``` + +The guest prints the **share route** on its own proxy port with the +**same doc id** as the host's boot URL and `ephemeral=true` — before +this change it printed the bare root URL `http://127.0.0.1:51233/`. + +Browser e2e (Playwright, headless Chromium, fresh profile — no +IDB/localStorage, i.e. the `needs-setup` path), driven at the guest's +printed URL: + +``` +PASS: .editor-container became visible +PASS: ProjectSetSetup never rendered +final url: http://127.0.0.1:51233/#/p//file/index.qmd +``` + +The final `#/p//file/index.qmd` URL proves the share handler +connected and loaded the document through the tunnel (it only +navigates after `connectAndLoadContents` succeeds). + +Control run against the same live guest proxy at the bare root URL +(the "before" behavior): `ProjectSetSetup` rendered, editor never +appeared (script exit 1) — confirming the fix, not the client build, +is what skips the gate. + +### Phase 5 — Bookkeeping + +- [x] Close bd-7htq16rx; keep this plan current. + +(No hub-client changelog entry: hub-client is untouched by this +change.) + +## Details + +### Design decisions + +1. **Boot info rides `/api/preview/config`, not a new endpoint.** The + endpoint already exists for boot-time session info, is served by + every preview server (viewer and editor), and is reachable through + the tunnel. Backward compatible in both directions: old hosts omit + the field (guest falls back to `/`, today's behavior); old guests + never fetch it. +2. **The CLI stashes, the server serves.** `pick_editor_file` and the + project-name derivation live in the CLI (they need `initial_page`, + which `PreviewConfig` deliberately doesn't carry); the server crate + just exposes the stash. `on_ready` fires before the listener binds + (`server.rs` contract), so the OnceLock is always set before any + guest can fetch. +3. **Guest prints one URL, after the probe.** Today the guest prints + `/` immediately and a spawned task gates the browser-open on + health. Printing before the probe would print the wrong (root) URL + for editor hosts; so the probe becomes sequential, the final URL + prints once, and the browser opens inline. Common case adds one + tunnel roundtrip (~ms); worst case (host still booting) waits out + the same 15 s budget the spawned probe had. +4. **`EditorBootInfo` is defined in `quarto-preview`** with both + `Serialize` and `Deserialize`; the guest (`quarto` crate) reuses + the same type — one wire-shape definition, no drift. +5. **`--no-project` editor hosts stash nothing** (there is no document + to join); guests land on `/` exactly as the host does. + +### Explicitly out of scope + +- `--no-project --share` editor hosts (degenerate: sharing an empty + editor; guest sees the same project selector the host sees). +- Changing the ticket wire format to carry boot params (impossible + without delaying the banner: the doc id doesn't exist at mint time). +- IDB accumulation on the guest for pinned-port join workflows (same + note as bd-zf4ryvuq; default guest ports are ephemeral). diff --git a/crates/quarto-hub/src/sync.rs b/crates/quarto-hub/src/sync.rs index 5721e3730..abaead734 100644 --- a/crates/quarto-hub/src/sync.rs +++ b/crates/quarto-hub/src/sync.rs @@ -15,7 +15,7 @@ use std::path::{Component, Path, PathBuf}; use std::str::FromStr; -use automerge::{ROOT, ReadDoc, transaction::Transactable}; +use automerge::{Automerge, ROOT, ReadDoc, transaction::Transactable}; use samod::{DocHandle, DocumentId, Repo}; use tracing::{debug, warn}; @@ -45,6 +45,63 @@ pub enum DiskWritePolicy { ReadOnly, } +/// Panic containment for automerge work inside samod's `with_document` +/// (bd-4062e8tn). samod's `WithDocGuard` panics in its `Drop` when the +/// closure unwinds without committing, and a destructor panic during +/// unwind aborts the whole process — so any panic inside automerge +/// (the change-collector `unwrap` of upstream automerge#1327's class) +/// would take down the server. Catch it instead, heal the document in +/// place, and let the caller surface an ordinary sync error. +/// +/// The heal is a `save` → `load` round-trip: the export path bypasses +/// the change collector (the doc data is consistent even when the +/// collector panics — the panic paths are all read paths), and loading +/// rebuilds clean internal indices. Heads and actor id are preserved, +/// so samod's guard commit observes no change and the doc's identity +/// stays stable for sync peers. +/// +/// Returns `Some(result)` when `f` completes — its `Err` is an +/// ordinary sync error, not a panic — and `None` when a panic was +/// caught (and the doc healed). +fn run_contained( + doc: &mut Automerge, + f: impl FnOnce(&mut Automerge) -> Result, +) -> Option> { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&mut *doc))) { + Ok(result) => Some(result), + Err(payload) => { + let msg = payload + .downcast_ref::<&str>() + .map(|s| (*s).to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic".to_string()); + // Heal in place; if save/load itself fails there is nothing + // more to do — the doc is left as-is and the caller still + // gets the panic signal (None) rather than a crash. The + // actor id is a per-session property that save/load does + // NOT round-trip, so restore it explicitly — samod and sync + // peers rely on the doc's actor staying stable. + let actor = doc.get_actor().clone(); + let healed = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let bytes = doc.save(); + Automerge::load(&bytes).map(|mut fresh| { + fresh.set_actor(actor); + fresh + }) + })); + if let Ok(Ok(fresh)) = healed { + *doc = fresh; + } + warn!( + error = %msg, + "automerge panicked during sync; document healed via save/load \ + (containment bd-4062e8tn — please report)" + ); + None + } + } +} + /// Synchronize a single document with its corresponding filesystem file. /// /// This implements the unified sync algorithm: @@ -57,6 +114,11 @@ pub enum DiskWritePolicy { /// [`DiskWritePolicy::ReadOnly`], where disk stays authoritative) /// 7. Update sync checkpoint /// +/// Steps 3-5 are skipped under [`DiskWritePolicy::ReadOnly`] when the +/// filesystem content is unchanged since the last checkpoint: with no +/// disk delta there is nothing to merge, and applying the stale (never +/// written-back) disk content would revert live doc-side edits. +/// /// # Arguments /// * `doc_handle` - Handle to the automerge document /// * `file_path` - Path to the filesystem file @@ -84,129 +146,166 @@ pub fn sync_document( })?; let result = doc_handle.with_document(|doc| { - // 1. Get sync checkpoint (use current heads if none exists or invalid) - let checkpoint_heads = sync_state.get_heads(&doc_id); - let last_sync_heads = checkpoint_heads - .filter(|heads| { - // Validate that all checkpoint heads exist in document history - heads.iter().all(|h| doc.get_change_by_hash(h).is_some()) - }) - .unwrap_or_else(|| doc.get_heads()); - - let current_heads = doc.get_heads(); - let heads_unchanged = last_sync_heads == current_heads; + // bd-4062e8tn: run the automerge work under panic containment — + // a panic anywhere in here (the change-collector unwrap class, + // upstream automerge#1327) must never unwind into samod's + // WithDocGuard, whose Drop would abort the process. On a caught + // panic the doc is healed via save/load and this cycle reports + // an ordinary sync error; the next cycle retries on clean + // indices. + run_contained(doc, |doc| { + // 1. Get sync checkpoint (use current heads if none exists or invalid) + let checkpoint_heads = sync_state.get_heads(&doc_id); + let last_sync_heads = checkpoint_heads + .filter(|heads| { + // Validate that all checkpoint heads exist in document history + heads.iter().all(|h| doc.get_change_by_hash(h).is_some()) + }) + .unwrap_or_else(|| doc.get_heads()); + + let current_heads = doc.get_heads(); + let heads_unchanged = last_sync_heads == current_heads; + + // Check if filesystem content matches what we synced last time + let last_content_hash = sync_state.get_content_hash(&doc_id); + let fs_content_hash = sha256_hash(&fs_content); + let fs_unchanged = last_content_hash == Some(fs_content_hash.as_str()); + + // Early exit: if nothing changed, we're done + if heads_unchanged && fs_unchanged { + debug!(doc_id = %doc_id, "No changes detected, skipping sync"); + return Ok(SyncResult::NoChanges); + } - // Check if filesystem content matches what we synced last time - let last_content_hash = sync_state.get_content_hash(&doc_id); - let fs_content_hash = sha256_hash(&fs_content); - let fs_unchanged = last_content_hash == Some(fs_content_hash.as_str()); + // Steps 3-5 merge the filesystem delta into the document. + // They must only run when the filesystem actually changed: + // the fork-apply rewrites the fork from its checkpoint state + // to the current disk content, and under ReadOnly the + // checkpoint pairs current heads with the *disk* hash (the + // file is never written back), so the doc state at those + // heads has diverged from the disk content. Applying that + // stale disk content anyway would manufacture a revert of + // every doc-side edit older than the previous checkpoint, + // and merging it would delete those edits from the live + // document (the `--ui editor` ephemeral-editing clobber). + // With no disk delta there is nothing to merge; the + // checkpoint update below still advances the heads so the + // next sync doesn't re-fire. WriteBack is unaffected: its + // checkpoint always pairs heads with the content written + // back, so the fork-apply is a no-op when only the doc + // changed — but running it keeps the merged-content read + // below honest, so the gate is ReadOnly-specific. + let merge_fs_delta = !fs_unchanged || policy == DiskWritePolicy::WriteBack; + if merge_fs_delta { + // 3. Fork at sync checkpoint (with fallback if fork_at fails) + let mut forked = doc.fork_at(&last_sync_heads).unwrap_or_else(|e| { + warn!( + doc_id = %doc_id, + error = %e, + "fork_at failed, falling back to current state" + ); + doc.fork() + }); - // Early exit: if nothing changed, we're done - if heads_unchanged && fs_unchanged { - debug!(doc_id = %doc_id, "No changes detected, skipping sync"); - return Ok(SyncResult::NoChanges); - } + // 4. Apply filesystem content to fork + let text_obj = forked + .get(ROOT, "text") + .map_err(|e| Error::Sync(format!("failed to get text object: {:?}", e)))? + .ok_or_else(|| { + Error::Sync(format!( + "document {} has no text field - was it initialized correctly?", + doc_id + )) + })? + .1; + + forked + .transact::<_, _, automerge::AutomergeError>(|tx| { + tx.update_text(&text_obj, &fs_content)?; + Ok(()) + }) + .map_err(|e| Error::Sync(format!("failed to update text in fork: {:?}", e)))?; + + // 5. Merge fork back into main document + doc.merge(&mut forked) + .map_err(|e| Error::Sync(format!("failed to merge fork: {:?}", e)))?; + } - // 3. Fork at sync checkpoint (with fallback if fork_at fails) - let mut forked = doc.fork_at(&last_sync_heads).unwrap_or_else(|e| { - warn!( - doc_id = %doc_id, - error = %e, - "fork_at failed, falling back to current state" - ); - doc.fork() - }); + // 6. Read merged content and write back to filesystem + let merged_text_obj = doc + .get(ROOT, "text") + .map_err(|e| Error::Sync(format!("failed to get merged text object: {:?}", e)))? + .ok_or_else(|| Error::Sync("merged document has no text field".to_string()))? + .1; - // 4. Apply filesystem content to fork - let text_obj = forked - .get(ROOT, "text") - .map_err(|e| Error::Sync(format!("failed to get text object: {:?}", e)))? - .ok_or_else(|| { - Error::Sync(format!( - "document {} has no text field - was it initialized correctly?", - doc_id - )) - })? - .1; + let merged_content = doc + .text(&merged_text_obj) + .map_err(|e| Error::Sync(format!("failed to read merged text: {:?}", e)))?; - forked - .transact::<_, _, automerge::AutomergeError>(|tx| { - tx.update_text(&text_obj, &fs_content)?; - Ok(()) - }) - .map_err(|e| Error::Sync(format!("failed to update text in fork: {:?}", e)))?; - - // 5. Merge fork back into main document - doc.merge(&mut forked) - .map_err(|e| Error::Sync(format!("failed to merge fork: {:?}", e)))?; - - // 6. Read merged content and write back to filesystem - let merged_text_obj = doc - .get(ROOT, "text") - .map_err(|e| Error::Sync(format!("failed to get merged text object: {:?}", e)))? - .ok_or_else(|| Error::Sync("merged document has no text field".to_string()))? - .1; - - let merged_content = doc - .text(&merged_text_obj) - .map_err(|e| Error::Sync(format!("failed to read merged text: {:?}", e)))?; - - // Determine what kind of sync happened - let result_type = if !heads_unchanged && !fs_unchanged { - SyncResult::BothChanged { - merged_len: merged_content.len(), - } - } else if !heads_unchanged { - SyncResult::AutomergeChanged { - new_len: merged_content.len(), - } - } else { - SyncResult::FilesystemChanged { - new_len: merged_content.len(), - } - }; - - // Write merged content back to filesystem (only if it differs and - // the policy allows the automerge → disk direction) - let merged_content_hash = sha256_hash(&merged_content); - if merged_content_hash != fs_content_hash { - match policy { - DiskWritePolicy::WriteBack => { - std::fs::write(file_path, &merged_content).map_err(|e| { - Error::Sync(format!( - "failed to write merged content to {}: {}", - file_path.display(), - e - )) - })?; - debug!( - doc_id = %doc_id, - path = %file_path.display(), - "Wrote merged content to filesystem" - ); + // Determine what kind of sync happened + let result_type = if !heads_unchanged && !fs_unchanged { + SyncResult::BothChanged { + merged_len: merged_content.len(), } - DiskWritePolicy::ReadOnly => { - debug!( - doc_id = %doc_id, - path = %file_path.display(), - "Disk write-back disabled; file left untouched" - ); + } else if !heads_unchanged { + SyncResult::AutomergeChanged { + new_len: merged_content.len(), + } + } else { + SyncResult::FilesystemChanged { + new_len: merged_content.len(), + } + }; + + // Write merged content back to filesystem (only if it differs and + // the policy allows the automerge → disk direction) + let merged_content_hash = sha256_hash(&merged_content); + if merged_content_hash != fs_content_hash { + match policy { + DiskWritePolicy::WriteBack => { + std::fs::write(file_path, &merged_content).map_err(|e| { + Error::Sync(format!( + "failed to write merged content to {}: {}", + file_path.display(), + e + )) + })?; + debug!( + doc_id = %doc_id, + path = %file_path.display(), + "Wrote merged content to filesystem" + ); + } + DiskWritePolicy::ReadOnly => { + debug!( + doc_id = %doc_id, + path = %file_path.display(), + "Disk write-back disabled; file left untouched" + ); + } } } - } - // 7. Update sync checkpoint. Under ReadOnly the file was not - // written, so the checkpoint must record the hash of what is - // actually on disk — otherwise the next sync would misread the - // unchanged file as a fresh filesystem edit. - let checkpoint_hash = match policy { - DiskWritePolicy::WriteBack => &merged_content_hash, - DiskWritePolicy::ReadOnly => &fs_content_hash, - }; - let new_heads = doc.get_heads(); - sync_state.set_checkpoint(&doc_id, &new_heads, checkpoint_hash); + // 7. Update sync checkpoint. Under ReadOnly the file was not + // written, so the checkpoint must record the hash of what is + // actually on disk — otherwise the next sync would misread the + // unchanged file as a fresh filesystem edit. + let checkpoint_hash = match policy { + DiskWritePolicy::WriteBack => &merged_content_hash, + DiskWritePolicy::ReadOnly => &fs_content_hash, + }; + let new_heads = doc.get_heads(); + sync_state.set_checkpoint(&doc_id, &new_heads, checkpoint_hash); - Ok(result_type) + Ok(result_type) + }) + .unwrap_or_else(|| { + Err(Error::Sync(format!( + "automerge panicked while syncing document {doc_id}; \ + the document was healed via save/load and sync will \ + retry on the next cycle" + ))) + }) }); match &result { @@ -933,6 +1032,154 @@ mod tests { doc } + /// Panic containment (bd-4062e8tn): a panic inside the automerge + /// closure must be caught, the doc healed in place (save → load + /// rebuilds clean internal indices; heads and actor id preserved), + /// and the outcome signaled as `None` — never an unwind escaping + /// into samod's `WithDocGuard` (whose Drop would abort the process). + #[test] + fn containment_catches_panic_and_heals_doc() { + let mut doc = create_doc_with_text("hello\n"); + let actor_before = doc.get_actor().clone(); + let heads_before = doc.get_heads(); + + let out: Option> = + run_contained(&mut doc, |_| panic!("simulated collector panic")); + assert!(out.is_none(), "a panic is contained, not propagated"); + + // Healed in place: same actor, same heads, fully functional. + assert_eq!(doc.get_actor(), &actor_before); + assert_eq!(doc.get_heads(), heads_before); + let text_obj = doc.get(ROOT, "text").unwrap().unwrap().1; + assert_eq!(doc.text(&text_obj).unwrap(), "hello\n"); + doc.transact::<_, _, automerge::AutomergeError>(|tx| { + tx.update_text(&text_obj, "hello again\n")?; + Ok(()) + }) + .unwrap(); + // Collector paths work after the heal. + let _ = doc.fork_at(&heads_before).unwrap(); + } + + /// Containment must not disturb the ordinary paths: `Ok` values and + /// ordinary `Err`s pass through untouched (an `Err` is not a panic). + #[test] + fn containment_passes_through_results_and_errors() { + let mut doc = create_doc_with_text("hello\n"); + let out = run_contained(&mut doc, |_| Ok(42)); + assert!(matches!(out, Some(Ok(42)))); + let out: Option> = run_contained(&mut doc, |_| Err(Error::Sync("nope".into()))); + assert!(matches!(out, Some(Err(_)))); + } + + /// Regression test for upstream automerge#1327 (MissingOps panic in + /// `op_set2/change/collector.rs` after `fork_at` + `merge`), adapted + /// from the issue's reproduction; covers the `[patch.crates-io]` + /// automerge pin carrying the #1366 fix (bd-4062e8tn) — if the pin + /// is removed before upstream ships the fix, this goes red again. + /// `sync_document`'s fork-at-checkpoint pattern hits this once + /// browser splices and sync/merge activity give the DAG the right + /// shape. + #[test] + fn fork_at_after_sync_and_merge_does_not_panic() { + use automerge::{ActorId, AutoCommit, sync, sync::SyncDoc}; + + fn sync_docs( + a: &mut AutoCommit, + sa: &mut sync::State, + b: &mut AutoCommit, + sb: &mut sync::State, + ) { + for _ in 0..20 { + let mut progressed = false; + if let Some(msg) = a.sync().generate_sync_message(sa) { + b.sync().receive_sync_message(sb, msg).unwrap(); + progressed = true; + } + if let Some(msg) = b.sync().generate_sync_message(sb) { + a.sync().receive_sync_message(sa, msg).unwrap(); + progressed = true; + } + if !progressed { + break; + } + } + } + + fn sync_one( + from: &mut AutoCommit, + sf: &mut sync::State, + to: &mut AutoCommit, + st: &mut sync::State, + ) { + if let Some(msg) = from.sync().generate_sync_message(sf) { + to.sync().receive_sync_message(st, msg).unwrap(); + } + if let Some(msg) = to.sync().generate_sync_message(st) { + from.sync().receive_sync_message(sf, msg).unwrap(); + } + } + + let mut doc_b = AutoCommit::new(); + doc_b.set_actor(ActorId::from(b"peer-b" as &[u8])); + let text = doc_b.put_object(ROOT, "source", ObjType::Text).unwrap(); + doc_b.splice_text(&text, 0, 0, "# cell\n").unwrap(); + doc_b.commit(); + let initial_heads = doc_b.get_heads(); + + let mut doc_a = AutoCommit::new(); + doc_a.set_actor(ActorId::from(b"peer-a" as &[u8])); + let mut sa = sync::State::new(); + let mut sb = sync::State::new(); + sync_docs(&mut doc_b, &mut sb, &mut doc_a, &mut sa); + + let mut checkpoint_heads = vec![initial_heads]; + + for i in 0..1000u32 { + // Peer A types one character. + let pos = doc_a.text(&text).unwrap().len(); + doc_a + .splice_text( + &text, + pos, + 0, + &format!("{}", (b'a' + (i % 26) as u8) as char), + ) + .unwrap(); + doc_a.commit(); + + if i % 10 == 0 { + sync_one(&mut doc_a, &mut sa, &mut doc_b, &mut sb); + + // Peer B fork+merge. + let mut fork = doc_b.fork(); + fork.set_actor(ActorId::from(format!("b:f{}", i).as_bytes())); + fork.put(ROOT, "counter", (i / 10) as i64).unwrap(); + fork.commit(); + doc_b.merge(&mut fork).unwrap(); + + sync_one(&mut doc_b, &mut sb, &mut doc_a, &mut sa); + } + + if i % 100 == 0 && i > 0 { + checkpoint_heads.push(doc_b.get_heads()); + } + + // Every 200 chars: fork_at historical + merge + sync. + if i % 200 == 0 && i > 0 { + let old = &checkpoint_heads[checkpoint_heads.len() / 2]; + let mut fork = doc_b.fork_at(old).unwrap(); + fork.set_actor(ActorId::from(format!("b:file{}", i).as_bytes())); + fork.put(ROOT, "marker", format!("save-{}", i)).unwrap(); + fork.commit(); + doc_b.merge(&mut fork).unwrap(); + + // Panics pre-fix (MissingOps in the change collector): + let _ = doc_b.sync().generate_sync_message(&mut sb); + } + } + } + /// Helper to read text from doc handle fn read_text_from_handle(handle: &DocHandle) -> String { handle.with_document(|doc| { @@ -1644,6 +1891,58 @@ mod tests { ); } + #[tokio::test] + async fn test_sync_readonly_repeated_doc_edits_are_not_clobbered() { + // Repro for the `--ui editor --share` clobber (ephemeral editing + // under ReadOnly): with the disk untouched, successive periodic + // syncs must preserve ALL doc-side edits, not just the latest. + let temp = TempDir::new().unwrap(); + let repo = create_test_repo().await; + + let doc = create_doc_with_text("Original content"); + let handle = repo.create(doc).await.unwrap(); + let doc_id = handle.document_id().to_string(); + + let file_path = temp.path().join("test.qmd"); + std::fs::write(&file_path, "Original content").unwrap(); + + let mut sync_state = SyncState::load(temp.path()).unwrap(); + let heads = handle.with_document(|doc| doc.get_heads()); + sync_state.set_checkpoint(&doc_id, &heads, &sha256_hash("Original content")); + + // First browser edit; the first periodic sync absorbs it (disk + // untouched, doc keeps the change). + update_text_in_handle(&handle, "Original content\nedit-1"); + sync_document( + &handle, + &file_path, + &mut sync_state, + DiskWritePolicy::ReadOnly, + ) + .unwrap(); + assert_eq!(read_text_from_handle(&handle), "Original content\nedit-1"); + + // A second browser edit lands before the next periodic sync. The + // disk has NOT changed, so this sync must leave both edits intact. + update_text_in_handle(&handle, "Original content\nedit-1\nedit-2"); + sync_document( + &handle, + &file_path, + &mut sync_state, + DiskWritePolicy::ReadOnly, + ) + .unwrap(); + assert_eq!( + read_text_from_handle(&handle), + "Original content\nedit-1\nedit-2", + "the second periodic sync clobbered the first edit" + ); + assert_eq!( + std::fs::read_to_string(&file_path).unwrap(), + "Original content" + ); + } + #[tokio::test] async fn test_sync_readonly_filesystem_change_syncs_to_doc() { let temp = TempDir::new().unwrap(); diff --git a/crates/quarto-p2p/Cargo.toml b/crates/quarto-p2p/Cargo.toml new file mode 100644 index 000000000..b3225e8dd --- /dev/null +++ b/crates/quarto-p2p/Cargo.toml @@ -0,0 +1,42 @@ +# P2P tunnel for `q2 preview --share` / `--join` (live share over iroh). +# Encapsulates ALL iroh usage; quarto-preview and the q2 CLI see only the +# ticket + tunnel types exported from this crate. Native-only: must never +# enter the wasm-quarto-hub-client closure (Gate 0 / Phase 0 static check: +# `cargo tree -i iroh` from that crate must fail). +# Plan: claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md +[package] +name = "quarto-p2p" +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +description = "iroh-based P2P tunnel for q2 preview live share (--share / --join)" + +[dependencies] +# StreamExt for the client's per-connection path watcher +# (`Connection::paths_stream`), which feeds the direct-vs-relay status. +futures = "0.3" +iroh = "1.0.3" +iroh-tickets = "1.0" +# Ticket wire format (postcard of {addr, token}); same encoding stack +# iroh-tickets itself uses. +postcard = { version = "1.1", features = ["use-std"] } +rand = "0.9" +serde.workspace = true +subtle = "2" +thiserror.workspace = true +# io-util + net are required (io::join / copy_bidirectional / TcpListener); +# time for token-read timeouts + re-dial backoff; sync for the status +# watch channel. The workspace default set (rt-multi-thread, macros) lacks +# all four and today they unify on only via quarto-hub's `full`. +tokio = { workspace = true, features = ["io-util", "net", "sync", "time"] } +tracing.workspace = true + +[dev-dependencies] +# Tunnel-target servers for the integration tests (plain HTTP + /ws echo). +axum = { version = "0.8", features = ["ws"] } +futures = "0.3" +tokio-tungstenite = "0.29" + +[lints] +workspace = true diff --git a/crates/quarto-p2p/examples/tunnel-client.rs b/crates/quarto-p2p/examples/tunnel-client.rs new file mode 100644 index 000000000..8ed573a3d --- /dev/null +++ b/crates/quarto-p2p/examples/tunnel-client.rs @@ -0,0 +1,43 @@ +//! Minimal guest-side driver for `q2 preview --share` sessions. +//! +//! `q2 preview --join ` (live-share plan Phase 3, bd-6y0p1bne) +//! is the real guest — it adds health gating, editor boot URLs, browser +//! open, and terminal rejection handling. This example stays as a bare +//! debugging driver: join a session, serve it on a local loopback port, +//! and print raw tunnel status transitions. +//! +//! ```text +//! cargo run -p quarto-p2p --example tunnel-client -- [local-port] +//! ``` + +use quarto_p2p::{PreviewShareTicket, TunnelClient, TunnelClientConfig}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let ticket: PreviewShareTicket = args + .next() + .ok_or("usage: tunnel-client [local-port]")? + .parse()?; + let port: u16 = match args.next() { + Some(p) => p.parse()?, + None => 0, + }; + + let (local, handle) = TunnelClient::bind( + TunnelClientConfig::default(), + ticket, + ([127, 0, 0, 1], port).into(), + ) + .await?; + println!("joined shared preview session: http://{local}/"); + + // Report status transitions until Ctrl-C. + let mut status = handle.status(); + loop { + println!("tunnel status: {:?}", *status.borrow_and_update()); + if status.changed().await.is_err() { + return Ok(()); + } + } +} diff --git a/crates/quarto-p2p/src/client.rs b/crates/quarto-p2p/src/client.rs new file mode 100644 index 000000000..b3d5d580a --- /dev/null +++ b/crates/quarto-p2p/src/client.rs @@ -0,0 +1,298 @@ +//! Guest side of the tunnel: local loopback TCP proxy, one accepted +//! connection = one token-prefixed QUIC bi-stream; re-dial loop with +//! backoff on connection loss. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use futures::StreamExt; +use iroh::address_lookup::memory::MemoryLookup; +use iroh::endpoint::{Connection, ConnectionError, PathList, VarInt}; +use iroh::{Endpoint, EndpointAddr}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{RwLock, watch}; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use crate::{ + ALPN, ERROR_CODE_UNAUTHORIZED, EndpointPreset, PathKind, PreviewShareTicket, TOKEN_LEN, + TunnelError, TunnelStatus, +}; + +/// Per-attempt cap on dialing the host (QUIC handshakes against a dead +/// UDP addr otherwise pend on retransmits for a long time). +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Exponential re-dial backoff bounds. +const REDIAL_BACKOFF_INITIAL: Duration = Duration::from_millis(250); +const REDIAL_BACKOFF_MAX: Duration = Duration::from_secs(5); + +/// How long an accepted local TCP connection waits for the tunnel to come +/// back before being dropped. Callers (browsers, the SPA's health +/// supervisor) retry with fresh connections, so failing one is cheap. +const STREAM_WAIT_TIMEOUT: Duration = Duration::from_secs(30); + +/// Poll grain while waiting for the supervisor to notice a dead connection. +const RETRY_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// Configuration for [`TunnelClient::bind`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct TunnelClientConfig { + /// Endpoint environment (production n0 vs. hermetic loopback). + pub preset: EndpointPreset, +} + +/// Guest side of the tunnel: dials the ticket's endpoint and serves a local +/// TCP listener; one accepted connection = one token-prefixed QUIC +/// bi-stream. Owns a re-dial loop with backoff. +pub struct TunnelClient; + +impl TunnelClient { + /// Dial the ticket's endpoint and bind the local proxy on `local` + /// (port 0 allowed). Returns the bound address and a handle. + /// + /// The initial dial happens here: an unreachable host is an error at + /// bind time (clear CLI UX), not a background retry. + pub async fn bind( + cfg: TunnelClientConfig, + ticket: PreviewShareTicket, + local: SocketAddr, + ) -> Result<(SocketAddr, TunnelClientHandle), TunnelError> { + // Seed a MemoryLookup with the ticket's addresses so re-dials + // re-resolve without n0 infrastructure. + let lookup = MemoryLookup::new(); + lookup.add_endpoint_info(ticket.addr.clone()); + let endpoint = + crate::bind_endpoint(cfg.preset, None, None, |b| b.address_lookup(lookup.clone())) + .await?; + + let conn = timeout(CONNECT_TIMEOUT, endpoint.connect(ticket.addr.clone(), ALPN)) + .await + .map_err(|_| TunnelError::Connect("timed out dialing the share host".into()))? + .map_err(|e| TunnelError::Connect(Box::new(e)))?; + + let listener = TcpListener::bind(local).await.map_err(TunnelError::Proxy)?; + let local_addr = listener.local_addr().map_err(TunnelError::Proxy)?; + + let (status_tx, status_rx) = + watch::channel(TunnelStatus::Connected(selected_path_kind(&conn.paths()))); + let shared = Arc::new(Shared { + endpoint: endpoint.clone(), + remote: ticket.addr, + token: ticket.token, + conn: RwLock::new(conn.clone()), + conn_generation: AtomicU64::new(0), + status_tx, + status_rx: status_rx.clone(), + }); + tokio::spawn(watch_paths(conn, shared.clone(), 0)); + + let supervisor = tokio::spawn(supervise_connection(shared.clone())); + let acceptor = tokio::spawn(accept_loop(listener, shared)); + + Ok(( + local_addr, + TunnelClientHandle { + endpoint, + supervisor, + acceptor, + status_rx, + }, + )) + } +} + +/// Handle to a running [`TunnelClient`]. +#[derive(Debug)] +pub struct TunnelClientHandle { + endpoint: Endpoint, + supervisor: JoinHandle<()>, + acceptor: JoinHandle<()>, + status_rx: watch::Receiver, +} + +impl TunnelClientHandle { + /// Watch channel for CLI messaging ("connected via …", + /// "reconnecting…", "rejected"). + pub fn status(&self) -> watch::Receiver { + self.status_rx.clone() + } + + /// Abort the accept loop (unbinding the local port) and close the + /// endpoint gracefully. + pub async fn shutdown(self) -> Result<(), TunnelError> { + self.acceptor.abort(); + self.supervisor.abort(); + // Await the aborted tasks so the listener is guaranteed dropped + // (port unbound) before we return. + let _ = self.acceptor.await; + let _ = self.supervisor.await; + self.endpoint.close().await; + Ok(()) + } +} + +/// Shared state between the accept loop, per-connection tasks, and the +/// connection supervisor. +struct Shared { + endpoint: Endpoint, + remote: EndpointAddr, + token: [u8; TOKEN_LEN], + conn: RwLock, + /// Bumped on every re-dial; a path watcher only updates the status + /// when its connection is still the current generation, so a + /// straggling snapshot from a dying connection can't overwrite the + /// fresh connection's path kind. + conn_generation: AtomicU64, + status_tx: watch::Sender, + status_rx: watch::Receiver, +} + +/// Classify the selected path of a connection snapshot. +fn selected_path_kind(paths: &PathList<'_>) -> PathKind { + match paths.iter().find(|p| p.is_selected()) { + Some(p) if p.is_relay() => PathKind::Relay, + Some(p) if p.is_ip() => PathKind::Direct, + // Custom transports don't occur here; selection may also simply + // not have happened yet. + _ => PathKind::Unknown, + } +} + +/// Follow one connection's path snapshots, updating a live `Connected` +/// status when the selected path changes kind (e.g. relay → direct once +/// hole-punching lands). Ends when the connection closes. Never touches +/// `Reconnecting`/`Rejected` — those belong to the supervisor. +async fn watch_paths(conn: Connection, shared: Arc, generation: u64) { + let mut stream = std::pin::pin!(conn.paths_stream()); + while let Some(paths) = stream.as_mut().next().await { + let kind = selected_path_kind(&paths); + if shared.conn_generation.load(Ordering::SeqCst) != generation { + return; // a newer connection owns the status now + } + shared.status_tx.send_if_modified(|status| match status { + TunnelStatus::Connected(old) if *old != kind => { + *status = TunnelStatus::Connected(kind); + true + } + _ => false, + }); + } +} + +/// Watches the current connection for death and re-dials with exponential +/// backoff, updating the status channel around the outage. A close carrying +/// the host's "unauthorized" code is terminal: the session token was +/// rejected, so re-dialing with the same ticket can never succeed. +async fn supervise_connection(shared: Arc) { + loop { + let conn = shared.conn.read().await.clone(); + let reason = conn.closed().await; + if let ConnectionError::ApplicationClosed(close) = &reason + && close.error_code == VarInt::from_u32(ERROR_CODE_UNAUTHORIZED) + { + tracing::warn!("preview tunnel: the host rejected the session token"); + shared.status_tx.send_replace(TunnelStatus::Rejected); + return; + } + tracing::info!(?reason, "preview tunnel: connection lost; re-dialing"); + shared.status_tx.send_replace(TunnelStatus::Reconnecting); + + let mut delay = REDIAL_BACKOFF_INITIAL; + loop { + match timeout( + CONNECT_TIMEOUT, + shared.endpoint.connect(shared.remote.clone(), ALPN), + ) + .await + { + Ok(Ok(new_conn)) => { + let generation = shared.conn_generation.fetch_add(1, Ordering::SeqCst) + 1; + *shared.conn.write().await = new_conn.clone(); + shared + .status_tx + .send_replace(TunnelStatus::Connected(selected_path_kind( + &new_conn.paths(), + ))); + tokio::spawn(watch_paths(new_conn, shared.clone(), generation)); + tracing::info!("preview tunnel: reconnected"); + break; + } + Ok(Err(err)) => tracing::debug!(%err, "preview tunnel: re-dial failed"), + Err(_) => tracing::debug!("preview tunnel: re-dial timed out"), + } + tokio::time::sleep(delay).await; + delay = (delay * 2).min(REDIAL_BACKOFF_MAX); + } + } +} + +async fn accept_loop(listener: TcpListener, shared: Arc) { + loop { + match listener.accept().await { + Ok((tcp, _peer)) => { + tokio::spawn(handle_local_conn(tcp, shared.clone())); + } + Err(err) => { + tracing::warn!(%err, "preview tunnel: local proxy accept failed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } +} + +/// One local TCP connection = one token-prefixed QUIC bi-stream. +async fn handle_local_conn(mut tcp: TcpStream, shared: Arc) { + let deadline = tokio::time::Instant::now() + STREAM_WAIT_TIMEOUT; + let (send, recv) = loop { + let conn = shared.conn.read().await.clone(); + match conn.open_bi().await { + Ok(pair) => break pair, + Err(err) => { + tracing::debug!(%err, "preview tunnel: open_bi failed; waiting for reconnect"); + // The supervisor notices the dead connection via + // `closed()` and re-dials; wait for it (with a poll grain + // in case it has not flipped the status yet), then retry + // on the — possibly swapped — connection. + tokio::time::sleep(RETRY_POLL_INTERVAL).await; + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) + else { + return; // budget exhausted; drop the TCP conn + }; + let mut status = shared.status_rx.clone(); + match timeout( + remaining, + status.wait_for(|s| !matches!(s, TunnelStatus::Reconnecting)), + ) + .await + { + // Reconnected: retry open_bi on the swapped connection. + Ok(Ok(current)) if matches!(*current, TunnelStatus::Connected(_)) => {} + // Rejected (terminal), channel closed, or budget + // exhausted: drop the TCP conn. + _ => return, + } + } + } + }; + + let mut send = send; + // Token prefix: authenticates the stream and satisfies iroh's + // write-first rule (the peer's accept_bi does not wake until bytes + // flow). Payload bytes pipeline right behind it — no extra RTT. + if send.write_all(&shared.token).await.is_err() { + return; + } + + let mut quic = tokio::io::join(recv, send); + match tokio::io::copy_bidirectional(&mut tcp, &mut quic).await { + Ok((to_host, from_host)) => { + tracing::debug!(to_host, from_host, "preview tunnel: local conn closed"); + } + Err(err) => { + tracing::debug!(%err, "preview tunnel: local conn ended with error"); + } + } +} diff --git a/crates/quarto-p2p/src/host.rs b/crates/quarto-p2p/src/host.rs new file mode 100644 index 000000000..185402406 --- /dev/null +++ b/crates/quarto-p2p/src/host.rs @@ -0,0 +1,217 @@ +//! Host side of the tunnel: iroh `Router` accept loop → token check → +//! TCP splice onto the local preview server. + +use std::fmt; +use std::net::SocketAddr; +use std::time::Duration; + +use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt}; +use iroh::protocol::{AcceptError, ProtocolHandler, Router}; +use iroh::{Endpoint, EndpointAddr, SecretKey, Watcher}; +use subtle::ConstantTimeEq; +use tokio::net::TcpStream; + +use crate::{ + ALPN, ERROR_CODE_UNAUTHORIZED, EndpointPreset, PreviewShareTicket, TOKEN_LEN, TunnelError, +}; + +/// How long a freshly accepted stream may take to present its token. +const TOKEN_READ_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long to wait for the endpoint to come "online" (relay contact) with +/// the production preset before proceeding without one. Matches iroh's +/// (private) net-report budget; do NOT use `iroh::NET_REPORT_TIMEOUT`, +/// which is a bare `u64` docs constant, not a `Duration`. +const ONLINE_TIMEOUT: Duration = Duration::from_secs(10); + +/// How long to wait for a freshly bound endpoint to report at least one +/// dialable address. +const ADDR_TIMEOUT: Duration = Duration::from_secs(10); + +/// QUIC application error code for "the local target refused a connection". +const ERROR_CODE_TARGET_UNAVAILABLE: u32 = 2; + +/// Configuration for [`TunnelHost::spawn`]. +/// +/// The defaults are the production posture (n0 preset, random identity, +/// random token, default UDP binds). The overrides exist for hermetic tests +/// — a fixed identity + token + UDP port lets a test restart the host and +/// exercise the client's re-dial path against an unchanged ticket. +#[derive(Default)] +pub struct TunnelHostConfig { + /// Endpoint environment (production n0 vs. hermetic loopback). + pub preset: EndpointPreset, + /// Fixed endpoint identity; `None` generates a fresh one. + pub secret_key: Option, + /// Fixed session token; `None` generates a random one. + pub token: Option<[u8; TOKEN_LEN]>, + /// Fixed UDP bind address; `None` uses the preset's default binds. + pub bind_addr: Option, +} + +impl fmt::Debug for TunnelHostConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TunnelHostConfig") + .field("preset", &self.preset) + .field( + "secret_key", + &self.secret_key.as_ref().map(|_| "[redacted]"), + ) + .field("token", &self.token.as_ref().map(|_| "[redacted]")) + .field("bind_addr", &self.bind_addr) + .finish() + } +} + +/// Host side of the tunnel: an iroh `Router` whose accept loop verifies the +/// per-stream token prefix, then splices the stream onto a fresh TCP +/// connection to the local preview server. +pub struct TunnelHost; + +impl TunnelHost { + /// Bind an iroh endpoint, start the accept loop targeting `target` + /// (the loopback-bound preview server), and return the join ticket + /// plus a shutdown handle. + pub async fn spawn( + cfg: TunnelHostConfig, + target: SocketAddr, + ) -> Result<(PreviewShareTicket, TunnelHostHandle), TunnelError> { + let token = cfg.token.unwrap_or_else(rand::random); + let endpoint = + crate::bind_endpoint(cfg.preset, cfg.secret_key, cfg.bind_addr, |b| b).await?; + + // With relays in play, only a completed net-report makes the relay + // URL part of `endpoint.addr()`. `online()` pends forever with no + // relay reachable, so cap it and degrade to direct/LAN-only. + if cfg.preset == EndpointPreset::N0 + && tokio::time::timeout(ONLINE_TIMEOUT, endpoint.online()) + .await + .is_err() + { + tracing::warn!( + "iroh relay unreachable after {}s — ticket will carry direct/LAN addresses only", + ONLINE_TIMEOUT.as_secs() + ); + } + + let addr = dialable_addr(&endpoint).await?; + let router = Router::builder(endpoint) + .accept(ALPN, TunnelProtocol { token, target }) + .spawn(); + + let ticket = PreviewShareTicket { addr, token }; + Ok((ticket, TunnelHostHandle { router })) + } +} + +/// Waits until the endpoint reports at least one dialable transport addr. +async fn dialable_addr(endpoint: &Endpoint) -> Result { + let mut watcher = endpoint.watch_addr(); + let deadline = tokio::time::Instant::now() + ADDR_TIMEOUT; + loop { + let addr = watcher.get(); + if !addr.addrs.is_empty() { + return Ok(addr); + } + let Some(remaining) = deadline.checked_duration_since(tokio::time::Instant::now()) else { + return Err(TunnelError::NoAddress); + }; + match tokio::time::timeout(remaining, watcher.updated()).await { + Ok(Ok(_)) => continue, + Ok(Err(_)) | Err(_) => return Err(TunnelError::NoAddress), + } + } +} + +/// Handle to a running [`TunnelHost`]. +#[derive(Debug)] +pub struct TunnelHostHandle { + router: Router, +} + +impl TunnelHostHandle { + /// Graceful shutdown: `Router::shutdown` closes the endpoint itself + /// (a trailing `Endpoint::close` would be an idempotent no-op). + pub async fn shutdown(self) -> Result<(), TunnelError> { + self.router + .shutdown() + .await + .map_err(|e| TunnelError::Shutdown(Box::new(e))) + } +} + +/// The `q2/preview-tunnel/0` protocol: per connection, accept bi-streams +/// forever; per stream, check the token prefix and splice onto the target. +#[derive(Debug, Clone)] +struct TunnelProtocol { + token: [u8; TOKEN_LEN], + target: SocketAddr, +} + +impl ProtocolHandler for TunnelProtocol { + async fn accept(&self, connection: Connection) -> Result<(), AcceptError> { + // Log joining peers for auditability (security model). + let remote = connection.remote_id().fmt_short(); + tracing::info!(%remote, "preview tunnel: peer connected"); + // The loop ends when accept_bi errors: peer disconnected or we + // shut down; either way this per-connection task is done. + while let Ok((send, recv)) = connection.accept_bi().await { + let proto = self.clone(); + let connection = connection.clone(); + tokio::spawn(async move { proto.handle_stream(connection, send, recv).await }); + } + tracing::info!(%remote, "preview tunnel: peer disconnected"); + Ok(()) + } +} + +impl TunnelProtocol { + async fn handle_stream( + &self, + connection: Connection, + mut send: SendStream, + mut recv: RecvStream, + ) { + let remote = connection.remote_id().fmt_short(); + + let mut presented = [0u8; TOKEN_LEN]; + let authorized = + match tokio::time::timeout(TOKEN_READ_TIMEOUT, recv.read_exact(&mut presented)).await { + Ok(Ok(())) => bool::from(presented.ct_eq(&self.token)), + // Short read (stream finished early) or timeout: unauthorized. + Ok(Err(_)) | Err(_) => false, + }; + if !authorized { + tracing::warn!(%remote, "preview tunnel: bad or missing token; dropping connection"); + let _ = send.reset(VarInt::from_u32(ERROR_CODE_UNAUTHORIZED)); + connection.close(VarInt::from_u32(ERROR_CODE_UNAUTHORIZED), b"unauthorized"); + return; + } + + let mut tcp = match TcpStream::connect(self.target).await { + Ok(tcp) => tcp, + Err(err) => { + tracing::warn!( + %remote, target = %self.target, %err, + "preview tunnel: local target refused connection" + ); + let _ = send.reset(VarInt::from_u32(ERROR_CODE_TARGET_UNAVAILABLE)); + return; + } + }; + + // Terminated splice. `copy_bidirectional` propagates read-EOF as a + // write-shutdown on the opposite side: QUIC FIN → TCP FIN via + // `TcpStream::poll_shutdown`, TCP FIN → QUIC FIN via + // `SendStream::poll_shutdown` (which calls `finish()`). + let mut quic = tokio::io::join(recv, send); + match tokio::io::copy_bidirectional(&mut quic, &mut tcp).await { + Ok((to_target, from_target)) => { + tracing::debug!(%remote, to_target, from_target, "preview tunnel: stream closed"); + } + Err(err) => { + tracing::debug!(%remote, %err, "preview tunnel: stream ended with error"); + } + } + } +} diff --git a/crates/quarto-p2p/src/lib.rs b/crates/quarto-p2p/src/lib.rs new file mode 100644 index 000000000..4c6d8fe36 --- /dev/null +++ b/crates/quarto-p2p/src/lib.rs @@ -0,0 +1,152 @@ +//! P2P tunnel for `q2 preview --share` / `--join` (live share over iroh). +//! +//! The host side ([`TunnelHost`]) exposes the local preview HTTP server +//! through token-authenticated iroh QUIC bi-streams; the guest side +//! ([`TunnelClient`]) is a local loopback TCP proxy that splices each +//! accepted connection onto one such stream. The join string +//! ([`PreviewShareTicket`]) carries the host's `EndpointAddr` and a random +//! 256-bit session token — possession of the string is the capability. +//! +//! Plan: `claude-notes/plans/2026-08-03-q2-preview-live-share-iroh.md`. + +mod client; +mod host; +mod ticket; + +pub use client::{TunnelClient, TunnelClientConfig, TunnelClientHandle}; +pub use host::{TunnelHost, TunnelHostConfig, TunnelHostHandle}; +/// Typed error for [`PreviewShareTicket`] parsing (`FromStr`). +pub use iroh_tickets::ParseError as TicketParseError; +pub use ticket::PreviewShareTicket; + +/// ALPN for the preview tunnel protocol. +/// +/// The trailing `/0` is the protocol version tag; a breaking wire change +/// bumps it together with the ticket KIND. +pub const ALPN: &[u8] = b"q2/preview-tunnel/0"; + +/// Session-token length in bytes (256-bit). +pub const TOKEN_LEN: usize = 32; + +/// Which iroh environment an endpoint binds into. +/// +/// Production code uses [`EndpointPreset::N0`]; tests use +/// [`EndpointPreset::HermeticLoopback`] so no n0 infrastructure (relays, +/// pkarr, DNS) is touched in CI. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum EndpointPreset { + /// n0 production defaults: pkarr publish/resolve + DNS lookup + + /// default relays. + #[default] + N0, + /// Hermetic mode for tests: crypto only, relays disabled, bound to + /// loopback. Peers are reachable only via explicit ticket addresses. + HermeticLoopback, +} + +/// Which kind of network path currently carries the tunnel's traffic +/// (the selected path's `is_relay()` is the discriminator). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PathKind { + /// Direct IP path (LAN or hole-punched). + Direct, + /// Via a relay server (the designed fallback when hole-punching + /// fails; traffic stays end-to-end encrypted). + Relay, + /// No selected path is visible right now (e.g. mid-migration). + Unknown, +} + +impl std::fmt::Display for PathKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + PathKind::Direct => "direct connection", + PathKind::Relay => "relay", + PathKind::Unknown => "unknown path", + }) + } +} + +/// Client connection status, surfaced to the CLI ("connected via relay", +/// "reconnecting…"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelStatus { + /// Tunnel is up; the payload says what kind of path carries it (and + /// tracks upgrades, e.g. relay → direct once hole-punching lands). + Connected(PathKind), + /// Connection lost; the client is re-dialing with backoff. + Reconnecting, + /// The host closed the connection as unauthorized: this join + /// string's token was rejected (the share session ended or the host + /// restarted with a fresh token). Terminal — re-dialing with the + /// same token cannot succeed, so the client stops trying. + Rejected, +} + +/// QUIC application error code the host closes with when a stream fails +/// token auth; the client maps it to [`TunnelStatus::Rejected`]. +pub(crate) const ERROR_CODE_UNAUTHORIZED: u32 = 1; + +pub(crate) type BoxedError = Box; + +/// Bind an iroh endpoint for the given preset (shared by host and client). +/// +/// `HermeticLoopback` builds a `presets::Minimal` endpoint bound to loopback +/// only, with relays explicitly disabled (Minimal already defaults to +/// `RelayMode::Disabled`; the explicit setting documents the hermetic +/// posture). `configure` lets callers add builder options (e.g. the +/// client's `MemoryLookup`). +pub(crate) async fn bind_endpoint( + preset: EndpointPreset, + secret_key: Option, + bind_addr: Option, + configure: impl FnOnce(iroh::endpoint::Builder) -> iroh::endpoint::Builder, +) -> Result { + let mut builder = match preset { + EndpointPreset::N0 => iroh::Endpoint::builder(iroh::endpoint::presets::N0), + EndpointPreset::HermeticLoopback => { + iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .relay_mode(iroh::RelayMode::Disabled) + .clear_ip_transports() + } + }; + let bind_addr = match (preset, bind_addr) { + (_, Some(addr)) => Some(addr), + (EndpointPreset::HermeticLoopback, None) => { + Some("127.0.0.1:0".parse().expect("loopback socket addr")) + } + (EndpointPreset::N0, None) => None, + }; + if let Some(addr) = bind_addr { + builder = builder + .bind_addr(addr) + .map_err(|e| TunnelError::Bind(Box::new(e)))?; + } + if let Some(secret_key) = secret_key { + builder = builder.secret_key(secret_key); + } + configure(builder) + .bind() + .await + .map_err(|e| TunnelError::Bind(Box::new(e))) +} + +/// Errors from the tunnel API. +#[derive(Debug, thiserror::Error)] +pub enum TunnelError { + /// Binding the iroh endpoint (or its UDP socket) failed. + #[error("failed to bind tunnel endpoint")] + Bind(#[source] BoxedError), + /// The endpoint never reported a dialable address. + #[error("tunnel endpoint has no dialable address")] + NoAddress, + /// Dialing the share host failed. + #[error("could not reach the share host")] + Connect(#[source] BoxedError), + /// The local TCP proxy listener failed. + #[error("local tunnel proxy error")] + Proxy(#[source] std::io::Error), + /// Graceful shutdown did not complete cleanly. + #[error("tunnel shutdown failed")] + Shutdown(#[source] BoxedError), +} diff --git a/crates/quarto-p2p/src/ticket.rs b/crates/quarto-p2p/src/ticket.rs new file mode 100644 index 000000000..0d33b14c3 --- /dev/null +++ b/crates/quarto-p2p/src/ticket.rs @@ -0,0 +1,100 @@ +//! The `q2preview…` join string: host `EndpointAddr` + session token. + +use std::collections::BTreeSet; +use std::fmt; +use std::str::FromStr; + +use iroh::{EndpointAddr, EndpointId, TransportAddr}; +use iroh_tickets::{ParseError, Ticket}; +use serde::{Deserialize, Serialize}; + +use crate::TOKEN_LEN; + +/// Join-string payload: the host's endpoint address plus the session token. +/// +/// Possession of the string is the capability: the token authenticates the +/// guest to the host (per-stream prefix), while QUIC's handshake against the +/// pinned `EndpointId` authenticates the host to the guest. +/// +/// `Debug` redacts the token; `Display` prints the full join string (which +/// necessarily encodes the token — that is what a join string is for). +#[derive(Clone, PartialEq, Eq)] +pub struct PreviewShareTicket { + pub addr: EndpointAddr, + pub token: [u8; TOKEN_LEN], +} + +/// Wire format, following the `iroh-tickets` versioned-enum convention: +/// the postcard enum tag doubles as the body version, alongside the KIND +/// string that tags the protocol itself. +#[derive(Serialize, Deserialize)] +enum TicketWireFormat { + Variant1(Variant1PreviewShareTicket), +} + +#[derive(Serialize, Deserialize)] +struct Variant1PreviewShareTicket { + id: EndpointId, + addrs: BTreeSet, + token: [u8; TOKEN_LEN], +} + +impl PreviewShareTicket { + /// Whether the host published a relay address into this ticket. + /// + /// `false` means the host's endpoint never came online with a relay + /// (or runs a hermetic test preset): guests can join over direct/LAN + /// paths only. Exposed so consumers can report reachability without + /// matching on iroh's `TransportAddr` themselves. + pub fn has_relay_addr(&self) -> bool { + self.addr + .addrs + .iter() + .any(|a| matches!(a, TransportAddr::Relay(_))) + } +} + +impl Ticket for PreviewShareTicket { + const KIND: &'static str = "q2preview"; + + fn encode_bytes(&self) -> Vec { + let data = TicketWireFormat::Variant1(Variant1PreviewShareTicket { + id: self.addr.id, + addrs: self.addr.addrs.clone(), + token: self.token, + }); + postcard::to_stdvec(&data).expect("postcard serialization failed") + } + + fn decode_bytes(bytes: &[u8]) -> Result { + let data: TicketWireFormat = postcard::from_bytes(bytes)?; + let TicketWireFormat::Variant1(Variant1PreviewShareTicket { id, addrs, token }) = data; + Ok(Self { + addr: EndpointAddr { id, addrs }, + token, + }) + } +} + +impl fmt::Display for PreviewShareTicket { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.encode_string()) + } +} + +impl FromStr for PreviewShareTicket { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + Self::decode_string(s) + } +} + +impl fmt::Debug for PreviewShareTicket { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PreviewShareTicket") + .field("addr", &self.addr) + .field("token", &"[redacted]") + .finish() + } +} diff --git a/crates/quarto-p2p/tests/integration/main.rs b/crates/quarto-p2p/tests/integration/main.rs new file mode 100644 index 000000000..549a586ca --- /dev/null +++ b/crates/quarto-p2p/tests/integration/main.rs @@ -0,0 +1,9 @@ +//! Single integration-test binary for quarto-p2p (see +//! `.claude/rules/integration-tests.md`). +//! +//! All tests are hermetic: `presets::Minimal`, `RelayMode::Disabled`, +//! explicit loopback `TransportAddr::Ip` addrs — no n0 infrastructure in CI. + +pub mod support; +pub mod ticket; +pub mod tunnel; diff --git a/crates/quarto-p2p/tests/integration/support.rs b/crates/quarto-p2p/tests/integration/support.rs new file mode 100644 index 000000000..a9a90fa37 --- /dev/null +++ b/crates/quarto-p2p/tests/integration/support.rs @@ -0,0 +1,107 @@ +//! Shared helpers for the quarto-p2p integration tests. + +use std::net::SocketAddr; +use std::time::Duration; + +use quarto_p2p::{EndpointPreset, TunnelClientConfig, TunnelHostConfig}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +/// Generous cap for individual awaits so a broken tunnel fails the test +/// instead of hanging it. Sized for CI, not for the happy path: iroh +/// endpoint operations inflate 5-10x under full-suite load (every +/// endpoint bind pays for netmon setup + handshake crypto on contended +/// cores), and ubuntu runners are the slowest we have. Never binds on a +/// healthy run — the uncontended cost of a step is milliseconds. +pub const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +pub fn hermetic_host_cfg() -> TunnelHostConfig { + TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + ..Default::default() + } +} + +pub fn hermetic_client_cfg() -> TunnelClientConfig { + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + } +} + +/// Serve `app` on a fresh loopback port; returns the bound address. +/// +/// The server task is detached — it dies with the test process, which is +/// fine for these hermetic tests. +pub async fn spawn_http_target(app: axum::Router) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind http target"); + let addr = listener.local_addr().expect("target local_addr"); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + addr +} + +/// Raw HTTP/1.1 GET with `Connection: close`; returns the full response +/// text (status line + headers + body). Panics on I/O failure. +pub async fn http_get_close(addr: SocketAddr, path: &str) -> String { + try_http_get_close(addr, path) + .await + .expect("http_get_close roundtrip") +} + +/// Non-panicking variant of [`http_get_close`] for eventually-succeeds +/// loops (e.g. while the client re-dials). +pub async fn try_http_get_close(addr: SocketAddr, path: &str) -> std::io::Result { + let mut stream = TcpStream::connect(addr).await?; + stream + .write_all( + format!("GET {path} HTTP/1.1\r\nHost: tunnel-test\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .await?; + let mut buf = Vec::new(); + stream.read_to_end(&mut buf).await?; + Ok(String::from_utf8_lossy(&buf).into_owned()) +} + +/// Send a GET on an already-open connection *without* `Connection: close` +/// and read exactly one response (headers + `Content-Length` body). +/// Returns the full response text; the connection stays usable. +pub async fn http_get_keepalive(stream: &mut TcpStream, path: &str) -> String { + stream + .write_all(format!("GET {path} HTTP/1.1\r\nHost: tunnel-test\r\n\r\n").as_bytes()) + .await + .expect("write keep-alive request"); + + // Read until end of headers. + let mut buf = Vec::new(); + let header_end = loop { + let mut byte = [0u8; 1]; + let n = stream.read(&mut byte).await.expect("read response header"); + assert!(n > 0, "connection closed while reading response headers"); + buf.push(byte[0]); + if buf.ends_with(b"\r\n\r\n") { + break buf.len(); + } + assert!(buf.len() < 64 * 1024, "response headers too large"); + }; + + let headers = String::from_utf8_lossy(&buf[..header_end]).into_owned(); + let content_length: usize = headers + .lines() + .find_map(|l| { + let (name, value) = l.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().expect("content-length value")) + }) + .expect("response has a Content-Length header"); + + let mut body = vec![0u8; content_length]; + stream + .read_exact(&mut body) + .await + .expect("read response body"); + format!("{headers}{}", String::from_utf8_lossy(&body)) +} diff --git a/crates/quarto-p2p/tests/integration/ticket.rs b/crates/quarto-p2p/tests/integration/ticket.rs new file mode 100644 index 000000000..9c9c8ba2a --- /dev/null +++ b/crates/quarto-p2p/tests/integration/ticket.rs @@ -0,0 +1,133 @@ +//! `PreviewShareTicket` encode/decode/redaction tests (plan Phase 1). + +use iroh::{EndpointAddr, SecretKey, TransportAddr}; +use iroh_tickets::endpoint::EndpointTicket; +use quarto_p2p::{PreviewShareTicket, TicketParseError}; + +fn sample_addr() -> EndpointAddr { + let id = SecretKey::from_bytes(&[7u8; 32]).public(); + EndpointAddr::from_parts( + id, + [ + TransportAddr::Relay("https://relay.example.com./".parse().unwrap()), + TransportAddr::Ip("127.0.0.1:4433".parse().unwrap()), + TransportAddr::Ip("[::1]:4433".parse().unwrap()), + ], + ) +} + +fn sample_token() -> [u8; 32] { + core::array::from_fn(|i| [0xde, 0xad, 0xbe, 0xef][i % 4]) +} + +#[test] +fn roundtrip() { + let ticket = PreviewShareTicket { + addr: sample_addr(), + token: sample_token(), + }; + + let s = ticket.to_string(); + assert!( + s.starts_with("q2preview"), + "join string must start with the q2preview KIND, got: {s}" + ); + + let parsed: PreviewShareTicket = s.parse().expect("roundtrip parse"); + assert_eq!(parsed, ticket); +} + +#[test] +fn rejects_garbage_and_foreign_kinds() { + // Empty string: no KIND prefix. + let err = "".parse::().unwrap_err(); + assert!( + matches!(err, TicketParseError::Kind { .. }), + "empty string should fail on the KIND prefix, got: {err:?}" + ); + + // Random text: no KIND prefix either. + let err = "hello world".parse::().unwrap_err(); + assert!( + matches!(err, TicketParseError::Kind { .. }), + "non-ticket text should fail on the KIND prefix, got: {err:?}" + ); + + // Correct prefix but not base32. + let err = "q2preview!!!not-base32!!!" + .parse::() + .unwrap_err(); + assert!( + matches!(err, TicketParseError::Encoding { .. }), + "invalid base32 should fail decoding, got: {err:?}" + ); + + // Correct prefix, valid base32 ("zzzzzzzz" decodes to 0xff bytes), + // but garbage postcard payload. + let err = "q2previewzzzzzzzz" + .parse::() + .unwrap_err(); + assert!( + matches!(err, TicketParseError::Postcard { .. }), + "garbage payload should fail postcard decoding, got: {err:?}" + ); + + // A bare iroh EndpointTicket (KIND "endpoint") is a foreign kind. + let foreign = EndpointTicket::new(sample_addr()).to_string(); + assert!(foreign.starts_with("endpoint")); + let err = foreign.parse::().unwrap_err(); + assert!( + matches!(err, TicketParseError::Kind { .. }), + "foreign ticket kinds must be rejected, got: {err:?}" + ); +} + +/// Phase 2 (bd-jhvkwosw): the share glue inspects the freshly minted +/// ticket to tell the user whether guests can join via relay or only +/// direct/LAN — without quarto-preview matching on iroh types itself. +#[test] +fn has_relay_addr_reflects_transport_addrs() { + let with_relay = PreviewShareTicket { + addr: sample_addr(), + token: sample_token(), + }; + assert!(with_relay.has_relay_addr()); + + let id = SecretKey::from_bytes(&[9u8; 32]).public(); + let direct_only = PreviewShareTicket { + addr: EndpointAddr::from_parts(id, [TransportAddr::Ip("127.0.0.1:4433".parse().unwrap())]), + token: sample_token(), + }; + assert!(!direct_only.has_relay_addr()); +} + +#[test] +fn debug_redacts_token() { + let ticket = PreviewShareTicket { + addr: sample_addr(), + token: sample_token(), + }; + + let dbg = format!("{ticket:?}"); + + // Not as hex… + assert!( + !dbg.to_lowercase().contains("deadbeef"), + "Debug leaked the token as hex: {dbg}" + ); + // …not as a derived byte-array dump (0xde, 0xad = 222, 173)… + assert!( + !dbg.contains("222, 173"), + "Debug leaked the token as a byte array: {dbg}" + ); + // …and not by embedding the full join string (base32 encodes the token). + assert!( + !dbg.contains(&ticket.to_string()), + "Debug embedded the full join string: {dbg}" + ); + + assert!( + dbg.contains("redacted"), + "Debug should mark the token as redacted: {dbg}" + ); +} diff --git a/crates/quarto-p2p/tests/integration/tunnel.rs b/crates/quarto-p2p/tests/integration/tunnel.rs new file mode 100644 index 000000000..2268a837c --- /dev/null +++ b/crates/quarto-p2p/tests/integration/tunnel.rs @@ -0,0 +1,538 @@ +//! Tunnel behavior tests (plan Phase 1): HTTP + WebSocket splicing, token +//! auth, re-dial, half-close, shutdown. All hermetic — no n0 infrastructure. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant}; + +use axum::Router; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::routing::get; +use futures::{SinkExt, StreamExt}; +use iroh::{SecretKey, TransportAddr}; +use quarto_p2p::{ + ALPN, EndpointPreset, PathKind, PreviewShareTicket, TunnelClient, TunnelHost, TunnelHostConfig, + TunnelStatus, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::time::timeout; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::protocol::Message as WsMessage; + +use crate::support::{ + STEP_TIMEOUT, hermetic_client_cfg, hermetic_host_cfg, http_get_close, http_get_keepalive, + spawn_http_target, try_http_get_close, +}; + +fn any_loopback() -> SocketAddr { + "127.0.0.1:0".parse().unwrap() +} + +/// Host + client pair in front of `target`, hermetic on both ends. +async fn spawn_tunnel_pair( + target: SocketAddr, +) -> ( + SocketAddr, + quarto_p2p::TunnelHostHandle, + quarto_p2p::TunnelClientHandle, +) { + let (ticket, host) = TunnelHost::spawn(hermetic_host_cfg(), target) + .await + .expect("spawn tunnel host"); + let (local, client) = TunnelClient::bind(hermetic_client_cfg(), ticket, any_loopback()) + .await + .expect("bind tunnel client"); + (local, host, client) +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_roundtrip_loopback() { + let target = spawn_http_target( + Router::new().route("/body", get(|| async { "hello-through-the-tunnel" })), + ) + .await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + // ≥8 concurrent TCP connections = ≥8 concurrent QUIC bi-streams. + let mut tasks = Vec::new(); + for _ in 0..8 { + tasks.push(tokio::spawn( + async move { http_get_close(local, "/body").await }, + )); + } + for task in tasks { + let response = timeout(STEP_TIMEOUT, task) + .await + .expect("request timed out") + .expect("request task panicked"); + assert!( + response.starts_with("HTTP/1.1 200"), + "expected a 200 response, got: {response}" + ); + assert!( + response.contains("hello-through-the-tunnel"), + "body did not survive the tunnel: {response}" + ); + } + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +async fn ws_echo(ws: WebSocketUpgrade) -> axum::response::Response { + ws.on_upgrade(|mut socket: WebSocket| async move { + while let Some(Ok(msg)) = socket.recv().await { + if matches!(msg, Message::Close(_)) { + break; + } + if socket.send(msg).await.is_err() { + break; + } + } + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_frames_survive() { + let target = spawn_http_target(Router::new().route("/ws", get(ws_echo))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + let (mut ws, _response) = timeout(STEP_TIMEOUT, connect_async(format!("ws://{local}/ws"))) + .await + .expect("ws upgrade timed out") + .expect("ws upgrade failed"); + + for i in 0..3 { + let text = format!("frame-{i}"); + ws.send(WsMessage::text(text.clone())) + .await + .expect("send text frame"); + let echoed = timeout(STEP_TIMEOUT, ws.next()) + .await + .expect("echo timed out") + .expect("ws stream ended early") + .expect("ws read failed"); + assert_eq!(echoed.into_text().expect("text frame").as_str(), text); + } + + let payload = vec![0u8, 1, 2, 3, 255]; + ws.send(WsMessage::binary(payload.clone())) + .await + .expect("send binary frame"); + let echoed = timeout(STEP_TIMEOUT, ws.next()) + .await + .expect("binary echo timed out") + .expect("ws stream ended early") + .expect("ws read failed"); + match echoed { + WsMessage::Binary(bytes) => assert_eq!(bytes.as_ref(), payload.as_slice()), + other => panic!("expected a binary echo, got: {other:?}"), + } + + ws.close(None).await.expect("ws close"); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn wrong_token_rejected() { + // Raw TCP target that counts accepted connections; a rejected stream + // must never produce one. + let accepts = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind(any_loopback()) + .await + .expect("bind target"); + let target = listener.local_addr().expect("target addr"); + { + let accepts = accepts.clone(); + tokio::spawn(async move { + loop { + let _ = listener.accept().await; + accepts.fetch_add(1, Ordering::SeqCst); + } + }); + } + + // Fix the session token so the wrong one below is wrong by construction. + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + token: Some([0xAA; 32]), + ..Default::default() + }; + let (ticket, host) = TunnelHost::spawn(cfg, target).await.expect("spawn host"); + + let dialer = iroh::Endpoint::builder(iroh::endpoint::presets::Minimal) + .relay_mode(iroh::RelayMode::Disabled) + .clear_ip_transports() + .bind_addr("127.0.0.1:0") + .expect("loopback bind addr") + .bind() + .await + .expect("bind raw dialer"); + + // Case 1: full-length token with the wrong bytes. + let conn = dialer + .connect(ticket.addr.clone(), ALPN) + .await + .expect("dial host"); + let (mut send, mut recv) = conn.open_bi().await.expect("open_bi"); + send.write_all(&[0u8; 32]).await.expect("write wrong token"); + let read = timeout(STEP_TIMEOUT, recv.read_to_end(16)) + .await + .expect("host did not react to the wrong token"); + assert!( + read.is_err(), + "stream should be reset after a wrong token, got: {read:?}" + ); + let closed = timeout(STEP_TIMEOUT, conn.closed()) + .await + .expect("host did not close the connection after a wrong token"); + drop(closed); + + // Case 2: short token (stream finished after 5 bytes). + let conn = dialer + .connect(ticket.addr.clone(), ALPN) + .await + .expect("re-dial host"); + let (mut send, mut recv) = conn.open_bi().await.expect("open_bi"); + send.write_all(&[0xAA; 5]).await.expect("write short token"); + send.finish().expect("finish short stream"); + let read = timeout(STEP_TIMEOUT, recv.read_to_end(16)) + .await + .expect("host did not react to the short token"); + assert!( + read.is_err(), + "stream should be reset after a short token, got: {read:?}" + ); + + // The target must never have seen a TCP connection. + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!( + accepts.load(Ordering::SeqCst), + 0, + "unauthenticated streams must not reach the target" + ); + + dialer.close().await; + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn client_redials_after_connection_loss() { + let target = spawn_http_target(Router::new().route("/", get(|| async { "redial-ok" }))).await; + + // Fixed identity + token + (after first spawn) UDP port, so the restarted + // host is reachable via the unchanged ticket. + let secret_key = SecretKey::from_bytes(&[13u8; 32]); + let token = [0x42u8; 32]; + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + secret_key: Some(secret_key.clone()), + token: Some(token), + bind_addr: None, + }; + let (ticket, first_host) = TunnelHost::spawn(cfg, target).await.expect("spawn host"); + let udp_addr = ticket + .addr + .addrs + .iter() + .find_map(|a| match a { + TransportAddr::Ip(sa) => Some(*sa), + _ => None, + }) + .expect("hermetic ticket has an ip transport addr"); + + let (local, client) = TunnelClient::bind(hermetic_client_cfg(), ticket.clone(), any_loopback()) + .await + .expect("bind client"); + let mut status = client.status(); + assert!( + matches!(*status.borrow(), TunnelStatus::Connected(_)), + "fresh client must start Connected" + ); + + let first = http_get_close(local, "/").await; + assert!( + first.contains("redial-ok"), + "sanity roundtrip failed: {first}" + ); + + // Drop the host-side connection by shutting the host down entirely. + first_host.shutdown().await.expect("first host shutdown"); + + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Reconnecting), + ) + .await + .expect("client never noticed the connection loss") + .expect("status channel closed"); + + // Restart the host: same identity, token, target, and UDP port. + // `shutdown()` is graceful but not synchronous with the UDP socket's + // release — iroh gives endpoint tasks a grace window to finish after + // it returns, and on a loaded CI runner an immediate rebind can race + // the teardown (EADDRINUSE, ubuntu CI 2026-08-09). Poll until the + // port is free. (A real host restart can't hit this: process exit + // releases the socket.) + let respawn_deadline = Instant::now() + Duration::from_secs(10); + let (restart_ticket, second_host) = loop { + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + secret_key: Some(secret_key.clone()), + token: Some(token), + bind_addr: Some(udp_addr), + }; + match TunnelHost::spawn(cfg, target).await { + Ok(pair) => break pair, + Err(e @ quarto_p2p::TunnelError::Bind(_)) => { + assert!( + Instant::now() < respawn_deadline, + "respawn host on the same udp addr: still failing after 10s: {e}" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(e) => panic!("respawn host on the same udp addr: {e}"), + } + }; + assert_eq!(restart_ticket.addr.id, ticket.addr.id); + + // The next local TCP connections succeed once the client re-dialed. + let deadline = Instant::now() + Duration::from_secs(30); + let mut recovered = false; + while Instant::now() < deadline { + if let Ok(response) = try_http_get_close(local, "/").await + && response.contains("redial-ok") + { + recovered = true; + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + assert!(recovered, "tunnel did not recover after the host restarted"); + + timeout( + STEP_TIMEOUT, + status.wait_for(|s| matches!(s, TunnelStatus::Connected(_))), + ) + .await + .expect("status never returned to Connected") + .expect("status channel closed"); + + client.shutdown().await.expect("client shutdown"); + second_host.shutdown().await.expect("second host shutdown"); +} + +/// Phase 3 (bd-6y0p1bne): the status watch must say *how* the tunnel is +/// connected — the CLI prints "connected via direct connection" vs +/// "connected via relay". Hermetic endpoints have IP transports only, so +/// the selected path must read as `Direct` (never `Relay`, never stuck on +/// `Unknown`). +#[tokio::test(flavor = "multi_thread")] +async fn status_reports_direct_path_kind() { + let target = spawn_http_target(Router::new().route("/", get(|| async { "path-ok" }))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + let mut status = client.status(); + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Connected(PathKind::Direct)), + ) + .await + .expect("status never reported a direct path") + .expect("status channel closed"); + + // Traffic actually flows on the reported path. + let response = http_get_close(local, "/").await; + assert!(response.contains("path-ok"), "roundtrip failed: {response}"); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +/// Phase 3 (bd-6y0p1bne): a stale join string — right endpoint, wrong +/// token (host restarted / session re-shared) — must surface as a +/// *terminal* `Rejected` status, not an endless silent re-dial loop. The +/// CLI turns this into "the host rejected this join string". +#[tokio::test(flavor = "multi_thread")] +async fn rejected_token_flips_status_terminal() { + // Raw TCP target that counts accepts; the rejected guest must never + // produce one. + let accepts = Arc::new(AtomicUsize::new(0)); + let listener = TcpListener::bind(any_loopback()) + .await + .expect("bind target"); + let target = listener.local_addr().expect("target addr"); + { + let accepts = accepts.clone(); + tokio::spawn(async move { + loop { + let _ = listener.accept().await; + accepts.fetch_add(1, Ordering::SeqCst); + } + }); + } + + let cfg = TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + token: Some([0xAA; 32]), + ..Default::default() + }; + let (ticket, host) = TunnelHost::spawn(cfg, target).await.expect("spawn host"); + + // The stale string: same endpoint address, zeroed token. + let stale = PreviewShareTicket { + addr: ticket.addr.clone(), + token: [0u8; 32], + }; + let (local, client) = TunnelClient::bind(hermetic_client_cfg(), stale, any_loopback()) + .await + .expect("bind client (QUIC connect itself carries no token)"); + + // Trigger one proxied connection: the host resets the stream and + // closes the connection as unauthorized. The request's outcome is + // irrelevant; the attempt is what trips the rejection. + let _ = timeout(STEP_TIMEOUT, try_http_get_close(local, "/")) + .await + .expect("rejected request should fail fast, not hang"); + + let mut status = client.status(); + timeout( + STEP_TIMEOUT, + status.wait_for(|s| *s == TunnelStatus::Rejected), + ) + .await + .expect("client never reported the token rejection") + .expect("status channel closed"); + + // Terminal: the supervisor must not re-dial its way back to + // Connected with a token the host refuses. + tokio::time::sleep(Duration::from_millis(750)).await; + assert_eq!( + *client.status().borrow(), + TunnelStatus::Rejected, + "Rejected must be terminal" + ); + + // The unauthenticated stream never reached the target. + assert_eq!( + accepts.load(Ordering::SeqCst), + 0, + "rejected streams must not reach the target" + ); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn half_close_propagates() { + // Raw TCP target driven by a task whose asserts propagate via join. + let listener = TcpListener::bind(any_loopback()) + .await + .expect("bind target"); + let target = listener.local_addr().expect("target addr"); + let target_task = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.expect("accept"); + let mut buf = [0u8; 4]; + sock.read_exact(&mut buf).await.expect("read ping"); + assert_eq!(&buf, b"ping"); + + // The guest shut down its write half → we must see EOF… + let n = sock.read(&mut [0u8; 8]).await.expect("read eof"); + assert_eq!(n, 0, "expected EOF after guest write-half shutdown"); + + // …while the reverse direction keeps flowing. + sock.write_all(b"pong").await.expect("write pong"); + sock.shutdown().await.expect("target write shutdown"); + }); + + let (local, host, client) = spawn_tunnel_pair(target).await; + + let mut guest = TcpStream::connect(local) + .await + .expect("connect local proxy"); + guest.write_all(b"ping").await.expect("write ping"); + guest.shutdown().await.expect("guest write-half shutdown"); + + let mut buf = [0u8; 4]; + timeout(STEP_TIMEOUT, guest.read_exact(&mut buf)) + .await + .expect("pong timed out") + .expect("read pong"); + assert_eq!(&buf, b"pong"); + + let n = timeout(STEP_TIMEOUT, guest.read(&mut [0u8; 8])) + .await + .expect("guest EOF timed out") + .expect("read guest eof"); + assert_eq!(n, 0, "expected EOF after target write shutdown"); + + timeout(STEP_TIMEOUT, target_task) + .await + .expect("target task timed out") + .expect("target task panicked"); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn clean_shutdown() { + let target = spawn_http_target(Router::new().route("/", get(|| async { "shutdown-ok" }))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + // Exercise the pair once so shutdown happens on a live tunnel. + let response = http_get_close(local, "/").await; + assert!(response.contains("shutdown-ok")); + + timeout(STEP_TIMEOUT, client.shutdown()) + .await + .expect("client shutdown hung") + .expect("client shutdown failed"); + + // The local proxy port must be unbound again. + let connect = TcpStream::connect(local).await; + assert!( + connect.is_err(), + "local proxy port still accepting after shutdown" + ); + + timeout(STEP_TIMEOUT, host.shutdown()) + .await + .expect("host shutdown hung") + .expect("host shutdown failed"); +} + +/// Verifies the plan's QUIC-keep-alive-vs-browser-connection-pooling item: +/// iroh's default 5 s keep-alive must hold the QUIC connection (30 s idle +/// timeout) open underneath an idle pooled HTTP/1.1 connection, so the next +/// request on that pooled TCP connection still succeeds. Idles for 35 s by +/// design — this is deliberately the slowest test in the crate. +#[tokio::test(flavor = "multi_thread")] +async fn idle_pooled_conn_survives_quic_keepalive() { + let target = + spawn_http_target(Router::new().route("/", get(|| async { "keepalive-ok" }))).await; + let (local, host, client) = spawn_tunnel_pair(target).await; + + let mut pooled = TcpStream::connect(local) + .await + .expect("connect local proxy"); + let first = http_get_keepalive(&mut pooled, "/").await; + assert!(first.contains("keepalive-ok"), "first response: {first}"); + + // Longer than the 30 s QUIC connection idle timeout. + tokio::time::sleep(Duration::from_secs(35)).await; + + let second = http_get_keepalive(&mut pooled, "/").await; + assert!( + second.contains("keepalive-ok"), + "pooled connection died during idle: {second}" + ); + + client.shutdown().await.expect("client shutdown"); + host.shutdown().await.expect("host shutdown"); +} diff --git a/crates/quarto-preview/Cargo.toml b/crates/quarto-preview/Cargo.toml index 68911b7d9..8bd787ae3 100644 --- a/crates/quarto-preview/Cargo.toml +++ b/crates/quarto-preview/Cargo.toml @@ -10,7 +10,7 @@ description = "Local HTTP server hosting the q2-preview SPA. Wraps quarto-hub fo anyhow.workspace = true tracing.workspace = true include_dir.workspace = true -tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "sync"] } axum = "0.8" flate2.workspace = true pollster.workspace = true @@ -28,6 +28,9 @@ samod = { version = "0.12.3", git = "https://github.com/quarto-dev/samod.git", b quarto-core.workspace = true quarto-error-reporting = { workspace = true, features = ["json"] } quarto-hub.workspace = true +# Live-share tunnel host (`--share`, plan Phase 2). Native-only; keeps +# all iroh usage behind quarto-p2p's API. +quarto-p2p.workspace = true quarto-pandoc-types.workspace = true quarto-source-map.workspace = true quarto-system-runtime.workspace = true @@ -39,6 +42,9 @@ tower = "0.5" http-body-util = "0.1" http = "1" reqwest = { version = "0.12", features = ["blocking", "json"] } +# The Phase 3 money test dials samod's `/ws` through the tunnel +# (`dial_websocket` takes a `url::Url`). +url = "2" [lints] workspace = true diff --git a/crates/quarto-preview/build.rs b/crates/quarto-preview/build.rs index 1d348c76c..37330cf65 100644 --- a/crates/quarto-preview/build.rs +++ b/crates/quarto-preview/build.rs @@ -1,18 +1,29 @@ -//! Build script that locates the q2-preview-spa bundle at compile time. +//! Build script that locates the SPA bundles embedded at compile time. //! -//! Mirrors `crates/quarto-trace-server/build.rs` exactly. The -//! `include_dir!` macro needs a concrete compile-time path; this script -//! resolves it: +//! Mirrors `crates/quarto-trace-server/build.rs` for the viewer SPA. +//! The `include_dir!` macro needs concrete compile-time paths; this +//! script resolves two of them: //! -//! 1. If `q2-preview-spa/dist/index.html` exists, embed that directory. -//! 2. Otherwise, write a placeholder `index.html` into the crate's -//! `OUT_DIR` and embed that, so the build still succeeds. The -//! placeholder tells the user to run `cargo xtask build-q2-preview-spa` -//! (added in A.4 / bd-501n). +//! 1. **Viewer** (`QUARTO_PREVIEW_EMBED_DIR`): the q2-preview SPA. If +//! `q2-preview-spa/dist/index.html` exists, embed that directory; +//! otherwise embed a placeholder `index.html` pointing at +//! `cargo xtask build-q2-preview-spa` (A.4 / bd-501n). +//! 2. **Editor** (`QUARTO_HUB_CLIENT_EMBED_DIR`): the full hub-client +//! editor served by `q2 preview --ui editor` (live-share plan +//! Phase 4, bd-jt1etjbn). If `hub-client/dist-preview-embed/` +//! exists, embed a *filtered copy*: files byte-identical to the +//! real viewer dist at the same relative path are stripped, because +//! the runtime lookup serves those paths from the viewer embed — +//! that is how the ~38 MB `wasm_quarto_hub_client_bg-*.wasm` (plus +//! the automerge/tree-sitter wasm and shared fonts, all with +//! content-hashed names identical across the two Vite builds) is +//! embedded once instead of twice. Otherwise embed a placeholder +//! pointing at `cargo xtask build-hub-client-embed`. No cargo +//! warning for the missing editor dist — unlike the viewer it is +//! opt-in (`--ui editor`), and the placeholder page names the fix. //! -//! The chosen path is exposed to the crate as -//! `QUARTO_PREVIEW_EMBED_DIR` via `cargo:rustc-env`, and `src/lib.rs` -//! consumes it via `include_dir!("$QUARTO_PREVIEW_EMBED_DIR")`. +//! Both paths are exposed via `cargo:rustc-env` and consumed by +//! `src/lib.rs` through `include_dir!("$VAR")`. use std::path::{Path, PathBuf}; @@ -21,7 +32,8 @@ fn main() { let workspace_root = manifest_dir.join("..").join(".."); let real_dist = workspace_root.join("q2-preview-spa").join("dist"); - let embed_dir = if real_dist.join("index.html").is_file() { + let viewer_is_real = real_dist.join("index.html").is_file(); + let embed_dir = if viewer_is_real { real_dist.clone() } else { make_placeholder_dist() @@ -40,6 +52,25 @@ fn main() { if real_dist.is_dir() { watch_recursive(&real_dist); } + + // Editor embed (Phase 4). The viewer dist is only a dedupe target + // when it is what the viewer embed actually serves — stripping + // against a dist that isn't embedded would 404 the shared assets. + let editor_dist = workspace_root.join("hub-client").join("dist-preview-embed"); + let editor_embed_dir = if editor_dist.join("index.html").is_file() { + let dedupe_against = viewer_is_real.then_some(real_dist.as_path()); + make_editor_embed(&editor_dist, dedupe_against) + } else { + make_editor_placeholder() + }; + println!( + "cargo:rustc-env=QUARTO_HUB_CLIENT_EMBED_DIR={}", + editor_embed_dir.display() + ); + println!("cargo:rerun-if-changed={}", editor_dist.display()); + if editor_dist.is_dir() { + watch_recursive(&editor_dist); + } } fn watch_recursive(root: &Path) { @@ -57,6 +88,67 @@ fn watch_recursive(root: &Path) { } } +/// Produce the editor embed directory in `OUT_DIR`: a copy of +/// `editor_dist` minus every file that is byte-identical to +/// `dedupe_against` at the same relative path (those are served through +/// the viewer embed at runtime). Rebuilt from scratch on every rerun so +/// deleted/renamed dist files can never linger in the embed. +fn make_editor_embed(editor_dist: &Path, dedupe_against: Option<&Path>) -> PathBuf { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let embed = out_dir.join("editor-embed"); + if embed.exists() { + std::fs::remove_dir_all(&embed).expect("clear stale editor embed"); + } + std::fs::create_dir_all(&embed).expect("create editor embed dir"); + copy_filtered(editor_dist, editor_dist, &embed, dedupe_against); + embed +} + +fn copy_filtered(root: &Path, dir: &Path, embed: &Path, dedupe_against: Option<&Path>) { + let entries = match std::fs::read_dir(dir) { + Ok(it) => it, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(root).expect("entry under walk root"); + if entry.file_type().is_ok_and(|t| t.is_dir()) { + copy_filtered(root, &path, embed, dedupe_against); + continue; + } + if let Some(viewer) = dedupe_against + && files_byte_identical(&path, &viewer.join(rel)) + { + continue; // shared with the viewer embed; served from there + } + let bytes = std::fs::read(&path).expect("read editor dist file"); + let dest = embed.join(rel); + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).expect("create embed subdir"); + } + std::fs::write(&dest, bytes).expect("write embed file"); + } +} + +/// Byte-equality check with a metadata cheap-reject: a missing viewer +/// counterpart or a size mismatch means "not identical" without reading +/// either file. Only same-size pairs pay for the full read-compare — +/// previously every editor-dist file slurped its viewer counterpart +/// (and itself, before the comparison was even possible), including +/// the ~38 MB WASM, whether or not the bytes could ever match. +fn files_byte_identical(a: &Path, b: &Path) -> bool { + let (Ok(a_meta), Ok(b_meta)) = (std::fs::metadata(a), std::fs::metadata(b)) else { + return false; + }; + if a_meta.len() != b_meta.len() { + return false; + } + match (std::fs::read(a), std::fs::read(b)) { + (Ok(a_bytes), Ok(b_bytes)) => a_bytes == b_bytes, + _ => false, + } +} + fn make_placeholder_dist() -> PathBuf { let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); let dist = out_dir.join("placeholder-dist"); @@ -106,6 +198,49 @@ cargo build -p quarto dist } +fn make_editor_placeholder() -> PathBuf { + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let dist = out_dir.join("editor-placeholder-dist"); + std::fs::create_dir_all(&dist).expect("create editor placeholder dist dir"); + + let index = dist.join("index.html"); + // Same `
` contract as the viewer placeholder: even + // on an unbuilt tree, `--ui editor` boots to a page with the React + // mount point and instructions. + let html = r#" + + + + q2 preview — editor UI not built + + + +
+

The q2 preview editor UI is not built

+

+ The embedded hub-client editor bundle is a placeholder. Build + the editor and rebuild the quarto binary: +

+
cargo xtask build-hub-client-embed
+cargo build -p quarto
+

+ Or run without --ui editor to use the default + read-only preview UI. +

+
+ + +"#; + write_if_changed(&index, html); + + dist +} + fn write_if_changed(path: &Path, contents: &str) { let existing = std::fs::read_to_string(path).ok(); if existing.as_deref() != Some(contents) { diff --git a/crates/quarto-preview/src/lib.rs b/crates/quarto-preview/src/lib.rs index 9e77c31e6..c04cd5972 100644 --- a/crates/quarto-preview/src/lib.rs +++ b/crates/quarto-preview/src/lib.rs @@ -29,6 +29,7 @@ pub mod config; pub mod deps; pub mod diagnostics; pub mod re_execute; +pub mod share; pub use config::EnginePolicy; @@ -37,6 +38,14 @@ pub use config::EnginePolicy; /// else a placeholder). static EMBEDDED_SPA: Dir<'_> = include_dir!("$QUARTO_PREVIEW_EMBED_DIR"); +/// The hub-client editor bundle embedded for `--ui editor` (live-share +/// plan Phase 4, bd-jt1etjbn). See `build.rs`: the real +/// `hub-client/dist-preview-embed/` when built (with files +/// byte-identical to the viewer dist stripped — those are served +/// through [`EMBEDDED_SPA`] by [`lookup_embedded`]), else a +/// placeholder pointing at `cargo xtask build-hub-client-embed`. +static EMBEDDED_EDITOR: Dir<'_> = include_dir!("$QUARTO_HUB_CLIENT_EMBED_DIR"); + /// Optional override pointing at a SPA bundle on disk. Set once at /// `run()` start and read on every SPA-fallback invocation. Process- /// wide because the SPA fallback handler is stateless from axum's POV @@ -61,6 +70,66 @@ static RESOURCE_DISK_MAP: OnceLock> = /// above (one preview server per process; nextest isolates tests). static ALLOW_EDIT: OnceLock = OnceLock::new(); +/// Which embedded frontend this session serves (`--ui`, Phase 4 +/// bd-jt1etjbn). Set once at `run()` from [`PreviewConfig::ui`] and +/// read by the SPA fallback handler. Same OnceLock pattern as above. +static PREVIEW_UI: OnceLock = OnceLock::new(); + +/// Editor-mode boot params for `--join` guests (bd-7htq16rx): the +/// share-route coordinates the host's own boot URL is built from. +/// Stashed by the CLI's editor-mode `on_ready` via [`set_editor_boot`] +/// and served at `GET /api/preview/config` as `editorBoot`, so a guest +/// can build the same share URL (with `ephemeral=true`) against its +/// local proxy origin and land straight in the document instead of +/// the project-set setup screen. Both `Serialize` (the host serves it) +/// and `Deserialize` (the `q2 preview --join` CLI parses it) — one +/// wire-shape definition, no drift. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EditorBootInfo { + /// Index document id, exactly as `HubContext::index().document_id()` + /// returns it (the guest-side URL builder strips any `automerge:` + /// prefix, same as the host's). + pub index_doc_id: String, + /// The share route's `file` param — a `.qmd` path in the project. + pub file: String, + /// Project name shown in the guest's editor UI. + pub name: String, +} + +/// The session's editor boot params. Set once by the editor-mode +/// caller's `on_ready` (which fires before the listener binds, so no +/// client can fetch config ahead of it) and read by +/// `preview_config_handler`. Same first-writer-wins OnceLock pattern +/// as the other handler state above. +static EDITOR_BOOT: OnceLock = OnceLock::new(); + +/// Stash the editor-mode boot params so `GET /api/preview/config` can +/// hand them to `--join` guests (bd-7htq16rx). Editor-mode callers +/// invoke this from their `on_ready` when a share file was picked; +/// viewer mode and `--no-project` editor boots never do, and their +/// config answers without `editorBoot`. +pub fn set_editor_boot(info: EditorBootInfo) { + let _ = EDITOR_BOOT.set(info); +} + +/// Which embedded frontend the preview server serves (`--ui`, +/// live-share plan Phase 4, bd-jt1etjbn). +/// +/// The flag *substitutes* which embedded dist the SPA fallback serves — +/// it is not additive — and it never changes the disk write policy: +/// UI × write policy is a real 2×2, so `--ui editor` without +/// `--allow-edit` is a deliberate sandbox mode (guests' edits drive the +/// live session; the host's disk stays authoritative). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PreviewUi { + /// The read-only preview SPA (q2-preview-spa) — the default. + #[default] + Viewer, + /// The full hub-client editor. + Editor, +} + /// Runtime configuration for the preview server. #[derive(Clone)] pub struct PreviewConfig { @@ -122,6 +191,22 @@ pub struct PreviewConfig { /// document changes from *any* connected client can never be /// written back to the user's files. pub allow_edit: bool, + /// Share this preview session over an end-to-end encrypted iroh + /// tunnel (`--share`, bd-jhvkwosw). When set, [`run`] starts a + /// [`share::ShareSession`] targeting `host:port` on a background + /// task once the server reaches `on_ready` — the tunnel's relay + /// wait never delays the host's own preview — and prints the join + /// banner when the tunnel is up. A tunnel start failure is + /// reported on stderr but does not fail the preview. The tunnel is + /// shut down after the server exits (before the CLI drops its + /// ephemeral `TempDir`). Requires a pre-resolved (non-zero) `port` + /// — the CLI probes one before calling in. + pub share: bool, + /// Which embedded frontend to serve (`--ui`, Phase 4 bd-jt1etjbn): + /// the read-only preview SPA (default) or the full hub-client + /// editor. Orthogonal to `allow_edit` — the UI choice never changes + /// the disk write policy. + pub ui: PreviewUi, } /// Run the preview server. Returns when the server is shut down (ctrl-c @@ -188,6 +273,10 @@ where // `/api/preview/config` handler before the server starts serving. let _ = ALLOW_EDIT.set(config.allow_edit); + // Phase 4 (bd-jt1etjbn): stash the UI choice for the SPA fallback + // handler before the server starts serving. + let _ = PREVIEW_UI.set(config.ui); + let storage = build_storage(&config).context("building storage manager")?; let hub_config = build_hub_config(&config); @@ -203,6 +292,12 @@ where .cache_dir .clone() .unwrap_or_else(|| config.data_dir.join("captures")); + // Live-share gate (bd-jhvkwosw): the share task below starts its + // tunnel only once the server reaches `on_ready`, so the production + // preset's relay wait never delays the host's own preview, and the + // join banner prints after the boot URL in every UI mode. + let (share_ready_tx, share_ready_rx) = tokio::sync::watch::channel(false); + let registry_for_on_ready = engine_registry.clone(); let cache_dir_for_on_ready = cache_dir.clone(); let project_root_for_scripts = config.project_root.clone(); @@ -236,6 +331,10 @@ where // Fire the extra hook after the driver is enqueued so callers // can rely on it being either in-flight or already done. extra_on_ready(ctx); + // Unblock the live-share tunnel task (a no-op send when not + // sharing). After `extra_on_ready` so the CLI's editor-mode + // boot URL prints before the join banner. + let _ = share_ready_tx.send(true); }); // Phase C.2 hook: after sync_file updates samod with the new @@ -282,15 +381,69 @@ where }); }); - server::run_server_with( + // bd-jhvkwosw (live-share Phase 2): when sharing, the tunnel runs + // on a background task gated on the server's `on_ready` (signaled + // in the callback above) rather than started inline here. The + // ticket's inputs (host, port, token, endpoint addr) all exist + // already, but the production preset's relay wait can run to ~10 s + // when no relay is reachable — too long to keep the host's own + // preview from binding and serving. A tunnel start failure is + // reported on stderr by the task and no longer fails the preview. + let share_task = if config.share { + anyhow::ensure!( + config.port != 0, + "--share requires a resolved port; the CLI probes a free one before starting \ + the server, library callers must do the same" + ); + Some(share::spawn_share_task( + quarto_p2p::TunnelHostConfig::default(), + config.host.clone(), + config.port, + config.allow_edit, + share_ready_rx, + |banner| println!("\n{banner}\n"), + )) + } else { + None + }; + + let server_result = server::run_server_with( storage, hub_config, Some(extend_with_preview), Some(on_ready), Some(on_file_changed), ) - .await - .context("quarto-hub server failed")?; + .await; + + // bd-jhvkwosw: tunnel teardown joins the graceful-shutdown path — + // after the server (and its final filesystem sync) exits, before + // the CLI drops its ephemeral TempDir. In normal operation the task + // finished long ago (it completes once the tunnel is up) and the + // session shuts down gracefully here; a task still parked on the + // gate or in its relay wait is aborted instead of awaited, so a + // Ctrl-C during startup doesn't hang on the ~10 s timeouts. + // Failure is logged, not fatal; the process is exiting either way. + if let Some(task) = share_task { + if task.is_finished() { + match task.await { + Ok(Some(session)) => { + if let Err(e) = session.shutdown().await { + tracing::warn!(error = %e, "live-share tunnel shutdown failed"); + } + } + // The gate never fired (the server failed before + // on_ready) or the tunnel start failed (already + // reported on stderr by the task). + Ok(None) => {} + Err(e) => tracing::warn!(error = %e, "live-share tunnel task failed"), + } + } else { + task.abort(); + } + } + + server_result.context("quarto-hub server failed")?; Ok(()) } @@ -494,9 +647,62 @@ pub fn extend_with_preview( /// `--allow-edit` CLI flag; the SPA uses it to enable or fully disable /// the inline block-editing surface, and the server independently /// enforces the same setting via [`quarto_hub::sync::DiskWritePolicy`]. +/// +/// `editorBoot` (bd-7htq16rx) is present only on editor-UI sessions +/// that stashed their share-route boot params via [`set_editor_boot`]; +/// `q2 preview --join` reads it through the tunnel to build the +/// guest's boot URL. The viewer SPA ignores the field. async fn preview_config_handler() -> Response { let allow_edit = ALLOW_EDIT.get().copied().unwrap_or(false); - axum::Json(serde_json::json!({ "allowEdit": allow_edit })).into_response() + let mut body = serde_json::json!({ "allowEdit": allow_edit }); + if let Some(boot) = EDITOR_BOOT.get() { + body["editorBoot"] = serde_json::to_value(boot).expect("EditorBootInfo always serializes"); + } + axum::Json(body).into_response() +} + +/// The UI mode this session serves. Defaults to the viewer when `run()` +/// hasn't stashed a choice (e.g. handler-level tests composing routers +/// directly via [`extend_with_spa`]). +fn current_ui() -> PreviewUi { + PREVIEW_UI.get().copied().unwrap_or_default() +} + +/// Resolve `rel` against the embedded bundles for the given UI mode. +/// +/// Editor mode looks in the editor embed first, then falls back to the +/// viewer embed — the dedupe seam (Phase 4, bd-jt1etjbn): `build.rs` +/// strips editor-dist files byte-identical to the viewer dist, so +/// shared content-hashed assets (most notably the ~38 MB +/// `wasm_quarto_hub_client_bg-*.wasm`) are embedded once, in the viewer +/// bundle. Viewer mode never reads the editor embed. +fn lookup_embedded(ui: PreviewUi, rel: &str) -> Option<&'static include_dir::File<'static>> { + match ui { + PreviewUi::Viewer => EMBEDDED_SPA.get_file(rel), + PreviewUi::Editor => EMBEDDED_EDITOR + .get_file(rel) + .or_else(|| EMBEDDED_SPA.get_file(rel)), + } +} + +/// The editor embed's `index.html`, when present (real dist or +/// placeholder). Pub-visible test seam: the editor-mode integration +/// test asserts the served body equals this, pinning that `--ui editor` +/// actually flips which embed the fallback serves. +pub fn embedded_editor_index_html() -> Option<&'static str> { + EMBEDDED_EDITOR + .get_file("index.html") + .and_then(|f| f.contents_utf8()) +} + +/// The note announced when `--ui editor` runs without `--allow-edit` +/// (the sandbox composition): session edits sync live to every +/// connected client, but the host's disk stays authoritative — a +/// host-side filesystem change converges the document back to disk +/// content (`quarto-hub/src/sync.rs`), and nothing persists. +pub fn editor_ephemeral_note(allow_edit: bool) -> Option<&'static str> { + (!allow_edit) + .then_some("session edits are ephemeral — pass --allow-edit to persist edits to disk") } async fn spa_handler(req: axum::http::Request) -> Response { @@ -508,13 +714,14 @@ async fn spa_handler(req: axum::http::Request) -> Response { return serve_from_disk(override_dir, rel).await; } + let ui = current_ui(); // Try the exact path first (an asset like `assets/index-.js`). - if let Some(file) = EMBEDDED_SPA.get_file(rel) { + if let Some(file) = lookup_embedded(ui, rel) { return asset_response(rel, file.contents().to_vec()); } // SPA fallback: any non-asset path gets `index.html` for client- // side routing. - if let Some(index) = EMBEDDED_SPA.get_file("index.html") { + if let Some(index) = lookup_embedded(ui, "index.html") { return asset_response("index.html", index.contents().to_vec()); } (StatusCode::NOT_FOUND, "no spa").into_response() @@ -549,7 +756,7 @@ async fn serve_spa_index() -> Response { if let Some(Some(override_dir)) = SPA_DIR_OVERRIDE.get() { return serve_from_disk(override_dir, "index.html").await; } - if let Some(index) = EMBEDDED_SPA.get_file("index.html") { + if let Some(index) = lookup_embedded(current_ui(), "index.html") { return asset_response("index.html", index.contents().to_vec()); } (StatusCode::NOT_FOUND, "no spa").into_response() @@ -597,3 +804,167 @@ fn content_type_for(path: &str) -> HeaderValue { }; HeaderValue::from_static(mime) } + +#[cfg(test)] +mod tests { + use super::*; + + // ────────────────────────────────────────────────────────────── + // Phase 4 (bd-jt1etjbn): `--ui` never touches the write policy. + // UI × write policy is a real 2×2 — `--ui editor` without + // `--allow-edit` is the deliberate sandbox mode (session edits + // sync live, disk stays authoritative). + // ────────────────────────────────────────────────────────────── + + fn test_config(ui: PreviewUi, allow_edit: bool) -> PreviewConfig { + PreviewConfig { + host: "127.0.0.1".to_string(), + port: 0, + project_root: None, + single_file: None, + data_dir: PathBuf::from("unused"), + spa_dir_override: None, + engine_registry: None, + engine_policy: EnginePolicy::Manual, + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit, + share: false, + ui, + } + } + + #[test] + fn ui_choice_never_changes_disk_write_policy() { + use quarto_hub::sync::DiskWritePolicy; + for ui in [PreviewUi::Viewer, PreviewUi::Editor] { + let read_only = build_hub_config(&test_config(ui, false)); + assert!( + matches!(read_only.disk_write_policy, DiskWritePolicy::ReadOnly), + "{ui:?} without --allow-edit must stay ReadOnly" + ); + let write_back = build_hub_config(&test_config(ui, true)); + assert!( + matches!(write_back.disk_write_policy, DiskWritePolicy::WriteBack), + "{ui:?} with --allow-edit must write back" + ); + } + } + + #[test] + fn editor_ephemeral_note_emitted_only_without_allow_edit() { + let note = editor_ephemeral_note(false) + .expect("editor without --allow-edit must emit the ephemeral-session note"); + assert!( + note.contains("session edits are ephemeral"), + "note must say what happens: {note}" + ); + assert!( + note.contains("--allow-edit"), + "note must name the fix: {note}" + ); + assert!( + editor_ephemeral_note(true).is_none(), + "no note when edits persist to disk" + ); + } + + // ────────────────────────────────────────────────────────────── + // Phase 4: embedded-editor asset lookup + the wasm dedupe + // contract. The editor embed is stripped of files byte-identical + // to the viewer embed at the same relative path (build.rs), and + // the runtime lookup routes those paths to the viewer's copy — + // that is how one ~38 MB WASM serves both frontends. + // ────────────────────────────────────────────────────────────── + + fn files_recursive( + dir: &'static include_dir::Dir<'static>, + ) -> Vec<&'static include_dir::File<'static>> { + let mut out = Vec::new(); + let mut stack = vec![dir]; + while let Some(d) = stack.pop() { + out.extend(d.files()); + stack.extend(d.dirs()); + } + out + } + + #[test] + fn editor_lookup_serves_editor_index_with_react_mount() { + let file = lookup_embedded(PreviewUi::Editor, "index.html") + .expect("the editor embed always has an index.html (real dist or placeholder)"); + let expected = EMBEDDED_EDITOR + .get_file("index.html") + .expect("editor embed index.html") + .contents(); + assert_eq!( + file.contents(), + expected, + "editor mode must serve the *editor* embed's index, never the viewer's" + ); + let html = std::str::from_utf8(file.contents()).expect("index.html is UTF-8"); + assert!( + html.contains(r#"id="root""#), + "even the placeholder must carry the React mount point:\n{html}" + ); + } + + #[test] + fn viewer_lookup_never_reads_the_editor_embed() { + // Viewer mode is Phase-A behavior, byte for byte: any file that + // exists only in the editor embed must miss in viewer mode. + for editor_file in files_recursive(&EMBEDDED_EDITOR) { + let rel = editor_file.path().to_str().expect("utf-8 rel path"); + if EMBEDDED_SPA.get_file(rel).is_some() { + continue; // viewer legitimately has its own copy + } + assert!( + lookup_embedded(PreviewUi::Viewer, rel).is_none(), + "viewer mode must not serve editor-only asset {rel}" + ); + } + } + + #[test] + fn editor_lookup_falls_back_to_viewer_embed_for_shared_assets() { + // The dedupe routing: every viewer file the editor embed does + // not shadow must resolve through the editor lookup with the + // viewer's bytes. On a placeholder tree the shared set is empty + // and this loop body never runs; on a built tree the ~38 MB + // wasm_quarto_hub_client_bg-*.wasm is the load-bearing case. + for viewer_file in files_recursive(&EMBEDDED_SPA) { + let rel = viewer_file.path().to_str().expect("utf-8 rel path"); + if EMBEDDED_EDITOR.get_file(rel).is_some() { + continue; // editor's own copy wins; covered elsewhere + } + let served = lookup_embedded(PreviewUi::Editor, rel) + .unwrap_or_else(|| panic!("shared asset {rel} must fall back to the viewer embed")); + assert_eq!( + served.contents(), + viewer_file.contents(), + "fallback for {rel} must serve the viewer's bytes" + ); + } + } + + #[test] + fn editor_embed_holds_no_byte_identical_duplicates_of_viewer_files() { + // The build.rs strip contract behind the dedupe: anything + // byte-identical at the same rel path in both dists must have + // been stripped from the editor embed (it is served via the + // viewer fallback instead). Guards against a naive double-embed + // quietly re-adding ~38 MB to the binary. + for editor_file in files_recursive(&EMBEDDED_EDITOR) { + let rel = editor_file.path(); + if let Some(viewer_file) = EMBEDDED_SPA.get_file(rel) { + assert_ne!( + viewer_file.contents(), + editor_file.contents(), + "{} is byte-identical in both embeds — build.rs should have stripped it \ + from the editor embed", + rel.display() + ); + } + } + } +} diff --git a/crates/quarto-preview/src/share.rs b/crates/quarto-preview/src/share.rs new file mode 100644 index 000000000..d081c3107 --- /dev/null +++ b/crates/quarto-preview/src/share.rs @@ -0,0 +1,169 @@ +//! `--share` glue (live-share plan Phase 2, bd-jhvkwosw): spawn the +//! quarto-p2p tunnel host in front of the preview server's loopback +//! port and announce the ready-to-paste join string. +//! +//! The tunnel is the *only* remote surface `--share` adds — the HTTP +//! port itself stays loopback-bound. Possession of the join string is +//! the capability, which is why the banner spells out exactly what it +//! grants (view + re-run, plus disk writes iff `--allow-edit`). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use quarto_p2p::{PreviewShareTicket, TunnelError, TunnelHost, TunnelHostConfig, TunnelHostHandle}; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +/// A live share session: the join ticket plus the running tunnel host. +pub struct ShareSession { + /// The join ticket (host `EndpointAddr` + session token). Public so + /// callers/tests can render or re-derive the join string. + pub ticket: PreviewShareTicket, + handle: TunnelHostHandle, +} + +impl ShareSession { + /// Gracefully shut the tunnel host down (closes the iroh endpoint). + pub async fn shutdown(self) -> Result<(), TunnelError> { + self.handle.shutdown().await + } +} + +/// Spawn the tunnel host targeting the preview server at +/// `share_target(host, port)` and announce the join banner via +/// `announce` (production passes a `println!` shim; tests capture). +/// +/// The port must already be resolved — the CLI probes it before the +/// server starts, so the ticket exists (and prints) ahead of the first +/// accept; a too-fast guest just retries via its health supervisor. +/// +/// With the production n0 preset this awaits the endpoint's relay +/// contact (bounded inside [`TunnelHost::spawn`], ~10 s worst case); +/// when no relay was reachable the minted ticket carries direct/LAN +/// addresses only, and the banner says so — quarto-p2p's own +/// `tracing::warn!` is invisible at the CLI's default `quarto=warn` +/// filter, so the banner is the user-facing signal. +pub async fn start_share_session( + cfg: TunnelHostConfig, + host: &str, + port: u16, + allow_edit: bool, + announce: impl FnOnce(&str), +) -> Result { + let (ticket, handle) = TunnelHost::spawn(cfg, share_target(host, port)).await?; + let banner = format_share_banner(&ticket.to_string(), allow_edit, ticket.has_relay_addr()); + announce(&banner); + Ok(ShareSession { ticket, handle }) +} + +/// Spawn the share session on a background task, starting the tunnel +/// only once `ready` flips to `true` (wired to the server's `on_ready`). +/// +/// The gate exists for two reasons: +/// +/// - The production preset's relay wait (up to ~10 s when no relay is +/// reachable) runs on this task, so the host's own preview server +/// binds and serves immediately instead of waiting for the tunnel. +/// - The banner prints after the server's boot URL in every UI mode, +/// keeping the join line the last thing on the terminal (the +/// copy-paste contract [`format_share_banner`] documents). +/// +/// A start failure is reported on stderr and yields `None` — the local +/// preview keeps serving without sharing rather than failing outright. +/// A `ready` channel that closes without ever flipping (the server +/// died before `on_ready`) also yields `None`, quietly. +pub fn spawn_share_task( + cfg: TunnelHostConfig, + host: String, + port: u16, + allow_edit: bool, + mut ready: watch::Receiver, + announce: impl FnOnce(&str) + Send + 'static, +) -> JoinHandle> { + tokio::spawn(async move { + // Park until the server readies; a closed gate (server died + // before on_ready) means there is nothing to share. + if ready.wait_for(|&r| r).await.is_err() { + return None; + } + match start_share_session(cfg, &host, port, allow_edit, announce).await { + Ok(session) => Some(session), + Err(e) => { + eprintln!( + "\n could not start the live-share tunnel: {e}\n \ + the local preview continues without sharing.\n" + ); + None + } + } + }) +} + +/// Render the share banner: capability warning + the bare +/// `q2 preview --join …` line (last, with nothing after it, so a +/// triple-click / drag copy survives terminal wrapping). +pub fn format_share_banner(join_string: &str, allow_edit: bool, relay_reachable: bool) -> String { + let mut banner = String::new(); + banner.push_str("Sharing this preview session (end-to-end encrypted via iroh).\n"); + if allow_edit { + banner.push_str( + "Anyone with the join string below can VIEW the project, RE-RUN its code\n\ + on this machine, and EDIT the project's files on disk (--allow-edit):\n", + ); + } else { + banner.push_str( + "Anyone with the join string below can VIEW the project and RE-RUN its\n\ + code on this machine:\n", + ); + } + if !relay_reachable { + banner.push_str( + "\nNote: no relay is reachable, so guests can join over direct/LAN\n\ + connections only.\n", + ); + } + banner.push('\n'); + banner.push_str(&format!("q2 preview --join {join_string}")); + banner +} + +/// Map the preview server's bind host to the tunnel's TCP target. +/// +/// The default (and the plan's stated posture) is `127.0.0.1:{port}`. +/// The other arms keep `--share` working when `--host` was also set: +/// an unspecified bind (`0.0.0.0` / `::`) is reachable via the +/// matching loopback, a concrete IP is its own target, and a hostname +/// (e.g. `localhost`) falls back to IPv4 loopback. +fn share_target(host: &str, port: u16) -> SocketAddr { + let ip = match host.parse::() { + Ok(ip) if ip.is_unspecified() => match ip { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::LOCALHOST), + }, + Ok(ip) => ip, + Err(_) => IpAddr::V4(Ipv4Addr::LOCALHOST), + }; + SocketAddr::new(ip, port) +} + +#[cfg(test)] +mod tests { + use super::share_target; + + #[test] + fn share_target_maps_bind_hosts_to_dialable_targets() { + for (host, expected) in [ + ("127.0.0.1", "127.0.0.1:7777"), + ("0.0.0.0", "127.0.0.1:7777"), + ("::", "[::1]:7777"), + ("::1", "[::1]:7777"), + ("192.168.1.5", "192.168.1.5:7777"), + ("localhost", "127.0.0.1:7777"), + ] { + assert_eq!( + share_target(host, 7777), + expected.parse().unwrap(), + "share_target({host:?})" + ); + } + } +} diff --git a/crates/quarto-preview/tests/integration/boot.rs b/crates/quarto-preview/tests/integration/boot.rs index 280300b0f..40445ece9 100644 --- a/crates/quarto-preview/tests/integration/boot.rs +++ b/crates/quarto-preview/tests/integration/boot.rs @@ -80,6 +80,8 @@ async fn boots_serves_spa_plus_hub_health() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; // Spawn the server. `run()` blocks until shutdown; we abort the diff --git a/crates/quarto-preview/tests/integration/config_endpoint.rs b/crates/quarto-preview/tests/integration/config_endpoint.rs index f9c5674e2..7488ad6a0 100644 --- a/crates/quarto-preview/tests/integration/config_endpoint.rs +++ b/crates/quarto-preview/tests/integration/config_endpoint.rs @@ -58,6 +58,8 @@ async fn boot_server_for_test( resource_html_files: Vec::new(), cache_dir: None, allow_edit, + share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); @@ -121,6 +123,10 @@ async fn config_reports_read_only_and_doc_edits_never_reach_disk() { Some(false), "without --allow-edit the endpoint must report allowEdit: false; body was {body:?}" ); + assert!( + body.get("editorBoot").is_none(), + "editorBoot must be absent when nothing was stashed (bd-7htq16rx); body was {body:?}" + ); // Disk behavior: a document-side edit + explicit sync must leave // the file untouched (DiskWritePolicy::ReadOnly). @@ -149,6 +155,10 @@ async fn config_reports_allow_edit_and_doc_edits_persist_to_disk() { Some(true), "with --allow-edit the endpoint must report allowEdit: true; body was {body:?}" ); + assert!( + body.get("editorBoot").is_none(), + "editorBoot must be absent when nothing was stashed (bd-7htq16rx); body was {body:?}" + ); let edited = INITIAL_QMD.replace("Hello.", "Hello, edited in the browser."); edit_doc_text(&ctx, "index.qmd", &edited).await; @@ -164,3 +174,79 @@ async fn config_reports_allow_edit_and_doc_edits_persist_to_disk() { handle.abort(); let _ = handle.await; } + +/// bd-7htq16rx: an editor-UI host stashes its share-route boot params +/// (mirroring what the CLI's editor-mode `on_ready` does) and +/// `GET /api/preview/config` then carries them as `editorBoot`, so a +/// `--join` guest can build the same share URL (with `ephemeral=true`) +/// against its local proxy and land straight in the document. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_reports_editor_boot_when_stashed() { + let project = tempfile::TempDir::with_prefix("q2-preview-config-editor-boot-").unwrap(); + std::fs::write(project.path().join("index.qmd"), INITIAL_QMD).unwrap(); + let data = tempfile::TempDir::with_prefix("q2-preview-config-editor-boot-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project.path().to_path_buf()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + share: false, + ui: quarto_preview::PreviewUi::Editor, + }; + + let (ready_tx, ready_rx) = oneshot::channel::(); + let mut ready_tx = Some(ready_tx); + let handle = tokio::spawn(async move { + run_with_on_ready(config, move |ctx| { + // Mirror the CLI's editor-mode on_ready: stash the params + // the host's own share-route boot URL is built from. + let doc_id = ctx.index().document_id(); + quarto_preview::set_editor_boot(quarto_preview::EditorBootInfo { + index_doc_id: doc_id.clone(), + file: "index.qmd".to_string(), + name: "fixture-project".to_string(), + }); + if let Some(tx) = ready_tx.take() { + let _ = tx.send(doc_id); + } + }) + .await + }); + + let doc_id = tokio::time::timeout(Duration::from_secs(10), ready_rx) + .await + .expect("server reached on_ready within 10s") + .expect("on_ready callback fired"); + + let body = fetch_config(port).await; + assert_eq!( + body.get("allowEdit").and_then(|v| v.as_bool()), + Some(false), + "allowEdit keeps reporting independently of editorBoot; body was {body:?}" + ); + let boot = body + .get("editorBoot") + .expect("editorBoot must be present once stashed"); + assert_eq!( + boot.get("indexDocId").and_then(|v| v.as_str()), + Some(doc_id.as_str()), + "editorBoot names the hub's index document" + ); + assert_eq!(boot.get("file").and_then(|v| v.as_str()), Some("index.qmd")); + assert_eq!( + boot.get("name").and_then(|v| v.as_str()), + Some("fixture-project") + ); + + handle.abort(); + let _ = handle.await; +} diff --git a/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs b/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs index d09cf55b8..9f9ca0cb2 100644 --- a/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs +++ b/crates/quarto-preview/tests/integration/diagnostics_capture_failure.rs @@ -79,6 +79,8 @@ async fn capture_failure_lands_in_sink() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs b/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs index 67be76db4..d2b4ce9f0 100644 --- a/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs +++ b/crates/quarto-preview/tests/integration/diagnostics_endpoint.rs @@ -61,6 +61,8 @@ async fn boot_server_for_test() -> ( resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/eager_capture.rs b/crates/quarto-preview/tests/integration/eager_capture.rs index 4da760b82..8bf6cbb9c 100644 --- a/crates/quarto-preview/tests/integration/eager_capture.rs +++ b/crates/quarto-preview/tests/integration/eager_capture.rs @@ -82,6 +82,8 @@ async fn eager_capture_populates_index_sidecar() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); @@ -197,6 +199,8 @@ async fn prose_only_doc_leaves_sidecar_empty() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; let (ready_tx, ready_rx) = oneshot::channel::>(); diff --git a/crates/quarto-preview/tests/integration/editor_ui.rs b/crates/quarto-preview/tests/integration/editor_ui.rs new file mode 100644 index 000000000..8a77689ef --- /dev/null +++ b/crates/quarto-preview/tests/integration/editor_ui.rs @@ -0,0 +1,125 @@ +//! `--ui editor` server-level test (live-share plan Phase 4, +//! bd-jt1etjbn): booting the real preview server with +//! `PreviewUi::Editor` must serve the *editor* embed at `/` (the full +//! hub-client build, or its placeholder on an unbuilt tree) while the +//! hub's own routes keep answering. This pins the config → OnceLock → +//! handler plumbing that the lib-level `lookup_embedded` unit tests +//! cannot see. +//! +//! The browser-tier verification (editor boots, sidebar, Monaco edit +//! write-back) is the phase's mandatory recorded e2e run, not a cargo +//! test. + +use std::net::TcpListener as StdTcpListener; +use std::time::{Duration, Instant}; + +use quarto_preview::{PreviewConfig, PreviewUi}; + +/// Bind `127.0.0.1:0`, capture the assigned port, release the listener. +fn pick_free_port() -> u16 { + let listener = StdTcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +/// Poll `GET /health` until 200 or a 10 s deadline. +async fn wait_for_health(port: u16) { + let url = format!("http://127.0.0.1:{port}/health"); + let deadline = Instant::now() + Duration::from_secs(10); + let client = reqwest::Client::new(); + loop { + if let Ok(resp) = client.get(&url).send().await + && resp.status().is_success() + { + return; + } + if Instant::now() >= deadline { + panic!("server didn't come up on port {port} within 10s"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn editor_ui_serves_editor_index_and_keeps_hub_routes() { + let project = tempfile::TempDir::with_prefix("q2-preview-editor-proj-").unwrap(); + std::fs::write( + project.path().join("foo.qmd"), + "# Hello\n\nEditor-mode fixture page.\n", + ) + .unwrap(); + let data = tempfile::TempDir::with_prefix("q2-preview-editor-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project.path().to_path_buf()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + share: false, + ui: PreviewUi::Editor, + }; + + let server = tokio::spawn(async move { + let _ = quarto_preview::run(config).await; + }); + + wait_for_health(port).await; + + let expected_index = quarto_preview::embedded_editor_index_html() + .expect("the editor embed always has an index.html (real dist or placeholder)"); + + // 1. GET / serves the *editor* embed's index.html. + let body = reqwest::get(format!("http://127.0.0.1:{port}/")) + .await + .expect("GET /") + .error_for_status() + .expect("200 OK") + .text() + .await + .expect("body"); + assert_eq!( + body, expected_index, + "`--ui editor` must serve the editor embed's index.html at /" + ); + + // 2. Unknown paths still fall back to the (editor) index for + // client-side routing. + let fallback = reqwest::get(format!("http://127.0.0.1:{port}/no/such/path")) + .await + .expect("GET /no/such/path") + .error_for_status() + .expect("200 OK") + .text() + .await + .expect("body"); + assert_eq!( + fallback, expected_index, + "SPA fallback in editor mode must serve the editor index" + ); + + // 3. Hub routes keep winning over the SPA fallback: /health already + // answered above; the preview-config route must too (the editor + // reads nothing from it today, but the composition shape is the + // same one the viewer relies on). + let cfg: serde_json::Value = + reqwest::get(format!("http://127.0.0.1:{port}/api/preview/config")) + .await + .expect("GET /api/preview/config") + .error_for_status() + .expect("200 OK") + .json() + .await + .expect("json body"); + assert_eq!(cfg["allowEdit"], serde_json::Value::Bool(false)); + + server.abort(); +} diff --git a/crates/quarto-preview/tests/integration/join_tunnel.rs b/crates/quarto-preview/tests/integration/join_tunnel.rs new file mode 100644 index 000000000..c2f6057c9 --- /dev/null +++ b/crates/quarto-preview/tests/integration/join_tunnel.rs @@ -0,0 +1,175 @@ +//! Phase 3 money test (live-share plan, bd-6y0p1bne): a real preview hub +//! in-process on a fixture project, a hermetic tunnel in front of it, and +//! a samod client dialing `/ws` through the guest port — proves +//! automerge-sync-over-tunnel without a browser. +//! +//! Hermetic: `EndpointPreset::HermeticLoopback` on both tunnel ends — no +//! n0 relays, pkarr, or DNS in CI. `repo.dial_websocket` works here +//! because preview's `/ws` takes no credentials (`auth_config: None` +//! skips credential and Origin checks; see the plan's Phase 3 notes). + +use std::net::TcpListener as StdTcpListener; +use std::time::{Duration, Instant}; + +use quarto_hub::index::IndexDocument; +use quarto_p2p::{EndpointPreset, TunnelClient, TunnelClientConfig, TunnelHost, TunnelHostConfig}; +use quarto_preview::PreviewConfig; +use samod::{BackoffConfig, Repo}; + +/// Bind `127.0.0.1:0`, capture the assigned port, release the listener. +/// Same tiny-race trade-off as the CLI's own port probe. +fn pick_free_port() -> u16 { + let listener = StdTcpListener::bind("127.0.0.1:0").expect("probe bind"); + let port = listener.local_addr().expect("local_addr").port(); + drop(listener); + port +} + +/// Poll `GET /health` (directly, not through the tunnel) until the hub +/// is up — HubContext::new can take a beat (samod init, initial fs sync). +async fn wait_for_health(port: u16) { + let url = format!("http://127.0.0.1:{port}/health"); + let deadline = Instant::now() + Duration::from_secs(20); + let client = reqwest::Client::new(); + loop { + if let Ok(resp) = client.get(&url).send().await + && resp.status().is_success() + { + return; + } + assert!( + Instant::now() < deadline, + "preview server didn't come up on port {port} within 20s" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn guest_syncs_project_through_tunnel() { + // Fixture project: `_quarto.yml` + two pages, like the e2e fixtures. + let project = tempfile::TempDir::with_prefix("q2-join-tunnel-proj-").unwrap(); + std::fs::write( + project.path().join("_quarto.yml"), + "project:\n type: website\n", + ) + .unwrap(); + std::fs::write(project.path().join("index.qmd"), "# Index\n\nHello.\n").unwrap(); + std::fs::write(project.path().join("about.qmd"), "# About\n").unwrap(); + let data = tempfile::TempDir::with_prefix("q2-join-tunnel-data-").unwrap(); + + let port = pick_free_port(); + let config = PreviewConfig { + host: "127.0.0.1".to_string(), + port, + project_root: Some(project.path().to_path_buf()), + single_file: None, + data_dir: data.path().to_path_buf(), + spa_dir_override: None, + engine_registry: None, + engine_policy: Default::default(), + resource_html_files: Vec::new(), + cache_dir: None, + allow_edit: false, + share: false, + ui: Default::default(), + }; + + // The real hub, exactly as `q2 preview` runs it. `run()` blocks until + // shutdown; the task dies with the test process (nextest isolates). + let _server = tokio::spawn(async move { + let _ = quarto_preview::run(config).await; + }); + wait_for_health(port).await; + + // Hermetic tunnel pair in front of the hub — the same shape + // `--share`/`--join` wire up with the production n0 preset. + let (ticket, tunnel_host) = TunnelHost::spawn( + TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + ..Default::default() + }, + ([127, 0, 0, 1], port).into(), + ) + .await + .expect("spawn tunnel host"); + let (guest, tunnel_client) = TunnelClient::bind( + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + }, + ticket, + "127.0.0.1:0".parse().unwrap(), + ) + .await + .expect("bind tunnel client"); + + // (a) `/health` through the guest port answers with the *host's* + // index document id — byte-identical to the direct answer. + let through_tunnel: serde_json::Value = reqwest::get(format!("http://{guest}/health")) + .await + .expect("GET /health through tunnel") + .error_for_status() + .expect("200 through tunnel") + .json() + .await + .expect("health json"); + let doc_id = through_tunnel["index_document_id"] + .as_str() + .expect("health carries index_document_id") + .to_string(); + assert!(!doc_id.is_empty(), "index_document_id must be non-empty"); + + let direct: serde_json::Value = reqwest::get(format!("http://127.0.0.1:{port}/health")) + .await + .expect("GET /health direct") + .json() + .await + .expect("direct health json"); + assert_eq!( + doc_id, + direct["index_document_id"].as_str().unwrap(), + "tunnel and direct /health must name the same index document" + ); + + // (b) samod client dials `/ws` through the guest port and loads the + // index document: automerge sync itself flows over the tunnel. + let repo = Repo::build_tokio().load().await; + let ws_url: url::Url = format!("ws://{guest}/ws").parse().unwrap(); + let handle = repo + .dial_websocket(ws_url, BackoffConfig::default()) + .expect("dial /ws through tunnel"); + tokio::time::timeout(Duration::from_secs(10), handle.established()) + .await + .expect("ws connection through tunnel timed out") + .expect("ws connection through tunnel failed"); + + let index = IndexDocument::load(&repo, &doc_id) + .await + .expect("load index document") + .expect("index document found through tunnel"); + + // The files map converges to the fixture's two pages. + let deadline = Instant::now() + Duration::from_secs(30); + let files = loop { + let files = index.get_all_files(); + if files.contains_key("index.qmd") && files.contains_key("about.qmd") { + break files; + } + assert!( + Instant::now() < deadline, + "fixture files never synced through the tunnel; got: {:?}", + files.keys().collect::>() + ); + tokio::time::sleep(Duration::from_millis(250)).await; + }; + // Every file entry maps to a per-file automerge doc id. + for (path, file_doc_id) in &files { + assert!( + !file_doc_id.is_empty(), + "file {path} has an empty document id" + ); + } + + tunnel_client.shutdown().await.expect("client shutdown"); + tunnel_host.shutdown().await.expect("host shutdown"); +} diff --git a/crates/quarto-preview/tests/integration/main.rs b/crates/quarto-preview/tests/integration/main.rs index 6b326eb68..b68e1a3d9 100644 --- a/crates/quarto-preview/tests/integration/main.rs +++ b/crates/quarto-preview/tests/integration/main.rs @@ -7,7 +7,10 @@ pub mod config_endpoint; pub mod diagnostics_capture_failure; pub mod diagnostics_endpoint; pub mod eager_capture; +pub mod editor_ui; +pub mod join_tunnel; pub mod render_scripts_boot; +pub mod share; pub mod smoke; pub mod staleness; diff --git a/crates/quarto-preview/tests/integration/render_scripts_boot.rs b/crates/quarto-preview/tests/integration/render_scripts_boot.rs index 7be6c1f1e..e87843f6c 100644 --- a/crates/quarto-preview/tests/integration/render_scripts_boot.rs +++ b/crates/quarto-preview/tests/integration/render_scripts_boot.rs @@ -117,6 +117,8 @@ async fn pre_render_scripts_run_once_at_boot() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; let server = tokio::spawn(async move { diff --git a/crates/quarto-preview/tests/integration/share.rs b/crates/quarto-preview/tests/integration/share.rs new file mode 100644 index 000000000..f3d749ac8 --- /dev/null +++ b/crates/quarto-preview/tests/integration/share.rs @@ -0,0 +1,288 @@ +//! `--share` glue tests (live-share plan Phase 2, bd-jhvkwosw). +//! +//! Hermetic: `EndpointPreset::HermeticLoopback` on both sides — no n0 +//! relays, pkarr, or DNS in CI. The n0-preset production path is covered +//! by the phase's mandatory recorded end-to-end run instead. + +use std::time::Duration; + +use quarto_p2p::{EndpointPreset, TunnelClient, TunnelClientConfig, TunnelHostConfig}; +use quarto_preview::share::{format_share_banner, spawn_share_task, start_share_session}; + +/// Generous cap for individual awaits so a broken tunnel fails the test +/// instead of hanging it. Sized for CI, not for the happy path: iroh +/// endpoint operations inflate 5-10x under full-suite load (every +/// endpoint bind pays for netmon setup + handshake crypto on contended +/// cores), and ubuntu runners are the slowest we have. Never binds on a +/// healthy run — the uncontended cost of a step is milliseconds. +const STEP_TIMEOUT: Duration = Duration::from_secs(60); + +fn hermetic_host_cfg() -> TunnelHostConfig { + TunnelHostConfig { + preset: EndpointPreset::HermeticLoopback, + ..Default::default() + } +} + +/// The core Phase 2 unit: the share glue spawns a tunnel whose target is +/// `127.0.0.1:{port}` (the pre-resolved preview port), and the join +/// banner goes through the injected callback — no stdout scraping. +/// +/// multi_thread like every other test that runs an iroh endpoint +/// (tunnel.rs, join_tunnel.rs): on a current-thread runtime the endpoint +/// actors, the axum stand-in, and reqwest all share one thread, and +/// under CI load the 20s step caps below get exceeded (ubuntu CI +/// failure, 2026-08-09). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn share_glue_tunnels_to_preview_port_and_announces_join_string() { + // Tiny axum server standing in for the preview hub on the loopback + // port the CLI would have pre-resolved. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stand-in preview server"); + let port = listener.local_addr().expect("local_addr").port(); + let app = axum::Router::new().route( + "/health", + axum::routing::get(|| async { "SHARE-GLUE-MARKER" }), + ); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + + let mut announced: Vec = Vec::new(); + let session = tokio::time::timeout( + STEP_TIMEOUT, + start_share_session(hermetic_host_cfg(), "127.0.0.1", port, false, |banner| { + announced.push(banner.to_string()) + }), + ) + .await + .expect("start_share_session should not hang") + .expect("share session starts"); + + // Banner delivered exactly once, via the callback. + assert_eq!(announced.len(), 1, "banner must be announced exactly once"); + let banner = &announced[0]; + let join_string = session.ticket.to_string(); + assert!(join_string.starts_with("q2preview")); + + // The ready-to-paste line is bare — `q2 preview --join ` with + // nothing else on it — and nothing follows it in the banner, so a + // triple-click / drag copy survives terminal wrapping. + let join_line = banner + .lines() + .find(|l| l.contains("--join")) + .expect("banner has a join line"); + assert_eq!(join_line, format!("q2 preview --join {join_string}")); + assert!( + banner.trim_end().ends_with(&join_string), + "nothing may follow the join line; got banner:\n{banner}" + ); + + // Hermetic endpoints have no relay addr → the banner must carry the + // direct/LAN-only notice (this is how relay-unreachable surfaces to + // users; quarto-p2p's tracing::warn is filtered out at default -v 0). + assert!( + !session.ticket.has_relay_addr(), + "hermetic ticket unexpectedly carries a relay addr" + ); + assert!( + banner.contains("direct/LAN"), + "no-relay banner must warn about direct/LAN-only reachability:\n{banner}" + ); + + // The ticket's tunnel target is the stand-in server: a hermetic + // guest client can fetch through it. + let (local, client) = tokio::time::timeout( + STEP_TIMEOUT, + TunnelClient::bind( + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + }, + session.ticket.clone(), + "127.0.0.1:0".parse().unwrap(), + ), + ) + .await + .expect("client bind should not hang") + .expect("tunnel client binds"); + + let body = tokio::time::timeout(STEP_TIMEOUT, reqwest::get(format!("http://{local}/health"))) + .await + .expect("GET through tunnel should not hang") + .expect("GET through tunnel succeeds") + .text() + .await + .expect("response body"); + assert_eq!( + body, "SHARE-GLUE-MARKER", + "tunnel target must be 127.0.0.1:{port}" + ); + + client.shutdown().await.expect("client shutdown"); + session.shutdown().await.expect("session shutdown"); +} + +/// The share task is gated on the server's ready signal: the tunnel's +/// relay wait must never delay server startup, and the banner must +/// print after the boot URL. Nothing may start before the gate fires — +/// with the hermetic preset an ungated spawn would finish in +/// milliseconds, so an early banner proves the gate is broken. +/// multi_thread: see `share_glue_tunnels_to_preview_port_and_announces_join_string`. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn share_task_waits_for_server_ready_gate() { + // Stand-in for the preview hub, as in the glue test above. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stand-in preview server"); + let port = listener.local_addr().expect("local_addr").port(); + let app = axum::Router::new().route("/health", axum::routing::get(|| async { "GATE-MARKER" })); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("axum serve"); + }); + + let announced = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (ready_tx, ready_rx) = tokio::sync::watch::channel(false); + let task = { + let announced = announced.clone(); + spawn_share_task( + hermetic_host_cfg(), + "127.0.0.1".to_string(), + port, + false, + ready_rx, + move |banner| announced.lock().unwrap().push(banner.to_string()), + ) + }; + + // Before the gate: no banner, no finished task. + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + announced.lock().unwrap().is_empty(), + "the banner must wait for the server-ready gate" + ); + assert!( + !task.is_finished(), + "the share task must park on the ready gate" + ); + + // Fire the gate: the tunnel starts and the banner prints. + ready_tx.send(true).expect("send ready"); + let session = tokio::time::timeout(STEP_TIMEOUT, task) + .await + .expect("share task should finish once gated") + .expect("share task panicked") + .expect("tunnel starts once the gate fires"); + assert_eq!(announced.lock().unwrap().len(), 1, "banner printed once"); + + // And the tunnel actually works. + let (local, client) = tokio::time::timeout( + STEP_TIMEOUT, + TunnelClient::bind( + TunnelClientConfig { + preset: EndpointPreset::HermeticLoopback, + }, + session.ticket.clone(), + "127.0.0.1:0".parse().unwrap(), + ), + ) + .await + .expect("client bind should not hang") + .expect("tunnel client binds"); + let body = tokio::time::timeout(STEP_TIMEOUT, reqwest::get(format!("http://{local}/health"))) + .await + .expect("GET through tunnel should not hang") + .expect("GET through tunnel succeeds") + .text() + .await + .expect("response body"); + assert_eq!(body, "GATE-MARKER"); + + client.shutdown().await.expect("client shutdown"); + session.shutdown().await.expect("session shutdown"); +} + +/// A server that dies before `on_ready` (the gate drops without ever +/// firing) must end the share task quietly: no banner, no session, no +/// lingering task for the caller to reap. +#[tokio::test] +async fn share_task_exits_quietly_when_server_dies_before_ready() { + let announced = std::sync::Arc::new(std::sync::Mutex::new(Vec::::new())); + let (ready_tx, ready_rx) = tokio::sync::watch::channel(false); + let task = { + let announced = announced.clone(); + spawn_share_task( + hermetic_host_cfg(), + "127.0.0.1".to_string(), + 1, // never dialed: the gate drops before the tunnel starts + false, + ready_rx, + move |banner| announced.lock().unwrap().push(banner.to_string()), + ) + }; + + drop(ready_tx); + let out = tokio::time::timeout(STEP_TIMEOUT, task) + .await + .expect("the share task must end when the gate drops") + .expect("share task panicked"); + assert!(out.is_none(), "no session when the server never readied"); + assert!( + announced.lock().unwrap().is_empty(), + "no banner when the server never readied" + ); +} + +/// Banner wording: what the token grants must be printed at share time +/// (security model). Read-only sessions must not claim edit capability; +/// `--allow-edit` sessions must warn about disk writes. +#[test] +fn banner_states_capabilities_per_allow_edit() { + let read_only = format_share_banner("q2previewexample", false, true); + assert!( + read_only.contains("VIEW") && read_only.contains("RE-RUN"), + "banner must state the view + re-run capability:\n{read_only}" + ); + assert!( + !read_only.contains("EDIT"), + "read-only banner must not claim edit capability:\n{read_only}" + ); + + let editable = format_share_banner("q2previewexample", true, true); + assert!( + editable.contains("EDIT") && editable.contains("--allow-edit"), + "--allow-edit banner must warn that guests can edit files on this machine:\n{editable}" + ); +} + +/// Relay reachability: when the endpoint never came online, the banner +/// says so (guests on other networks won't be able to join). +#[test] +fn banner_warns_when_relay_unreachable() { + let no_relay = format_share_banner("q2previewexample", false, false); + assert!( + no_relay.contains("direct/LAN"), + "no-relay banner must warn about direct/LAN-only reachability:\n{no_relay}" + ); + + let with_relay = format_share_banner("q2previewexample", false, true); + assert!( + !with_relay.contains("direct/LAN"), + "relay-reachable banner must not carry the LAN-only warning:\n{with_relay}" + ); +} + +/// The join line stays last in every banner variant (copy-paste contract). +#[test] +fn banner_join_line_is_always_last() { + for (allow_edit, relay) in [(false, true), (true, true), (false, false), (true, false)] { + let banner = format_share_banner("q2previewexample", allow_edit, relay); + assert!( + banner + .trim_end() + .ends_with("q2 preview --join q2previewexample"), + "join line must be the banner's last content \ + (allow_edit={allow_edit}, relay={relay}):\n{banner}" + ); + } +} diff --git a/crates/quarto-preview/tests/integration/staleness.rs b/crates/quarto-preview/tests/integration/staleness.rs index acf9396bf..0d6ec0c67 100644 --- a/crates/quarto-preview/tests/integration/staleness.rs +++ b/crates/quarto-preview/tests/integration/staleness.rs @@ -112,6 +112,8 @@ async fn cell_edit_flips_staleness_in_sidecar() { resource_html_files: Vec::new(), cache_dir: None, allow_edit: false, + share: false, + ui: Default::default(), }; // ── Boot the server, capture the HubContext via on_ready ─────── diff --git a/crates/quarto/Cargo.toml b/crates/quarto/Cargo.toml index 14d0ff6eb..25d746e1d 100644 --- a/crates/quarto/Cargo.toml +++ b/crates/quarto/Cargo.toml @@ -20,8 +20,10 @@ tracing-subscriber.workspace = true serde.workspace = true serde_json.workspace = true pollster.workspace = true -# `signal` for the `provide-hub` serve loop's Ctrl-C shutdown. -tokio = { workspace = true, features = ["signal"] } +# `signal` for the `provide-hub` serve loop's Ctrl-C shutdown; `net` + +# `io-util` for `q2 preview`'s readiness probes (TCP accept probe, and +# the `--join` health probe through the tunnel). +tokio = { workspace = true, features = ["signal", "net", "io-util"] } quarto-core = { workspace = true, features = ["clap"] } quarto-error-catalog.workspace = true @@ -44,6 +46,10 @@ quarto-hub.workspace = true quarto-hub-provider = { path = "../quarto-hub-provider" } quarto-mcp-launcher = { path = "../quarto-mcp-launcher" } quarto-preview = { path = "../quarto-preview" } +# `q2 preview --join`: ticket parsing + the guest-side tunnel client +# (live-share plan Phase 3, bd-6y0p1bne). All iroh usage stays behind +# quarto-p2p's API. +quarto-p2p.workspace = true url = "2.5" quarto-publish.workspace = true quarto-sass.workspace = true diff --git a/crates/quarto/src/commands/preview.rs b/crates/quarto/src/commands/preview.rs index c0becb8aa..92d9c0875 100644 --- a/crates/quarto/src/commands/preview.rs +++ b/crates/quarto/src/commands/preview.rs @@ -34,6 +34,10 @@ pub struct PreviewArgs { pub host: Option, /// Skip the browser-open step. pub no_browser: bool, + /// Open the preview in this browser instead of the system + /// default (`--browser `). clap makes it conflict with + /// `--no-browser`, so the two never both reach here. + pub browser: Option, /// Override the ephemeral samod storage dir. Default: a fresh /// `tempfile::TempDir` that's deleted on shutdown. pub data_dir: Option, @@ -46,6 +50,14 @@ pub struct PreviewArgs { /// files on disk (bd-ov4gqk3m). Off by default: without it the /// preview is read-only end to end. pub allow_edit: bool, + /// Share this preview session over an end-to-end encrypted iroh + /// tunnel (bd-jhvkwosw). The server prints a `q2preview…` join + /// string; the HTTP port itself stays loopback-bound. + pub share: bool, + /// Which embedded frontend the server serves (`--ui`, live-share + /// plan Phase 4, bd-jt1etjbn): the read-only preview SPA (default) + /// or the full hub-client editor. Never changes the write policy. + pub ui: quarto_preview::PreviewUi, } pub fn execute(args: PreviewArgs) -> Result<()> { @@ -55,6 +67,28 @@ pub fn execute(args: PreviewArgs) -> Result<()> { runtime.block_on(run(args)) } +/// Guest-mode argument shape for `q2 preview --join ` (live-share +/// plan Phase 3, bd-6y0p1bne). Deliberately tiny: the guest has no local +/// project, hub, or disk surface — clap rejects every host-mode flag. +pub struct JoinArgs { + /// The `q2preview…` join string printed by `q2 preview --share`. + pub ticket: String, + /// Local proxy port. Default: OS-assigned. + pub port: Option, + /// Local proxy bind host. Default: 127.0.0.1. + pub host: Option, + /// Skip the browser-open step. + pub no_browser: bool, + /// Open the session in this browser instead of the system + /// default (`--browser `). + pub browser: Option, +} + +pub fn execute_join(args: JoinArgs) -> Result<()> { + let runtime = tokio::runtime::Runtime::new()?; + runtime.block_on(run_join(args)) +} + async fn run(args: PreviewArgs) -> Result<()> { // Project mode is the default (epic plan Q5); --no-project is // the explicit standalone-server escape hatch. Canonicalize so @@ -133,50 +167,39 @@ async fn run(args: PreviewArgs) -> Result<()> { // Phase D.2: encode the initial page (if any) as `?page=` // so the SPA's `pickInitialPage` helper can seed `activeFile`. - let url = build_boot_url(&host, port, initial_page.as_deref()); - info!(%url, "starting q2 preview server"); - println!(); - println!(" q2 preview"); - println!(" → {url}"); - println!(); - - // Phase D.1 (bd-kw93.8): actually open a browser tab. Failure to - // open is logged + non-fatal (the URL is already printed for - // copy-paste). Suppressed by --no-browser. // - // bd-a6dvrdg1: open only once the server is actually accepting - // connections. The server doesn't start until `quarto_preview::run` - // below (which then blocks until shutdown), so the open has to run - // on a spawned task that waits for readiness while the main task - // goes on to start the server. Opening eagerly here — as we used to - // — raced the server's startup: on larger projects the browser - // connected before `axum::serve` was live and showed "Unable to - // connect" until a manual reload. The probe (`wait_until_accepting`) - // closes that race by gating on the real accept condition. The port - // is the one we pre-probed; nothing is listening on it until the - // server binds, so the probe naturally retries across the gap. - if !args.no_browser { - let url_for_open = url.clone(); - let host_for_open = host.clone(); - tokio::spawn(async move { - const READY_TIMEOUT: Duration = Duration::from_secs(10); - if wait_until_accepting(&host_for_open, port, READY_TIMEOUT).await { - info!(host = %host_for_open, port, "preview server accepting connections; opening browser"); - } else { - // We still consider a >10s startup a bug; opening anyway - // (rather than never) preserves the old behavior as a - // floor, and the warning gives the slow start visibility - // instead of leaving it silent. - tracing::warn!( - host = %host_for_open, - port, - timeout_secs = READY_TIMEOUT.as_secs(), - "preview server has not accepted a connection within the timeout; \ - opening the browser anyway (it may need a manual reload)" - ); + // Phase 4 (bd-jt1etjbn): only viewer mode can print its boot URL + // here. The editor boot URL is the hub-client share route, which + // needs the index document id — that only exists once the hub is + // up, so editor mode defers the print + browser-open into the + // server's `on_ready` callback below (which fires before the + // listener binds, the same print-before-accept contract as here). + match args.ui { + quarto_preview::PreviewUi::Viewer => { + let url = build_boot_url(&host, port, initial_page.as_deref()); + info!(%url, "starting q2 preview server"); + println!(); + println!(" q2 preview"); + println!(" → {url}"); + println!(); + + // Phase D.1 (bd-kw93.8): actually open a browser tab, gated on + // the server accepting connections (bd-a6dvrdg1 — see + // `spawn_browser_open_when_ready`). Failure to open is logged + + // non-fatal (the URL is already printed for copy-paste). + // Suppressed by --no-browser. + if !args.no_browser { + spawn_browser_open_when_ready(host.clone(), port, url, args.browser.clone()); } - open_browser_or_log(&url_for_open, false); - }); + } + quarto_preview::PreviewUi::Editor => { + info!("starting q2 preview server (editor UI)"); + println!(); + println!(" q2 preview — editor UI"); + if let Some(note) = quarto_preview::editor_ephemeral_note(args.allow_edit) { + println!(" {note}"); + } + } } // Phase C.6: read `preview.engine` from `_quarto.yml` so the @@ -220,8 +243,342 @@ async fn run(args: PreviewArgs) -> Result<()> { // (per-session; tracked as a follow-up for per-project reuse). cache_dir: None, allow_edit: args.allow_edit, + share: args.share, + ui: args.ui, + }; + + // Host-side Ctrl-C acknowledgment, symmetric with the guest's line + // in run_join: the hub's own "initiating graceful shutdown" goes to + // tracing::info, which the CLI's default filter hides — so without + // this the process just vanished. tokio allows any number of ctrl_c + // listeners; the hub's own handler still drives the graceful + // shutdown (and its final filesystem sync). + let sharing = args.share; + let ctrl_c_printer = tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + println!(); + println!(" {}", shutdown_message(sharing)); + }); + + let result = match args.ui { + quarto_preview::PreviewUi::Viewer => quarto_preview::run(config).await, + quarto_preview::PreviewUi::Editor => { + // Phase 4 (bd-jt1etjbn): the share-route boot URL needs the + // index doc id, which exists only server-side. `on_ready` + // channels it back: build the URL there, print it, and gate + // the browser-open on the same accept probe viewer mode uses. + let host_for_ready = config.host.clone(); + let project_name = config + .project_root + .as_deref() + .and_then(|r| r.file_name()) + .map_or_else( + || "q2 preview".to_string(), + |n| n.to_string_lossy().into_owned(), + ); + let no_browser = args.no_browser; + let browser = args.browser.clone(); + quarto_preview::run_with_on_ready(config, move |ctx| { + let paths: Vec = ctx.index().get_all_files().into_keys().collect(); + let url = match pick_editor_file(initial_page.as_deref(), &paths) { + Some(file) => { + // bd-7htq16rx: hand `--join` guests the same + // boot params via /api/preview/config so they + // boot the share route (skipping the project-set + // setup screen) instead of the editor's root + // route, which can never join the document. + quarto_preview::set_editor_boot(quarto_preview::EditorBootInfo { + index_doc_id: ctx.index().document_id(), + file: file.clone(), + name: project_name.clone(), + }); + build_editor_boot_url( + &host_for_ready, + port, + &ctx.index().document_id(), + &file, + &project_name, + ) + } + None => { + // No `.qmd` to seed the share route with (e.g. + // --no-project): boot to the editor's project + // selector instead of a broken share link. + tracing::warn!( + "no .qmd file found to open in the editor; \ + booting to the project selector" + ); + format!("http://{host_for_ready}:{port}/") + } + }; + info!(%url, "editor UI ready"); + println!(" → {url}"); + println!(); + if !no_browser { + spawn_browser_open_when_ready( + host_for_ready.clone(), + port, + url, + browser.clone(), + ); + } + }) + .await + } }; - quarto_preview::run(config).await + // The server exited on its own (error path): disarm the printer so + // a late Ctrl-C can't announce a shutdown that already happened. + ctrl_c_printer.abort(); + result +} + +/// The host's Ctrl-C line: a plain preview vs. an active `--share` +/// session (whose guests lose their tunnel when the host exits). +fn shutdown_message(share: bool) -> &'static str { + if share { + "Received Ctrl-C, ending the shared session…" + } else { + "Received Ctrl-C, shutting down the preview…" + } +} + +/// Guest path (live-share plan Phase 3, bd-6y0p1bne): parse the join +/// string, dial the host over iroh, serve the session on a local +/// loopback proxy, and report connection status until Ctrl-C. Bypasses +/// project resolution, the TempDir, and the hub entirely — the *host's* +/// preview server serves everything through the tunnel. +async fn run_join(args: JoinArgs) -> Result<()> { + let ticket: quarto_p2p::PreviewShareTicket = args.ticket.trim().parse().map_err(|e| { + anyhow::anyhow!( + "invalid join string ({e})\n\ + Expected the `q2preview…` string printed by `q2 preview --share` on the \ + host machine. Copy the whole string — it is long and may wrap across \ + several terminal lines." + ) + })?; + + let host = args.host.unwrap_or_else(|| "127.0.0.1".to_string()); + // An explicit --port gets the friendly availability check. Port 0 / + // absent means the OS assigns one; unlike host mode there is no + // pre-probe — `TunnelClient::bind` reports the port it bound. + if let Some(p) = args.port + && p != 0 + { + validate_explicit_port(&host, p)?; + } + let requested_port = args.port.unwrap_or(0); + let local = tokio::net::lookup_host((host.as_str(), requested_port)) + .await + .ok() + .and_then(|mut addrs| addrs.next()) + .ok_or_else(|| anyhow::anyhow!("could not resolve --host {host}"))?; + + println!(); + println!(" q2 preview — joining a shared session (end-to-end encrypted via iroh)"); + + // The initial dial happens inside `bind`: an unreachable host is a + // clear error right here, not a silent background retry. + let (bound, handle) = + quarto_p2p::TunnelClient::bind(quarto_p2p::TunnelClientConfig::default(), ticket, local) + .await + .map_err(join_bind_error)?; + + // Resolve the boot URL through the tunnel before printing it. + // Readiness = the first successful GET /health *through the + // tunnel*: a bare TCP accept (host mode's readiness signal) would + // lie here — the local proxy accepts even when the host is + // unreachable — so only an end-to-end HTTP roundtrip proves the + // session is usable. Editor-UI hosts then carry their share-route + // boot params in /api/preview/config (bd-7htq16rx): boot the guest + // to the same share URL the host printed, ephemeral flag included, + // so hub-client skips project-set onboarding and joins the + // document. Viewer-mode and older hosts answer without + // `editorBoot` and keep the root URL. + const READY_TIMEOUT: Duration = Duration::from_secs(15); + let url = if wait_until_healthy(bound, READY_TIMEOUT).await { + info!(local = %bound, "shared session healthy through the tunnel"); + match fetch_editor_boot(bound).await { + Some(boot) => build_guest_editor_url(&bound, &boot), + None => format!("http://{bound}/"), + } + } else { + // We still consider a >15s startup a bug; printing the root + // URL anyway (rather than never) preserves the old behavior as + // a floor, and the warning gives the slow start visibility + // instead of leaving it silent. + tracing::warn!( + local = %bound, + timeout_secs = READY_TIMEOUT.as_secs(), + "shared session did not answer /health within the timeout; \ + the URL below may need a manual reload" + ); + format!("http://{bound}/") + }; + + println!(" → {url}"); + println!(); + println!(" Press Ctrl-C to leave the session."); + println!(); + + if !args.no_browser { + open_browser_or_log(&url, args.browser.as_deref()); + } + + // Report status transitions ("connected via relay", "reconnecting…") + // until Ctrl-C — or fail fast when the host rejects the token. + let mut status = handle.status(); + let status_reporter = async move { + loop { + match *status.borrow_and_update() { + quarto_p2p::TunnelStatus::Connected(kind) => { + println!(" ● connected via {kind}"); + } + quarto_p2p::TunnelStatus::Reconnecting => { + println!(" ○ connection lost — reconnecting…"); + } + quarto_p2p::TunnelStatus::Rejected => { + return Err(anyhow::anyhow!( + "the host rejected this join string — the share session has \ + ended or was restarted with a new string.\n\ + Ask the host for a fresh `q2 preview --share` join string." + )); + } + } + if status.changed().await.is_err() { + // Sender gone = tunnel client shut down; stop reporting. + return Ok(()); + } + } + }; + + let outcome = tokio::select! { + _ = tokio::signal::ctrl_c() => { + println!(); + println!(" Received Ctrl-C, leaving the shared session…"); + Ok(()) + } + reported = status_reporter => reported, + }; + + if let Err(e) = handle.shutdown().await { + tracing::warn!(error = %e, "tunnel client shutdown failed"); + } + outcome +} + +/// Map a tunnel bind failure to actionable CLI guidance. +fn join_bind_error(e: quarto_p2p::TunnelError) -> anyhow::Error { + use quarto_p2p::TunnelError; + match e { + TunnelError::Connect(src) => anyhow::anyhow!( + "could not reach the share host ({src})\n\ + Check that `q2 preview --share` is still running on the host machine \ + and that both machines are online, then retry with the same join string." + ), + TunnelError::Proxy(src) => anyhow::anyhow!( + "could not bind the local proxy port: {src}\n\ + Pass --port 0 to let the OS pick a free port." + ), + other => anyhow::Error::new(other).context("starting the tunnel client"), + } +} + +/// Poll `GET /health` on the local proxy until it answers 200 — i.e. the +/// host's preview hub answered *through the tunnel* — or `total_timeout` +/// elapses. Join mode's readiness gate; same backoff shape and +/// open-anyway-on-timeout contract as [`wait_until_accepting`]. +async fn wait_until_healthy(addr: std::net::SocketAddr, total_timeout: Duration) -> bool { + const INITIAL_BACKOFF: Duration = Duration::from_millis(50); + const MAX_BACKOFF: Duration = Duration::from_secs(1); + // A tunnel roundtrip can legitimately take a relay RTT, but one + // wedged attempt must not eat the whole budget. + const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(5); + + let deadline = tokio::time::Instant::now() + total_timeout; + let mut backoff = INITIAL_BACKOFF; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return false; + } + if let Ok(true) = + tokio::time::timeout(remaining.min(ATTEMPT_TIMEOUT), health_get_ok(addr)).await + { + return true; + } + + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + return false; + } + tokio::time::sleep(backoff.min(remaining)).await; + backoff = (backoff * 8 / 5).min(MAX_BACKOFF); + } +} + +/// One raw HTTP/1.1 `GET /health` against `addr`; `true` iff the status +/// line comes back 200. Hand-rolled so the CLI doesn't grow an HTTP +/// client dependency for a one-line probe. +async fn health_get_ok(addr: std::net::SocketAddr) -> bool { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let Ok(mut stream) = tokio::net::TcpStream::connect(addr).await else { + return false; + }; + if stream + .write_all(b"GET /health HTTP/1.1\r\nHost: q2-preview-join\r\nConnection: close\r\n\r\n") + .await + .is_err() + { + return false; + } + let mut response = Vec::new(); + if stream.read_to_end(&mut response).await.is_err() { + return false; + } + response.starts_with(b"HTTP/1.1 200") +} + +/// One raw HTTP/1.1 `GET /api/preview/config` against `addr`; the +/// parsed `editorBoot` params when the host is an editor-UI session +/// that stashed them (bd-7htq16rx). Hand-rolled for the same reason as +/// [`health_get_ok`]. Any failure — connect, non-200, malformed body, +/// absent field — is `None`, and the caller falls back to the root URL. +async fn fetch_editor_boot(addr: std::net::SocketAddr) -> Option { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut stream = tokio::net::TcpStream::connect(addr).await.ok()?; + stream + .write_all( + b"GET /api/preview/config HTTP/1.1\r\nHost: q2-preview-join\r\nConnection: close\r\n\r\n", + ) + .await + .ok()?; + let mut response = Vec::new(); + stream.read_to_end(&mut response).await.ok()?; + if !response.starts_with(b"HTTP/1.1 200") { + return None; + } + let body_start = response.windows(4).position(|w| w == b"\r\n\r\n")? + 4; + parse_editor_boot(&response[body_start..]) +} + +/// Parse the `editorBoot` field of a `/api/preview/config` body. +/// `None` for viewer-mode and older hosts (no field), for malformed +/// JSON, and for a field whose doc id or file is empty — a boot URL +/// built from those could never join the document. +fn parse_editor_boot(body: &[u8]) -> Option { + #[derive(serde::Deserialize)] + struct PreviewConfigWire { + #[serde(rename = "editorBoot")] + editor_boot: Option, + } + let boot = serde_json::from_slice::(body) + .ok()? + .editor_boot?; + if boot.index_doc_id.is_empty() || boot.file.is_empty() { + return None; + } + Some(boot) } /// Bind `host:0`, read the OS-assigned port, drop the listener. @@ -255,21 +612,66 @@ fn validate_explicit_port(host: &str, port: u16) -> Result<()> { } } -/// Phase D.1: open the boot URL in the user's default browser unless -/// `--no-browser` was passed. Failure is logged + non-fatal — the -/// URL was already printed for copy-paste before this fires. -fn open_browser_or_log(url: &str, suppress: bool) { - if suppress { - return; - } - if let Err(e) = open::that(url) { - tracing::warn!( - error = %e, - "could not auto-open browser; the URL is printed above" - ); +/// Phase D.1: open the boot URL in the user's browser. `browser` is +/// the `--browser ` value: `Some` opens that specific application +/// (via `open::with`, i.e. `open -a` on macOS), `None` the system +/// default. Failure is logged + non-fatal — the URL was already +/// printed for copy-paste before this fires. Callers gate on +/// `--no-browser` before calling. +fn open_browser_or_log(url: &str, browser: Option<&str>) { + let result = match browser { + Some(app) => open::with(url, app), + None => open::that(url), + }; + if let Err(e) = result { + if let Some(app) = browser { + tracing::warn!( + error = %e, + browser = app, + "could not open the preview in the requested browser; the URL is printed above" + ); + } else { + tracing::warn!( + error = %e, + "could not auto-open browser; the URL is printed above" + ); + } } } +/// bd-a6dvrdg1: spawn the "open the browser once the server accepts +/// connections" task both UI modes share. The server doesn't start +/// until `quarto_preview::run` below (which then blocks until +/// shutdown), so the open has to run on a spawned task that waits for +/// readiness while the main task goes on to start the server. Opening +/// eagerly — as we used to — raced the server's startup: on larger +/// projects the browser connected before `axum::serve` was live and +/// showed "Unable to connect" until a manual reload. The probe +/// (`wait_until_accepting`) closes that race by gating on the real +/// accept condition. The port is the pre-probed one; nothing is +/// listening on it until the server binds, so the probe naturally +/// retries across the gap. A >10 s startup is still considered a bug, +/// but opening anyway (rather than never) preserves the old behavior +/// as a floor, and the warning gives the slow start visibility instead +/// of leaving it silent. +fn spawn_browser_open_when_ready(host: String, port: u16, url: String, browser: Option) { + tokio::spawn(async move { + const READY_TIMEOUT: Duration = Duration::from_secs(10); + if wait_until_accepting(&host, port, READY_TIMEOUT).await { + info!(host = %host, port, "preview server accepting connections; opening browser"); + } else { + tracing::warn!( + host = %host, + port, + timeout_secs = READY_TIMEOUT.as_secs(), + "preview server has not accepted a connection within the timeout; \ + opening the browser anyway (it may need a manual reload)" + ); + } + open_browser_or_log(&url, browser.as_deref()); + }); +} + /// bd-a6dvrdg1: poll `host:port` with `TcpStream::connect` until the /// preview server is accepting connections, or `total_timeout` elapses. /// @@ -459,9 +861,21 @@ pub(crate) fn build_boot_url(host: &str, port: u16, initial_page: Option<&str>) /// with literal slashes); percent-encodes everything else. Avoids /// pulling in a full URL crate just for one helper. fn percent_encode_path(s: &str) -> String { + percent_encode(s, true) +} + +/// Strict variant of [`percent_encode_path`] for query-param *values*: +/// also encodes `/`, matching what `URLSearchParams.toString()` emits +/// on the hub-client side (its parser is what decodes these). +fn percent_encode_component(s: &str) -> String { + percent_encode(s, false) +} + +fn percent_encode(s: &str, keep_slash: bool) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { - if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~' | b'/') { + let unreserved = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~'); + if unreserved || (keep_slash && b == b'/') { out.push(b as char); } else { use std::fmt::Write; @@ -471,6 +885,90 @@ fn percent_encode_path(s: &str) -> String { out } +/// Phase 4 (bd-jt1etjbn): build the `--ui editor` boot URL — the +/// hub-client share route against this host's own origin. See +/// [`editor_share_route`] for the route shape and param contract. +pub(crate) fn build_editor_boot_url( + host: &str, + port: u16, + index_doc_id: &str, + file: &str, + project_name: &str, +) -> String { + format!( + "http://{host}:{port}{}", + editor_share_route(index_doc_id, file, project_name) + ) +} + +/// The hub-client share route both editor boot-URL builders emit (host +/// above, guest below). Single source for the route shape: the params +/// ride the *hash fragment* (the SPA router parses `location.hash`, not +/// the URL query; `hub-client/src/utils/routing.ts`), the client's +/// validation requires `server` / `file` / `name`, and `server=%2Fws` +/// is the relative sync endpoint hub-client resolves against the page +/// origin — the preview server for the host, the local tunnel proxy +/// for a `--join` guest. The doc id travels bare: the client re-adds +/// the `automerge:` prefix, and `buildShareableUrl` on the TS side +/// strips it symmetrically. +/// +/// `ephemeral=true` (bd-zf4ryvuq) marks the serving hub as a throwaway +/// per-session preview server: the client captures the flag before the +/// share handler clears the URL, silently establishes a project-set +/// root against `/ws`, and skips the setup/migration gate so the user +/// lands straight in the preview. Only preview boot URLs carry it — +/// `buildShareableUrl` never emits it. +fn editor_share_route(index_doc_id: &str, file: &str, project_name: &str) -> String { + let doc_id = index_doc_id + .strip_prefix("automerge:") + .unwrap_or(index_doc_id); + format!( + "/#/share/{}?server=%2Fws&file={}&name={}&ephemeral=true", + percent_encode_component(doc_id), + percent_encode_component(file), + percent_encode_component(project_name), + ) +} + +/// bd-7htq16rx: build the `--join` guest's boot URL — the same share +/// route the host prints, but against the guest's local proxy origin. +/// The params arrive from the host's `/api/preview/config` through the +/// tunnel ([`fetch_editor_boot`]); `server=%2Fws` resolves against the +/// page origin, i.e. the proxy, so the guest's hub-client syncs with +/// the host through the tunnel. +fn build_guest_editor_url( + addr: &std::net::SocketAddr, + boot: &quarto_preview::EditorBootInfo, +) -> String { + format!( + "http://{addr}{}", + editor_share_route(&boot.index_doc_id, &boot.file, &boot.name) + ) +} + +/// Choose the share route's `file` param: the CLI-resolved initial +/// page when there is one, else a root `index.qmd` when the project +/// has one (the front door of a website project), else the +/// lexicographically first `.qmd` known to the index (its files map is +/// unordered; taking the minimum keeps the boot deterministic). `None` +/// when the project has no `.qmd` at all. +pub(crate) fn pick_editor_file( + initial_page: Option<&str>, + index_paths: &[String], +) -> Option { + if let Some(page) = initial_page { + return Some(page.to_string()); + } + if index_paths.iter().any(|p| p == "index.qmd") { + return Some("index.qmd".to_string()); + } + index_paths + .iter() + .filter(|p| p.ends_with(".qmd")) + .min() + .cloned() +} + #[cfg(test)] mod tests { use super::*; @@ -503,14 +1001,6 @@ mod tests { ); } - #[test] - fn open_browser_or_log_is_noop_when_suppressed() { - // The `suppress` branch must return without touching the - // OS — we never want a test run to fork a browser. Asserting - // "doesn't panic, returns" is the contract. - open_browser_or_log("https://invalid.example.invalid/", true); - } - // ────────────────────────────────────────────────────────────── // Phase D.2 (bd-kw93.13): resolve_project_and_initial_page + // build_boot_url + percent_encode_path @@ -692,6 +1182,171 @@ mod tests { assert_eq!(percent_encode_path("a&b=c"), "a%26b%3Dc"); } + // ────────────────────────────────────────────────────────────── + // Phase 4 (bd-jt1etjbn): editor boot URL — the hub-client share + // route. Params ride the *hash fragment* (the SPA router parses + // location.hash, hub-client/src/utils/routing.ts), the doc id is + // bare (the client re-adds the `automerge:` prefix), and `server` + // is the relative `/ws` the client resolves against the page + // origin — which the preview server itself serves. + // ────────────────────────────────────────────────────────────── + + #[test] + fn build_editor_boot_url_emits_share_route_in_hash() { + assert_eq!( + build_editor_boot_url( + "127.0.0.1", + 8080, + "4XyZabc123", + "posts/intro.qmd", + "My Project" + ), + "http://127.0.0.1:8080/#/share/4XyZabc123?server=%2Fws&file=posts%2Fintro.qmd&name=My%20Project&ephemeral=true", + ); + } + + #[test] + fn build_editor_boot_url_marks_hub_ephemeral() { + // The preview hub is a throwaway per-session server; the client + // reads `ephemeral=true` to skip project-set onboarding and go + // straight to the preview (bd-zf4ryvuq). + let url = build_editor_boot_url("127.0.0.1", 8080, "4XyZ", "a.qmd", "p"); + assert!( + url.ends_with("&ephemeral=true"), + "preview boot URLs must carry the ephemeral flag; got {url}" + ); + } + + #[test] + fn build_editor_boot_url_strips_automerge_prefix() { + // `ctx.index().document_id()` is already bare, but the share + // route must never carry the prefix even if a caller passes it + // (mirrors routing.ts's buildShareableUrl, which strips it). + let url = build_editor_boot_url("127.0.0.1", 8080, "automerge:4XyZ", "a.qmd", "p"); + assert!( + url.contains("#/share/4XyZ?"), + "the route wants the bare doc id; got {url}" + ); + assert!( + !url.contains("automerge"), + "the automerge: prefix must not leak into the URL; got {url}" + ); + } + + // ────────────────────────────────────────────────────────────── + // bd-7htq16rx: `--join` guests of an editor-UI host boot the same + // share route (ephemeral flag included) so hub-client skips + // project-set onboarding and joins the document. The boot params + // arrive from the host's `/api/preview/config` through the tunnel. + // ────────────────────────────────────────────────────────────── + + fn guest_addr() -> std::net::SocketAddr { + "127.0.0.1:8080".parse().unwrap() + } + + #[test] + fn guest_editor_url_is_share_route_with_ephemeral_flag() { + let boot = quarto_preview::EditorBootInfo { + index_doc_id: "4XyZabc123".to_string(), + file: "posts/intro.qmd".to_string(), + name: "My Project".to_string(), + }; + assert_eq!( + build_guest_editor_url(&guest_addr(), &boot), + "http://127.0.0.1:8080/#/share/4XyZabc123?server=%2Fws&file=posts%2Fintro.qmd&name=My%20Project&ephemeral=true", + ); + } + + #[test] + fn guest_editor_url_strips_automerge_prefix() { + let boot = quarto_preview::EditorBootInfo { + index_doc_id: "automerge:4XyZ".to_string(), + file: "a.qmd".to_string(), + name: "p".to_string(), + }; + let url = build_guest_editor_url(&guest_addr(), &boot); + assert!( + url.contains("#/share/4XyZ?"), + "the route wants the bare doc id; got {url}" + ); + assert!( + !url.contains("automerge"), + "the automerge: prefix must not leak into the URL; got {url}" + ); + } + + #[test] + fn parse_editor_boot_reads_config_body() { + let body = br#"{"allowEdit":false,"editorBoot":{"indexDocId":"4XyZ","file":"index.qmd","name":"proj"}}"#; + let boot = parse_editor_boot(body).expect("editorBoot parses"); + assert_eq!(boot.index_doc_id, "4XyZ"); + assert_eq!(boot.file, "index.qmd"); + assert_eq!(boot.name, "proj"); + } + + #[test] + fn parse_editor_boot_absent_without_the_field() { + // Viewer-mode (and older) hosts answer config without + // editorBoot — the guest falls back to the root URL. + assert_eq!(parse_editor_boot(br#"{"allowEdit":false}"#), None); + } + + #[test] + fn parse_editor_boot_rejects_malformed_or_empty() { + assert_eq!(parse_editor_boot(b"not json"), None); + assert_eq!(parse_editor_boot(b""), None); + // A boot URL with an empty doc id or file can never join. + assert_eq!( + parse_editor_boot(br#"{"editorBoot":{"indexDocId":"","file":"index.qmd","name":"p"}}"#), + None + ); + assert_eq!( + parse_editor_boot(br#"{"editorBoot":{"indexDocId":"4XyZ","file":"","name":"p"}}"#), + None + ); + } + + #[test] + fn pick_editor_file_prefers_initial_page() { + let files = vec!["about.qmd".to_string(), "index.qmd".to_string()]; + assert_eq!( + pick_editor_file(Some("posts/intro.qmd"), &files).as_deref(), + Some("posts/intro.qmd"), + "an initial page resolved by the CLI wins over the index scan" + ); + } + + #[test] + fn pick_editor_file_prefers_root_index_qmd_over_sorted_first() { + // A website project's front door wins over the deterministic + // sorted-first fallback even when it isn't alphabetically first. + let files = vec!["about.qmd".to_string(), "index.qmd".to_string()]; + assert_eq!(pick_editor_file(None, &files).as_deref(), Some("index.qmd")); + // Only a *root* index.qmd gets the preference — a nested one is + // just another page. + let files = vec!["about.qmd".to_string(), "posts/index.qmd".to_string()]; + assert_eq!(pick_editor_file(None, &files).as_deref(), Some("about.qmd")); + } + + #[test] + fn pick_editor_file_falls_back_to_first_qmd_sorted() { + // The index files map is unordered (HashMap); the fallback must + // sort so the chosen page is deterministic across boots. + let files = vec![ + "zeta.qmd".to_string(), + "styles.css".to_string(), + "about.qmd".to_string(), + ]; + assert_eq!(pick_editor_file(None, &files).as_deref(), Some("about.qmd")); + } + + #[test] + fn pick_editor_file_none_without_any_qmd() { + let files = vec!["styles.css".to_string()]; + assert_eq!(pick_editor_file(None, &files), None); + assert_eq!(pick_editor_file(None, &[]), None); + } + // ────────────────────────────────────────────────────────────── // bd-a6dvrdg1: wait_until_accepting — gate the browser-open on the // preview server actually accepting connections (not on the @@ -780,4 +1435,80 @@ mod tests { ); late.abort(); } + + // ────────────────────────────────────────────────────────────── + // Phase 3 (bd-6y0p1bne): wait_until_healthy — join mode's + // browser-open gate is an HTTP /health roundtrip through the local + // proxy, not a bare TCP accept (which the proxy always grants, + // even when the tunnel's far side is gone). + // ────────────────────────────────────────────────────────────── + + /// Serve a fixed HTTP/1.1 response to every connection on a fresh + /// loopback port; returns the bound address. Task dies with the test. + async fn spawn_canned_http(response: &'static str) -> std::net::SocketAddr { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind canned server"); + let addr = listener.local_addr().expect("local_addr"); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + break; + }; + tokio::spawn(async move { + let mut buf = [0u8; 1024]; + let _ = sock.read(&mut buf).await; + let _ = sock.write_all(response.as_bytes()).await; + }); + } + }); + addr + } + + #[tokio::test] + async fn wait_until_healthy_true_on_200() { + let addr = spawn_canned_http( + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await; + assert!( + wait_until_healthy(addr, std::time::Duration::from_secs(5)).await, + "a 200 /health must count as ready" + ); + } + + #[tokio::test] + async fn wait_until_healthy_false_on_non_200() { + // A reachable server that answers 503 (e.g. the proxy is up but + // the host hub is not) must NOT count as ready. + let addr = spawn_canned_http( + "HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await; + let timeout = std::time::Duration::from_millis(300); + let start = std::time::Instant::now(); + let ready = wait_until_healthy(addr, timeout).await; + assert!(!ready, "non-200 answers must not count as healthy"); + assert!( + start.elapsed() >= timeout, + "must keep polling until the deadline in case health recovers" + ); + } + + #[tokio::test] + async fn wait_until_healthy_false_when_nothing_listening() { + let port = reserve_free_port(); + let addr: std::net::SocketAddr = format!("127.0.0.1:{port}").parse().unwrap(); + let ready = wait_until_healthy(addr, std::time::Duration::from_millis(200)).await; + assert!(!ready, "no listener → not healthy"); + } + + #[test] + fn shutdown_message_marks_shared_sessions() { + // The share variant tells the host (and anyone watching) that + // guests are about to lose their tunnel. + assert!(shutdown_message(true).contains("ending the shared session")); + assert!(shutdown_message(false).contains("shutting down the preview")); + } } diff --git a/crates/quarto/src/main.rs b/crates/quarto/src/main.rs index 8d92c8afb..c399844fc 100644 --- a/crates/quarto/src/main.rs +++ b/crates/quarto/src/main.rs @@ -217,6 +217,15 @@ enum Commands { #[arg(long)] no_browser: bool, + /// Open the preview in a specific browser instead of the + /// system default — e.g. `--browser firefox` or `--browser + /// "Google Chrome"`. The value is handed to the OS opener + /// (`open -a` on macOS), so use a command on PATH or an + /// installed application name. Incompatible with + /// --no-browser. + #[arg(long, value_name = "BROWSER", conflicts_with = "no_browser")] + browser: Option, + /// Override the directory the preview uses for ephemeral /// per-session state. Default: a fresh tempdir that is /// deleted when `q2 preview` exits. @@ -240,6 +249,45 @@ enum Commands { /// document changes to your files. #[arg(long)] allow_edit: bool, + + /// Share this preview session over an end-to-end encrypted + /// peer-to-peer tunnel (via iroh). Prints a join string; + /// anyone who has it can VIEW the project and RE-RUN its code + /// on this machine (and EDIT the files if --allow-edit is also + /// set), so treat the string like a password. + #[arg(long)] + share: bool, + + /// Which frontend to serve: the read-only preview UI + /// (`viewer`, the default) or the full collaborative editor + /// (`editor`: Monaco, file sidebar, live preview pane). + /// + /// Choosing `editor` never changes the write policy: without + /// --allow-edit, edits made in the editor drive the live + /// session for everyone connected but are never written to + /// your files (and a file change on disk converges the + /// session back to the disk content). Two caveats: the editor + /// stores a project entry and a local document cache in the + /// browser for every preview session, and with --share the + /// host's --ui choice is what all guests get. + #[arg(long, value_enum, default_value_t = PreviewUiArg::Viewer)] + ui: PreviewUiArg, + + /// Join a shared preview session using the `q2preview…` string + /// printed by `q2 preview --share` on the host machine. + /// + /// Runs a local proxy for the host's session — no local project + /// is read and nothing is written to disk on this machine, so + /// the host-mode flags (a path, --share, --no-project, + /// --allow-edit, --ui, --data-dir, --preview-dir) don't combine + /// with it. --port/--host pick where the local proxy listens; + /// --no-browser and --browser still apply. + #[arg( + long, + value_name = "TICKET", + conflicts_with_all = ["path", "share", "no_project", "allow_edit", "data_dir", "preview_dir", "ui"] + )] + join: Option, }, /// Serve a Shiny interactive document @@ -723,6 +771,279 @@ enum TraceCommand { }, } +/// `--ui` values for `q2 preview` (live-share plan Phase 4, +/// bd-jt1etjbn): which embedded frontend the preview server serves. +/// CLI-side mirror of [`quarto_preview::PreviewUi`] so the library +/// doesn't grow a clap dependency. +#[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)] +enum PreviewUiArg { + /// The read-only preview UI (the default). + Viewer, + /// The full hub-client editor. Does not imply --allow-edit. + Editor, +} + +impl From for quarto_preview::PreviewUi { + fn from(arg: PreviewUiArg) -> Self { + match arg { + PreviewUiArg::Viewer => quarto_preview::PreviewUi::Viewer, + PreviewUiArg::Editor => quarto_preview::PreviewUi::Editor, + } + } +} + +#[cfg(test)] +mod cli_parse_tests { + //! clap parse harness (live-share plan, Phase 2). These are the first + //! parse-level tests for the `q2` CLI; the Phase 3 `--join` conflict + //! matrix extends this module. + + use clap::Parser; + + use super::{Cli, Commands}; + + /// Parse argv (without the implicit binary name) into `Cli`. + fn try_parse(args: &[&str]) -> Result { + Cli::try_parse_from(std::iter::once("q2").chain(args.iter().copied())) + } + + /// Unwrap a parsed `Preview` command or panic with the actual variant. + fn parse_preview(args: &[&str]) -> Commands { + let cli = try_parse(args).expect("args should parse"); + match cli.command { + cmd @ Commands::Preview { .. } => cmd, + _ => panic!("expected a Preview command from {args:?}"), + } + } + + #[test] + fn preview_share_flag_parses() { + let Commands::Preview { share, .. } = parse_preview(&["preview", "--share"]) else { + unreachable!() + }; + assert!(share, "--share must set PreviewArgs::share"); + } + + #[test] + fn preview_share_defaults_off() { + let Commands::Preview { share, .. } = parse_preview(&["preview"]) else { + unreachable!() + }; + assert!(!share, "share must default to false"); + } + + #[test] + fn preview_share_composes_with_allow_edit() { + // Composition from the plan's CLI surface: `--share --allow-edit` + // (viewer with inline-edit write-back for guests). + let Commands::Preview { + share, allow_edit, .. + } = parse_preview(&["preview", "--share", "--allow-edit"]) + else { + unreachable!() + }; + assert!(share && allow_edit, "--share --allow-edit must both parse"); + } + + #[test] + fn preview_share_conflicts_with_join() { + // (match instead of expect_err: `Cli` deliberately has no Debug impl) + let err = match try_parse(&["preview", "--share", "--join", "x"]) { + Ok(_) => panic!("--share and --join are host vs. guest; must conflict"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } + + // ── Phase 3 (bd-6y0p1bne): `--join` conflict matrix ────────────── + // The guest path has no local project, hub, or disk surface, so + // every host-mode-only flag must be a hard parse error, not a + // silent no-op. (`--ui` joins this matrix in Phase 4, when the + // flag itself lands.) + + /// Assert argv is rejected specifically as an argument conflict + /// (not, say, an unknown-arg error). + fn assert_join_conflict(args: &[&str]) { + let err = match try_parse(args) { + Ok(_) => panic!("{args:?} mixes guest mode with a host-only flag; must conflict"), + Err(e) => e, + }; + assert_eq!( + err.kind(), + clap::error::ErrorKind::ArgumentConflict, + "args: {args:?}" + ); + } + + #[test] + fn preview_join_parses_and_captures_ticket() { + let Commands::Preview { join, .. } = parse_preview(&["preview", "--join", "q2previewabc"]) + else { + unreachable!() + }; + assert_eq!(join.as_deref(), Some("q2previewabc")); + } + + #[test] + fn preview_join_conflicts_with_positional_path() { + assert_join_conflict(&["preview", "some/project", "--join", "x"]); + } + + #[test] + fn preview_join_conflicts_with_share() { + assert_join_conflict(&["preview", "--join", "x", "--share"]); + } + + #[test] + fn preview_join_conflicts_with_no_project() { + assert_join_conflict(&["preview", "--join", "x", "--no-project"]); + } + + #[test] + fn preview_join_conflicts_with_allow_edit() { + assert_join_conflict(&["preview", "--join", "x", "--allow-edit"]); + } + + #[test] + fn preview_join_conflicts_with_data_dir() { + assert_join_conflict(&["preview", "--join", "x", "--data-dir", "d"]); + } + + #[test] + fn preview_join_conflicts_with_preview_dir() { + assert_join_conflict(&["preview", "--join", "x", "--preview-dir", "d"]); + } + + // ── Phase 4 (bd-jt1etjbn): `--ui ` ──────────────── + // The flag substitutes which embedded frontend the server serves. + // It is orthogonal to `--allow-edit` (UI × write policy is a real + // 2×2) and meaningless for a guest (the host's server serves the + // UI through the tunnel), hence the `--join` conflict. + + use super::PreviewUiArg; + + #[test] + fn preview_ui_defaults_to_viewer() { + let Commands::Preview { ui, .. } = parse_preview(&["preview"]) else { + unreachable!() + }; + assert_eq!(ui, PreviewUiArg::Viewer, "--ui must default to viewer"); + } + + #[test] + fn preview_ui_parses_viewer_and_editor() { + for (value, expected) in [ + ("viewer", PreviewUiArg::Viewer), + ("editor", PreviewUiArg::Editor), + ] { + let Commands::Preview { ui, .. } = parse_preview(&["preview", "--ui", value]) else { + unreachable!() + }; + assert_eq!(ui, expected, "--ui {value}"); + } + } + + #[test] + fn preview_ui_rejects_unknown_value_listing_the_valid_ones() { + let err = match try_parse(&["preview", "--ui", "monaco"]) { + Ok(_) => panic!("--ui monaco is not a frontend we ship; must be rejected"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue); + let msg = err.to_string(); + assert!( + msg.contains("viewer") && msg.contains("editor"), + "the error must list the valid values; got: {msg}" + ); + } + + #[test] + fn preview_ui_composes_with_share_and_allow_edit() { + // The full-collaboration composition from the plan's CLI surface: + // `--share --ui editor --allow-edit`. + let Commands::Preview { + share, + ui, + allow_edit, + .. + } = parse_preview(&["preview", "--share", "--ui", "editor", "--allow-edit"]) + else { + unreachable!() + }; + assert!(share && allow_edit); + assert_eq!(ui, PreviewUiArg::Editor); + } + + #[test] + fn preview_join_conflicts_with_ui() { + // The Phase 3 conflict-matrix entry deferred to Phase 4 (the + // flag didn't exist yet). + assert_join_conflict(&["preview", "--join", "x", "--ui", "editor"]); + } + + #[test] + fn preview_join_composes_with_guest_flags() { + // `--port` picks the local proxy port, `--host` its bind + // interface, `--no-browser` suppresses the auto-open — all + // meaningful for a guest and must keep parsing. + let Commands::Preview { + join, + port, + host, + no_browser, + .. + } = parse_preview(&[ + "preview", + "--join", + "q2previewabc", + "--port", + "9280", + "--host", + "127.0.0.1", + "--no-browser", + ]) + else { + unreachable!() + }; + assert_eq!(join.as_deref(), Some("q2previewabc")); + assert_eq!(port, Some(9280)); + assert_eq!(host.as_deref(), Some("127.0.0.1")); + assert!(no_browser); + } + + #[test] + fn preview_browser_parses_and_composes_with_share() { + let Commands::Preview { browser, share, .. } = + parse_preview(&["preview", "--share", "--browser", "firefox"]) + else { + unreachable!() + }; + assert!(share); + assert_eq!(browser.as_deref(), Some("firefox")); + } + + #[test] + fn preview_browser_composes_with_join() { + // Guests pick the browser for the local proxy URL too. + let Commands::Preview { join, browser, .. } = + parse_preview(&["preview", "--join", "q2previewabc", "--browser", "safari"]) + else { + unreachable!() + }; + assert_eq!(join.as_deref(), Some("q2previewabc")); + assert_eq!(browser.as_deref(), Some("safari")); + } + + #[test] + fn preview_browser_conflicts_with_no_browser() { + let err = match try_parse(&["preview", "--browser", "firefox", "--no-browser"]) { + Ok(_) => panic!("--browser and --no-browser must conflict"), + Err(e) => e, + }; + assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); + } +} + fn main() -> Result<()> { // Install Quarto's `Q-*` error catalog into the catalog-agnostic // `quarto-error-reporting` host, so diagnostics can resolve docs URLs and @@ -780,20 +1101,41 @@ fn main() -> Result<()> { port, host, no_browser, + browser, data_dir, preview_dir, no_project, allow_edit, - } => commands::preview::execute(commands::preview::PreviewArgs { - path, - port, - host, - no_browser, - data_dir, - preview_dir, - no_project, - allow_edit, - }), + share, + ui, + join, + } => { + if let Some(ticket) = join { + // Guest mode (live-share plan Phase 3): clap has already + // rejected every host-mode flag via conflicts_with_all. + commands::preview::execute_join(commands::preview::JoinArgs { + ticket, + port, + host, + no_browser, + browser, + }) + } else { + commands::preview::execute(commands::preview::PreviewArgs { + path, + port, + host, + no_browser, + browser, + data_dir, + preview_dir, + no_project, + allow_edit, + share, + ui: ui.into(), + }) + } + } Commands::Serve { .. } => commands::serve::execute(), Commands::Create { type_, diff --git a/crates/quarto/tests/integration/preview_cli.rs b/crates/quarto/tests/integration/preview_cli.rs index 1fa0fae41..b6c324a41 100644 --- a/crates/quarto/tests/integration/preview_cli.rs +++ b/crates/quarto/tests/integration/preview_cli.rs @@ -41,11 +41,13 @@ fn preview_help_advertises_phase_a_args() { let help = String::from_utf8(output.stdout).expect("help is UTF-8"); // Each Phase A flag — see claude-notes/plans/2026-05-13-q2-preview- - // phase-a.md §A.1. If one disappears or gets renamed, the user- - // facing contract changes; fail noisily. + // phase-a.md §A.1 — plus later additions like `--browser`. If one + // disappears or gets renamed, the user-facing contract changes; + // fail noisily. for flag in [ "--port", "--no-browser", + "--browser", "--data-dir", "--preview-dir", "--no-project", @@ -75,3 +77,82 @@ fn preview_help_accepts_positional_path() { "help output missing `Usage: ... preview ...` line:\n{help}" ); } + +/// Ctrl-C on the host prints a shutdown line (live-share review +/// follow-up): the hub's own "initiating graceful shutdown" is +/// tracing::info and invisible at the CLI's default filter, so the +/// process used to just vanish — while `--join` guests have always had +/// the friendly "leaving the shared session" line. Boots the real +/// binary in --no-project mode, waits for the port to accept, sends +/// SIGINT, and checks the printed line and the clean exit. Unix-only: +/// relies on `kill -INT`. +#[cfg(unix)] +#[test] +fn preview_prints_shutdown_message_on_ctrl_c() { + use std::io::{BufRead, BufReader, Read}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + let temp = tempfile::TempDir::new().expect("tempdir"); + let mut child = Command::new(Q2_BIN) + .args(["preview", "--no-project", "--no-browser"]) + .current_dir(temp.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn q2 preview"); + + let mut stdout = BufReader::new(child.stdout.take().expect("stdout piped")); + + // Read to the boot URL line, then poll the port until the server + // accepts — by then every Ctrl-C handler (the hub's own and the + // CLI's printer) is armed, so the signal can't slip through a + // startup gap. + let mut line = String::new(); + let boot_line = loop { + line.clear(); + let n = stdout.read_line(&mut line).expect("read boot line"); + assert!(n > 0, "preview exited before printing its boot URL"); + if line.contains("→ http") { + break line.trim().to_string(); + } + }; + let port: u16 = boot_line + .rsplit(':') + .next() + .expect("boot URL has a port") + .trim_end_matches('/') + .parse() + .expect("boot URL port parses"); + let deadline = Instant::now() + Duration::from_secs(10); + while std::net::TcpStream::connect(("127.0.0.1", port)).is_err() { + assert!( + Instant::now() < deadline, + "preview port {port} never accepted a connection" + ); + std::thread::sleep(Duration::from_millis(50)); + } + + // The terminal Ctrl-C, delivered as SIGINT. + let status = Command::new("kill") + .args(["-INT", &child.id().to_string()]) + .status() + .expect("run kill -INT"); + assert!(status.success(), "kill -INT failed"); + + // Draining stdout to EOF waits out the graceful shutdown (the + // exiting process closes the pipe). + let mut rest = String::new(); + stdout + .read_to_string(&mut rest) + .expect("drain stdout after SIGINT"); + let status = child.wait().expect("wait on child"); + assert!( + status.success(), + "preview must exit cleanly on Ctrl-C, got {status:?}" + ); + assert!( + rest.contains("Received Ctrl-C, shutting down the preview"), + "missing the Ctrl-C shutdown line; got:\n{rest}" + ); +} diff --git a/crates/wasm-quarto-hub-client/Cargo.lock b/crates/wasm-quarto-hub-client/Cargo.lock index 633c97c42..370b50ca2 100644 --- a/crates/wasm-quarto-hub-client/Cargo.lock +++ b/crates/wasm-quarto-hub-client/Cargo.lock @@ -377,7 +377,7 @@ dependencies = [ [[package]] name = "comrak-to-pandoc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "comrak", "hashlink", @@ -2074,7 +2074,7 @@ dependencies = [ [[package]] name = "quarto-analysis" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-error-reporting", "quarto-pandoc-types", @@ -2096,7 +2096,7 @@ dependencies = [ [[package]] name = "quarto-brand" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pathdiff", "quarto-util", @@ -2107,7 +2107,7 @@ dependencies = [ [[package]] name = "quarto-citeproc" -version = "0.13.0" +version = "0.14.0" dependencies = [ "glob", "hashlink", @@ -2138,7 +2138,7 @@ dependencies = [ [[package]] name = "quarto-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "anyhow", "async-trait", @@ -2190,7 +2190,7 @@ dependencies = [ [[package]] name = "quarto-csl" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-error-reporting", "quarto-source-map", @@ -2241,7 +2241,7 @@ dependencies = [ [[package]] name = "quarto-highlight" -version = "0.13.0" +version = "0.14.0" dependencies = [ "once_cell", "quarto-highlight-encoding", @@ -2267,7 +2267,7 @@ dependencies = [ [[package]] name = "quarto-highlight-encoding" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "serde_json", @@ -2275,7 +2275,7 @@ dependencies = [ [[package]] name = "quarto-lsp-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "pampa", "pollster", @@ -2296,7 +2296,7 @@ dependencies = [ [[package]] name = "quarto-navigation" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-config", "quarto-pandoc-types", @@ -2331,7 +2331,7 @@ dependencies = [ [[package]] name = "quarto-project-create" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-doctemplate", "serde", @@ -2341,7 +2341,7 @@ dependencies = [ [[package]] name = "quarto-sass" -version = "0.13.0" +version = "0.14.0" dependencies = [ "include_dir", "once_cell", @@ -2371,7 +2371,7 @@ dependencies = [ [[package]] name = "quarto-system-runtime" -version = "0.13.0" +version = "0.14.0" dependencies = [ "async-trait", "base64", @@ -2388,7 +2388,7 @@ dependencies = [ [[package]] name = "quarto-trace" -version = "0.13.0" +version = "0.14.0" dependencies = [ "flate2", "serde", @@ -2406,7 +2406,7 @@ dependencies = [ [[package]] name = "quarto-util" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "thiserror 2.0.18", @@ -2414,7 +2414,7 @@ dependencies = [ [[package]] name = "quarto-xml" -version = "0.13.0" +version = "0.14.0" dependencies = [ "quarto-error-reporting", "quarto-source-map", @@ -4021,7 +4021,7 @@ dependencies = [ [[package]] name = "wasm-printf-fmt" -version = "0.13.0" +version = "0.14.0" [[package]] name = "wasm-quarto-hub-client" diff --git a/crates/xtask/src/build_hub_client_embed.rs b/crates/xtask/src/build_hub_client_embed.rs new file mode 100644 index 000000000..c65f8a730 --- /dev/null +++ b/crates/xtask/src/build_hub_client_embed.rs @@ -0,0 +1,58 @@ +//! `cargo xtask build-hub-client-embed` — build the hub-client editor +//! bundle that `q2 preview --ui editor` embeds. +//! +//! Runs hub-client's `build:preview-embed` npm script (live-share plan +//! Phase 4, bd-jt1etjbn): a hub-client production build with auth off +//! (no `VITE_GOOGLE_CLIENT_ID`), the sync server pinned to the relative +//! `/ws` the preview server itself serves, and the PWA service worker +//! disabled, emitted to `hub-client/dist-preview-embed/`. The +//! `quarto-preview` crate's `include_dir!` picks the directory up on +//! the next Rust compile (files byte-identical to the q2-preview-spa +//! viewer dist — notably the ~38 MB WASM — are stripped at embed time +//! and served through the viewer embed instead). +//! +//! Mirrors `build_q2_preview_spa.rs` for the viewer SPA. + +use anyhow::{Context, Result, bail}; +use std::path::PathBuf; + +use crate::util::nested_command; + +pub fn run() -> Result<()> { + let project_root = find_project_root()?; + let hub_client_dir = project_root.join("hub-client"); + if !hub_client_dir.join("package.json").is_file() { + bail!( + "hub-client/package.json not found under {}", + project_root.display() + ); + } + + println!("━━━ Building hub-client preview embed ━━━"); + let status = nested_command("npm") + .args(["run", "build:preview-embed"]) + .current_dir(&hub_client_dir) + .status() + .with_context(|| format!("Failed to spawn npm in {}", hub_client_dir.display()))?; + if !status.success() { + bail!("hub-client preview-embed build failed"); + } + println!("✓ hub-client/dist-preview-embed/ is up to date"); + Ok(()) +} + +fn find_project_root() -> Result { + let mut dir = std::env::current_dir().context("Failed to get current directory")?; + loop { + let cargo_toml = dir.join("Cargo.toml"); + if cargo_toml.exists() { + let content = std::fs::read_to_string(&cargo_toml)?; + if content.contains("[workspace]") { + return Ok(dir); + } + } + if !dir.pop() { + bail!("Could not find workspace root (Cargo.toml with [workspace])"); + } + } +} diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index b3ea134e6..7bfc34c33 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -15,11 +15,13 @@ //! - `verify`: Run full project verification (build + tests for Rust and hub-client) //! - `build-all`: Fresh-clone build orchestration (npm install + hub-client + Rust workspace) //! - `build-trace-viewer`: Build just the trace-viewer SPA +//! - `build-hub-client-embed`: Build the hub-client editor bundle for `q2 preview --ui editor` //! - `build-hub-mcp-bundle`: Build the self-contained hub MCP server bundle //! - `stage-doc-examples`: Render `examples/manifest.yml` projects into `docs/examples/` mod braid_snapshot; mod build_all; +mod build_hub_client_embed; mod build_hub_mcp_bundle; mod build_q2_preview_spa; mod build_trace_viewer; @@ -240,6 +242,14 @@ enum Command { /// build -p quarto-preview` (via `include_dir!`). BuildQ2PreviewSpa {}, + /// Build the hub-client editor bundle for `q2 preview --ui editor`. + /// + /// Produces `hub-client/dist-preview-embed/` (auth off, sync server + /// pinned to the relative `/ws`, PWA disabled) — picked up on the + /// next `cargo build -p quarto-preview` (via `include_dir!`, with + /// viewer-dist-identical files stripped at embed time). + BuildHubClientEmbed {}, + /// Build the self-contained hub MCP server bundle. /// /// Produces `ts-packages/quarto-hub-mcp/dist-bundle/` (esbuild) — @@ -366,6 +376,7 @@ fn main() -> Result<()> { Command::StageDocExamples {} => stage_doc_examples::run(), Command::BuildTraceViewer {} => build_trace_viewer::run(), Command::BuildQ2PreviewSpa {} => build_q2_preview_spa::run(), + Command::BuildHubClientEmbed {} => build_hub_client_embed::run(), Command::BuildHubMcpBundle {} => build_hub_mcp_bundle::run(), Command::BuildAll { skip_npm_install, diff --git a/hub-client/.gitignore b/hub-client/.gitignore index 664761f4e..030b33c59 100644 --- a/hub-client/.gitignore +++ b/hub-client/.gitignore @@ -10,6 +10,7 @@ lerna-debug.log* node_modules dist dist-ssr +dist-preview-embed *.local # Playwright diff --git a/hub-client/changelog.md b/hub-client/changelog.md index 675f43631..7cc56ac54 100644 --- a/hub-client/changelog.md +++ b/hub-client/changelog.md @@ -23,6 +23,13 @@ WASM rebuild is needed for a changelog-only edit. --> +### 2026-08-09 + +- [`6ffe5211`](https://github.com/quarto-dev/q2/commits/6ffe5211): The ephemeral-session banner's config fetch now retries once on a transient network failure, so a dropped first request (for example a `--join` guest's tunnel mid-handshake) no longer hides the banner for the whole session. +- [`4111452f`](https://github.com/quarto-dev/q2/commits/4111452f): The editor shows a persistent "Ephemeral session — edits won't be saved to disk" banner when the serving `q2 preview` was started without `--allow-edit`; guests of a shared session see it too. +- [`bcfbb47f`](https://github.com/quarto-dev/q2/commits/bcfbb47f): `q2 preview --ui editor` boots skip the project-set setup and migration screens and land straight in the previewed document. +- [`9dfce50e`](https://github.com/quarto-dev/q2/commits/9dfce50e): `q2 preview --ui editor` serves the full collaborative editor (Monaco, file sidebar, live preview pane) from the preview server, to the host and any `--join` guests alike. + ### 2026-08-07 - [`279d7e5e`](https://github.com/quarto-dev/q2/commits/279d7e5e): `.md` files are now first-class source files: they sync into hub projects, get live preview, outline, folding, diagnostics, and qmd highlighting in the editor, and cross-document links into `.md` pages navigate like `.qmd` ones. diff --git a/hub-client/package.json b/hub-client/package.json index 5b57d58a2..25db2c38d 100644 --- a/hub-client/package.json +++ b/hub-client/package.json @@ -12,6 +12,7 @@ "build:sandboxed": "cd quarto-hub-sandboxed-preview && npm install --silent && npm run build", "build:all": "npm run build:wasm && npm run build:sandboxed && npm run build", "build:local-prod": "../scripts/build-local-prod.sh", + "build:preview-embed": "npm run build:wasm && npm run build:sandboxed && tsc -b && VITE_GOOGLE_CLIENT_ID= VITE_DISABLE_PWA=1 VITE_DEFAULT_SYNC_SERVER=/ws NODE_OPTIONS=--max-old-space-size=4096 vite build --outDir dist-preview-embed", "postinstall": "cd quarto-hub-sandboxed-preview && npm install --silent", "typecheck": "tsc -p tsconfig.app.json --noEmit", "lint": "eslint .", diff --git a/hub-client/src/App.tsx b/hub-client/src/App.tsx index 2bf909e31..38cacad29 100644 --- a/hub-client/src/App.tsx +++ b/hub-client/src/App.tsx @@ -42,9 +42,10 @@ import { useAuth } from './hooks/useAuth'; import { useAuthProbe } from './hooks/useAuthProbe'; import { useSessionKeepAlive } from './hooks/useSessionKeepAlive'; import { useExecutionChannel } from './hooks/useExecutionChannel'; +import { usePreviewSession } from './hooks/usePreviewSession'; import { resolveActorId as resolveActorIdRequest } from './services/authService'; import type { Route, ShareRoute, LinkProjectSetRoute } from './utils/routing'; -import { resolveSyncServerUrl, DEFAULT_SYNC_SERVER } from './utils/routing'; +import { resolveSyncServerUrl, DEFAULT_SYNC_SERVER, parseHashRoute } from './utils/routing'; import './App.css'; /** @@ -188,6 +189,24 @@ function App() { return reason; }); + // Capture the ephemeral-hub flag from the boot URL (once, before the + // share handler below clears the hash from the address bar). Only + // `q2 preview --ui editor` emits it: the serving hub is a throwaway + // per-session server, so project-set onboarding is skipped entirely + // (bd-zf4ryvuq). + const [ephemeralHub] = useState(() => { + const bootRoute = parseHashRoute(window.location.hash); + return bootRoute.type === 'share' && bootRoute.ephemeral === true; + }); + + // `q2 preview` session config (bd-ov4gqk3m): when the serving server + // is a preview started without --allow-edit, the editor shows an + // ephemeral-session banner. Null on a standalone hub (no such + // endpoint), which never shows the banner. Unlike the boot-URL flag + // above this survives reloads and works for --join guests, whose + // proxy splices every connection through to the host. + const previewSession = usePreviewSession(); + // Load screen name from IndexedDB (for identity mapping in Automerge docs). // When auth is enabled, wait for it to resolve so we can upgrade anonymous // names to the OIDC display name on first login. Without auth, load immediately. @@ -251,6 +270,29 @@ function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [route.type, projectSetState.status]); + // Ephemeral preview boot (bd-zf4ryvuq): same invite-first pattern as + // join-collection above. The user asked for a preview, not project + // management, so establish the personal root silently — create on a + // fresh browser, migrate when legacy IDB projects exist — and never + // show the setup/migration screens. DEFAULT_SYNC_SERVER is '/ws' in + // the preview-embed build, i.e. the ephemeral hub itself; the root + // doc lives in IndexedDB and re-syncs to whatever ephemeral hub serves + // this origin next. + const ephemeralRootInitiatedRef = useRef(false); + useEffect(() => { + if (!ephemeralHub || ephemeralRootInitiatedRef.current) return; + if (projectSetState.status === 'needs-setup') { + ephemeralRootInitiatedRef.current = true; + projectSetActions.createProjectSet(DEFAULT_SYNC_SERVER); + } else if (projectSetState.status === 'needs-migration') { + // Fire once: migrateProjects resets to needs-migration on failure, so + // an unguarded effect would retry-loop against an unreachable server. + ephemeralRootInitiatedRef.current = true; + projectSetActions.migrateProjects(DEFAULT_SYNC_SERVER); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ephemeralHub, projectSetState.status]); + // Denormalize a peek summary onto this user's project-set entry while a // project is open. Kept current as files and identities change (both are // low-frequency: file add/remove/rename, presence join). This is a per-user @@ -736,10 +778,13 @@ function App() { ); } - // Show project set setup/migration screen if needed + // Show project set setup/migration screen if needed. Ephemeral + // preview boots skip it: the effect above establishes the root + // silently while the share handler connects. if ( - projectSetState.status === 'needs-setup' || - projectSetState.status === 'needs-migration' + !ephemeralHub && + (projectSetState.status === 'needs-setup' || + projectSetState.status === 'needs-migration') ) { return ( 0} onRequestExecution={requestExecution} isOnline={isOnline} + sessionEphemeral={previewSession?.allowEdit === false} /> diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index 01d4acb79..07ae278b3 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -409,6 +409,18 @@ flex-shrink: 0; } +/* Ephemeral-session banner — `q2 preview --ui editor` without --allow-edit */ +.ephemeral-session-banner { + background: var(--warning-bg); + color: var(--warning-text); + border-bottom: 1px solid var(--warning-border); + padding: 4px 16px; + font-size: 12px; + text-align: center; + flex-shrink: 0; + cursor: help; +} + /* Replay mode banner — overlays bottom edge of header */ .replay-mode-banner { position: absolute; diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 8aa87afef..56189507d 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -34,6 +34,7 @@ import { useReplayMode } from '../hooks/useReplayMode'; import { useAutomergeSync } from '../hooks/useAutomergeSync'; import { diffToMonacoEdits } from '../utils/diffToMonacoEdits'; import { diagnosticsToMarkers } from '../utils/diagnosticToMonaco'; +import EphemeralSessionBanner from './EphemeralSessionBanner'; import FileSidebar from './FileSidebar'; import NewFileDialog from './NewFileDialog'; import NewAssetDialog from './NewAssetDialog'; @@ -77,6 +78,12 @@ interface Props { onRequestExecution?: (path: string) => string | null; /** Whether the project is connected to the sync server */ isOnline: boolean; + /** + * `q2 preview --ui editor` without `--allow-edit` (bd-ov4gqk3m): edits + * sync live to everyone connected but are never written to disk. + * Drives the ephemeral-session banner. Absent/false on a real hub. + */ + sessionEphemeral?: boolean; } // Map file extension to Monaco language ID @@ -165,7 +172,7 @@ function selectDefaultFile(files: FileEntry[]): FileEntry | null { return files[0]; } -export default function Editor({ project, files, fileContents, onDisconnect, onContentOperations, route, onNavigateToFile, identities, captures, executorsOnline, onRequestExecution, isOnline }: Props) { +export default function Editor({ project, files, fileContents, onDisconnect, onContentOperations, route, onNavigateToFile, identities, captures, executorsOnline, onRequestExecution, isOnline, sessionEphemeral }: Props) { // View mode for pane sizing const { viewMode } = useViewMode(); const { effectiveTheme } = useTheme(); @@ -993,6 +1000,8 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
)} + {!isFullscreenPreview && sessionEphemeral && } + {!isFullscreenPreview && unlocatedErrors.length > 0 && (
{unlocatedErrors.map((diag, i) => ( diff --git a/hub-client/src/components/EphemeralSessionBanner.test.tsx b/hub-client/src/components/EphemeralSessionBanner.test.tsx new file mode 100644 index 000000000..1ed140066 --- /dev/null +++ b/hub-client/src/components/EphemeralSessionBanner.test.tsx @@ -0,0 +1,36 @@ +/** + * Tests for the ephemeral-session banner shown in the editor when the + * serving `q2 preview` session runs without --allow-edit. + * + * The data path behind it (fetch + validation of /api/preview/config) + * is covered by previewConfig.test.ts and usePreviewSession.test.tsx; + * these tests pin the banner's rendered contract: the copy a user + * sees, the status role, and the hover text naming the --allow-edit + * fix. + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import EphemeralSessionBanner from './EphemeralSessionBanner'; + +afterEach(cleanup); + +describe('EphemeralSessionBanner', () => { + it('renders the ephemeral-session copy as a status region', () => { + render(); + + const banner = screen.getByRole('status'); + expect(banner.className).toBe('ephemeral-session-banner'); + expect(banner.textContent).toContain("edits won't be saved to disk"); + }); + + it('hover text explains the cause and names the --allow-edit fix', () => { + render(); + + const { title } = screen.getByRole('status'); + expect(title).toContain('never written'); + expect(title).toContain('--allow-edit'); + }); +}); diff --git a/hub-client/src/components/EphemeralSessionBanner.tsx b/hub-client/src/components/EphemeralSessionBanner.tsx new file mode 100644 index 000000000..fee049d59 --- /dev/null +++ b/hub-client/src/components/EphemeralSessionBanner.tsx @@ -0,0 +1,21 @@ +/** + * EphemeralSessionBanner — the persistent banner for `q2 preview + * --ui editor` sessions started without --allow-edit: edits sync live + * to everyone connected but are never written to the host's files. + * + * Editor renders this when App wires `sessionEphemeral` (the serving + * server's /api/preview/config reports `allowEdit === false`; a --join + * guest reads the host's value through the tunnel). The copy is fixed + * — nothing here is interpolated from server data. + */ +export default function EphemeralSessionBanner() { + return ( +
+ Ephemeral session — edits won't be saved to disk +
+ ); +} diff --git a/hub-client/src/hooks/usePreviewSession.test.tsx b/hub-client/src/hooks/usePreviewSession.test.tsx new file mode 100644 index 000000000..b135f9d90 --- /dev/null +++ b/hub-client/src/hooks/usePreviewSession.test.tsx @@ -0,0 +1,57 @@ +/** + * Unit tests for usePreviewSession. The hook is a thin boot-time + * wrapper over fetchPreviewSessionConfig: null until the fetch + * resolves, and null forever when the serving server is not a + * `q2 preview` session (callers gate on an explicit value, so null is + * always the banner-free case). + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; + +vi.mock('../services/previewConfig', () => ({ + fetchPreviewSessionConfig: vi.fn(), +})); + +import { usePreviewSession } from './usePreviewSession'; +import { fetchPreviewSessionConfig } from '../services/previewConfig'; + +const mockFetchConfig = vi.mocked(fetchPreviewSessionConfig); + +describe('usePreviewSession', () => { + beforeEach(() => { + mockFetchConfig.mockReset(); + }); + + it('starts null and resolves to the session config', async () => { + mockFetchConfig.mockResolvedValue({ allowEdit: false }); + + const { result } = renderHook(() => usePreviewSession()); + expect(result.current).toBeNull(); + + await waitFor(() => expect(result.current).toEqual({ allowEdit: false })); + }); + + it('stays null when the server is not a preview session', async () => { + mockFetchConfig.mockResolvedValue(null); + + const { result } = renderHook(() => usePreviewSession()); + await waitFor(() => expect(mockFetchConfig).toHaveBeenCalled()); + // Flush the resolved promise's .then callback. + await act(async () => {}); + + expect(result.current).toBeNull(); + }); + + it('fetches once at boot and does not refetch on rerender', async () => { + mockFetchConfig.mockResolvedValue({ allowEdit: true }); + + const { rerender } = renderHook(() => usePreviewSession()); + await waitFor(() => expect(mockFetchConfig).toHaveBeenCalledTimes(1)); + + rerender(); + expect(mockFetchConfig).toHaveBeenCalledTimes(1); + }); +}); diff --git a/hub-client/src/hooks/usePreviewSession.ts b/hub-client/src/hooks/usePreviewSession.ts new file mode 100644 index 000000000..8f5b45151 --- /dev/null +++ b/hub-client/src/hooks/usePreviewSession.ts @@ -0,0 +1,29 @@ +/** + * usePreviewSession — the serving server's `q2 preview` session config. + * + * Fetched once at boot: the values mirror the CLI flags the server was + * started with and are fixed for its lifetime, so there is no polling. + * Returns null while loading and whenever the server is not a + * `q2 preview` session (standalone hub, vite dev) — callers gate UI on + * an explicit value (e.g. `config?.allowEdit === false`), so null is + * always the safe, banner-free case. + */ + +import { useEffect, useState } from 'react'; +import { fetchPreviewSessionConfig, type PreviewSessionConfig } from '../services/previewConfig'; + +export function usePreviewSession(): PreviewSessionConfig | null { + const [config, setConfig] = useState(null); + + useEffect(() => { + let cancelled = false; + void fetchPreviewSessionConfig().then((fetched) => { + if (!cancelled && fetched) setConfig(fetched); + }); + return () => { + cancelled = true; + }; + }, []); + + return config; +} diff --git a/hub-client/src/services/previewConfig.test.ts b/hub-client/src/services/previewConfig.test.ts new file mode 100644 index 000000000..e1ef2be0c --- /dev/null +++ b/hub-client/src/services/previewConfig.test.ts @@ -0,0 +1,131 @@ +/** + * Unit tests for previewConfig. + * + * fetchPreviewSessionConfig decides whether the serving server is a + * `q2 preview` session and, if so, whether edits persist to disk + * (`allowEdit`). Null is the safe default: standalone hubs (no such + * route), SPA-fallback HTML, malformed bodies, and network errors must + * all yield null so the editor never shows the ephemeral-session + * banner against a real hub. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { fetchPreviewSessionConfig } from './previewConfig'; + +function jsonResponse(body: unknown, init?: { ok?: boolean; status?: number }): Response { + return { + ok: init?.ok ?? true, + status: init?.status ?? 200, + json: () => Promise.resolve(body), + } as Response; +} + +describe('fetchPreviewSessionConfig', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + it('returns the config when the server is a preview session', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ allowEdit: false })); + + await expect(fetchPreviewSessionConfig()).resolves.toEqual({ allowEdit: false }); + expect(fetch).toHaveBeenCalledWith('/api/preview/config', { + credentials: 'same-origin', + }); + }); + + it('passes allowEdit: true through (persistent session)', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({ allowEdit: true })); + + await expect(fetchPreviewSessionConfig()).resolves.toEqual({ allowEdit: true }); + }); + + it('ignores extra fields such as editorBoot', async () => { + vi.mocked(fetch).mockResolvedValue( + jsonResponse({ + allowEdit: false, + editorBoot: { indexDocId: 'doc', file: 'index.qmd', name: 'proj' }, + }), + ); + + await expect(fetchPreviewSessionConfig()).resolves.toEqual({ allowEdit: false }); + }); + + it('returns null on 404 (a standalone hub has no such route)', async () => { + vi.mocked(fetch).mockResolvedValue(jsonResponse({}, { ok: false, status: 404 })); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + // A definitive answer is not retried. + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('returns null when the body is SPA-fallback HTML (json() throws)', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError('Unexpected token <')), + } as unknown as Response); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + // SPA-fallback HTML is a definitive answer: not retried. + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it.each([{}, { allowEdit: 'false' }, { allowEdit: 0 }, null, 'allowEdit'])( + 'returns null when allowEdit is missing or not a boolean: %j', + async (body) => { + vi.mocked(fetch).mockResolvedValue(jsonResponse(body)); + + await expect(fetchPreviewSessionConfig()).resolves.toBeNull(); + }, + ); + + it('retries a transport failure once and returns the retried config', async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetch) + .mockRejectedValueOnce(new TypeError('fetch failed')) + .mockResolvedValueOnce(jsonResponse({ allowEdit: false })); + + const pending = fetchPreviewSessionConfig(); + // Advance well past the retry delay (RETRY_DELAY_MS, 750ms). + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toEqual({ allowEdit: false }); + expect(fetch).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('returns null when the fetch and its single retry both fail', async () => { + vi.useFakeTimers(); + try { + vi.mocked(fetch).mockRejectedValue(new TypeError('fetch failed')); + + const pending = fetchPreviewSessionConfig(); + await vi.advanceTimersByTimeAsync(5_000); + await expect(pending).resolves.toBeNull(); + // One retry, not a loop: exactly two attempts. + expect(fetch).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it('prefixes the path with VITE_HUB_BASE_PATH when set', async () => { + vi.stubEnv('VITE_HUB_BASE_PATH', '/subpath'); + vi.mocked(fetch).mockResolvedValue(jsonResponse({ allowEdit: true })); + + await fetchPreviewSessionConfig(); + expect(fetch).toHaveBeenCalledWith('/subpath/api/preview/config', { + credentials: 'same-origin', + }); + }); +}); diff --git a/hub-client/src/services/previewConfig.ts b/hub-client/src/services/previewConfig.ts new file mode 100644 index 000000000..abf52b471 --- /dev/null +++ b/hub-client/src/services/previewConfig.ts @@ -0,0 +1,66 @@ +/** + * Preview Session Config + * + * `q2 preview` serves `GET /api/preview/config` with session-level + * settings (bd-ov4gqk3m) — currently `allowEdit`, which mirrors the + * CLI's `--allow-edit` flag: whether edits made in the UI are written + * back to the host's files on disk. Without `--allow-edit` the session + * is an ephemeral sandbox: edits sync live to everyone connected but + * are never persisted. + * + * The endpoint exists only on the per-session preview server (a `--join` + * guest's local TCP proxy splices every connection through to the + * host, so guests read the host's value). A standalone hub has no such + * route — it 404s or answers with the SPA fallback — and both outcomes + * are treated here as "not a preview session" (null), so the editor + * shows no ephemeral-session UI against a real hub. + */ + +import { hubPath } from '../utils/routing'; + +export interface PreviewSessionConfig { + /** Mirrors the host's `--allow-edit`: edits persist to disk. */ + allowEdit: boolean; +} + +/** Delay before the single retry of a transport-failed config fetch. */ +const RETRY_DELAY_MS = 750; + +/** + * Fetch the preview session config, or null when the serving server is + * not a `q2 preview` session (standalone hub, dev server) or the fetch + * fails. Only a response carrying an explicit boolean `allowEdit` + * counts — SPA-fallback HTML and older servers both yield null. + * + * A transport-level failure (fetch rejects) is retried once: for a + * `--join` guest the boot fetch can race the tunnel's connection + * handshake, and the config is fetched only once per boot — a dropped + * request would hide the ephemeral-session banner for the whole + * session. Definitive answers (non-ok status, non-JSON or malformed + * body) are not retried: on a standalone hub they are the expected + * "not a preview session" signal. + */ +export async function fetchPreviewSessionConfig(): Promise { + for (let attempt = 0; ; attempt++) { + let res: Response; + try { + res = await fetch(hubPath('/api/preview/config'), { credentials: 'same-origin' }); + } catch { + if (attempt === 0) { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + continue; + } + return null; + } + if (!res.ok) return null; + try { + const data: unknown = await res.json(); + if (typeof data !== 'object' || data === null) return null; + const { allowEdit } = data as { allowEdit?: unknown }; + if (typeof allowEdit !== 'boolean') return null; + return { allowEdit }; + } catch { + return null; + } + } +} diff --git a/hub-client/src/utils/routing.test.ts b/hub-client/src/utils/routing.test.ts index 656ecccb2..7838d1a88 100644 --- a/hub-client/src/utils/routing.test.ts +++ b/hub-client/src/utils/routing.test.ts @@ -176,6 +176,38 @@ describe('parseHashRoute', () => { expect(parseHashRoute('#/share/')).toEqual({ type: 'project-selector' }); expect(parseHashRoute('#/share')).toEqual({ type: 'project-selector' }); }); + + // `q2 preview --ui editor` boot URLs mark the serving hub as an + // ephemeral per-session server so the app can skip project-set + // onboarding (bd-zf4ryvuq). + it('parses ephemeral=true on share routes', () => { + const result = parseHashRoute( + '#/share/4XyZabc123?server=%2Fws&file=index.qmd&name=Preview&ephemeral=true' + ); + expect(result).toEqual({ + type: 'share', + indexDocId: '4XyZabc123', + syncServer: '/ws', + filePath: 'index.qmd', + name: 'Preview', + ephemeral: true, + }); + }); + + it('omits ephemeral when the param is absent or not exactly "true"', () => { + const withoutParam = parseHashRoute('#/share/4XyZabc123?server=%2Fws&file=a.qmd&name=P'); + expect(withoutParam).not.toHaveProperty('ephemeral'); + + const falseValue = parseHashRoute( + '#/share/4XyZabc123?server=%2Fws&file=a.qmd&name=P&ephemeral=false' + ); + expect(falseValue).not.toHaveProperty('ephemeral'); + + const otherValue = parseHashRoute( + '#/share/4XyZabc123?server=%2Fws&file=a.qmd&name=P&ephemeral=1' + ); + expect(otherValue).not.toHaveProperty('ephemeral'); + }); }); describe('link-project-set routes', () => { diff --git a/hub-client/src/utils/routing.ts b/hub-client/src/utils/routing.ts index b25407a05..ccc1a391b 100644 --- a/hub-client/src/utils/routing.ts +++ b/hub-client/src/utils/routing.ts @@ -106,6 +106,14 @@ export interface ShareRoute { filePath: string; /** Human-readable project name */ name: string; + /** + * True only on boot URLs generated by `q2 preview --ui editor`: the + * serving hub is an ephemeral per-session server, so the app skips + * project-set onboarding and silently establishes a root set against + * it (bd-zf4ryvuq). Never emitted by buildHashRoute — production + * share links must not carry it. + */ + ephemeral?: boolean; } /** @@ -223,11 +231,13 @@ export function parseHashRoute(hash: string): Route { // Parse share route: /share/?server=&file=&name= // All three query parameters are required. Missing fields are set to empty // strings; App.tsx validates and shows an error for malformed share links. + // `ephemeral=true` is optional and marks a `q2 preview` boot URL. if (segments[0] === 'share' && segments[1]) { const indexDocId = decodeURIComponent(segments[1]); const server = queryParams.get('server') ?? ''; const fileParam = queryParams.get('file') ?? ''; const nameParam = queryParams.get('name') ?? ''; + const ephemeral = queryParams.get('ephemeral') === 'true'; return { type: 'share', @@ -235,6 +245,7 @@ export function parseHashRoute(hash: string): Route { syncServer: server, filePath: fileParam ? decodeURIComponent(fileParam) : '', name: nameParam ? decodeURIComponent(nameParam) : '', + ...(ephemeral && { ephemeral: true }), }; } diff --git a/hub-client/vite.config.ts b/hub-client/vite.config.ts index 62bbe6865..794cdee6c 100644 --- a/hub-client/vite.config.ts +++ b/hub-client/vite.config.ts @@ -57,6 +57,14 @@ const hubTarget = process.env.VITE_HUB_SERVER || 'http://localhost:3000'; /** Disable service worker in E2E tests to avoid caching interference */ const isE2E = process.env.VITE_E2E === '1'; +/** + * Disable the PWA service worker entirely (`build:preview-embed`). + * The q2-preview embed serves this app from an ephemeral localhost + * origin per `q2 preview` session; a service worker would precache + * ~67 MB (WASM included) into Cache Storage for every random port. + */ +const disablePwa = process.env.VITE_DISABLE_PWA === '1'; + // https://vite.dev/config/ export default defineConfig({ base: './', @@ -95,7 +103,7 @@ export default defineConfig({ }, }, // Disable PWA service worker in E2E tests to avoid caching interference - ...(!isE2E ? [VitePWA({ + ...(!isE2E && !disablePwa ? [VitePWA({ registerType: 'autoUpdate', includeAssets: ['quarto-icon.svg'], manifest: {