diff --git a/.github/workflows/live-messaging.yml b/.github/workflows/live-messaging.yml new file mode 100644 index 000000000..e5bc246cd --- /dev/null +++ b/.github/workflows/live-messaging.yml @@ -0,0 +1,149 @@ +name: Live Messaging + +on: + workflow_dispatch: + inputs: + x0x_ref: + description: "x0x git ref to test against" + required: true + default: "v0.21.4" + schedule: + - cron: "23 2 * * *" + +concurrency: + group: live-messaging-${{ github.ref }} + cancel-in-progress: true + +env: + X0X_REF: ${{ inputs.x0x_ref || 'v0.21.4' }} + +jobs: + dioxus-live-messaging: + name: Dioxus Live Messaging E2E + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: saorsa-labs/x0x + ref: ${{ env.X0X_REF }} + path: x0x + + - name: Make x0x available as sibling checkout + run: | + if [ ! -e "$GITHUB_WORKSPACE/../x0x" ]; then + ln -s "$GITHUB_WORKSPACE/x0x" "$GITHUB_WORKSPACE/../x0x" + fi + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + curl \ + file \ + libssl-dev \ + pkg-config \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libsoup-3.0-dev \ + libpipewire-0.3-dev \ + libasound2-dev \ + libdbus-1-dev \ + libxdo-dev + sudo apt-get install -y libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev \ + || sudo apt-get install -y libwebkit2gtk-4.0-dev libjavascriptcoregtk-4.0-dev + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . -> target + x0x -> target + cache-on-failure: false + shared-key: live-messaging-dioxus + + - name: Run Dioxus live parity harness + env: + X0X_DIR: ${{ github.workspace }}/x0x + run: bash communitas-dioxus/tests/e2e.sh + + swift-live-messaging: + name: Swift Live Messaging + Golden Paths + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: saorsa-labs/x0x + ref: ${{ env.X0X_REF }} + path: x0x + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build x0xd + run: cargo build --manifest-path x0x/Cargo.toml --release --bin x0xd + + - name: Run Swift live WebSocket round-trips + working-directory: communitas-apple + env: + X0X_LIVE_TESTS: "1" + X0XD_BIN: ${{ github.workspace }}/x0x/target/release/x0xd + run: swift test --filter PubSubRoundTripTests + + - name: Start x0xd for golden-path UI tests + run: | + "$GITHUB_WORKSPACE/x0x/target/release/x0xd" --skip-update-check > "$RUNNER_TEMP/x0xd-ui.log" 2>&1 & + echo "$!" > "$RUNNER_TEMP/x0xd-ui.pid" + for _ in $(seq 1 60); do + if [ -f "$HOME/Library/Application Support/x0x/api.port" ] \ + && [ -f "$HOME/Library/Application Support/x0x/api-token" ]; then + break + fi + sleep 1 + done + PORT="$(cat "$HOME/Library/Application Support/x0x/api.port")" + TOKEN="$(cat "$HOME/Library/Application Support/x0x/api-token")" + if [[ "$PORT" == *:* ]]; then + ADDRESS="$PORT" + else + ADDRESS="127.0.0.1:$PORT" + fi + echo "X0X_API_BASE=http://$ADDRESS" >> "$GITHUB_ENV" + echo "X0X_API_TOKEN=$TOKEN" >> "$GITHUB_ENV" + + - name: Run Swift golden-path UI tests + working-directory: communitas-apple + env: + COMMUNITAS_RUN_XCUITEST: "1" + X0X_LIVE_TESTS: "1" + run: | + xcodebuild \ + -scheme Communitas \ + -destination 'platform=macOS' \ + -only-testing:CommunitasUITests \ + test + + - name: Stop x0xd + if: always() + run: | + if [ -f "$RUNNER_TEMP/x0xd-ui.pid" ]; then + kill "$(cat "$RUNNER_TEMP/x0xd-ui.pid")" || true + fi + + - name: Upload x0xd UI-test log + uses: actions/upload-artifact@v4 + if: always() + with: + name: swift-x0xd-ui-log + path: ${{ runner.temp }}/x0xd-ui.log + if-no-files-found: ignore diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index ba88905c9..dbc895a2d 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -104,6 +104,18 @@ jobs: # Find the generated .app (use absolute path so subsequent steps work from repo root) APP_PATH=$(find "$(pwd)/dist" -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "ERROR: dx bundle did not produce a macOS .app" + exit 1 + fi + + INFO_PLIST="$APP_PATH/Contents/Info.plist" + if /usr/libexec/PlistBuddy -c "Print :LSRequiresCarbon" "$INFO_PLIST" >/dev/null 2>&1; then + /usr/libexec/PlistBuddy -c "Delete :LSRequiresCarbon" "$INFO_PLIST" + echo "Removed generated LSRequiresCarbon from $INFO_PLIST" + fi + plutil -p "$INFO_PLIST" | grep -E 'CFBundleIdentifier|CFBundleName|CFBundleDisplayName|LSRequiresCarbon' || true + echo "app_path=$APP_PATH" >> $GITHUB_OUTPUT # Replace binary with universal one @@ -307,7 +319,7 @@ jobs: run: | cargo install --locked \ --git https://github.com/saorsa-labs/x0x \ - --tag v0.19.51 \ + --tag v0.21.4 \ --bin x0x-keygen \ x0x x0x-keygen --help diff --git a/.github/workflows/release-swift.yml b/.github/workflows/release-swift.yml index e24ca3130..a4747894d 100644 --- a/.github/workflows/release-swift.yml +++ b/.github/workflows/release-swift.yml @@ -175,9 +175,21 @@ jobs: EOF - # Ensure the binary can find Sparkle at @rpath - # SPM sets @rpath to the build directory; we need it to point into the .app bundle - install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/Communitas" 2>/dev/null || true + # Ensure the binary can find Sparkle at @rpath. + # SPM leaves build-directory rpaths in the binary; packaged apps must resolve + # frameworks from inside Contents/Frameworks. + add_bundle_rpath() { + local rpath="$1" + if otool -l "$MACOS_DIR/Communitas" | grep -A2 LC_RPATH | grep -Fq "path ${rpath} "; then + echo "Rpath already present: ${rpath}" + else + install_name_tool -add_rpath "$rpath" "$MACOS_DIR/Communitas" + echo "Added rpath: ${rpath}" + fi + } + + add_bundle_rpath "@executable_path/../Frameworks" + add_bundle_rpath "@loader_path/../Frameworks" echo "Rpaths configured:" otool -l "$MACOS_DIR/Communitas" | grep -A2 LC_RPATH || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb5bfc7d6..029b43c8b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -104,6 +104,18 @@ jobs: # Find the generated .app (use absolute path so subsequent steps work from repo root) APP_PATH=$(find "$(pwd)/dist" -name "*.app" -type d | head -1) + if [ -z "$APP_PATH" ]; then + echo "ERROR: dx bundle did not produce a macOS .app" + exit 1 + fi + + INFO_PLIST="$APP_PATH/Contents/Info.plist" + if /usr/libexec/PlistBuddy -c "Print :LSRequiresCarbon" "$INFO_PLIST" >/dev/null 2>&1; then + /usr/libexec/PlistBuddy -c "Delete :LSRequiresCarbon" "$INFO_PLIST" + echo "Removed generated LSRequiresCarbon from $INFO_PLIST" + fi + plutil -p "$INFO_PLIST" | grep -E 'CFBundleIdentifier|CFBundleName|CFBundleDisplayName|LSRequiresCarbon' || true + echo "app_path=$APP_PATH" >> $GITHUB_OUTPUT # Replace binary with universal one diff --git a/Cargo.lock b/Cargo.lock index f9e96166a..00fcab728 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -38,6 +38,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -84,6 +102,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + [[package]] name = "arc-swap" version = "1.9.0" @@ -93,6 +117,17 @@ dependencies = [ "rustversion", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "argon2" version = "0.5.3" @@ -117,6 +152,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "ascii" version = "1.1.0" @@ -212,6 +256,49 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.16.2" @@ -330,6 +417,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -354,6 +447,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "blake2" version = "0.10.6" @@ -401,18 +503,36 @@ dependencies = [ "objc2", ] +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -695,6 +815,12 @@ dependencies = [ "objc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -795,11 +921,14 @@ dependencies = [ "criterion", "dioxus", "dioxus-history", - "dioxus-logger 0.5.1", + "dioxus-logger", "dioxus-ssr", "dirs 5.0.1", "futures", + "futures-timer", "native-dialog", + "objc2", + "objc2-app-kit", "pulldown-cmark", "reqwest", "serde", @@ -881,16 +1010,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "console_error_panic_hook" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" -dependencies = [ - "cfg-if", - "wasm-bindgen", -] - [[package]] name = "const-serialize" version = "0.7.2" @@ -1334,9 +1453,9 @@ dependencies = [ [[package]] name = "dioxus" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92b583b48ac77158495e6678fe3a2b5954fc8866fc04cb9695dd146e88bc329d" +checksum = "7c01ecf7ddbae18a419ad3d83c486101a85ffc5740ea09cdd0f09a30dc12170d" dependencies = [ "dioxus-asset-resolver", "dioxus-cli-config", @@ -1350,7 +1469,7 @@ dependencies = [ "dioxus-history", "dioxus-hooks", "dioxus-html", - "dioxus-logger 0.7.3", + "dioxus-logger", "dioxus-router", "dioxus-signals", "dioxus-stores", @@ -1362,9 +1481,9 @@ dependencies = [ [[package]] name = "dioxus-asset-resolver" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0161af1d3cfc8ff31503ff1b7ee0068c97771fc38d0cc6566e23483142ddf4f" +checksum = "69387edbbc60c7cb93ad96d8cc7a22b49a76e21643380b89b1c49a78d347ff60" dependencies = [ "dioxus-cli-config", "http 1.4.0", @@ -1380,18 +1499,18 @@ dependencies = [ [[package]] name = "dioxus-cli-config" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccd67ab405e1915a47df9769cd5408545d1b559d5c01ce7a0f442caef520d1f3" +checksum = "c000f584ddf608e2b272b3074bf11512a474eeeb2eb85a1915f276ce5c4a8615" dependencies = [ "wasm-bindgen", ] [[package]] name = "dioxus-config-macro" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f040ec7c41aa5428283f56bb0670afba9631bfe3ffd885f4814807f12c8c9d91" +checksum = "7637091592978fbfdb45a16b26bd99fd97fb1bd7e31c6a963530e00c022af321" dependencies = [ "proc-macro2", "quote", @@ -1399,15 +1518,15 @@ dependencies = [ [[package]] name = "dioxus-config-macros" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10c41b47b55a433b61f7c12327c85ba650572bacbcc42c342ba2e87a57975264" +checksum = "54f9ed8fc1a215ad34bb8dbae42a4ea54efbcd26ca9006bbe5cca78e511bf25f" [[package]] name = "dioxus-core" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b389b0e3cc01c7da292ad9b884b088835fdd1671d45fbd2f737506152b22eef0" +checksum = "45887100ff0cf89abeb8b659808294fda48cd53f3b424e36407dedffcfea830b" dependencies = [ "anyhow", "const_format", @@ -1427,9 +1546,9 @@ dependencies = [ [[package]] name = "dioxus-core-macro" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a82d65f0024fc86f01911a16156d280eea583be5a82a3bed85e7e8e4194302d" +checksum = "370c63663dff0f24df5dfea643ca239283542c6b228a302f69b32e1d36762b7f" dependencies = [ "convert_case 0.8.0", "dioxus-rsx", @@ -1440,16 +1559,17 @@ dependencies = [ [[package]] name = "dioxus-core-types" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfc4b8cdc440a55c17355542fc2089d97949bba674255d84cac77805e1db8c9f" +checksum = "36963eab106b169737762f9cd5ee5fd97f585989dcb2d8e30a596e97a6999009" [[package]] name = "dioxus-desktop" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6ec66749d1556636c5b4f661495565c155a7f78a46d4d007d7478c6bdc288c" +checksum = "662cd78c73ca3f17346adbf2d64757df40dd0ce20536c05123097fd31828d2bd" dependencies = [ + "anyhow", "async-trait", "base64 0.22.1", "bytes", @@ -1470,6 +1590,7 @@ dependencies = [ "futures-util", "generational-box", "global-hotkey", + "image", "infer", "jni 0.21.1", "lazy-js-bundle", @@ -1494,16 +1615,16 @@ dependencies = [ "tokio", "tracing", "tray-icon", - "tungstenite 0.27.0", + "tungstenite 0.28.0", "webbrowser", "wry", ] [[package]] name = "dioxus-devtools" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcf89488bad8fb0f18b9086ee2db01f95f709801c10c68be42691a36378a0f2d" +checksum = "2349cedbdf1b429df1f1bea61fdee0ad3dae7b2548eedfbeca82710122a57da0" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1514,14 +1635,14 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tracing", - "tungstenite 0.27.0", + "tungstenite 0.28.0", ] [[package]] name = "dioxus-devtools-types" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e7381d9d7d0a0f66b9d5082d584853c3d53be21d34007073daca98ddf26fc4d" +checksum = "0ab9b0f7565d1916b70915f59b89ea8054ef0a9d67a364a32bbee68ef5f3818d" dependencies = [ "dioxus-core", "serde", @@ -1530,9 +1651,9 @@ dependencies = [ [[package]] name = "dioxus-document" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0aeeff26d9d06441f59fd8d7f4f76098ba30ca9728e047c94486161185ceb" +checksum = "37e3a5bec7ffc999ff23446a487eb5cd86111d1574a23533dd3f8b3c69a53a22" dependencies = [ "dioxus-core", "dioxus-core-macro", @@ -1549,9 +1670,9 @@ dependencies = [ [[package]] name = "dioxus-history" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d00ba43bfe6e5ca226fef6128f240ca970bea73cac0462416188026360ccdcf" +checksum = "1a15232302d1933015fcf2d6fe9e286ad36f6e9c205a546089a0f326023bb0d2" dependencies = [ "dioxus-core", "tracing", @@ -1559,9 +1680,9 @@ dependencies = [ [[package]] name = "dioxus-hooks" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab2da4f038c33cb38caa37ffc3f5d6dfbc018f05da35b238210a533bb075823" +checksum = "4534f91cf6305204b948bdec130076ac9ecc7c22faab29475b76870558bf73ea" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1575,9 +1696,9 @@ dependencies = [ [[package]] name = "dioxus-html" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eded5fa6d2e677b7442a93f4228bf3c0ad2597a8bd3292cae50c869d015f3a99" +checksum = "e03d6ad4040b667f2b2eefcb678840e630938c09bf9ec39b04ea4d1d96d90d44" dependencies = [ "async-trait", "bytes", @@ -1602,9 +1723,9 @@ dependencies = [ [[package]] name = "dioxus-html-internal-macro" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45462ab85fe059a36841508d40545109fd0e25855012d22583a61908eb5cd02a" +checksum = "584e2772127ab00f0d5e1d4d9795f39fecebc828ece0b7a02349d438bc1b1ce7" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1614,9 +1735,9 @@ dependencies = [ [[package]] name = "dioxus-interpreter-js" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f" +checksum = "11999d6eb5bb179a9512dad30e5de408aab66f2cb65de9098c9fbe02927e2978" dependencies = [ "dioxus-core", "dioxus-core-types", @@ -1634,21 +1755,9 @@ dependencies = [ [[package]] name = "dioxus-logger" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81fe09dc9773dc1f1bb0d38529203d6555d08f67aadca5cf955ac3d1a9e69880" -dependencies = [ - "console_error_panic_hook", - "tracing", - "tracing-subscriber", - "tracing-wasm", -] - -[[package]] -name = "dioxus-logger" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1eeab114cb009d9e6b85ea10639a18cfc54bb342f3b837770b004c4daeb89c2" +checksum = "a28ccdfe36d2cb830a2784e40f7e6f7199805a2c6da99bd65b1ca308f11aed28" dependencies = [ "dioxus-cli-config", "tracing", @@ -1658,9 +1767,9 @@ dependencies = [ [[package]] name = "dioxus-router" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d5b31f9e27231389bf5a117b7074d22d8c58358b484a2558e56fbab20e64ca4" +checksum = "38e47f62d680429badfcb99bf5dec17ee92b0cb9623f264e36bc003a1359bfdc" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1678,9 +1787,9 @@ dependencies = [ [[package]] name = "dioxus-router-macro" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "838b9b441a95da62b39cae4defd240b5ebb0ec9f2daea1126099e00a838dc86f" +checksum = "6f83fb667d27e256f8c9eca49963fbace66a8722cb64ee15a10ffc97d092357e" dependencies = [ "base16", "digest", @@ -1693,9 +1802,9 @@ dependencies = [ [[package]] name = "dioxus-rsx" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53128858f0ccca9de54292a4d48409fda1df75fd5012c6243f664042f0225d68" +checksum = "2106afda239a4c7c22ffa1ca19117011225fc1c735c139c0a5b765996aa8bb1d" dependencies = [ "proc-macro2", "proc-macro2-diagnostics", @@ -1706,9 +1815,9 @@ dependencies = [ [[package]] name = "dioxus-signals" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f48020bc23bc9766e7cce986c0fd6de9af0b8cbfd432652ec6b1094439c1ec6" +checksum = "3705754f5e043deec9fc7af0d159f18e5b21c02c47d255c7e477f31368f0b6d2" dependencies = [ "dioxus-core", "futures-channel", @@ -1722,9 +1831,9 @@ dependencies = [ [[package]] name = "dioxus-ssr" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44cf9294a21fcd1098e02ad7a3ba61b99be8310ad3395fecf8210387c83f26b9" +checksum = "d261c5c9907b84fb1ed52f59f46d68c84a4ae860a65cc5effd0cea740ee428af" dependencies = [ "askama_escape", "dioxus-core", @@ -1734,9 +1843,9 @@ dependencies = [ [[package]] name = "dioxus-stores" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77aaa9ac56d781bb506cf3c0d23bea96b768064b89fe50d3b4d4659cc6bd8058" +checksum = "64bec7b21c86b1360ec965a07a53a2c96b7caee3465049e1c299a45024e87614" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1746,9 +1855,9 @@ dependencies = [ [[package]] name = "dioxus-stores-macro" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1a728622e7b63db45774f75e71504335dd4e6115b235bbcff272980499493a" +checksum = "40a5875e9f890f27b1cc3e5b56c1e23601211470315a1fb8627c4ca4f3b2be9a" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1758,9 +1867,9 @@ dependencies = [ [[package]] name = "dioxus-web" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b33fe739fed4e8143dac222a9153593f8e2451662ce8fc4c9d167a9d6ec0923" +checksum = "bc0a0be76b404e8242a597db0fb239d05f8dee4e7856bc1fc7144f7e244822fd" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1973,6 +2082,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1999,6 +2128,21 @@ dependencies = [ "serde", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2008,6 +2152,12 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2199,6 +2349,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + [[package]] name = "futures-util" version = "0.3.32" @@ -2312,9 +2468,9 @@ dependencies = [ [[package]] name = "generational-box" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4ed190b9de8e734d47a70be59b1e7588b9e8e0d0036e332f4c014e8aed1bc5" +checksum = "8cd0d825b8d339701ad330dbcd6399519ced4d143484954daf6e3185dace4f77" dependencies = [ "parking_lot 0.12.5", "tracing", @@ -2400,6 +2556,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -3073,6 +3239,46 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error 2.0.1", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + [[package]] name = "indexmap" version = "1.9.3" @@ -3122,6 +3328,17 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -3176,6 +3393,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3334,9 +3560,9 @@ dependencies = [ [[package]] name = "lazy-js-bundle" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7b88b715ab1496c6e6b8f5e927be961c4235196121b6ae59bcb51077a21dd36" +checksum = "ccafada6c9541db44db758619236f2748f6e1bdaa84d04ded858567cd1e89321" [[package]] name = "lazy_static" @@ -3350,6 +3576,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -3380,6 +3612,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libloading" version = "0.7.4" @@ -3476,6 +3718,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru" version = "0.7.8" @@ -3528,21 +3779,25 @@ dependencies = [ [[package]] name = "manganis" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cce7d688848bf9d034168513b9a2ffbfe5f61df2ff14ae15e6cfc866efdd344" +checksum = "8bfcf56309de35b48b8780ea097ace5c3b773a617b52edc49dfc9a63a7d9dc43" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", + "jni 0.21.1", "manganis-core", "manganis-macro", + "ndk-context", + "objc2", + "thiserror 2.0.18", ] [[package]] name = "manganis-core" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ce917b978268fe8a7db49e216343ec7c8f471f7e686feb70940d67293f19d4" +checksum = "a24d6be68f594495aea60850a284029d585d7b7839b26096c1b6d758f8518648" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", @@ -3554,9 +3809,9 @@ dependencies = [ [[package]] name = "manganis-macro" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad513e990f7c0bca86aa68659a7a3dc4c705572ed4c22fd6af32ccf261334cc2" +checksum = "5e782a10318d707c0833e31876ded8acf91287eee0010af8392559af614c7226" dependencies = [ "dunce", "macro-string", @@ -3640,6 +3895,16 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3716,6 +3981,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -3732,7 +4007,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -3813,6 +4088,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3829,6 +4113,21 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "ntapi" version = "0.4.3" @@ -3847,12 +4146,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3922,8 +4262,38 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.11.0", "block2", + "libc", "objc2", + "objc2-cloud-kit", + "objc2-core-data", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.11.0", + "objc2", "objc2-foundation", ] @@ -3945,7 +4315,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.11.0", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.11.0", + "objc2", "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", ] [[package]] @@ -3975,6 +4383,28 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.0", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-ui-kit" version = "0.3.2" @@ -4245,6 +4675,18 @@ dependencies = [ "subtle", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pbkdf2" version = "0.12.2" @@ -4480,6 +4922,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "pollster" version = "0.4.0" @@ -4652,6 +5107,25 @@ dependencies = [ "version_check", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "proptest" version = "1.10.0" @@ -4713,12 +5187,33 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quinn" version = "0.11.9" @@ -4914,6 +5409,56 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error 2.0.1", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -5108,6 +5653,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -5220,7 +5771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" dependencies = [ "fnv", - "quick-error", + "quick-error 1.2.3", "tempfile", "wait-timeout", ] @@ -5540,6 +6091,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -5712,9 +6272,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "subsecond" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8438668e545834d795d04c4335aafc332ce046106521a29f0a5c6501de34187c" +checksum = "9cc79674bd55726e6b123204403389400229a95fe4a3b2c5453dada70b06ca95" dependencies = [ "js-sys", "libc", @@ -5731,9 +6291,9 @@ dependencies = [ [[package]] name = "subsecond-types" -version = "0.7.3" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e72f747606fc19fe81d6c59e491af93ed7dcbcb6aad9d1d18b05129914ec298" +checksum = "e9798bfed58797aed51c672aa99810aac30a50d3120ecfdcf28c13784e9a8f1c" dependencies = [ "serde", ] @@ -5970,6 +6530,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -6427,7 +7001,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -6459,9 +7033,9 @@ dependencies = [ [[package]] name = "tungstenite" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -6585,6 +7159,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "validator" version = "0.20.0" @@ -6646,7 +7231,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c73a36bc44e3039f51fbee93e39f41225f6b17b380eb70cc2aab942df06b34dd" dependencies = [ "itertools 0.11.0", - "nom", + "nom 7.1.3", ] [[package]] @@ -6978,6 +7563,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wfd" version = "0.1.8" @@ -7734,6 +8325,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.1" @@ -7872,3 +8469,27 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/clippy.toml b/clippy.toml index 5720d6136..8a2646af6 100644 --- a/clippy.toml +++ b/clippy.toml @@ -5,6 +5,7 @@ # This affects all code marked with #[test], #[cfg(test)], and test module functions allow-unwrap-in-tests = true allow-expect-in-tests = true +allow-panic-in-tests = true # Cognitive complexity threshold (default is 25) cognitive-complexity-threshold = 30 diff --git a/communitas-apple/Package.swift b/communitas-apple/Package.swift index 3207549bd..59966347d 100644 --- a/communitas-apple/Package.swift +++ b/communitas-apple/Package.swift @@ -17,6 +17,12 @@ let package = Package( dependencies: [ "X0xClient", .product(name: "Sparkle", package: "Sparkle"), + ], + linkerSettings: [ + .unsafeFlags([ + "-Xlinker", "-rpath", + "-Xlinker", "@executable_path/../Frameworks", + ]), ] ), .target(name: "X0xClient"), diff --git a/communitas-apple/Sources/Communitas/CommunitasApp.swift b/communitas-apple/Sources/Communitas/CommunitasApp.swift index ad9dc361a..7600fd6b2 100644 --- a/communitas-apple/Sources/Communitas/CommunitasApp.swift +++ b/communitas-apple/Sources/Communitas/CommunitasApp.swift @@ -1,10 +1,28 @@ import SwiftUI +import AppKit /// App delegate for dock menu and lifecycle hooks. @MainActor final class AppDelegate: NSObject, NSApplicationDelegate { weak var appState: AppState? var statusBarController: StatusBarController? + private var fallbackWindow: NSWindow? + private var fallbackAppState: AppState? + private var fallbackUpdaterController: UpdaterController? + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + logWindowDiagnostics("didFinishLaunching") + if shouldForceMainWindow { + ensureMainWindowExists(after: 0.75, createIfMissing: true) + } + positionMainWindowForAutomationIfRequested() + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + showMainWindow(createIfMissing: true) + return true + } func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { let menu = NSMenu() @@ -53,6 +71,141 @@ final class AppDelegate: NSObject, NSApplicationDelegate { appState?.selectedSystemPage = .network appState?.selectedDMContact = nil } + + func showMainWindow(createIfMissing: Bool = false) { + if let window = mainWindow { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + guard createIfMissing || shouldForceMainWindow else { + NSApp.activate(ignoringOtherApps: true) + return + } + ensureMainWindowExists(after: 0, force: true, createIfMissing: true) + } + + func ensureMainWindowExists(after delay: TimeInterval, force: Bool = false, createIfMissing: Bool = false) { + DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + self.logWindowDiagnostics("ensureMainWindowExists before fallback") + if let window = self.visibleMainWindow { + if force || self.shouldForceMainWindow { + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + positionMainWindowForAutomationIfRequested() + } + return + } + let needsAutomationFallback = self.shouldForceMainWindow && self.fallbackWindow == nil + guard force || createIfMissing || needsAutomationFallback else { return } + self.createFallbackMainWindow() + } + } + + private var mainWindow: NSWindow? { + NSApp.windows.first { window in + window != fallbackWindow && window.isCommunitasContentWindow + } ?? fallbackWindow + } + + private var visibleMainWindow: NSWindow? { + NSApp.windows.first { window in + window != fallbackWindow && window.isCommunitasContentWindow && window.isVisible + } ?? (fallbackWindow?.isVisible == true ? fallbackWindow : nil) + } + + private func createFallbackMainWindow() { + if let fallbackWindow { + fallbackWindow.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + return + } + + let appState = AppState() + let updaterController = UpdaterController() + fallbackAppState = appState + fallbackUpdaterController = updaterController + self.appState = appState + + let rootView = CommunitasRootView( + appState: appState, + updaterController: updaterController, + appDelegate: self + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 1100, height: 750), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.identifier = NSUserInterfaceItemIdentifier("CommunitasMainWindow") + let title = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "Communitas" + window.title = title + window.setAccessibilityTitle(title) + window.setAccessibilityIdentifier("CommunitasMainWindow") + window.isReleasedWhenClosed = false + window.level = .normal + window.collectionBehavior = [.managed, .fullScreenPrimary] + window.center() + window.contentView = NSHostingView(rootView: rootView) + fallbackWindow = window + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + NSApp.activate(ignoringOtherApps: true) + logWindowDiagnostics("created fallback window") + positionMainWindowForAutomationIfRequested() + } + + private func logWindowDiagnostics(_ context: String) { + guard shouldForceMainWindow else { return } + + let windows = NSApp.windows.enumerated().map { index, window in + let className = NSStringFromClass(type(of: window)) + return "#\(index): \(className) title=\(window.title) visible=\(window.isVisible) keyable=\(window.canBecomeKey) level=\(window.level.rawValue)" + }.joined(separator: " | ") + NSLog("Communitas window diagnostics \(context): count=\(NSApp.windows.count) \(windows)") + } + + private var shouldForceMainWindow: Bool { + let process = ProcessInfo.processInfo + return process.environment["COMMUNITAS_FORCE_MAIN_WINDOW"] == "1" + || process.arguments.contains("-CommunitasForceMainWindow") + } +} + +private struct CommunitasRootView: View { + @ObservedObject var appState: AppState + @ObservedObject var updaterController: UpdaterController + let appDelegate: AppDelegate + + var body: some View { + OnboardingView { + ContentView() + .environmentObject(appState) + .environmentObject(updaterController) + .task { + appDelegate.showMainWindow() + NotificationService.shared.requestPermission() + await appState.refresh() + appState.startPresencePolling() + + appDelegate.appState = appState + if appDelegate.statusBarController == nil { + appDelegate.statusBarController = StatusBarController(appState: appState) + } + } + .onAppear { + appDelegate.showMainWindow() + NotificationService.shared.requestPermission() + positionMainWindowForAutomationIfRequested() + } + } + .environmentObject(appState) + .environmentObject(updaterController) + } } @main @@ -61,27 +214,17 @@ struct CommunitasApp: App { @StateObject private var appState = AppState() @StateObject private var updaterController = UpdaterController() + init() { + NSApplication.shared.setActivationPolicy(.regular) + } + var body: some Scene { WindowGroup { - OnboardingView { - ContentView() - .environmentObject(appState) - .environmentObject(updaterController) - .task { - NotificationService.shared.requestPermission() - await appState.refresh() - appState.startPresencePolling() - - // Wire up app delegate and status bar - appDelegate.appState = appState - appDelegate.statusBarController = StatusBarController(appState: appState) - } - .onAppear { - NotificationService.shared.requestPermission() - } - } - .environmentObject(appState) - .environmentObject(updaterController) + CommunitasRootView( + appState: appState, + updaterController: updaterController, + appDelegate: appDelegate + ) } .windowStyle(.titleBar) .defaultSize(width: 1100, height: 750) @@ -182,3 +325,50 @@ struct CommunitasApp: App { } } } + +@MainActor +private func positionMainWindowForAutomationIfRequested() { + let process = ProcessInfo.processInfo + let forceByEnvironment = process.environment["COMMUNITAS_FORCE_MAIN_WINDOW"] == "1" + let forceByArgument = process.arguments.contains("-CommunitasForceMainWindow") + guard forceByEnvironment || forceByArgument else { return } + + for attempt in 1...12 { + DispatchQueue.main.asyncAfter(deadline: .now() + (Double(attempt) * 0.25)) { + guard let screen = NSScreen.screens.first(where: { $0.localizedName.contains("Built-in") }) ?? NSScreen.main else { + return + } + let visibleFrame = screen.visibleFrame + let fallbackSize = NSSize(width: 1100, height: 750) + + for window in NSApp.windows where window.isCommunitasContentWindow { + window.deminiaturize(nil) + window.collectionBehavior.insert(.moveToActiveSpace) + let currentSize = window.frame.size + let width = min(max(currentSize.width, fallbackSize.width), visibleFrame.width) + let height = min(max(currentSize.height, fallbackSize.height), visibleFrame.height) + let frame = NSRect( + x: visibleFrame.midX - width / 2, + y: visibleFrame.midY - height / 2, + width: width, + height: height + ) + window.setFrame(frame, display: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + } + + NSApp.activate(ignoringOtherApps: true) + } + } +} + +extension NSWindow { + var isCommunitasContentWindow: Bool { + let className = NSStringFromClass(type(of: self)) + guard !className.contains("NSStatusBarWindow") else { return false } + guard level == .normal else { return false } + guard styleMask.contains(.titled) else { return false } + return isVisible || canBecomeKey + } +} diff --git a/communitas-apple/Sources/Communitas/Models/ChannelManager.swift b/communitas-apple/Sources/Communitas/Models/ChannelManager.swift index 759de867b..68c1e1466 100644 --- a/communitas-apple/Sources/Communitas/Models/ChannelManager.swift +++ b/communitas-apple/Sources/Communitas/Models/ChannelManager.swift @@ -46,6 +46,7 @@ final class ChannelManager: ObservableObject { /// and binds it to the current state-hash. MlsEncrypted (or /// unknown / legacy daemon) keeps the existing gossip path. @Published private(set) var confidentiality: GroupConfidentiality = .mlsEncrypted + private var confidentialityResolved = false /// IDs of SignedPublic messages already merged into `messages`. /// Bounded on long-running sessions. Shared by the WS-push path @@ -85,6 +86,7 @@ final class ChannelManager: ObservableObject { if let info = try? await client.groupInfo(groupId: groupId), let policy = info.policy { self.confidentiality = policy.confidentiality + self.confidentialityResolved = true } } @@ -96,23 +98,15 @@ final class ChannelManager: ObservableObject { /// — the existing WebSocket session then streams events for /// that topic into `handleWebSocketMessage`. /// 3. Start the 30 s poll backstop behind the push path. - private func startSignedPublicReceiveIfNeeded() async { + private func startSignedPublicReceiveIfNeeded(forceSubscribe: Bool = false) async { guard confidentiality == .signedPublic else { return } - if stableGroupId == nil, - let state = try? await client.getGroupState(groupId: groupId) { - stableGroupId = state.groupId - } - if let stableId = stableGroupId, publicTopic == nil { - let topic = "x0x.groups.public.\(stableId)" - do { - _ = try await client.subscribe(topic: topic) - publicTopic = topic - } catch { - // If the subscribe REST call fails, we still have the - // poll backstop. Log and continue. - errorMessage = "public topic subscribe failed: \(error.localizedDescription)" - } + do { + try await ensureSignedPublicSubscription(forceSubscribe: forceSubscribe) + } catch { + // If the subscribe REST call fails, we still have the + // poll backstop. Log and continue. + errorMessage = "public topic subscribe failed: \(error.localizedDescription)" } if signedPublicPollTask == nil { signedPublicPollTask = Task { [weak self] in @@ -121,6 +115,49 @@ final class ChannelManager: ObservableObject { } } + private func ensureSignedPublicSubscription(forceSubscribe: Bool) async throws { + let stableId = try await resolveStableGroupId() + let topic = "x0x.groups.public.\(stableId)" + if forceSubscribe || publicTopic != topic { + _ = try await client.subscribe(topic: topic) + publicTopic = topic + } + } + + @discardableResult + private func resolveStableGroupId() async throws -> String { + if let stableGroupId { + return stableGroupId + } + let state = try await client.getGroupState(groupId: groupId) + stableGroupId = state.groupId + return state.groupId + } + + private func resolveConfidentialityForSend() async throws -> GroupConfidentiality { + if !confidentialityResolved { + let info = try await client.groupInfo(groupId: groupId) + guard let policy = info.policy else { + throw channelManagerError("Group policy is unavailable; cannot route the message safely.") + } + confidentiality = policy.confidentiality + confidentialityResolved = true + } + if confidentiality == .signedPublic { + _ = try await resolveStableGroupId() + await startSignedPublicReceiveIfNeeded(forceSubscribe: true) + } + return confidentiality + } + + private func channelManagerError(_ message: String) -> NSError { + NSError( + domain: "Communitas.ChannelManager", + code: 1, + userInfo: [NSLocalizedDescriptionKey: message] + ) + } + /// Poll loop. Runs until the ChannelManager is torn down. private func pollSignedPublicLoop() async { while !Task.isCancelled { @@ -436,24 +473,57 @@ final class ChannelManager: ObservableObject { } private func startListening() { - let ws = X0xWebSocket(baseURL: client.webSocketBaseURL, path: "/ws", token: client.token) - self.webSocket = ws - ws.connect() - + listeningTask?.cancel() + webSocket?.disconnect() listeningTask = Task { [weak self] in + var retryDelay: UInt64 = 1_000_000_000 while !Task.isCancelled { + guard let self else { return } + let ws = await self.openWebSocketAndResubscribe() do { - let text = try await ws.receive() - await self?.handleWebSocketMessage(text) + retryDelay = 1_000_000_000 + while !Task.isCancelled { + let text = try await ws.receive() + await self.handleWebSocketMessage(text) + } } catch { + self.clearWebSocket(ws) if !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) + try? await Task.sleep(nanoseconds: retryDelay) + retryDelay = min(retryDelay * 2, 30_000_000_000) } } } } } + private func openWebSocketAndResubscribe() async -> X0xWebSocket { + let ws = X0xWebSocket(baseURL: client.webSocketBaseURL, path: "/ws", token: client.token) + self.webSocket = ws + ws.connect() + await resubscribeActiveTopics() + return ws + } + + private func clearWebSocket(_ ws: X0xWebSocket) { + if webSocket === ws { + webSocket = nil + } + ws.disconnect() + } + + private func resubscribeActiveTopics() async { + do { + _ = try await client.subscribe(topic: channelTopic(name: currentChannel)) + } catch { + errorMessage = "Failed to resubscribe: \(error.localizedDescription)" + } + await refreshConfidentiality() + if confidentiality == .signedPublic { + await startSignedPublicReceiveIfNeeded(forceSubscribe: true) + } + } + private func handleWebSocketMessage(_ text: String) async { guard let data = text.data(using: .utf8) else { return } @@ -657,6 +727,7 @@ final class ChannelManager: ObservableObject { /// for these groups. /// - `MlsEncrypted` (default) → existing gossip path, unchanged. func sendMessage(text: String, replyToId: String? = nil) async throws { + let route = try await resolveConfidentialityForSend() let msg = ChannelChatMessage( id: UUID().uuidString, text: text, @@ -667,7 +738,7 @@ final class ChannelManager: ObservableObject { replyToId: replyToId ) - switch confidentiality { + switch route { case .signedPublic: _ = try await client.sendGroupPublicMessage( groupId: groupId, diff --git a/communitas-apple/Sources/Communitas/Services/StatusBarController.swift b/communitas-apple/Sources/Communitas/Services/StatusBarController.swift index 48d80dccb..ac4289cf1 100644 --- a/communitas-apple/Sources/Communitas/Services/StatusBarController.swift +++ b/communitas-apple/Sources/Communitas/Services/StatusBarController.swift @@ -139,7 +139,7 @@ final class StatusBarController { @objc private func handleShowWindow() { NSApp.activate(ignoringOtherApps: true) - if let window = NSApp.windows.first { + if let window = NSApp.windows.first(where: { $0.isCommunitasContentWindow }) { window.makeKeyAndOrderFront(nil) } } diff --git a/communitas-apple/Sources/Communitas/Views/AppKitFormControls.swift b/communitas-apple/Sources/Communitas/Views/AppKitFormControls.swift new file mode 100644 index 000000000..d6cfd0f7a --- /dev/null +++ b/communitas-apple/Sources/Communitas/Views/AppKitFormControls.swift @@ -0,0 +1,259 @@ +import AppKit +import SwiftUI + +struct AppKitInlineButton: NSViewRepresentable { + let title: String + let systemSymbolName: String? + let accessibilityIdentifier: String + let onPress: () -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(onPress: onPress) + } + + func makeNSView(context: Context) -> NSButton { + let button = NSButton(title: title, target: context.coordinator, action: #selector(Coordinator.press(_:))) + button.bezelStyle = .rounded + button.controlSize = .small + if let systemSymbolName { + button.image = NSImage(systemSymbolName: systemSymbolName, accessibilityDescription: title) + button.imagePosition = .imageLeading + } + button.setAccessibilityIdentifier(accessibilityIdentifier) + return button + } + + func updateNSView(_ button: NSButton, context: Context) { + button.title = title + context.coordinator.onPress = onPress + } + + final class Coordinator: NSObject { + var onPress: () -> Void + + init(onPress: @escaping () -> Void) { + self.onPress = onPress + } + + @objc func press(_ sender: NSButton) { + onPress() + } + } +} + +struct AppKitPageCreatePanel: NSViewRepresentable { + let placeholder: String + let buttonTitle: String + let accessibilityPrefix: String + let onSubmit: (String) -> Bool + + func makeCoordinator() -> Coordinator { + Coordinator(onSubmit: onSubmit) + } + + func makeNSView(context: Context) -> NSStackView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .width + stack.spacing = 6 + stack.translatesAutoresizingMaskIntoConstraints = false + + let field = NSTextField() + field.placeholderString = placeholder + field.bezelStyle = .roundedBezel + field.delegate = context.coordinator + field.setAccessibilityIdentifier("\(accessibilityPrefix)-field") + + let button = NSButton( + title: buttonTitle, + target: context.coordinator, + action: #selector(Coordinator.submit(_:)) + ) + button.bezelStyle = .rounded + button.controlSize = .small + button.image = NSImage(systemSymbolName: "plus", accessibilityDescription: buttonTitle) + button.imagePosition = .imageLeading + button.setAccessibilityIdentifier("\(accessibilityPrefix)-button") + + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + let buttonRow = NSStackView(views: [spacer, button]) + buttonRow.orientation = .horizontal + buttonRow.alignment = .centerY + buttonRow.distribution = .fill + + stack.addArrangedSubview(field) + stack.addArrangedSubview(buttonRow) + + context.coordinator.field = field + context.coordinator.button = button + context.coordinator.refreshButtonState() + + return stack + } + + func updateNSView(_ stack: NSStackView, context: Context) { + context.coordinator.onSubmit = onSubmit + context.coordinator.refreshButtonState() + } + + final class Coordinator: NSObject, NSTextFieldDelegate { + weak var field: NSTextField? + weak var button: NSButton? + var onSubmit: (String) -> Bool + + init(onSubmit: @escaping (String) -> Bool) { + self.onSubmit = onSubmit + } + + func controlTextDidChange(_ notification: Notification) { + refreshButtonState() + } + + func controlTextDidEndEditing(_ notification: Notification) { + refreshButtonState() + } + + func refreshButtonState() { + let hasText = !(field?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + button?.isEnabled = hasText + } + + @objc func submit(_ sender: NSButton) { + guard let field else { return } + if onSubmit(field.stringValue) { + field.stringValue = "" + refreshButtonState() + } + } + } +} + +struct AppKitTextEditorPanel: NSViewRepresentable { + let title: String + let content: String + let saveTitle: String + let accessibilityPrefix: String + let isMonospaced: Bool + let onCancel: () -> Void + let onSave: (String) -> Bool + + func makeCoordinator() -> Coordinator { + Coordinator(content: content, onCancel: onCancel, onSave: onSave) + } + + func makeNSView(context: Context) -> NSStackView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .width + stack.spacing = 10 + stack.translatesAutoresizingMaskIntoConstraints = false + + let titleLabel = NSTextField(labelWithString: title) + titleLabel.font = .systemFont(ofSize: NSFont.systemFontSize + 5, weight: .bold) + titleLabel.setAccessibilityIdentifier("\(accessibilityPrefix)-title") + + let cancelButton = NSButton( + title: "Cancel", + target: context.coordinator, + action: #selector(Coordinator.cancel(_:)) + ) + cancelButton.bezelStyle = .rounded + cancelButton.controlSize = .small + cancelButton.setAccessibilityIdentifier("\(accessibilityPrefix)-cancel") + + let saveButton = NSButton( + title: saveTitle, + target: context.coordinator, + action: #selector(Coordinator.save(_:)) + ) + saveButton.bezelStyle = .rounded + saveButton.controlSize = .small + saveButton.image = NSImage(systemSymbolName: "checkmark", accessibilityDescription: saveTitle) + saveButton.imagePosition = .imageLeading + saveButton.setAccessibilityIdentifier("\(accessibilityPrefix)-save") + + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + let header = NSStackView(views: [titleLabel, spacer, cancelButton, saveButton]) + header.orientation = .horizontal + header.alignment = .centerY + header.spacing = 8 + header.distribution = .fill + + let textView = NSTextView() + textView.string = content + textView.isRichText = false + textView.allowsUndo = true + textView.font = isMonospaced ? .monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular) : .systemFont(ofSize: NSFont.systemFontSize) + textView.drawsBackground = true + textView.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.45) + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.delegate = context.coordinator + textView.setAccessibilityIdentifier("\(accessibilityPrefix)-body") + + let textScroll = NSScrollView() + textScroll.hasVerticalScroller = true + textScroll.borderType = .bezelBorder + textScroll.documentView = textView + textScroll.translatesAutoresizingMaskIntoConstraints = false + textScroll.setAccessibilityIdentifier("\(accessibilityPrefix)-body-scroll") + textScroll.heightAnchor.constraint(equalToConstant: 260).isActive = true + + stack.addArrangedSubview(header) + stack.addArrangedSubview(textScroll) + + context.coordinator.titleLabel = titleLabel + context.coordinator.textView = textView + context.coordinator.loadedContent = content + + return stack + } + + func updateNSView(_ stack: NSStackView, context: Context) { + context.coordinator.onCancel = onCancel + context.coordinator.onSave = onSave + if context.coordinator.titleLabel?.stringValue != title { + context.coordinator.titleLabel?.stringValue = title + } + if context.coordinator.loadedContent != content, !context.coordinator.hasUserEdited { + context.coordinator.textView?.string = content + context.coordinator.loadedContent = content + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + weak var titleLabel: NSTextField? + weak var textView: NSTextView? + var loadedContent: String + var hasUserEdited = false + var onCancel: () -> Void + var onSave: (String) -> Bool + + init(content: String, onCancel: @escaping () -> Void, onSave: @escaping (String) -> Bool) { + self.loadedContent = content + self.onCancel = onCancel + self.onSave = onSave + } + + func textDidChange(_ notification: Notification) { + hasUserEdited = true + } + + @objc func cancel(_ sender: NSButton) { + onCancel() + } + + @objc func save(_ sender: NSButton) { + guard let textView else { return } + if onSave(textView.string) { + loadedContent = textView.string + hasUserEdited = false + } + } + } +} diff --git a/communitas-apple/Sources/Communitas/Views/BoardView.swift b/communitas-apple/Sources/Communitas/Views/BoardView.swift index 2a5c8cfba..cda62eac9 100644 --- a/communitas-apple/Sources/Communitas/Views/BoardView.swift +++ b/communitas-apple/Sources/Communitas/Views/BoardView.swift @@ -8,10 +8,11 @@ struct BoardView: View { @State private var listId: String? @State private var tasks: [TaskItem] = [] - @State private var isLoading = true + @State private var isLoading = false @State private var errorMessage: String? @State private var newTaskTitle = "" @State private var addingToColumn: String? + @State private var hasStartedBoard = false @State private var pollTask: Task? private let columns = ["todo", "in_progress", "done"] @@ -30,11 +31,11 @@ struct BoardView: View { kanbanColumns } } - .task { - await loadOrCreateBoard() - startPolling() + .onAppear { + scheduleBoardStartup() } .onDisappear { + hasStartedBoard = false pollTask?.cancel() pollTask = nil } @@ -80,7 +81,7 @@ struct BoardView: View { .font(.subheadline) .fontWeight(.semibold) - let count = tasks.filter { $0.state == status }.count + let count = tasks.filter { taskLane($0.state) == status }.count Text("\(count)") .font(.caption2) .foregroundStyle(.secondary) @@ -104,7 +105,7 @@ struct BoardView: View { ScrollView { LazyVStack(spacing: 6) { - ForEach(tasks.filter { $0.state == status }) { task in + ForEach(tasks.filter { taskLane($0.state) == status }) { task in taskCard(task: task, status: status) } @@ -121,7 +122,7 @@ struct BoardView: View { private func taskCard(task: TaskItem, status: String) -> some View { VStack(alignment: .leading, spacing: 6) { - Text(task.description) + Text(taskDisplayTitle(task)) .font(.subheadline) .lineLimit(3) @@ -155,6 +156,32 @@ struct BoardView: View { .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) } + private func taskLane(_ state: String?) -> String { + let normalized = state?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if normalized.isEmpty || normalized == "empty" || normalized == "todo" { + return "todo" + } + if normalized == "in_progress" || normalized.hasPrefix("in_progress:") + || normalized == "claimed" || normalized.hasPrefix("claimed:") + { + return "in_progress" + } + if normalized == "done" || normalized.hasPrefix("done:") { + return "done" + } + return "todo" + } + + private func taskDisplayTitle(_ task: TaskItem) -> String { + if let title = task.title?.trimmingCharacters(in: .whitespacesAndNewlines), + !title.isEmpty + { + return title + } + let description = task.description.trimmingCharacters(in: .whitespacesAndNewlines) + return description.isEmpty ? "Untitled task" : description + } + private var addTaskField: some View { VStack(spacing: 6) { TextField("Task title...", text: $newTaskTitle) @@ -205,6 +232,17 @@ struct BoardView: View { return "x0x-board-\(prefix)" } + private func scheduleBoardStartup() { + guard !hasStartedBoard else { return } + hasStartedBoard = true + DispatchQueue.main.async { + Task { @MainActor in + await loadOrCreateBoard() + startPolling() + } + } + } + private func ensureBoardStore() async { do { let stores = try await appState.client.listStores() @@ -215,7 +253,6 @@ struct BoardView: View { } private func loadOrCreateBoard() async { - isLoading = true errorMessage = nil let prefix = appState.groupPrefix(for: groupId) let kvKey = "board.\(prefix).listId" @@ -238,7 +275,6 @@ struct BoardView: View { errorMessage = "Failed to create board: \(error.localizedDescription)" } } - isLoading = false } private func refreshTasks() async { diff --git a/communitas-apple/Sources/Communitas/Views/ContactsView.swift b/communitas-apple/Sources/Communitas/Views/ContactsView.swift index 1e39f1878..36e4935fd 100644 --- a/communitas-apple/Sources/Communitas/Views/ContactsView.swift +++ b/communitas-apple/Sources/Communitas/Views/ContactsView.swift @@ -586,7 +586,11 @@ struct ComposeDirectMessageSheet: View { sendError = nil sentConfirmation = false do { - try await appState.client.sendDirect(agentId: rcpt, payload: payload) + try await appState.client.sendDirect( + agentId: rcpt, + payload: payload, + requireGossipAck: true + ) sentConfirmation = true } catch { sendError = "Send failed: \(error.localizedDescription)" diff --git a/communitas-apple/Sources/Communitas/Views/DirectMessageView.swift b/communitas-apple/Sources/Communitas/Views/DirectMessageView.swift index 296ec3cad..70a30d6fc 100644 --- a/communitas-apple/Sources/Communitas/Views/DirectMessageView.swift +++ b/communitas-apple/Sources/Communitas/Views/DirectMessageView.swift @@ -173,6 +173,7 @@ struct DirectMessageView: View { startListening(contact: contact) } .onDisappear { + hasConnected = false listeningTask?.cancel() webSocket?.disconnect() } @@ -320,7 +321,11 @@ struct DirectMessageView: View { ) let payloadData = try JSONEncoder().encode(wire) let payload = payloadData.base64EncodedString() - try await appState.client.sendDirect(agentId: contact.agentId, payload: payload) + try await appState.client.sendDirect( + agentId: contact.agentId, + payload: payload, + requireGossipAck: true + ) messages.append(msg) saveHistory(agentId: contact.agentId) } catch { @@ -329,8 +334,8 @@ struct DirectMessageView: View { } } - private func connectToAgent(contact: Contact) async { - guard !hasConnected else { return } + private func connectToAgent(contact: Contact, force: Bool = false) async { + guard force || !hasConnected else { return } do { try await appState.client.connectAgent(agentId: contact.agentId) hasConnected = true @@ -340,18 +345,29 @@ struct DirectMessageView: View { } private func startListening(contact: Contact) { - let ws = X0xWebSocket(baseURL: appState.client.webSocketBaseURL, path: "/ws/direct", token: appState.client.token) - self.webSocket = ws - ws.connect() - - listeningTask = Task { + listeningTask?.cancel() + webSocket?.disconnect() + listeningTask = Task { @MainActor in + var retryDelay: UInt64 = 1_000_000_000 while !Task.isCancelled { + await connectToAgent(contact: contact, force: true) + let ws = X0xWebSocket(baseURL: appState.client.webSocketBaseURL, path: "/ws/direct", token: appState.client.token) + self.webSocket = ws + ws.connect() do { - let text = try await ws.receive() - await handleDirectMessage(text, from: contact) + retryDelay = 1_000_000_000 + while !Task.isCancelled { + let text = try await ws.receive() + await handleDirectMessage(text, from: contact) + } } catch { + if webSocket === ws { + webSocket = nil + } + ws.disconnect() if !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) + try? await Task.sleep(nanoseconds: retryDelay) + retryDelay = min(retryDelay * 2, 30_000_000_000) } } } diff --git a/communitas-apple/Sources/Communitas/Views/FeedView.swift b/communitas-apple/Sources/Communitas/Views/FeedView.swift index 8fca94014..e6edb2c88 100644 --- a/communitas-apple/Sources/Communitas/Views/FeedView.swift +++ b/communitas-apple/Sources/Communitas/Views/FeedView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import X0xClient @@ -26,8 +27,8 @@ struct FeedView: View { @EnvironmentObject var appState: AppState @State private var posts: [FeedPost] = [] - @State private var draft = "" @State private var isPosting = false + @State private var hasStartedFeed = false @State private var webSocket: X0xWebSocket? @State private var listeningTask: Task? @@ -47,11 +48,11 @@ struct FeedView: View { Divider() postList } - .task { - await subscribeToFeed() - startListening() + .onAppear { + scheduleFeedStartup() } .onDisappear { + hasStartedFeed = false listeningTask?.cancel() webSocket?.disconnect() } @@ -73,29 +74,10 @@ struct FeedView: View { // MARK: - Post Composer private var postComposer: some View { - VStack(spacing: 8) { - TextEditor(text: $draft) - .font(.body) - .frame(height: 60) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) - - HStack { - Spacer() - Button { - Task { await publishPost() } - } label: { - HStack(spacing: 4) { - Image(systemName: "paperplane.fill") - Text("Post") - } - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || isPosting) - } + FeedPostComposerPanel(isPosting: isPosting) { text in + publishPost(text: text) } + .frame(height: 118) .padding(12) } @@ -167,11 +149,21 @@ struct FeedView: View { // MARK: - Actions - private func publishPost() async { - let text = draft.trimmingCharacters(in: .whitespaces) - guard !text.isEmpty else { return } + private func scheduleFeedStartup() { + guard !hasStartedFeed else { return } + hasStartedFeed = true + DispatchQueue.main.async { + Task { @MainActor in + await subscribeToFeed() + startListening() + } + } + } + + private func publishPost(text rawText: String) -> Bool { + let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty, !isPosting else { return false } isPosting = true - defer { isPosting = false } let post = FeedPost( id: UUID().uuidString, @@ -181,15 +173,34 @@ struct FeedView: View { timestamp: Int64(Date().timeIntervalSince1970 * 1000) ) + let payload: String do { let data = try JSONEncoder().encode(post) - let payload = data.base64EncodedString() - try await appState.client.publish(topic: feedTopic, payload: payload) - posts.append(post) - draft = "" + payload = data.base64EncodedString() } catch { + isPosting = false appState.errorMessage = "Failed to post: \(error.localizedDescription)" + return false } + + let client = appState.client + let topic = feedTopic + posts.append(post) + + Task { + do { + try await client.publish(topic: topic, payload: payload) + } catch { + await MainActor.run { + appState.errorMessage = "Failed to post: \(error.localizedDescription)" + } + } + await MainActor.run { + isPosting = false + } + } + + return true } private func subscribeToFeed() async { @@ -201,18 +212,29 @@ struct FeedView: View { } private func startListening() { - let ws = X0xWebSocket(baseURL: appState.client.webSocketBaseURL, path: "/ws", token: appState.client.token) - self.webSocket = ws - ws.connect() - - listeningTask = Task { + listeningTask?.cancel() + webSocket?.disconnect() + listeningTask = Task { @MainActor in + var retryDelay: UInt64 = 1_000_000_000 while !Task.isCancelled { + await subscribeToFeed() + let ws = X0xWebSocket(baseURL: appState.client.webSocketBaseURL, path: "/ws", token: appState.client.token) + self.webSocket = ws + ws.connect() do { - let text = try await ws.receive() - await handleWebSocketMessage(text) + retryDelay = 1_000_000_000 + while !Task.isCancelled { + let text = try await ws.receive() + await handleWebSocketMessage(text) + } } catch { + if webSocket === ws { + webSocket = nil + } + ws.disconnect() if !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) + try? await Task.sleep(nanoseconds: retryDelay) + retryDelay = min(retryDelay * 2, 30_000_000_000) } } } @@ -251,3 +273,104 @@ struct FeedView: View { return colors[index] } } + +private struct FeedPostComposerPanel: NSViewRepresentable { + let isPosting: Bool + let onSubmit: (String) -> Bool + + func makeCoordinator() -> Coordinator { + Coordinator(onSubmit: onSubmit) + } + + func makeNSView(context: Context) -> NSStackView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .width + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + + let textView = NSTextView() + textView.isRichText = false + textView.allowsUndo = true + textView.font = .systemFont(ofSize: NSFont.systemFontSize) + textView.drawsBackground = true + textView.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.45) + textView.textContainerInset = NSSize(width: 8, height: 8) + textView.isHorizontallyResizable = false + textView.isVerticallyResizable = true + textView.autoresizingMask = [.width] + textView.textContainer?.widthTracksTextView = true + textView.setAccessibilityIdentifier("feed-post-body") + textView.delegate = context.coordinator + + let textScroll = NSScrollView() + textScroll.hasVerticalScroller = true + textScroll.borderType = .bezelBorder + textScroll.documentView = textView + textScroll.translatesAutoresizingMaskIntoConstraints = false + textScroll.setAccessibilityIdentifier("feed-post-body-scroll") + textScroll.heightAnchor.constraint(equalToConstant: 74).isActive = true + + let button = NSButton( + title: "Post", + target: context.coordinator, + action: #selector(Coordinator.submit(_:)) + ) + button.bezelStyle = .rounded + button.controlSize = .regular + button.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: "Post") + button.imagePosition = .imageLeading + button.setAccessibilityIdentifier("feed-post-button") + + let spacer = NSView() + spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) + + let buttonRow = NSStackView(views: [spacer, button]) + buttonRow.orientation = .horizontal + buttonRow.alignment = .centerY + buttonRow.distribution = .fill + + stack.addArrangedSubview(textScroll) + stack.addArrangedSubview(buttonRow) + + context.coordinator.textView = textView + context.coordinator.button = button + context.coordinator.refreshButtonState() + + return stack + } + + func updateNSView(_ stack: NSStackView, context: Context) { + context.coordinator.onSubmit = onSubmit + context.coordinator.isPosting = isPosting + context.coordinator.refreshButtonState() + } + + final class Coordinator: NSObject, NSTextViewDelegate { + weak var textView: NSTextView? + weak var button: NSButton? + var isPosting = false + var onSubmit: (String) -> Bool + + init(onSubmit: @escaping (String) -> Bool) { + self.onSubmit = onSubmit + } + + func textDidChange(_ notification: Notification) { + refreshButtonState() + } + + func refreshButtonState() { + let hasText = !(textView?.string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + button?.isEnabled = !isPosting && hasText + } + + @objc func submit(_ sender: NSButton) { + guard let textView else { return } + if onSubmit(textView.string) { + textView.string = "" + refreshButtonState() + } + } + } +} diff --git a/communitas-apple/Sources/Communitas/Views/FilesView.swift b/communitas-apple/Sources/Communitas/Views/FilesView.swift index fd4a8c411..ee8da0197 100644 --- a/communitas-apple/Sources/Communitas/Views/FilesView.swift +++ b/communitas-apple/Sources/Communitas/Views/FilesView.swift @@ -1,4 +1,5 @@ import CryptoKit +import AppKit import SwiftUI import X0xClient @@ -10,7 +11,7 @@ struct FilesView: View { @State private var isLoading = true @State private var errorMessage: String? @State private var selectedAgentId = "" - @State private var showFilePicker = false + @State private var hasStartedFiles = false @State private var pollTask: Task? var body: some View { @@ -32,20 +33,14 @@ struct FilesView: View { } } } - .task { - await refreshTransfers() - isLoading = false - startPolling() + .onAppear { + scheduleFilesStartup() } .onDisappear { + hasStartedFiles = false pollTask?.cancel() pollTask = nil } - .fileImporter(isPresented: $showFilePicker, allowedContentTypes: [.data]) { result in - if case .success(let url) = result { - Task { await sendFileAction(url: url) } - } - } } private var filesHeader: some View { @@ -208,7 +203,7 @@ struct FilesView: View { .frame(maxWidth: 240) Button { - showFilePicker = true + chooseFileAction() } label: { Label("Choose File", systemImage: "folder") } @@ -222,6 +217,32 @@ struct FilesView: View { // MARK: - Actions + @MainActor + private func chooseFileAction() { + guard !selectedAgentId.isEmpty else { return } + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.canCreateDirectories = false + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + Task { await sendFileAction(url: url) } + } + } + + private func scheduleFilesStartup() { + guard !hasStartedFiles else { return } + hasStartedFiles = true + DispatchQueue.main.async { + Task { @MainActor in + await refreshTransfers() + isLoading = false + startPolling() + } + } + } + private func refreshTransfers() async { do { transfers = try await appState.client.listTransfers() @@ -253,6 +274,12 @@ struct FilesView: View { let filename = url.lastPathComponent let fileData: Data do { + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + } fileData = try Data(contentsOf: url) } catch { errorMessage = "Cannot read file: \(error.localizedDescription)" diff --git a/communitas-apple/Sources/Communitas/Views/MessagingView.swift b/communitas-apple/Sources/Communitas/Views/MessagingView.swift index 045a23823..3a659672f 100644 --- a/communitas-apple/Sources/Communitas/Views/MessagingView.swift +++ b/communitas-apple/Sources/Communitas/Views/MessagingView.swift @@ -515,6 +515,8 @@ struct MessagingView: View { .clipShape(Circle()) .disabled(draft.trimmingCharacters(in: .whitespaces).isEmpty || isSending) .keyboardShortcut(.return, modifiers: .command) + .accessibilityLabel("Send message") + .accessibilityHint("Sends the current message to the selected channel") } .padding(12) } @@ -550,6 +552,7 @@ struct MessagingView: View { // MARK: - Send + @MainActor private func sendMessage(manager: ChannelManager) { let text = draft.trimmingCharacters(in: .whitespaces) guard !text.isEmpty else { return } @@ -566,7 +569,7 @@ struct MessagingView: View { // Mention notification: if we send a message mentioning someone, no local notification needed. // Notification for receiving a mention is handled in handleWebSocketMessage. - Task { + Task { @MainActor in defer { isSending = false } do { try await manager.sendMessage(text: text, replyToId: currentReplyToId) diff --git a/communitas-apple/Sources/Communitas/Views/NetworkView.swift b/communitas-apple/Sources/Communitas/Views/NetworkView.swift index 18e7d2f0d..3b44bf7f5 100644 --- a/communitas-apple/Sources/Communitas/Views/NetworkView.swift +++ b/communitas-apple/Sources/Communitas/Views/NetworkView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import X0xClient @@ -6,7 +7,25 @@ private struct PeerDiagnostic { let probe: String } -/// Network view showing connection stats, external addresses, and connected peers. +private struct NetworkMetric: Identifiable { + let id: String + let title: String + let value: String + let systemImage: String + let color: Color +} + +private struct NetworkInfoRow: Identifiable { + let id: String + let label: String + let value: String +} + +/// Network view showing connection stats, diagnostics, discovered machines, and peers. +/// +/// The layout intentionally stays flat and bounded. A previous version used a +/// deeply nested GroupBox/table tree and crashed in SwiftUI constraint/body +/// evaluation on macOS while opening the page. struct NetworkView: View { @EnvironmentObject var appState: AppState @@ -26,512 +45,406 @@ struct NetworkView: View { @State private var connectMachineResult: ConnectMachineResponse? @State private var peerDiagnostics: [String: PeerDiagnostic] = [:] @State private var isRefreshing = false + @State private var lastUpgradeCheckAt: Date? - private let statsColumns = Array(repeating: GridItem(.flexible(), spacing: 12), count: 4) + private let upgradeCheckCooldown: TimeInterval = 6 * 60 * 60 var body: some View { ScrollView { - VStack(spacing: 20) { - statsGrid - nodeHealthSection - externalAddressesSection - networkStatsSection - diagnosticsSection - discoveredMachinesSection - peersTable + VStack(alignment: .leading, spacing: 18) { + header + metricGrid + sectionPanel(title: "Node Health", systemImage: "server.rack") { + infoRows(nodeHealthRows) + } + sectionPanel(title: "Network Statistics", systemImage: "chart.bar") { + infoRows(networkRows, emptyMessage: "Connect to peers to see statistics") + } + sectionPanel(title: "External Addresses", systemImage: "globe") { + addressList + } + sectionPanel(title: "Daemon Diagnostics", systemImage: "stethoscope") { + infoRows(diagnosticRows, emptyMessage: "No extended diagnostics available") + } + sectionPanel(title: "Discovered Machines", systemImage: "network") { + discoveredMachineList + } + sectionPanel(title: "Connected Peers (\(peers.count))", systemImage: "person.2") { + peerList + } } .padding(24) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(DeepSpace.bg) .navigationTitle("Network") - .toolbar { - ToolbarItem(placement: .automatic) { - Button { - Task { await pollData() } - } label: { - Label("Refresh", systemImage: "arrow.clockwise") - } - .disabled(isRefreshing) - } - } .task { await pollData() - startPolling() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5_000_000_000) + await pollData() + } } } - // MARK: - Stats Grid - - private var statsGrid: some View { - LazyVGrid(columns: statsColumns, spacing: 12) { - StatCard( - title: "Status", - value: connectionStatusLabel, - icon: "wifi", - color: (networkStatus?.connectedPeers ?? 0) > 0 ? DeepSpace.green : DeepSpace.red - ) - StatCard( - title: "Peers", - value: "\(networkStatus?.connectedPeers ?? 0)", - icon: "person.2", - color: DeepSpace.cyan - ) - StatCard( - title: "Addresses", - value: "\(networkStatus?.externalAddrs?.count ?? 0)", - icon: "globe", - color: DeepSpace.amber - ) - StatCard( - title: "Direct", - value: "\(directConnections.count)", - icon: "bolt.horizontal.circle", - color: DeepSpace.green - ) + private var header: some View { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Network") + .font(.title2) + .fontWeight(.semibold) + .foregroundStyle(DeepSpace.textPrimary) + Text(healthLabel) + .font(.caption) + .foregroundStyle(DeepSpace.textSecondary) + } + Spacer() + ProgressView() + .controlSize(.small) + .opacity(isRefreshing ? 1 : 0) + AppKitInlineButton( + title: "Refresh", + systemSymbolName: "arrow.clockwise", + accessibilityIdentifier: "network-refresh-button" + ) { + Task { await pollData() } + } + .frame(width: 96, height: 28) } } - private var connectionStatusLabel: String { - (networkStatus?.connectedPeers ?? 0) > 0 ? "Connected" : "Disconnected" - } - - // MARK: - Node Health Section - - private var nodeHealthSection: some View { - GroupBox { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Circle() - .fill(healthColor) - .frame(width: 10, height: 10) - Text(healthLabel) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(DeepSpace.textPrimary) - Spacer() - } - - Divider().background(DeepSpace.border) - - if let agentId = appState.agentIdentity?.agentId { - copyableRow(label: "Agent ID", value: agentId) - } - - if let version = healthStatus?.version ?? daemonStatus?.version { - HStack { - Text("Version") - .font(.caption) - .foregroundStyle(DeepSpace.textSecondary) - .frame(width: 100, alignment: .leading) - Text(version) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(DeepSpace.textPrimary) - Spacer() - } - } - - if let uptime = healthStatus?.uptimeSecs ?? daemonStatus?.uptimeSecs { - HStack { - Text("Uptime") - .font(.caption) - .foregroundStyle(DeepSpace.textSecondary) - .frame(width: 100, alignment: .leading) - Text(formatUptime(uptime)) - .font(.caption) - .foregroundStyle(DeepSpace.textPrimary) - Spacer() - } + private var metricGrid: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + ForEach(Array(metricCards.prefix(2))) { metric in + metricCard(metric) } } - .padding(4) - } label: { - Label("Node Health", systemImage: "server.rack") - .foregroundStyle(DeepSpace.textPrimary) - } - .backgroundStyle(DeepSpace.surface1) - } - - private var healthColor: Color { - switch appState.daemonState { - case .running: - if (networkStatus?.connectedPeers ?? 0) > 0 { return DeepSpace.green } - return .yellow - case .starting: return .yellow - case .notRunning, .notInstalled, .error: return DeepSpace.red - } - } - - private var healthLabel: String { - switch appState.daemonState { - case .running: - if (networkStatus?.connectedPeers ?? 0) > 0 { return "Online — Connected to network" } - return "Running — No peers yet" - case .starting: return "Starting..." - case .notRunning: return "Not Running" - case .notInstalled: return "Not Installed" - case .error: return "Error" - } - } - - // MARK: - External Addresses Section - - private var externalAddressesSection: some View { - GroupBox { - if let addrs = networkStatus?.externalAddrs, !addrs.isEmpty { - let ipv4 = addrs.filter { isIPv4($0) } - let ipv6 = addrs.filter { !isIPv4($0) } - - VStack(alignment: .leading, spacing: 12) { - if !ipv4.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("IPv4") - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(DeepSpace.textMuted) - .textCase(.uppercase) - - ForEach(ipv4, id: \.self) { addr in - addressRow(addr) - } - } - } - - if !ipv6.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text("IPv6") - .font(.caption2) - .fontWeight(.semibold) - .foregroundStyle(DeepSpace.textMuted) - .textCase(.uppercase) - - ForEach(ipv6, id: \.self) { addr in - addressRow(addr) - } - } - } + HStack(spacing: 12) { + ForEach(Array(metricCards.dropFirst(2))) { metric in + metricCard(metric) } - .padding(4) - } else { - emptyState(icon: "globe", message: "No external addresses detected") } - } label: { - Label("External Addresses", systemImage: "globe") - .foregroundStyle(DeepSpace.textPrimary) } - .backgroundStyle(DeepSpace.surface1) } - private func addressRow(_ addr: String) -> some View { - HStack { - Text(addr) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(DeepSpace.textPrimary) - .textSelection(.enabled) - Spacer() - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(addr, forType: .string) - } label: { - Image(systemName: "doc.on.doc") + private func metricCard(_ metric: NetworkMetric) -> some View { + HStack(spacing: 12) { + Image(systemName: metric.systemImage) + .font(.title3) + .foregroundStyle(metric.color) + .frame(width: 28) + VStack(alignment: .leading, spacing: 2) { + Text(metric.title) .font(.caption2) .foregroundStyle(DeepSpace.textMuted) + Text(metric.value) + .font(.headline) + .foregroundStyle(DeepSpace.textPrimary) + .lineLimit(1) } - .buttonStyle(.plain) - } - .padding(.vertical, 2) + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 70) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(DeepSpace.surface1) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(DeepSpace.border, lineWidth: 1) + ) } - // MARK: - Network Statistics Section - - private var networkStatsSection: some View { - GroupBox { - VStack(alignment: .leading, spacing: 8) { - if let rtt = networkStatus?.avgRttMs { - statRow(label: "Avg RTT", value: String(format: "%.0f ms", rtt)) - } - - if let direct = networkStatus?.directConnections { - statRow(label: "Direct Connections", value: "\(direct)") - } - - if let canReceive = networkStatus?.canReceiveDirect { - statRow(label: "Can Receive Direct", value: canReceive ? "Yes" : "No") - } - - if let holePunch = networkStatus?.holePunchSuccessRate { - statRow(label: "Hole Punch Rate", value: String(format: "%.0f%%", holePunch * 100)) - } - - if networkStatus?.avgRttMs == nil - && networkStatus?.directConnections == nil - && networkStatus?.canReceiveDirect == nil { - Text("Connect to peers to see statistics") - .font(.caption) - .foregroundStyle(DeepSpace.textMuted) - .padding(.vertical, 4) - } - } - .padding(4) - } label: { - Label("Network Statistics", systemImage: "chart.bar") + private func sectionPanel( + title: String, + systemImage: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 10) { + Label(title, systemImage: systemImage) + .font(.headline) .foregroundStyle(DeepSpace.textPrimary) - } - .backgroundStyle(DeepSpace.surface1) + content() + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(DeepSpace.surface1) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(DeepSpace.border, lineWidth: 1) + ) } - private func statRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.caption) - .foregroundStyle(DeepSpace.textSecondary) - .frame(minWidth: 160, alignment: .leading) - Text(value) - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(DeepSpace.textPrimary) - Spacer() + private func infoRows(_ rows: [NetworkInfoRow], emptyMessage: String? = nil) -> some View { + VStack(alignment: .leading, spacing: 8) { + if rows.isEmpty, let emptyMessage { + Text(emptyMessage) + .font(.caption) + .foregroundStyle(DeepSpace.textMuted) + .padding(.vertical, 4) + } else { + ForEach(rows) { row in + HStack(alignment: .top, spacing: 12) { + Text(row.label) + .font(.caption) + .foregroundStyle(DeepSpace.textSecondary) + .frame(width: 160, alignment: .leading) + Text(row.value) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(DeepSpace.textPrimary) + .textSelection(.enabled) + Spacer(minLength: 0) + } + } + } } } - // MARK: - Diagnostics Section - - private var diagnosticsSection: some View { - GroupBox { - VStack(alignment: .leading, spacing: 8) { - if let bootstrapCount = bootstrapCache?.connectionCount { - statRow(label: "Bootstrap Cache", value: "\(bootstrapCount) connected") - } - - if let diagnostics = connectivityDiagnostics { - statRow(label: "Connectivity Peer", value: truncatedId(diagnostics.peerId)) - statRow(label: "mDNS Peers", value: "\(diagnostics.mdns.discoveredPeers)") - statRow(label: "Relay Enabled", value: diagnostics.services.relayEnabled ? "Yes" : "No") - statRow(label: "Coordinator Enabled", value: diagnostics.services.coordinatorEnabled ? "Yes" : "No") - } - - if let sessions = webSocketSessions?.sessions.count { - statRow(label: "WebSocket Sessions", value: "\(sessions)") - } - - if let shared = webSocketSessions?.sharedSubscriptions, !shared.isEmpty { - statRow(label: "Shared Topic Subs", value: "\(shared.count)") - } - - if let stats = gossipStats { - statRow(label: "Gossip Published", value: "\(stats.publishTotal)") - statRow(label: "Gossip Incoming", value: "\(stats.incomingTotal)") - statRow(label: "Gossip Delivered", value: "\(stats.deliveredToSubscriber)") - statRow( - label: "Decode to Delivery Drops", - value: "\(stats.decodeToDeliveryDrops)" - ) - } - - if let available = upgradeStatus?.updateAvailable { - if available { - statRow(label: "Upgrade", value: "Update available: \(upgradeStatus?.version ?? "unknown")") - } else if let current = upgradeStatus?.currentVersion ?? upgradeStatus?.version { - statRow(label: "Upgrade", value: "Up to date (\(current))") + private var addressList: some View { + VStack(alignment: .leading, spacing: 8) { + let addresses = networkStatus?.externalAddrs ?? [] + if addresses.isEmpty { + Text("No external addresses detected") + .font(.caption) + .foregroundStyle(DeepSpace.textMuted) + .padding(.vertical, 4) + } else { + ForEach(addresses.prefix(12), id: \.self) { address in + HStack(alignment: .top, spacing: 10) { + Text(addressFamily(address)) + .font(.caption2) + .fontWeight(.semibold) + .foregroundStyle(DeepSpace.textMuted) + .frame(width: 42, alignment: .leading) + Text(address) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(DeepSpace.textPrimary) + .textSelection(.enabled) + .lineLimit(2) + Spacer(minLength: 0) } } - - if bootstrapCache == nil - && connectivityDiagnostics == nil - && webSocketSessions == nil - && upgradeStatus == nil - && gossipStats == nil { - Text("No extended diagnostics available") - .font(.caption) - .foregroundStyle(DeepSpace.textMuted) - .padding(.vertical, 4) - } } - .padding(4) - } label: { - Label("Daemon Diagnostics", systemImage: "stethoscope") - .foregroundStyle(DeepSpace.textPrimary) } - .backgroundStyle(DeepSpace.surface1) } - // MARK: - Discovered Machines - - private var discoveredMachinesSection: some View { - GroupBox { + private var discoveredMachineList: some View { + VStack(alignment: .leading, spacing: 8) { if discoveredMachines.isEmpty { - emptyState(icon: "network.slash", message: "No machine announcements discovered") + Text("No machine announcements discovered") + .font(.caption) + .foregroundStyle(DeepSpace.textMuted) + .padding(.vertical, 4) } else { - VStack(spacing: 0) { - if let agentMachine { - statRow( + if let agentMachine { + infoRows([ + NetworkInfoRow( + id: "agent-machine", label: "Current Agent Machine", value: truncatedId(agentMachine.machine.machineId) ) - } - - if let userMachines { - statRow(label: "Machines for User", value: "\(userMachines.machines.count)") - } - - if let connectMachineResult { - statRow(label: "Last Connect", value: connectMachineResult.outcome) - } - - Divider() - .background(DeepSpace.border) - .padding(.vertical, 6) - - ForEach(discoveredMachines) { machine in - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 3) { - Text(truncatedId(machine.machineId)) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(DeepSpace.textPrimary) - Text("\(machine.addresses.count) addresses") - .font(.caption2) - .foregroundStyle(DeepSpace.textMuted) - } - - Spacer() - - Button { - Task { await connect(machine: machine) } - } label: { - Image(systemName: "link") - .font(.caption) - } - .buttonStyle(.borderless) - .help("Connect") + ]) + } + if let userMachines { + infoRows([ + NetworkInfoRow( + id: "user-machines", + label: "Machines for User", + value: "\(userMachines.machines.count)" + ) + ]) + } + if let connectMachineResult { + infoRows([ + NetworkInfoRow(id: "last-connect", label: "Last Connect", value: connectMachineResult.outcome) + ]) + } + ForEach(discoveredMachines.prefix(12)) { machine in + HStack(alignment: .center, spacing: 10) { + VStack(alignment: .leading, spacing: 3) { + Text(truncatedId(machine.machineId)) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(DeepSpace.textPrimary) + .textSelection(.enabled) + Text("\(machine.addresses.count) addresses") + .font(.caption2) + .foregroundStyle(DeepSpace.textMuted) + } + Spacer(minLength: 0) + AppKitInlineButton( + title: "Connect", + systemSymbolName: "link", + accessibilityIdentifier: "network-connect-\(machine.machineId)" + ) { + Task { await connect(machine: machine) } } - .padding(.vertical, 5) + .frame(width: 92, height: 26) } + .padding(.vertical, 4) } - .padding(4) } - } label: { - Label("Discovered Machines", systemImage: "network") - .foregroundStyle(DeepSpace.textPrimary) } - .backgroundStyle(DeepSpace.surface1) } - // MARK: - Peers Table - - private var peersTable: some View { - GroupBox { + private var peerList: some View { + VStack(alignment: .leading, spacing: 8) { if peers.isEmpty { - emptyState(icon: "person.2.slash", message: "No connected peers") - } else { - VStack(spacing: 0) { - // Header - HStack { - Text("Peer ID") - .frame(maxWidth: .infinity, alignment: .leading) - Text("Health") - .frame(width: 180, alignment: .leading) - Text("Probe") - .frame(width: 96, alignment: .leading) - } - .font(.caption2) - .fontWeight(.semibold) + Text("No connected peers") + .font(.caption) .foregroundStyle(DeepSpace.textMuted) - .padding(.horizontal, 8) - .padding(.vertical, 6) - - Divider() - .background(DeepSpace.border) - - // Rows - ForEach(peers) { peer in - HStack(spacing: 8) { - Circle() - .fill(DeepSpace.green) - .frame(width: 6, height: 6) + .padding(.vertical, 4) + } else { + ForEach(peers.prefix(25)) { peer in + HStack(alignment: .top, spacing: 10) { + Circle() + .fill(DeepSpace.green) + .frame(width: 7, height: 7) + .padding(.top, 5) + VStack(alignment: .leading, spacing: 3) { Text(truncatedId(peer.peerId)) .font(.system(.caption, design: .monospaced)) .foregroundStyle(DeepSpace.textPrimary) - .frame(maxWidth: .infinity, alignment: .leading) - .lineLimit(1) - let diagnostics = peerDiagnostics[peer.peerId] - Text(diagnostics?.health ?? "Not checked") - .font(.caption) - .foregroundStyle(DeepSpace.textSecondary) - .frame(width: 180, alignment: .leading) - .lineLimit(1) - .help(diagnostics?.health ?? "Not checked") - Text(diagnostics?.probe ?? "-") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(DeepSpace.cyan) - .frame(width: 96, alignment: .leading) - .lineLimit(1) - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(peer.peerId, forType: .string) - } label: { - Image(systemName: "doc.on.doc") + .textSelection(.enabled) + if let diagnostic = peerDiagnostics[peer.peerId] { + Text("Health: \(diagnostic.health)") + .font(.caption2) + .foregroundStyle(DeepSpace.textSecondary) + .lineLimit(2) + Text("Probe: \(diagnostic.probe)") + .font(.caption2) + .foregroundStyle(DeepSpace.cyan) + .lineLimit(1) + } else { + Text("Diagnostics not checked yet") .font(.caption2) .foregroundStyle(DeepSpace.textMuted) } - .buttonStyle(.plain) - } - .padding(.horizontal, 8) - .padding(.vertical, 5) - .contextMenu { - Button("Copy Peer ID") { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(peer.peerId, forType: .string) - } } + Spacer(minLength: 0) } + .padding(.vertical, 4) } } - } label: { - Label("Connected Peers (\(peers.count))", systemImage: "person.2") - .foregroundStyle(DeepSpace.textPrimary) } - .backgroundStyle(DeepSpace.surface1) } - // MARK: - Helpers + private var metricCards: [NetworkMetric] { + [ + NetworkMetric( + id: "status", + title: "Status", + value: connectionStatusLabel, + systemImage: "wifi", + color: (networkStatus?.connectedPeers ?? 0) > 0 ? DeepSpace.green : DeepSpace.red + ), + NetworkMetric( + id: "peers", + title: "Peers", + value: "\(networkStatus?.connectedPeers ?? 0)", + systemImage: "person.2", + color: DeepSpace.cyan + ), + NetworkMetric( + id: "addresses", + title: "Addresses", + value: "\(networkStatus?.externalAddrs?.count ?? 0)", + systemImage: "globe", + color: DeepSpace.amber + ), + NetworkMetric( + id: "direct", + title: "Direct", + value: "\(directConnections.count)", + systemImage: "bolt.horizontal.circle", + color: DeepSpace.green + ) + ] + } - private func emptyState(icon: String, message: String) -> some View { - HStack { - Spacer() - VStack(spacing: 8) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(DeepSpace.textMuted) - Text(message) - .font(.caption) - .foregroundStyle(DeepSpace.textMuted) - } - .padding(.vertical, 16) - Spacer() + private var nodeHealthRows: [NetworkInfoRow] { + var rows = [ + NetworkInfoRow(id: "health", label: "Health", value: healthLabel) + ] + if let agentId = appState.agentIdentity?.agentId { + rows.append(NetworkInfoRow(id: "agent", label: "Agent ID", value: agentId)) + } + if let version = healthStatus?.version ?? daemonStatus?.version { + rows.append(NetworkInfoRow(id: "version", label: "Version", value: version)) } + if let uptime = healthStatus?.uptimeSecs ?? daemonStatus?.uptimeSecs { + rows.append(NetworkInfoRow(id: "uptime", label: "Uptime", value: formatUptime(uptime))) + } + return rows } - private func copyableRow(label: String, value: String) -> some View { - HStack { - Text(label) - .font(.caption) - .foregroundStyle(DeepSpace.textSecondary) - .frame(width: 100, alignment: .leading) - Text(truncatedId(value)) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(DeepSpace.textPrimary) - .textSelection(.enabled) - .lineLimit(1) - Spacer() - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(value, forType: .string) - } label: { - Image(systemName: "doc.on.doc") - .font(.caption2) - .foregroundStyle(DeepSpace.textMuted) - } - .buttonStyle(.plain) + private var networkRows: [NetworkInfoRow] { + var rows: [NetworkInfoRow] = [] + if let rtt = networkStatus?.avgRttMs { + rows.append(NetworkInfoRow(id: "rtt", label: "Avg RTT", value: String(format: "%.0f ms", rtt))) + } + if let direct = networkStatus?.directConnections { + rows.append(NetworkInfoRow(id: "direct", label: "Direct Connections", value: "\(direct)")) + } + if let canReceive = networkStatus?.canReceiveDirect { + rows.append(NetworkInfoRow(id: "can-receive", label: "Can Receive Direct", value: canReceive ? "Yes" : "No")) + } + if let holePunch = networkStatus?.holePunchSuccessRate { + rows.append(NetworkInfoRow(id: "hole-punch", label: "Hole Punch Rate", value: String(format: "%.0f%%", holePunch * 100))) + } + return rows + } + + private var diagnosticRows: [NetworkInfoRow] { + var rows: [NetworkInfoRow] = [] + if let bootstrapCount = bootstrapCache?.connectionCount { + rows.append(NetworkInfoRow(id: "bootstrap", label: "Bootstrap Cache", value: "\(bootstrapCount) connected")) + } + if let diagnostics = connectivityDiagnostics { + rows.append(NetworkInfoRow(id: "conn-peer", label: "Connectivity Peer", value: truncatedId(diagnostics.peerId))) + rows.append(NetworkInfoRow(id: "mdns", label: "mDNS Peers", value: "\(diagnostics.mdns.discoveredPeers)")) + rows.append(NetworkInfoRow(id: "relay", label: "Relay Enabled", value: diagnostics.services.relayEnabled ? "Yes" : "No")) + rows.append(NetworkInfoRow(id: "coordinator", label: "Coordinator Enabled", value: diagnostics.services.coordinatorEnabled ? "Yes" : "No")) + } + if let sessions = webSocketSessions?.sessions.count { + rows.append(NetworkInfoRow(id: "ws", label: "WebSocket Sessions", value: "\(sessions)")) + } + if let shared = webSocketSessions?.sharedSubscriptions, !shared.isEmpty { + rows.append(NetworkInfoRow(id: "shared-subs", label: "Shared Topic Subs", value: "\(shared.count)")) + } + if let stats = gossipStats { + rows.append(NetworkInfoRow(id: "gossip-pub", label: "Gossip Published", value: "\(stats.publishTotal)")) + rows.append(NetworkInfoRow(id: "gossip-in", label: "Gossip Incoming", value: "\(stats.incomingTotal)")) + rows.append(NetworkInfoRow(id: "gossip-delivered", label: "Gossip Delivered", value: "\(stats.deliveredToSubscriber)")) + rows.append(NetworkInfoRow(id: "gossip-drops", label: "Decode to Delivery Drops", value: "\(stats.decodeToDeliveryDrops)")) + } + if let available = upgradeStatus?.updateAvailable { + let value = available + ? "Update available: \(upgradeStatus?.version ?? "unknown")" + : "Up to date (\(upgradeStatus?.currentVersion ?? upgradeStatus?.version ?? "unknown"))" + rows.append(NetworkInfoRow(id: "upgrade", label: "Upgrade", value: value)) + } + return rows + } + + private var connectionStatusLabel: String { + (networkStatus?.connectedPeers ?? 0) > 0 ? "Connected" : "Disconnected" + } + + private var healthLabel: String { + switch appState.daemonState { + case .running: + if (networkStatus?.connectedPeers ?? 0) > 0 { return "Online - connected to network" } + return "Running - no peers yet" + case .starting: return "Starting..." + case .notRunning: return "Not running" + case .notInstalled: return "Not installed" + case .error: return "Error" } } @@ -542,17 +455,23 @@ struct NetworkView: View { return id } - private func isIPv4(_ addr: String) -> Bool { - // Quick heuristic: IPv6 addresses contain colons, IPv4 do not (before the port) - let hostPart = addr.split(separator: ":").first.map(String.init) ?? addr - return !hostPart.contains(":") + private func addressFamily(_ address: String) -> String { + let host: String + if address.hasPrefix("[") { + host = String(address.dropFirst().prefix { $0 != "]" }) + } else { + host = String(address.split(separator: ":").first ?? "") + } + if host.contains(".") { return "IPv4" } + if address.contains(":") { return "IPv6" } + return "Addr" } private func formatUptime(_ seconds: UInt64) -> String { if seconds < 60 { return "\(seconds)s" } - if seconds < 3600 { return "\(seconds / 60)m \(seconds % 60)s" } - if seconds < 86400 { return "\(seconds / 3600)h \((seconds % 3600) / 60)m" } - return "\(seconds / 86400)d \((seconds % 86400) / 3600)h" + if seconds < 3_600 { return "\(seconds / 60)m \(seconds % 60)s" } + if seconds < 86_400 { return "\(seconds / 3_600)h \((seconds % 3_600) / 60)m" } + return "\(seconds / 86_400)d \((seconds % 86_400) / 3_600)h" } private func formatPeerHealth(_ health: PeerHealth) -> String { @@ -578,140 +497,88 @@ struct NetworkView: View { return (result.ok ?? false) ? "OK" : "No RTT" } + @MainActor private func pollData() async { + guard !isRefreshing else { return } isRefreshing = true defer { isRefreshing = false } guard appState.daemonState == .running else { return } - do { - networkStatus = try await appState.client.networkStatus() - } catch { - networkStatus = nil - } - - do { - connectivityDiagnostics = try await appState.client.connectivityDiagnostics() - } catch { - connectivityDiagnostics = nil - } + networkStatus = try? await appState.client.networkStatus() + connectivityDiagnostics = try? await appState.client.connectivityDiagnostics() - do { - let machines = try await appState.client.discoveredMachines() + if let machines = try? await appState.client.discoveredMachines() { discoveredMachines = machines - if let first = machines.first { - _ = try? await appState.client.discoveredMachine(machineId: first.machineId) - } - } catch { + } else { discoveredMachines = [] } - do { - if let agentId = appState.agentIdentity?.agentId { - agentMachine = try await appState.client.machineForAgent(agentId: agentId) - } else { - agentMachine = nil - } - } catch { + if let agentId = appState.agentIdentity?.agentId { + agentMachine = try? await appState.client.machineForAgent(agentId: agentId) + } else { agentMachine = nil } - do { - if let userId = appState.agentIdentity?.userId { - userMachines = try await appState.client.machinesByUser(userId: userId) - } else { - userMachines = nil - } - } catch { + if let userId = appState.agentIdentity?.userId { + userMachines = try? await appState.client.machinesByUser(userId: userId) + } else { userMachines = nil } - do { - let currentPeers = try await appState.client.peers() + if let currentPeers = try? await appState.client.peers() { peers = currentPeers - var diagnostics: [String: PeerDiagnostic] = [:] - for peer in currentPeers { - let health: String - do { - let snapshot = try await appState.client.peerHealth(peerId: peer.peerId) - health = formatPeerHealth(snapshot) - } catch { - health = "Unavailable: \(error.localizedDescription)" - } - - let probe: String - do { - let result = try await appState.client.probePeer(peerId: peer.peerId) - probe = formatProbeResult(result) - } catch { - probe = "Probe failed" - } - - diagnostics[peer.peerId] = PeerDiagnostic(health: health, probe: probe) - } - peerDiagnostics = diagnostics - } catch { + peerDiagnostics = await diagnostics(for: currentPeers) + } else { peers = [] peerDiagnostics = [:] } - do { - healthStatus = try await appState.client.health() - } catch { - healthStatus = nil - } - - do { - daemonStatus = try await appState.client.status() - } catch { - daemonStatus = nil - } - - do { - directConnections = try await appState.client.directConnections() - } catch { - directConnections = [] + healthStatus = try? await appState.client.health() + daemonStatus = try? await appState.client.status() + directConnections = (try? await appState.client.directConnections()) ?? [] + bootstrapCache = try? await appState.client.bootstrapCache() + webSocketSessions = try? await appState.client.wsSessions() + gossipStats = try? await appState.client.gossipStats() + if shouldCheckUpgradeStatus { + lastUpgradeCheckAt = Date() + upgradeStatus = try? await appState.client.checkUpgrade() } + } - do { - bootstrapCache = try await appState.client.bootstrapCache() - } catch { - bootstrapCache = nil - } + private var shouldCheckUpgradeStatus: Bool { + guard let lastUpgradeCheckAt else { return true } + return Date().timeIntervalSince(lastUpgradeCheckAt) >= upgradeCheckCooldown + } - do { - webSocketSessions = try await appState.client.wsSessions() - } catch { - webSocketSessions = nil - } + private func diagnostics(for currentPeers: [PeerInfo]) async -> [String: PeerDiagnostic] { + var diagnostics: [String: PeerDiagnostic] = [:] + for peer in currentPeers.prefix(12) { + let health: String + if let snapshot = try? await appState.client.peerHealth(peerId: peer.peerId) { + health = formatPeerHealth(snapshot) + } else { + health = "Unavailable" + } - do { - gossipStats = try await appState.client.gossipStats() - } catch { - gossipStats = nil - } + let probe: String + if let result = try? await appState.client.probePeer(peerId: peer.peerId) { + probe = formatProbeResult(result) + } else { + probe = "Probe failed" + } - do { - upgradeStatus = try await appState.client.checkUpgrade() - } catch { - upgradeStatus = nil + diagnostics[peer.peerId] = PeerDiagnostic(health: health, probe: probe) } + return diagnostics } + @MainActor private func connect(machine: DiscoveredMachine) async { - do { - connectMachineResult = try await appState.client.connectMachine(machineId: machine.machineId) - } catch { + if let result = try? await appState.client.connectMachine(machineId: machine.machineId) { + connectMachineResult = result + } else { connectMachineResult = ConnectMachineResponse(ok: false, outcome: "Failed", addr: nil) } } - - private func startPolling() { - Task { - while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 5_000_000_000) // 5s - await pollData() - } - } - } } diff --git a/communitas-apple/Sources/Communitas/Views/OnboardingView.swift b/communitas-apple/Sources/Communitas/Views/OnboardingView.swift index 41a595d14..cb90ab29c 100644 --- a/communitas-apple/Sources/Communitas/Views/OnboardingView.swift +++ b/communitas-apple/Sources/Communitas/Views/OnboardingView.swift @@ -26,7 +26,8 @@ final class OnboardingViewModel: ObservableObject { func checkDaemon() async { state = .checking - let daemonState = await daemon.state() + let probeClient = X0xClient.fromDiscovery() ?? X0xClient() + let daemonState = await daemon.state(using: probeClient) switch daemonState { case .running: state = .ready diff --git a/communitas-apple/Sources/Communitas/Views/ParityViews.swift b/communitas-apple/Sources/Communitas/Views/ParityViews.swift index f0578cf44..515659a5b 100644 --- a/communitas-apple/Sources/Communitas/Views/ParityViews.swift +++ b/communitas-apple/Sources/Communitas/Views/ParityViews.swift @@ -459,6 +459,7 @@ enum FourWordResolver { "/usr/local/bin/x0x", "/opt/homebrew/bin/x0x", "/opt/zerobrew/bin/x0x", + "\(NSHomeDirectory())/.local/bin/x0x", "\(NSHomeDirectory())/.cargo/bin/x0x", "\(NSHomeDirectory())/.x0x/bin/x0x", ] diff --git a/communitas-apple/Sources/Communitas/Views/SettingsView.swift b/communitas-apple/Sources/Communitas/Views/SettingsView.swift index 3c7d29719..ac152a8d1 100644 --- a/communitas-apple/Sources/Communitas/Views/SettingsView.swift +++ b/communitas-apple/Sources/Communitas/Views/SettingsView.swift @@ -210,7 +210,11 @@ struct SettingsView: View { private func loadAgentCardLink() async { do { - let resp = try await appState.client.agentCard(displayName: nil, includeGroups: true) + let resp = try await appState.client.agentCard( + displayName: nil, + includeGroups: true, + includeLocalAddresses: true + ) agentCardLink = resp.link } catch { agentCardLink = nil @@ -318,7 +322,8 @@ struct SettingsView: View { let name = displayName.trimmingCharacters(in: .whitespaces) let resp = try await appState.client.agentCard( displayName: name.isEmpty ? nil : name, - includeGroups: includeGroups + includeGroups: includeGroups, + includeLocalAddresses: true ) await MainActor.run { agentCardLink = resp.link diff --git a/communitas-apple/Sources/Communitas/Views/SpaceView.swift b/communitas-apple/Sources/Communitas/Views/SpaceView.swift index f583969e8..85cfd5ced 100644 --- a/communitas-apple/Sources/Communitas/Views/SpaceView.swift +++ b/communitas-apple/Sources/Communitas/Views/SpaceView.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import X0xClient /// Container view for a Space (group) with tabbed sub-views. @@ -21,33 +22,9 @@ struct SpaceView: View { private var tabBar: some View { HStack(spacing: 0) { - ForEach(SpaceTab.allCases) { tab in - Button { - appState.selectedSpaceTab = tab - } label: { - HStack(spacing: 4) { - Image(systemName: tab.systemImage) - .font(.caption) - Text(tab.rawValue) - .font(.caption) - .fontWeight(.medium) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background( - appState.selectedSpaceTab == tab - ? Color.accentColor.opacity(0.15) - : Color.clear, - in: RoundedRectangle(cornerRadius: 6) - ) - .foregroundStyle( - appState.selectedSpaceTab == tab - ? Color.accentColor - : .secondary - ) - } - .buttonStyle(.plain) - } + SpaceTabButtonBar(selection: $appState.selectedSpaceTab) + .frame(width: 440, height: 28) + Spacer() if let group = appState.selectedGroup { Text(group.name) @@ -96,3 +73,60 @@ struct SpaceView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } + +private struct SpaceTabButtonBar: NSViewRepresentable { + @Binding var selection: SpaceTab + + func makeCoordinator() -> Coordinator { + Coordinator(selection: $selection) + } + + func makeNSView(context: Context) -> NSStackView { + let stack = NSStackView() + stack.orientation = .horizontal + stack.alignment = .centerY + stack.distribution = .fillEqually + stack.spacing = 4 + + for (index, tab) in SpaceTab.allCases.enumerated() { + let button = NSButton( + title: tab.rawValue, + target: context.coordinator, + action: #selector(Coordinator.selectionChanged(_:)) + ) + button.tag = index + button.setButtonType(.momentaryPushIn) + button.bezelStyle = .rounded + button.controlSize = .small + button.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + button.setAccessibilityIdentifier("SpaceTab-\(tab.rawValue)") + button.setAccessibilityLabel(tab.rawValue) + stack.addArrangedSubview(button) + } + + return stack + } + + func updateNSView(_ stack: NSStackView, context: Context) { + context.coordinator.selection = $selection + for (index, view) in stack.arrangedSubviews.enumerated() { + guard let button = view as? NSButton else { continue } + let isSelected = SpaceTab.allCases[index] == selection + button.state = isSelected ? .on : .off + button.contentTintColor = isSelected ? .controlAccentColor : .labelColor + } + } + + final class Coordinator: NSObject { + var selection: Binding + + init(selection: Binding) { + self.selection = selection + } + + @objc func selectionChanged(_ sender: NSButton) { + guard SpaceTab.allCases.indices.contains(sender.tag) else { return } + selection.wrappedValue = SpaceTab.allCases[sender.tag] + } + } +} diff --git a/communitas-apple/Sources/Communitas/Views/SwarmView.swift b/communitas-apple/Sources/Communitas/Views/SwarmView.swift index e1d64d5d9..ccccdb847 100644 --- a/communitas-apple/Sources/Communitas/Views/SwarmView.swift +++ b/communitas-apple/Sources/Communitas/Views/SwarmView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import X0xClient @@ -38,9 +39,8 @@ struct SwarmView: View { @EnvironmentObject var appState: AppState @State private var events: [SwarmEvent] = [] - @State private var taskDescription = "" - @State private var taskCapabilities = "" @State private var isPosting = false + @State private var hasStartedSwarm = false @State private var webSocket: X0xWebSocket? @State private var listeningTask: Task? @@ -75,11 +75,11 @@ struct SwarmView: View { eventFeed } } - .task { - await subscribeToTopics() - startListening() + .onAppear { + scheduleSwarmStartup() } .onDisappear { + hasStartedSwarm = false listeningTask?.cancel() webSocket?.disconnect() } @@ -106,32 +106,10 @@ struct SwarmView: View { private var taskSubmission: some View { VStack(alignment: .leading, spacing: 8) { - Label("Post Task", systemImage: "plus.circle") - .font(.subheadline) - .fontWeight(.semibold) - - TextEditor(text: $taskDescription) - .font(.body) - .frame(height: 80) - .scrollContentBackground(.hidden) - .padding(6) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) - - TextField("Required capabilities (optional)", text: $taskCapabilities) - .textFieldStyle(.plain) - .padding(8) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) - - Button { - Task { await postTask() } - } label: { - HStack { - Image(systemName: "paperplane.fill") - Text("Post Task") - } + SwarmTaskSubmissionPanel(isPosting: isPosting) { description, capabilities in + postTask(description: description, capabilities: capabilities) } - .buttonStyle(.borderedProminent) - .disabled(taskDescription.trimmingCharacters(in: .whitespaces).isEmpty || isPosting) + .frame(height: 164) } } @@ -303,35 +281,63 @@ struct SwarmView: View { // MARK: - Actions - private func postTask() async { - let desc = taskDescription.trimmingCharacters(in: .whitespaces) - guard !desc.isEmpty else { return } + private func scheduleSwarmStartup() { + guard !hasStartedSwarm else { return } + hasStartedSwarm = true + DispatchQueue.main.async { + Task { @MainActor in + await subscribeToTopics() + startListening() + } + } + } + + private func postTask(description rawDescription: String, capabilities rawCapabilities: String) -> Bool { + let desc = rawDescription.trimmingCharacters(in: .whitespacesAndNewlines) + guard !desc.isEmpty, !isPosting else { return false } isPosting = true - defer { isPosting = false } let event = SwarmEvent( id: UUID().uuidString, type: .posted, taskId: UUID().uuidString, description: desc, - capabilities: taskCapabilities.trimmingCharacters(in: .whitespaces).isEmpty - ? nil : taskCapabilities.trimmingCharacters(in: .whitespaces), + capabilities: rawCapabilities.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? nil : rawCapabilities.trimmingCharacters(in: .whitespacesAndNewlines), agentId: appState.agentIdentity?.agentId, agentName: appState.displayName, timestamp: Int64(Date().timeIntervalSince1970 * 1000), result: nil ) + let payload: String do { let data = try JSONEncoder().encode(event) - let payload = data.base64EncodedString() - try await appState.client.publish(topic: tasksTopic, payload: payload) - events.append(event) - taskDescription = "" - taskCapabilities = "" + payload = data.base64EncodedString() } catch { + isPosting = false appState.errorMessage = "Failed to post task: \(error.localizedDescription)" + return false } + + let client = appState.client + let topic = tasksTopic + events.append(event) + + Task { + do { + try await client.publish(topic: topic, payload: payload) + } catch { + await MainActor.run { + appState.errorMessage = "Failed to post task: \(error.localizedDescription)" + } + } + await MainActor.run { + isPosting = false + } + } + + return true } private func subscribeToTopics() async { @@ -344,19 +350,29 @@ struct SwarmView: View { } private func startListening() { - let ws = X0xWebSocket(baseURL: appState.client.webSocketBaseURL, path: "/ws", token: appState.client.token) - self.webSocket = ws - ws.connect() - - listeningTask = Task { [weak appState] in - _ = appState + listeningTask?.cancel() + webSocket?.disconnect() + listeningTask = Task { @MainActor in + var retryDelay: UInt64 = 1_000_000_000 while !Task.isCancelled { + await subscribeToTopics() + let ws = X0xWebSocket(baseURL: appState.client.webSocketBaseURL, path: "/ws", token: appState.client.token) + self.webSocket = ws + ws.connect() do { - let text = try await ws.receive() - await handleWebSocketMessage(text) + retryDelay = 1_000_000_000 + while !Task.isCancelled { + let text = try await ws.receive() + await handleWebSocketMessage(text) + } } catch { + if webSocket === ws { + webSocket = nil + } + ws.disconnect() if !Task.isCancelled { - try? await Task.sleep(nanoseconds: 1_000_000_000) + try? await Task.sleep(nanoseconds: retryDelay) + retryDelay = min(retryDelay * 2, 30_000_000_000) } } } @@ -390,6 +406,101 @@ struct SwarmView: View { } } +private struct SwarmTaskSubmissionPanel: NSViewRepresentable { + let isPosting: Bool + let onSubmit: (String, String) -> Bool + + func makeCoordinator() -> Coordinator { + Coordinator(onSubmit: onSubmit) + } + + func makeNSView(context: Context) -> NSStackView { + let stack = NSStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + stack.translatesAutoresizingMaskIntoConstraints = false + + let label = NSTextField(labelWithString: "Post Task") + label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .semibold) + label.setAccessibilityIdentifier("swarm-post-task-label") + + let textView = NSTextView() + textView.isRichText = false + textView.allowsUndo = true + textView.font = .systemFont(ofSize: NSFont.systemFontSize) + textView.drawsBackground = true + textView.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.45) + textView.textContainerInset = NSSize(width: 6, height: 6) + textView.setAccessibilityIdentifier("swarm-task-description") + + let textScroll = NSScrollView() + textScroll.hasVerticalScroller = true + textScroll.borderType = .bezelBorder + textScroll.documentView = textView + textScroll.translatesAutoresizingMaskIntoConstraints = false + textScroll.setAccessibilityIdentifier("swarm-task-description-scroll") + NSLayoutConstraint.activate([ + textScroll.widthAnchor.constraint(equalToConstant: 308), + textScroll.heightAnchor.constraint(equalToConstant: 80) + ]) + + let capabilitiesField = NSTextField() + capabilitiesField.placeholderString = "Required capabilities (optional)" + capabilitiesField.bezelStyle = .roundedBezel + capabilitiesField.translatesAutoresizingMaskIntoConstraints = false + capabilitiesField.setAccessibilityIdentifier("swarm-task-capabilities") + capabilitiesField.widthAnchor.constraint(equalToConstant: 304).isActive = true + + let button = NSButton( + title: "Post Task", + target: context.coordinator, + action: #selector(Coordinator.submit(_:)) + ) + button.bezelStyle = .rounded + button.controlSize = .regular + button.image = NSImage(systemSymbolName: "paperplane.fill", accessibilityDescription: "Post Task") + button.imagePosition = .imageLeading + button.setAccessibilityIdentifier("swarm-post-task-button") + + stack.addArrangedSubview(label) + stack.addArrangedSubview(textScroll) + stack.addArrangedSubview(capabilitiesField) + stack.addArrangedSubview(button) + + context.coordinator.textView = textView + context.coordinator.capabilitiesField = capabilitiesField + context.coordinator.button = button + + return stack + } + + func updateNSView(_ stack: NSStackView, context: Context) { + context.coordinator.onSubmit = onSubmit + context.coordinator.button?.isEnabled = !isPosting + } + + final class Coordinator: NSObject { + weak var textView: NSTextView? + weak var capabilitiesField: NSTextField? + weak var button: NSButton? + var onSubmit: (String, String) -> Bool + + init(onSubmit: @escaping (String, String) -> Bool) { + self.onSubmit = onSubmit + } + + @objc func submit(_ sender: NSButton) { + let description = textView?.string ?? "" + let capabilities = capabilitiesField?.stringValue ?? "" + if onSubmit(description, capabilities) { + textView?.string = "" + capabilitiesField?.stringValue = "" + } + } + } +} + // MARK: - Flow Layout /// Simple flow layout for pill badges. diff --git a/communitas-apple/Sources/Communitas/Views/WebPublishView.swift b/communitas-apple/Sources/Communitas/Views/WebPublishView.swift index 253f5492c..ced5bda29 100644 --- a/communitas-apple/Sources/Communitas/Views/WebPublishView.swift +++ b/communitas-apple/Sources/Communitas/Views/WebPublishView.swift @@ -11,9 +11,8 @@ struct WebPublishView: View { @State private var pageContent = "" @State private var isEditing = false @State private var editBuffer = "" - @State private var isLoading = true - @State private var newPath = "" - @State private var showCreatePage = false + @State private var isLoading = false + @State private var hasLoadedIndex = false private var prefix: String { appState.groupPrefix(for: groupId) @@ -35,15 +34,15 @@ struct WebPublishView: View { VStack(spacing: 0) { webHeader Divider() - HSplitView { + HStack(spacing: 0) { pathList - .frame(minWidth: 180, maxWidth: 240) + .frame(width: 220) + Divider() pageDetail } } - .task { - await loadIndex() - isLoading = false + .onAppear { + scheduleInitialLoad() } } @@ -54,17 +53,6 @@ struct WebPublishView: View { Text("Web") .font(.headline) Spacer() - Button { - showCreatePage = true - } label: { - Label("New Page", systemImage: "plus") - .font(.caption) - } - .buttonStyle(.bordered) - .controlSize(.small) - .popover(isPresented: $showCreatePage) { - createPagePopover - } } .padding(.horizontal, 16) .padding(.vertical, 10) @@ -74,23 +62,57 @@ struct WebPublishView: View { // MARK: - Path List private var pathList: some View { - List(pagePaths, id: \.self, selection: $selectedPath) { path in - HStack { - Image(systemName: "globe") - .font(.caption) - .foregroundStyle(.secondary) - Text(path) - .font(.body) + VStack(alignment: .leading, spacing: 8) { + Text("Pages") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + AppKitPageCreatePanel( + placeholder: "Path, e.g. index.html", + buttonTitle: "Create", + accessibilityPrefix: "web-create-page" + ) { rawPath in + Task { await createPage(rawPath: rawPath) } + return true } - .tag(path) - } - .listStyle(.sidebar) - .onChange(of: selectedPath) { - isEditing = false - if let path = selectedPath { - Task { await loadPage(path: path) } + .frame(height: 58) + + Divider() + + if pagePaths.isEmpty { + Text("No pages yet") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(pagePaths, id: \.self) { path in + Button { + selectPath(path) + } label: { + HStack(spacing: 6) { + Image(systemName: "globe") + .font(.caption) + .foregroundStyle(.secondary) + Text(path) + .font(.body) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + selectedPath == path ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + } + } + } } } + .padding(12) } // MARK: - Page Detail @@ -126,15 +148,15 @@ struct WebPublishView: View { .font(.title2) .fontWeight(.bold) Spacer() - Button { + AppKitInlineButton( + title: "Edit", + systemSymbolName: "pencil", + accessibilityIdentifier: "web-edit-page-button" + ) { editBuffer = pageContent isEditing = true - } label: { - Label("Edit", systemImage: "pencil") - .font(.caption) } - .buttonStyle(.bordered) - .controlSize(.small) + .frame(width: 74, height: 26) } ScrollView { @@ -157,67 +179,39 @@ struct WebPublishView: View { } private func editorView(path: String) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Editing: \(path)") - .font(.title2) - .fontWeight(.bold) - Spacer() - Button("Cancel") { - isEditing = false - } - .buttonStyle(.bordered) - .controlSize(.small) - - Button("Publish") { - Task { await publishPage(path: path) } - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - - TextEditor(text: $editBuffer) - .font(.system(.body, design: .monospaced)) - .scrollContentBackground(.hidden) - .padding(8) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + AppKitTextEditorPanel( + title: "Editing: \(path)", + content: editBuffer, + saveTitle: "Publish", + accessibilityPrefix: "web-editor", + isMonospaced: true + ) { + isEditing = false + } onSave: { content in + Task { await publishPage(path: path, content: content) } + return true } .padding(16) } - // MARK: - Create Page - - private var createPagePopover: some View { - VStack(spacing: 12) { - Text("New Web Page") - .font(.headline) - - TextField("Path (e.g. index.html)", text: $newPath) - .textFieldStyle(.plain) - .padding(8) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) - - HStack { - Button("Cancel") { - newPath = "" - showCreatePage = false - } - .buttonStyle(.bordered) - .controlSize(.small) + // MARK: - Actions - Button("Create") { - Task { await createPage() } - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(newPath.trimmingCharacters(in: .whitespaces).isEmpty) + private func scheduleInitialLoad() { + guard !hasLoadedIndex else { return } + hasLoadedIndex = true + DispatchQueue.main.async { + Task { @MainActor in + await loadIndex() + isLoading = false } } - .padding(16) - .frame(width: 280) } - // MARK: - Actions + private func selectPath(_ path: String) { + selectedPath = path + isEditing = false + Task { await loadPage(path: path) } + } private func ensureStore() async { do { @@ -249,18 +243,19 @@ struct WebPublishView: View { } } - private func publishPage(path: String) async { + private func publishPage(path: String, content: String) async { do { - try await appState.client.storePut(storeId: storeName, key: webKey(path), value: editBuffer) - pageContent = editBuffer + try await appState.client.storePut(storeId: storeName, key: webKey(path), value: content) + pageContent = content + editBuffer = content isEditing = false } catch { appState.errorMessage = "Failed to publish: \(error.localizedDescription)" } } - private func createPage() async { - let path = newPath.trimmingCharacters(in: .whitespaces) + private func createPage(rawPath: String) async { + let path = rawPath.trimmingCharacters(in: .whitespaces) guard !path.isEmpty else { return } do { @@ -277,8 +272,9 @@ struct WebPublishView: View { pagePaths = paths selectedPath = path - newPath = "" - showCreatePage = false + pageContent = "" + editBuffer = "" + isEditing = true } catch { appState.errorMessage = "Failed to create page: \(error.localizedDescription)" } diff --git a/communitas-apple/Sources/Communitas/Views/WikiView.swift b/communitas-apple/Sources/Communitas/Views/WikiView.swift index 5461dfca9..0d1a2aef4 100644 --- a/communitas-apple/Sources/Communitas/Views/WikiView.swift +++ b/communitas-apple/Sources/Communitas/Views/WikiView.swift @@ -11,9 +11,8 @@ struct WikiView: View { @State private var pageContent = "" @State private var isEditing = false @State private var editBuffer = "" - @State private var isLoading = true - @State private var newSlug = "" - @State private var showCreatePage = false + @State private var isLoading = false + @State private var hasLoadedIndex = false private var prefix: String { appState.groupPrefix(for: groupId) @@ -35,15 +34,15 @@ struct WikiView: View { VStack(spacing: 0) { wikiHeader Divider() - HSplitView { + HStack(spacing: 0) { pageList - .frame(minWidth: 180, maxWidth: 240) + .frame(width: 220) + Divider() pageDetail } } - .task { - await loadIndex() - isLoading = false + .onAppear { + scheduleInitialLoad() } } @@ -54,17 +53,6 @@ struct WikiView: View { Text("Wiki") .font(.headline) Spacer() - Button { - showCreatePage = true - } label: { - Label("New Page", systemImage: "plus") - .font(.caption) - } - .buttonStyle(.bordered) - .controlSize(.small) - .popover(isPresented: $showCreatePage) { - createPagePopover - } } .padding(.horizontal, 16) .padding(.vertical, 10) @@ -74,23 +62,57 @@ struct WikiView: View { // MARK: - Page List private var pageList: some View { - List(pageSlugs, id: \.self, selection: $selectedSlug) { slug in - HStack { - Image(systemName: "doc.text") - .font(.caption) - .foregroundStyle(.secondary) - Text(slug) - .font(.body) + VStack(alignment: .leading, spacing: 8) { + Text("Pages") + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + AppKitPageCreatePanel( + placeholder: "Page slug", + buttonTitle: "Create", + accessibilityPrefix: "wiki-create-page" + ) { rawSlug in + Task { await createPage(rawSlug: rawSlug) } + return true } - .tag(slug) - } - .listStyle(.sidebar) - .onChange(of: selectedSlug) { - isEditing = false - if let slug = selectedSlug { - Task { await loadPage(slug: slug) } + .frame(height: 58) + + Divider() + + if pageSlugs.isEmpty { + Text("No pages yet") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + ForEach(pageSlugs, id: \.self) { slug in + Button { + selectPage(slug) + } label: { + HStack(spacing: 6) { + Image(systemName: "doc.text") + .font(.caption) + .foregroundStyle(.secondary) + Text(slug) + .font(.body) + Spacer() + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + selectedSlug == slug ? Color.accentColor.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 6) + ) + } + .buttonStyle(.plain) + } + } + } } } + .padding(12) } // MARK: - Page Detail @@ -126,15 +148,15 @@ struct WikiView: View { .font(.title2) .fontWeight(.bold) Spacer() - Button { + AppKitInlineButton( + title: "Edit", + systemSymbolName: "pencil", + accessibilityIdentifier: "wiki-edit-page-button" + ) { editBuffer = pageContent isEditing = true - } label: { - Label("Edit", systemImage: "pencil") - .font(.caption) } - .buttonStyle(.bordered) - .controlSize(.small) + .frame(width: 74, height: 26) } ScrollView { @@ -149,68 +171,39 @@ struct WikiView: View { } private func editorView(slug: String) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Editing: \(slug)") - .font(.title2) - .fontWeight(.bold) - Spacer() - Button("Cancel") { - isEditing = false - } - .buttonStyle(.bordered) - .controlSize(.small) - - Button("Save") { - Task { await savePage(slug: slug) } - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - } - - TextEditor(text: $editBuffer) - .font(.body) - .scrollContentBackground(.hidden) - .padding(8) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) + AppKitTextEditorPanel( + title: "Editing: \(slug)", + content: editBuffer, + saveTitle: "Save", + accessibilityPrefix: "wiki-editor", + isMonospaced: false + ) { + isEditing = false + } onSave: { content in + Task { await savePage(slug: slug, content: content) } + return true } .padding(16) } - // MARK: - Create Page - - private var createPagePopover: some View { - VStack(spacing: 12) { - Text("Create Page") - .font(.headline) - - TextField("Page slug", text: $newSlug) - .textFieldStyle(.plain) - .padding(8) - .background(Color.secondary.opacity(0.06), in: RoundedRectangle(cornerRadius: 6)) - .help("Lowercase, hyphens for spaces") - - HStack { - Button("Cancel") { - newSlug = "" - showCreatePage = false - } - .buttonStyle(.bordered) - .controlSize(.small) + // MARK: - Actions - Button("Create") { - Task { await createPage() } - } - .buttonStyle(.borderedProminent) - .controlSize(.small) - .disabled(newSlug.trimmingCharacters(in: .whitespaces).isEmpty) + private func scheduleInitialLoad() { + guard !hasLoadedIndex else { return } + hasLoadedIndex = true + DispatchQueue.main.async { + Task { @MainActor in + await loadIndex() + isLoading = false } } - .padding(16) - .frame(width: 280) } - // MARK: - Actions + private func selectPage(_ slug: String) { + selectedSlug = slug + isEditing = false + Task { await loadPage(slug: slug) } + } private func ensureStore() async { do { @@ -242,18 +235,19 @@ struct WikiView: View { } } - private func savePage(slug: String) async { + private func savePage(slug: String, content: String) async { do { - try await appState.client.storePut(storeId: storeName, key: pageKey(slug), value: editBuffer) - pageContent = editBuffer + try await appState.client.storePut(storeId: storeName, key: pageKey(slug), value: content) + pageContent = content + editBuffer = content isEditing = false } catch { appState.errorMessage = "Failed to save: \(error.localizedDescription)" } } - private func createPage() async { - let slug = newSlug + private func createPage(rawSlug: String) async { + let slug = rawSlug .trimmingCharacters(in: .whitespaces) .lowercased() .replacingOccurrences(of: " ", with: "-") @@ -275,8 +269,9 @@ struct WikiView: View { pageSlugs = slugs selectedSlug = slug - newSlug = "" - showCreatePage = false + pageContent = "" + editBuffer = "" + isEditing = true } catch { appState.errorMessage = "Failed to create page: \(error.localizedDescription)" } diff --git a/communitas-apple/Sources/X0xClient/DaemonManager.swift b/communitas-apple/Sources/X0xClient/DaemonManager.swift index 338321689..2668b710a 100644 --- a/communitas-apple/Sources/X0xClient/DaemonManager.swift +++ b/communitas-apple/Sources/X0xClient/DaemonManager.swift @@ -10,6 +10,7 @@ public final class DaemonManager: Sendable { "/usr/local/bin/x0x", "/opt/homebrew/bin/x0x", "/opt/zerobrew/bin/x0x", + "\(NSHomeDirectory())/.local/bin/x0x", "\(NSHomeDirectory())/.cargo/bin/x0x", "\(NSHomeDirectory())/.x0x/bin/x0x", ] diff --git a/communitas-apple/Sources/X0xClient/Models/Group.swift b/communitas-apple/Sources/X0xClient/Models/Group.swift index 91d21777f..bb57a6572 100644 --- a/communitas-apple/Sources/X0xClient/Models/Group.swift +++ b/communitas-apple/Sources/X0xClient/Models/Group.swift @@ -416,6 +416,23 @@ public struct SendGroupMessageRequest: Codable, Sendable { } } +/// Response from `POST /groups/:id/send`. +public struct GroupPublicSendResponse: Codable, Sendable, Hashable { + public let ok: Bool? + public let groupId: String + public let topic: String + public let fallbackTopic: String? + public let timestamp: UInt64 + + enum CodingKeys: String, CodingKey { + case ok + case groupId = "group_id" + case topic + case fallbackTopic = "fallback_topic" + case timestamp + } +} + /// Response wrapper for `GET /groups/:id/messages`. public struct GroupMessagesResponse: Codable, Sendable { public let ok: Bool? @@ -669,15 +686,22 @@ public struct UpdateMemberRoleRequest: Codable, Sendable { public struct AddNamedGroupMemberRequest: Codable, Sendable { public let agentId: String public let displayName: String? + public let treekemKeyPackageB64: String? enum CodingKeys: String, CodingKey { case agentId = "agent_id" case displayName = "display_name" + case treekemKeyPackageB64 = "treekem_key_package_b64" } - public init(agentId: String, displayName: String? = nil) { + public init( + agentId: String, + displayName: String? = nil, + treekemKeyPackageB64: String? = nil + ) { self.agentId = agentId self.displayName = displayName + self.treekemKeyPackageB64 = treekemKeyPackageB64 } } @@ -716,14 +740,16 @@ public struct SecureEncryptRequest: Codable, Sendable { public struct SecureEncryptResponse: Codable, Sendable { public let ok: Bool? public let ciphertextB64: String - public let nonceB64: String + public let nonceB64: String? public let secretEpoch: UInt64 + public let securePlane: String? enum CodingKeys: String, CodingKey { case ok case ciphertextB64 = "ciphertext_b64" case nonceB64 = "nonce_b64" case secretEpoch = "secret_epoch" + case securePlane = "secure_plane" } } @@ -750,10 +776,32 @@ public struct SecureDecryptRequest: Codable, Sendable { public struct SecureDecryptResponse: Codable, Sendable { public let ok: Bool? public let plaintextB64: String + public let secretEpoch: UInt64? + public let securePlane: String? enum CodingKeys: String, CodingKey { case ok case plaintextB64 = "plaintext_b64" + case payloadB64 = "payload_b64" + case secretEpoch = "secret_epoch" + case securePlane = "secure_plane" + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.ok = try container.decodeIfPresent(Bool.self, forKey: .ok) + self.plaintextB64 = try container.decodeIfPresent(String.self, forKey: .plaintextB64) + ?? container.decode(String.self, forKey: .payloadB64) + self.secretEpoch = try container.decodeIfPresent(UInt64.self, forKey: .secretEpoch) + self.securePlane = try container.decodeIfPresent(String.self, forKey: .securePlane) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(ok, forKey: .ok) + try container.encode(plaintextB64, forKey: .plaintextB64) + try container.encodeIfPresent(secretEpoch, forKey: .secretEpoch) + try container.encodeIfPresent(securePlane, forKey: .securePlane) } } @@ -799,4 +847,3 @@ public struct SecureShareEnvelope: Codable, Sendable, Hashable { self.aeadCiphertextB64 = aeadCiphertextB64 } } - diff --git a/communitas-apple/Sources/X0xClient/Models/Types.swift b/communitas-apple/Sources/X0xClient/Models/Types.swift index 63f94f22b..a68447d3f 100644 --- a/communitas-apple/Sources/X0xClient/Models/Types.swift +++ b/communitas-apple/Sources/X0xClient/Models/Types.swift @@ -113,6 +113,10 @@ public struct SubscribeResponse: Codable, Sendable { public struct DirectMessageRequest: Codable, Sendable { public let agentId: String public let payload: String // base64 + /// x0x 0.22+ defaults `/direct/send` to waiting for the recipient inbox + /// gossip ACK. Keep this explicit so fire-and-forget sends opt out + /// deliberately. + public let requireGossipAck: Bool? /// Opt-in: wait up to this many ms for an ant-quic `probe_peer` ACK /// after send. Omit for legacy fire-and-forget. (x0xd ≥ 0.19.6.) public let requireAckMs: UInt64? @@ -120,12 +124,19 @@ public struct DirectMessageRequest: Codable, Sendable { enum CodingKeys: String, CodingKey { case agentId = "agent_id" case payload + case requireGossipAck = "require_gossip_ack" case requireAckMs = "require_ack_ms" } - public init(agentId: String, payload: String, requireAckMs: UInt64? = nil) { + public init( + agentId: String, + payload: String, + requireGossipAck: Bool? = nil, + requireAckMs: UInt64? = nil + ) { self.agentId = agentId self.payload = payload + self.requireGossipAck = requireGossipAck self.requireAckMs = requireAckMs } } diff --git a/communitas-apple/Sources/X0xClient/X0xClient.swift b/communitas-apple/Sources/X0xClient/X0xClient.swift index d069006e5..7e70e73e5 100644 --- a/communitas-apple/Sources/X0xClient/X0xClient.swift +++ b/communitas-apple/Sources/X0xClient/X0xClient.swift @@ -181,11 +181,16 @@ public final class X0xClient: Sendable { /// - Parameters: /// - displayName: Display name to include in the card. /// - includeGroups: Whether to include group invites in the card. + /// - includeLocalAddresses: Whether to include LAN/private addresses for nearby app pairing. public func agentSign(payloadB64: String) async throws -> AgentSignResponse { try await post("/agent/sign", body: AgentSignRequest(payloadB64: payloadB64)) } - public func agentCard(displayName: String? = nil, includeGroups: Bool? = nil) async throws -> AgentCardResponse { + public func agentCard( + displayName: String? = nil, + includeGroups: Bool? = nil, + includeLocalAddresses: Bool? = nil + ) async throws -> AgentCardResponse { var queryItems: [URLQueryItem] = [] if let displayName { queryItems.append(URLQueryItem(name: "display_name", value: displayName)) @@ -193,6 +198,9 @@ public final class X0xClient: Sendable { if let includeGroups { queryItems.append(URLQueryItem(name: "include_groups", value: includeGroups ? "true" : "false")) } + if let includeLocalAddresses { + queryItems.append(URLQueryItem(name: "include_local_addresses", value: includeLocalAddresses ? "true" : "false")) + } return try await get("/agent/card", queryItems: queryItems) } @@ -246,9 +254,10 @@ public final class X0xClient: Sendable { /// Send a direct message to an agent. `POST /direct/send` /// - /// Fire-and-forget: returns when the daemon has accepted the message - /// for transmission, with no delivery confirmation. For ACK-confirmed - /// delivery use `sendDirect(agentId:payload:requireAckMs:)`. + /// User-facing Communitas DMs require the x0x 0.22 recipient inbox gossip + /// ACK by default so send failures surface instead of silently dropping + /// receive paths. Pass `requireGossipAck: false` only for deliberate + /// fire-and-forget sends. public func execRun(_ request: ExecRunRequest) async throws -> ExecRunResponse { try await post("/exec/run", body: request) } @@ -261,8 +270,16 @@ public final class X0xClient: Sendable { try await get("/exec/sessions") } - public func sendDirect(agentId: String, payload: String) async throws { - let body = DirectMessageRequest(agentId: agentId, payload: payload) + public func sendDirect( + agentId: String, + payload: String, + requireGossipAck: Bool = true + ) async throws { + let body = DirectMessageRequest( + agentId: agentId, + payload: payload, + requireGossipAck: requireGossipAck + ) let _: Empty = try await post("/direct/send", body: body) } @@ -282,6 +299,7 @@ public final class X0xClient: Sendable { let body = DirectMessageRequest( agentId: agentId, payload: payload, + requireGossipAck: true, requireAckMs: requireAckMs ) let resp: DirectSendResponse = try await post("/direct/send", body: body) @@ -461,9 +479,14 @@ public final class X0xClient: Sendable { public func addNamedGroupMember( groupId: String, agentId: String, - displayName: String? = nil + displayName: String? = nil, + treekemKeyPackageB64: String? = nil ) async throws { - let body = AddNamedGroupMemberRequest(agentId: agentId, displayName: displayName) + let body = AddNamedGroupMemberRequest( + agentId: agentId, + displayName: displayName, + treekemKeyPackageB64: treekemKeyPackageB64 + ) let _: Empty = try await post("/groups/\(groupId)/members", body: body) } @@ -573,7 +596,7 @@ public final class X0xClient: Sendable { groupId: String, body: String, kind: String? = nil - ) async throws -> GroupPublicMessage { + ) async throws -> GroupPublicSendResponse { let req = SendGroupMessageRequest(body: body, kind: kind) return try await post("/groups/\(groupId)/send", body: req) } @@ -844,27 +867,33 @@ public final class X0xClient: Sendable { /// List tasks in a task list. `GET /task-lists/:id/tasks` /// Returns wrapped: `{"ok":true,"tasks":[...]}` public func listTasks(listId: String) async throws -> [TaskItem] { - let resp: TaskList = try await get("/task-lists/\(listId)/tasks") + let encodedListId = pathComponent(listId) + let resp: TaskList = try await get("/task-lists/\(encodedListId)/tasks") return resp.tasks } /// Add a task to a task list. `POST /task-lists/:id/tasks` public func addTask(listId: String, title: String, description: String? = nil) async throws -> String { let body = AddTaskRequest(title: title, description: description) - let resp: AddTaskResponse = try await post("/task-lists/\(listId)/tasks", body: body) + let encodedListId = pathComponent(listId) + let resp: AddTaskResponse = try await post("/task-lists/\(encodedListId)/tasks", body: body) return resp.taskId } /// Claim a task. `PATCH /task-lists/:id/tasks/:tid` public func claimTask(listId: String, taskId: String) async throws { let body = ["action": "claim"] - let _: Empty = try await patch("/task-lists/\(listId)/tasks/\(taskId)", body: body) + let encodedListId = pathComponent(listId) + let encodedTaskId = pathComponent(taskId) + let _: Empty = try await patch("/task-lists/\(encodedListId)/tasks/\(encodedTaskId)", body: body) } /// Complete a task. `PATCH /task-lists/:id/tasks/:tid` public func completeTask(listId: String, taskId: String) async throws { let body = ["action": "complete"] - let _: Empty = try await patch("/task-lists/\(listId)/tasks/\(taskId)", body: body) + let encodedListId = pathComponent(listId) + let encodedTaskId = pathComponent(taskId) + let _: Empty = try await patch("/task-lists/\(encodedListId)/tasks/\(encodedTaskId)", body: body) } // MARK: - Agent Connection @@ -892,7 +921,8 @@ public final class X0xClient: Sendable { /// Join a store. `POST /stores/:id/join` public func joinStore(storeId: String) async throws { - let _: Empty = try await post("/stores/\(storeId)/join", body: Empty()) + let encodedStoreId = pathComponent(storeId) + let _: Empty = try await post("/stores/\(encodedStoreId)/join", body: Empty()) } /// List keys in a store. `GET /stores/:id/keys` @@ -903,13 +933,16 @@ public final class X0xClient: Sendable { /// List detailed key metadata in a store. `GET /stores/:id/keys` public func storeKeyEntries(storeId: String) async throws -> [StoreKeyEntry] { - let resp: StoreKeysResponse = try await get("/stores/\(storeId)/keys") + let encodedStoreId = pathComponent(storeId) + let resp: StoreKeysResponse = try await get("/stores/\(encodedStoreId)/keys") return resp.keys } /// Fetch the raw store entry from `GET /stores/:id/:key`. public func storeEntry(storeId: String, key: String) async throws -> StoreGetResponse { - try await get("/stores/\(storeId)/\(key)") + let encodedStoreId = pathComponent(storeId) + let encodedKey = pathComponent(key) + return try await get("/stores/\(encodedStoreId)/\(encodedKey)") } /// Get a value from a store. `GET /stores/:id/:key` @@ -932,7 +965,9 @@ public final class X0xClient: Sendable { /// Put a raw base64-encoded value in a store. `PUT /stores/:id/:key` public func putStoreValue(storeId: String, key: String, base64Value: String, contentType: String? = nil) async throws { let body = StorePutRequest(value: base64Value, contentType: contentType) - let _: Empty = try await put("/stores/\(storeId)/\(key)", body: body) + let encodedStoreId = pathComponent(storeId) + let encodedKey = pathComponent(key) + let _: Empty = try await put("/stores/\(encodedStoreId)/\(encodedKey)", body: body) } /// Put a UTF-8 string value in a store. `PUT /stores/:id/:key` @@ -944,7 +979,9 @@ public final class X0xClient: Sendable { /// Delete a key from a store. `DELETE /stores/:id/:key` public func storeDelete(storeId: String, key: String) async throws { - let _: Empty = try await delete("/stores/\(storeId)/\(key)") + let encodedStoreId = pathComponent(storeId) + let encodedKey = pathComponent(key) + let _: Empty = try await delete("/stores/\(encodedStoreId)/\(encodedKey)") } // MARK: - MLS Groups (Encrypted) @@ -1102,6 +1139,12 @@ public final class X0xClient: Sendable { // MARK: - Private Helpers + private func pathComponent(_ value: String) -> String { + var allowed = CharacterSet.urlPathAllowed + allowed.remove(charactersIn: "/%?#[]@!$&'()*+,;=") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + private func get(_ path: String) async throws -> T { guard let url = URL(string: path, relativeTo: baseURL) else { throw X0xError.invalidURL(path: path) diff --git a/communitas-apple/Sources/X0xClient/X0xWebSocket.swift b/communitas-apple/Sources/X0xClient/X0xWebSocket.swift index 1d016b842..715806acd 100644 --- a/communitas-apple/Sources/X0xClient/X0xWebSocket.swift +++ b/communitas-apple/Sources/X0xClient/X0xWebSocket.swift @@ -22,7 +22,7 @@ public final class X0xWebSocket: NSObject, Sendable { rawPath = path } - guard let wsURL = URL(string: rawPath, relativeTo: baseURL) else { + guard let wsURL = URL(string: rawPath, relativeTo: baseURL)?.absoluteURL else { // Fall back to the base URL so we never crash; the connection will simply fail. self.url = baseURL self.task = URLSession.shared.webSocketTask(with: baseURL) diff --git a/communitas-apple/Tests/X0xClientTests/GroupsRoundTripTests.swift b/communitas-apple/Tests/X0xClientTests/GroupsRoundTripTests.swift index 3e77778e6..7abd835e0 100644 --- a/communitas-apple/Tests/X0xClientTests/GroupsRoundTripTests.swift +++ b/communitas-apple/Tests/X0xClientTests/GroupsRoundTripTests.swift @@ -66,7 +66,7 @@ struct GroupsRoundTripTests { name: "ban-\(UUID().uuidString.prefix(8))", description: nil, displayName: nil, - preset: .privateSecure + preset: .publicOpen ) let target = Self.hex64(0x99) diff --git a/communitas-apple/Tests/X0xClientTests/PubSubRoundTripTests.swift b/communitas-apple/Tests/X0xClientTests/PubSubRoundTripTests.swift index fbda46884..e2e477b04 100644 --- a/communitas-apple/Tests/X0xClientTests/PubSubRoundTripTests.swift +++ b/communitas-apple/Tests/X0xClientTests/PubSubRoundTripTests.swift @@ -3,6 +3,10 @@ import Testing @testable import X0xClient +private enum PubSubTestTimeout: Error { + case exceeded +} + /// Live x0xd round-trip tests for the **Pub/sub** row of the parity /// matrix. Closes the WebSocket-live-feed Apple-column 🟡. @Suite("Pub/sub round-trip (live x0xd)") @@ -33,6 +37,43 @@ struct PubSubRoundTripTests { try await daemon.client.unsubscribe(subscriptionId: subId) } + @Test("WebSocket subscribe + REST publish receives payload") + func webSocketReceivesPublishedPayload() async throws { + guard DaemonFixture.liveTestsEnabled else { return } + let daemon = try await DaemonFixture.start(prefix: "pubsub-ws") + defer { daemon.terminate() } + + let topic = "ui-ws-pubsub-\(UUID().uuidString.prefix(8))" + let message = "hello-\(UUID().uuidString)" + let payload = Data(message.utf8).base64EncodedString() + + var wsComponents = URLComponents(url: daemon.baseURL, resolvingAgainstBaseURL: false) + wsComponents?.scheme = "ws" + let wsBase = try #require(wsComponents?.url) + let ws = X0xWebSocket(baseURL: wsBase, path: "/ws", token: daemon.token) + ws.connect() + defer { ws.disconnect() } + + _ = try await receiveFrame(ws, timeoutSeconds: 5) + try await ws.send(#"{"type":"subscribe","topics":["\#(topic)"]}"#) + + _ = try await waitForFrame(ws, timeoutSeconds: 5) { frame in + frame["type"] as? String == "subscribed" + && (frame["topics"] as? [String])?.contains(topic) == true + } + + try await daemon.client.publish(topic: topic, payload: payload) + + let messageFrame = try await waitForFrame(ws, timeoutSeconds: 10) { frame in + frame["type"] as? String == "message" + && frame["topic"] as? String == topic + } + let inboundPayload = try #require(messageFrame["payload"] as? String) + let inboundData = try #require(Data(base64Encoded: inboundPayload)) + let inboundMessage = String(decoding: inboundData, as: UTF8.self) + #expect(inboundMessage == message) + } + @Test("WebSocket session list decodes shape end-to-end") func webSocketSessionListEndToEnd() async throws { guard DaemonFixture.liveTestsEnabled else { return } @@ -64,4 +105,44 @@ struct PubSubRoundTripTests { #expect(wsURL.host == "127.0.0.1") #expect(wsURL.port == 12700) } + + private func waitForFrame( + _ ws: X0xWebSocket, + timeoutSeconds: UInt64, + matching predicate: ([String: Any]) -> Bool + ) async throws -> [String: Any] { + let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds)) + while Date() < deadline { + let frame = try await receiveFrame(ws, timeoutSeconds: timeoutSeconds) + if predicate(frame) { + return frame + } + } + throw PubSubTestTimeout.exceeded + } + + private func receiveFrame( + _ ws: X0xWebSocket, + timeoutSeconds: UInt64 + ) async throws -> [String: Any] { + let text = try await withThrowingTaskGroup(of: String.self) { group in + group.addTask { + try await ws.receive() + } + group.addTask { + try await Task.sleep(nanoseconds: timeoutSeconds * 1_000_000_000) + throw PubSubTestTimeout.exceeded + } + guard let value = try await group.next() else { + throw PubSubTestTimeout.exceeded + } + group.cancelAll() + return value + } + let data = Data(text.utf8) + return try #require( + JSONSerialization.jsonObject(with: data) as? [String: Any], + "Expected JSON object frame, got: \(text)" + ) + } } diff --git a/communitas-apple/Tests/X0xClientTests/X0xClientTests.swift b/communitas-apple/Tests/X0xClientTests/X0xClientTests.swift index 933993eb7..0ff085495 100644 --- a/communitas-apple/Tests/X0xClientTests/X0xClientTests.swift +++ b/communitas-apple/Tests/X0xClientTests/X0xClientTests.swift @@ -38,6 +38,20 @@ struct X0xClientTests { #expect(cases.contains(.trusted)) } + @Test("AddNamedGroupMemberRequest encodes optional TreeKEM key package") + func addNamedGroupMemberRequestEncodesTreeKemKeyPackage() throws { + let request = AddNamedGroupMemberRequest( + agentId: "aabb", + displayName: "Alice", + treekemKeyPackageB64: "a2V5cGtn" + ) + let data = try JSONEncoder().encode(request) + let object = try JSONSerialization.jsonObject(with: data) as? [String: String] + #expect(object?["agent_id"] == "aabb") + #expect(object?["display_name"] == "Alice") + #expect(object?["treekem_key_package_b64"] == "a2V5cGtn") + } + @Test("Contact model decodes from JSON") func contactDecoding() throws { let json = """ @@ -776,7 +790,8 @@ struct X0xClientTests { @Test("DirectSendResponse decodes legacy fire-and-forget response") func directSendResponseLegacyDecoding() throws { - // Without require_ack_ms the daemon omits the `require_ack` block. + // Without require_ack_ms the daemon omits the peer-probe `require_ack` + // block. x0x 0.22 may still have required the recipient inbox gossip ACK. let json = """ { "ok": true, @@ -791,17 +806,19 @@ struct X0xClientTests { #expect(resp.requireAck == nil) } - @Test("DirectMessageRequest encodes require_ack_ms when set") + @Test("DirectMessageRequest encodes ACK controls when set") func directMessageRequestAckEncoding() throws { let req = DirectMessageRequest( agentId: "abc", payload: "aGVsbG8=", + requireGossipAck: true, requireAckMs: 3000 ) let data = try JSONEncoder().encode(req) let jsonStr = String(data: data, encoding: .utf8)! #expect(jsonStr.contains("\"agent_id\":\"abc\"")) #expect(jsonStr.contains("\"payload\":\"aGVsbG8=\"")) + #expect(jsonStr.contains("\"require_gossip_ack\":true")) #expect(jsonStr.contains("\"require_ack_ms\":3000")) } diff --git a/communitas-bench/benches/core_flows.rs b/communitas-bench/benches/core_flows.rs index 28039d7e5..21aeed50e 100644 --- a/communitas-bench/benches/core_flows.rs +++ b/communitas-bench/benches/core_flows.rs @@ -1,9 +1,10 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Core flow benchmarks for messaging, drive, kanban, and canvas services. //! //! Note: Benchmarks use `expect()` for error handling clarity, similar to tests. -#![allow(clippy::expect_used)] use std::sync::Arc; diff --git a/communitas-bench/src/lib.rs b/communitas-bench/src/lib.rs index b3cc95016..1399a3a73 100644 --- a/communitas-bench/src/lib.rs +++ b/communitas-bench/src/lib.rs @@ -4,6 +4,8 @@ //! //! This library exists to expose dependencies to benchmark targets. +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + // Re-export dependencies for benchmarks // Note: communitas-core has lib name = "communitas_bindings" pub use communitas_bindings as communitas_core; diff --git a/communitas-core/tests/crdt_delta_test.rs b/communitas-core/tests/crdt_delta_test.rs index d0f080e62..572c2acc5 100644 --- a/communitas-core/tests/crdt_delta_test.rs +++ b/communitas-core/tests/crdt_delta_test.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use yrs::updates::decoder::Decode; use yrs::{Doc, GetString, ReadTxn, Text, Transact, Update}; diff --git a/communitas-core/tests/crdt_document_lifecycle_test.rs b/communitas-core/tests/crdt_document_lifecycle_test.rs index e4180db13..12e2045d6 100644 --- a/communitas-core/tests/crdt_document_lifecycle_test.rs +++ b/communitas-core/tests/crdt_document_lifecycle_test.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // Copyright (c) 2025 Saorsa Labs Limited // // Licensed under the AGPL-3.0 license diff --git a/communitas-core/tests/crdt_error_handling_test.rs b/communitas-core/tests/crdt_error_handling_test.rs index ac1c50c58..badfefdb7 100644 --- a/communitas-core/tests/crdt_error_handling_test.rs +++ b/communitas-core/tests/crdt_error_handling_test.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // Copyright (c) 2025 Saorsa Labs Limited // // Licensed under the AGPL-3.0 license diff --git a/communitas-core/tests/crdt_tests.rs b/communitas-core/tests/crdt_tests.rs index 9111cb75e..528728078 100644 --- a/communitas-core/tests/crdt_tests.rs +++ b/communitas-core/tests/crdt_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Comprehensive CRDT Tests with Property-Based Testing //! //! Tests vector clocks, causal ordering, and CRDT message synchronization diff --git a/communitas-core/tests/doc_replicator_tests.rs b/communitas-core/tests/doc_replicator_tests.rs index 84dea3238..c4d151e9e 100644 --- a/communitas-core/tests/doc_replicator_tests.rs +++ b/communitas-core/tests/doc_replicator_tests.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // Copyright (c) 2025 Saorsa Labs Limited // // This file is part of the Saorsa P2P network. diff --git a/communitas-core/tests/exponential_backoff_test.rs b/communitas-core/tests/exponential_backoff_test.rs index f6e29e55e..a046e7760 100644 --- a/communitas-core/tests/exponential_backoff_test.rs +++ b/communitas-core/tests/exponential_backoff_test.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // Copyright (c) 2025 Saorsa Labs Limited // // Licensed under the AGPL-3.0 license diff --git a/communitas-core/tests/member_removal_all_entities_test.rs b/communitas-core/tests/member_removal_all_entities_test.rs index 5e33e97f0..8d8d214f2 100644 --- a/communitas-core/tests/member_removal_all_entities_test.rs +++ b/communitas-core/tests/member_removal_all_entities_test.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + // Copyright (c) 2025 Saorsa Labs Limited // // Integration tests for member removal across all entity types diff --git a/communitas-core/tests/message_sync_tests.rs b/communitas-core/tests/message_sync_tests.rs index 1c1dac629..39408aed9 100644 --- a/communitas-core/tests/message_sync_tests.rs +++ b/communitas-core/tests/message_sync_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Comprehensive MessageSyncService Tests //! //! Tests message synchronization, out-of-order detection, missing message recovery, diff --git a/communitas-core/tests/resource_limits_integration_tests.rs b/communitas-core/tests/resource_limits_integration_tests.rs index 940f2f681..8e7e166db 100644 --- a/communitas-core/tests/resource_limits_integration_tests.rs +++ b/communitas-core/tests/resource_limits_integration_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + // Copyright (c) 2025 Saorsa Labs Limited // // Integration tests for resource limits enforcement diff --git a/communitas-core/tests/resource_limits_test.rs b/communitas-core/tests/resource_limits_test.rs index 6ff5f700c..b05ed7366 100644 --- a/communitas-core/tests/resource_limits_test.rs +++ b/communitas-core/tests/resource_limits_test.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // Copyright (c) 2025 Saorsa Labs Limited // // Licensed under the AGPL-3.0 license diff --git a/communitas-core/tests/retry_integration_tests.rs b/communitas-core/tests/retry_integration_tests.rs index 7dc6aade9..5565ae7c9 100644 --- a/communitas-core/tests/retry_integration_tests.rs +++ b/communitas-core/tests/retry_integration_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + // Copyright (c) 2025 Saorsa Labs Limited // // Integration tests for exponential backoff retry logic diff --git a/communitas-core/tests/watchdog_integration_tests.rs b/communitas-core/tests/watchdog_integration_tests.rs index d15a5145f..ac6b6ab52 100644 --- a/communitas-core/tests/watchdog_integration_tests.rs +++ b/communitas-core/tests/watchdog_integration_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + // Copyright (c) 2025 Saorsa Labs Limited // // Integration tests for connectivity watchdog diff --git a/communitas-core/tests/website_storage_test.rs b/communitas-core/tests/website_storage_test.rs index 83d04bdc6..6ef2f5d81 100644 --- a/communitas-core/tests/website_storage_test.rs +++ b/communitas-core/tests/website_storage_test.rs @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] // Copyright (c) 2025 Saorsa Labs Limited // // Licensed under the AGPL-3.0 license diff --git a/communitas-dioxus/Cargo.toml b/communitas-dioxus/Cargo.toml index 80be664d9..420e8e3e2 100644 --- a/communitas-dioxus/Cargo.toml +++ b/communitas-dioxus/Cargo.toml @@ -17,7 +17,8 @@ communitas-core = { version = "0.12.4", package = "communitas-core", path = "../ communitas-ui-api = { version = "0.12.4", path = "../communitas-ui-api" } communitas-ui-service = { version = "0.12.4", path = "../communitas-ui-service" } dioxus = { version = "0.7", features = ["desktop", "router", "signals"] } -dioxus-logger = "0.5.1" +dioxus-logger = "0.7" +futures-timer = "3" tracing.workspace = true futures.workspace = true chrono.workspace = true @@ -32,6 +33,10 @@ pulldown-cmark = "0.12" sha2 = "0.10" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSWindow"] } + [dependencies.tokio] workspace = true diff --git a/communitas-dioxus/benches/startup.rs b/communitas-dioxus/benches/startup.rs index 0e610fc31..418b5ed4c 100644 --- a/communitas-dioxus/benches/startup.rs +++ b/communitas-dioxus/benches/startup.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Startup performance benchmarks for Communitas Dioxus application. //! //! Measures the time to bootstrap UI services, which is the primary diff --git a/communitas-dioxus/src/components/announcer.rs b/communitas-dioxus/src/components/announcer.rs index 41cf12f1d..f5409ca4c 100644 --- a/communitas-dioxus/src/components/announcer.rs +++ b/communitas-dioxus/src/components/announcer.rs @@ -185,7 +185,7 @@ pub fn Announcer() -> Element { } #[cfg(not(target_arch = "wasm32"))] { - tokio::time::sleep(std::time::Duration::from_millis(1000)).await; + crate::ui_sleep(std::time::Duration::from_millis(1000)).await; } ctx.clear_polite(); // Process next in queue if any @@ -206,7 +206,7 @@ pub fn Announcer() -> Element { } #[cfg(not(target_arch = "wasm32"))] { - tokio::time::sleep(std::time::Duration::from_millis(2000)).await; + crate::ui_sleep(std::time::Duration::from_millis(2000)).await; } ctx.clear_assertive(); // Process next in queue if any diff --git a/communitas-dioxus/src/components/app_sidebar.rs b/communitas-dioxus/src/components/app_sidebar.rs index 0e4525f1d..c8f286969 100644 --- a/communitas-dioxus/src/components/app_sidebar.rs +++ b/communitas-dioxus/src/components/app_sidebar.rs @@ -707,7 +707,16 @@ fn SpaceAccordionItem(props: SpaceAccordionItemProps) -> Element { button { style: "{header_style}", aria_expanded: if props.is_expanded { "true" } else { "false" }, - onclick: move |_| props.on_toggle.call(()), + onclick: { + let space_path = space_path.clone(); + let should_expand = !props.is_expanded; + move |_| { + props.on_navigate.call(space_path.clone()); + if should_expand { + props.on_toggle.call(()); + } + } + }, // Presence / expand indicator dot span { diff --git a/communitas-dioxus/src/components/board_view.rs b/communitas-dioxus/src/components/board_view.rs index a0cd6c70a..757f14d80 100644 --- a/communitas-dioxus/src/components/board_view.rs +++ b/communitas-dioxus/src/components/board_view.rs @@ -117,11 +117,13 @@ pub fn BoardView(props: BoardViewProps) -> Element { // ── Polling: refresh tasks every POLL_SECS ── use_coroutine(move |_: UnboundedReceiver<()>| async move { loop { - tokio::time::sleep(tokio::time::Duration::from_secs(POLL_SECS)).await; - let _key = *refresh_key.read(); - if let Some(id) = list_id.read().clone() { + crate::poll_sleep(tokio::time::Duration::from_secs(POLL_SECS)).await; + let _key = *refresh_key.peek(); + if let Some(id) = list_id.peek().clone() { let client = X0xClient::new(); - if let Ok(t) = client.list_tasks(&id).await { + if let Ok(t) = client.list_tasks(&id).await + && !same_tasks(tasks.peek().as_slice(), t.as_slice()) + { tasks.set(t); } } @@ -153,17 +155,17 @@ pub fn BoardView(props: BoardViewProps) -> Element { let todo: Vec<_> = current_tasks .iter() - .filter(|t| t.state.as_deref() == Some("todo") || t.state.is_none()) + .filter(|t| task_lane(t.state.as_deref()) == TaskLane::Todo) .cloned() .collect(); let in_progress: Vec<_> = current_tasks .iter() - .filter(|t| t.state.as_deref() == Some("in_progress")) + .filter(|t| task_lane(t.state.as_deref()) == TaskLane::InProgress) .cloned() .collect(); let done: Vec<_> = current_tasks .iter() - .filter(|t| t.state.as_deref() == Some("done")) + .filter(|t| task_lane(t.state.as_deref()) == TaskLane::Done) .cloned() .collect(); @@ -186,11 +188,12 @@ pub fn BoardView(props: BoardViewProps) -> Element { if !title.is_empty() { new_task_title.set(String::new()); spawn(async move { - if let Some(id) = list_id.read().clone() { + if let Some(id) = list_id.peek().clone() { let client = X0xClient::new(); if let Err(e) = client.add_task(&id, &title, None).await { warn!(target: "ui.board", "add task failed: {e}"); } + refresh_tasks(&id, tasks).await; refresh_key.set(refresh_key() + 1); } }); @@ -205,11 +208,12 @@ pub fn BoardView(props: BoardViewProps) -> Element { if !title.is_empty() { new_task_title.set(String::new()); spawn(async move { - if let Some(id) = list_id.read().clone() { + if let Some(id) = list_id.peek().clone() { let client = X0xClient::new(); if let Err(e) = client.add_task(&id, &title, None).await { warn!(target: "ui.board", "add task failed: {e}"); } + refresh_tasks(&id, tasks).await; refresh_key.set(refresh_key() + 1); } }); @@ -224,24 +228,73 @@ pub fn BoardView(props: BoardViewProps) -> Element { style: "display: flex; gap: {spacing::MD}; flex: 1; overflow: auto;", // To Do - {board_column("To Do", colors::TEXT_MUTED, &todo, list_id, refresh_key, "claim")} + {board_column("To Do", colors::TEXT_MUTED, &todo, list_id, tasks, refresh_key, "claim")} // In Progress - {board_column("In Progress", "#e67e22", &in_progress, list_id, refresh_key, "complete")} + {board_column("In Progress", "#e67e22", &in_progress, list_id, tasks, refresh_key, "complete")} // Done - {board_column("Done", colors::SUCCESS, &done, list_id, refresh_key, "")} + {board_column("Done", colors::SUCCESS, &done, list_id, tasks, refresh_key, "")} } } } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TaskLane { + Todo, + InProgress, + Done, +} + +fn task_lane(state: Option<&str>) -> TaskLane { + let Some(state) = state else { + return TaskLane::Todo; + }; + let state = state.trim(); + if state.is_empty() || state == "empty" || state == "todo" { + return TaskLane::Todo; + } + if state == "in_progress" + || state.starts_with("in_progress:") + || state == "claimed" + || state.starts_with("claimed:") + { + return TaskLane::InProgress; + } + if state == "done" || state.starts_with("done:") { + return TaskLane::Done; + } + TaskLane::Todo +} + +async fn refresh_tasks(list_id: &str, mut tasks: Signal>) { + let client = X0xClient::new(); + match client.list_tasks(list_id).await { + Ok(updated) => tasks.set(updated), + Err(e) => warn!(target: "ui.board", "task refresh failed: {e}"), + } +} + +fn same_tasks(left: &[Task], right: &[Task]) -> bool { + left.len() == right.len() + && left.iter().zip(right).all(|(left, right)| { + left.id == right.id + && left.title == right.title + && left.description == right.description + && left.state == right.state + && left.assignee == right.assignee + && left.priority == right.priority + }) +} + /// Render a single board column with task cards and action buttons. fn board_column( title: &str, accent: &str, tasks: &[Task], list_id: Signal>, + task_signal: Signal>, refresh_key: Signal, action: &str, ) -> Element { @@ -282,7 +335,7 @@ fn board_column( } } for task in tasks.iter() { - {task_card(task, list_id, refresh_key, &action_str, action_label)} + {task_card(task, list_id, task_signal, refresh_key, &action_str, action_label)} } } } @@ -293,6 +346,7 @@ fn board_column( fn task_card( task: &Task, list_id: Signal>, + task_signal: Signal>, mut refresh_key: Signal, action: &str, action_label: Option<&str>, @@ -349,6 +403,7 @@ fn task_card( if let Err(e) = result { warn!(target: "ui.board", "{act} task failed: {e}"); } + refresh_tasks(&lid, task_signal).await; refresh_key.set(refresh_key() + 1); } }); diff --git a/communitas-dioxus/src/components/channel_chat.rs b/communitas-dioxus/src/components/channel_chat.rs index f9921498b..54ed481b6 100644 --- a/communitas-dioxus/src/components/channel_chat.rs +++ b/communitas-dioxus/src/components/channel_chat.rs @@ -52,6 +52,76 @@ const TYPING_INBOUND_RATE_LIMIT_SECS: u64 = 1; /// Maximum number of messages kept in memory per channel. const MAX_MESSAGES: usize = 500; +fn set_signal_if_changed(signal: &mut Signal, next: T) { + let changed = { + let current = signal.peek(); + *current != next + }; + if changed { + signal.set(next); + } +} + +fn signed_public_key(gpm: &communitas_x0x_client::GroupPublicMessage) -> String { + format!( + "{}:{}:{}", + gpm.author_agent_id, + gpm.timestamp, + gpm.signature.chars().take(12).collect::() + ) +} + +fn signed_public_chat_message( + gpm: &communitas_x0x_client::GroupPublicMessage, + channel_name: &str, + key: String, +) -> ChatMessage { + ChatMessage { + id: key, + text: gpm.body.clone(), + sender_name: x0x_contract::fallback_sender_name(&gpm.author_agent_id), + sender_id: gpm.author_agent_id.clone(), + timestamp: gpm.timestamp, + channel: channel_name.to_owned(), + thread_root: None, + broadcast: false, + is_deleted: false, + reply_count: 0, + reactions: HashMap::new(), + } +} + +fn insert_sorted_unique_message(messages: &mut Vec, chat: ChatMessage) -> bool { + if messages.iter().any(|m| m.id == chat.id) { + return false; + } + + let pos = messages.partition_point(|m| m.timestamp <= chat.timestamp); + messages.insert(pos, chat); + if messages.len() > MAX_MESSAGES { + messages.drain(..messages.len() - MAX_MESSAGES); + } + true +} + +fn insert_signal_message(messages: &mut Signal>, chat: ChatMessage) -> bool { + let mut inserted = false; + messages.with_mut(|msgs| { + inserted = insert_sorted_unique_message(msgs, chat); + }); + inserted +} + +fn chat_auto_scroll_enabled() -> bool { + if std::env::var_os("COMMUNITAS_DIOXUS_DISABLE_CHAT_AUTOSCROLL").is_some() { + return false; + } + if cfg!(target_os = "macos") { + return std::env::var_os("COMMUNITAS_DIOXUS_ENABLE_CHAT_AUTOSCROLL").is_some(); + } + true +} + // --------------------------------------------------------------------------- // x0x-compatible wire payloads // --------------------------------------------------------------------------- @@ -307,7 +377,7 @@ pub fn ChannelChatView( let mut messages = use_signal(Vec::::new); let mut composer_text = use_signal(String::new); let mut sending = use_signal(|| false); - let mut ws_connected = use_signal(|| false); + let mut ws_connected = use_signal(|| !crate::live_streams_enabled()); let mut discovered_agent_ids = use_signal(HashSet::::new); // Shared HTTP client — created once at mount, reused across all closures. @@ -360,10 +430,13 @@ pub fn ChannelChatView( if let Ok(info) = client.get_group(&policy_group_id).await && let Some(policy) = info.policy { - is_signed_public.set(matches!( - policy.confidentiality, - communitas_x0x_client::GroupConfidentiality::SignedPublic - )); + set_signal_if_changed( + &mut is_signed_public, + matches!( + policy.confidentiality, + communitas_x0x_client::GroupConfidentiality::SignedPublic + ), + ); } } }); @@ -383,7 +456,7 @@ pub fn ChannelChatView( // banned-author before caching, so we just render. // // Dedup key: (author_agent_id, timestamp, signature[0..12]) - let seen_signed: Signal> = use_signal(HashSet::new); + let mut seen_signed: Signal> = use_signal(HashSet::new); let push_group_id = group_id.clone(); let push_channel_name = channel_name.clone(); @@ -392,12 +465,17 @@ pub fn ChannelChatView( let push_channel_name = push_channel_name.clone(); let mut seen_signed = seen_signed; async move { + if !crate::live_streams_enabled() { + return; + } + // Gate the subscription on the component actually being // SignedPublic. Loop on failure so a brief daemon blip // doesn't permanently disable the push path. + let mut retry_delay = std::time::Duration::from_secs(1); loop { - if !is_signed_public() { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + if !*is_signed_public.peek() { + crate::ui_sleep(std::time::Duration::from_millis(500)).await; continue; } let client = communitas_x0x_client::X0xClient::new(); @@ -408,7 +486,8 @@ pub fn ChannelChatView( target: "ui.channel_chat", "get_group_state failed for public WS: {e}" ); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); continue; } }; @@ -417,16 +496,19 @@ pub fn ChannelChatView( Ok(ws) => ws, Err(e) => { warn!(target: "ui.channel_chat", "public WS connect failed: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); continue; } }; if let Err(e) = ws.subscribe(vec![topic.clone()]) { warn!(target: "ui.channel_chat", "public WS subscribe failed: {e}"); - tokio::time::sleep(std::time::Duration::from_secs(5)).await; + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); continue; } info!(target: "ui.channel_chat", "Subscribed to public topic: {topic}"); + retry_delay = std::time::Duration::from_secs(1); while let Some(inbound) = ws.recv().await { if let communitas_x0x_client::WsInbound::Message { @@ -446,40 +528,13 @@ pub fn ChannelChatView( Ok(g) => g, Err(_) => continue, }; - let key = format!( - "{}:{}:{}", - gpm.author_agent_id, - gpm.timestamp, - gpm.signature.chars().take(12).collect::() - ); + let key = signed_public_key(&gpm); if !seen_signed.write().insert(key.clone()) { continue; } - let chat = ChatMessage { - id: key, - text: gpm.body.clone(), - sender_name: x0x_contract::fallback_sender_name(&gpm.author_agent_id), - sender_id: gpm.author_agent_id.clone(), - timestamp: gpm.timestamp, - channel: push_channel_name.clone(), - thread_root: None, - broadcast: false, - is_deleted: false, - reply_count: 0, - reactions: HashMap::new(), - }; + let chat = signed_public_chat_message(&gpm, &push_channel_name, key); let persist_msg = chat.clone(); - let mut inserted = false; - messages.with_mut(|msgs| { - if !msgs.iter().any(|m| m.id == chat.id) { - let pos = msgs.partition_point(|m| m.timestamp <= chat.timestamp); - msgs.insert(pos, chat); - if msgs.len() > MAX_MESSAGES { - msgs.drain(..msgs.len() - MAX_MESSAGES); - } - inserted = true; - } - }); + let inserted = insert_signal_message(&mut messages, chat); if inserted { // Persist so a remount of the chat view // replays SignedPublic messages alongside @@ -495,7 +550,8 @@ pub fn ChannelChatView( } // recv returned None → reconnect. warn!(target: "ui.channel_chat", "public WS closed; reconnecting"); - tokio::time::sleep(std::time::Duration::from_secs(1)).await; + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); } } }); @@ -509,44 +565,17 @@ pub fn ChannelChatView( async move { let client = communitas_x0x_client::X0xClient::new(); loop { - if is_signed_public() + if *is_signed_public.peek() && let Ok(list) = client.get_group_public_messages(&poll_group_id).await { for gpm in &list { - let key = format!( - "{}:{}:{}", - gpm.author_agent_id, - gpm.timestamp, - gpm.signature.chars().take(12).collect::() - ); + let key = signed_public_key(gpm); if !seen_signed.write().insert(key.clone()) { continue; } - let chat = ChatMessage { - id: key, - text: gpm.body.clone(), - sender_name: x0x_contract::fallback_sender_name(&gpm.author_agent_id), - sender_id: gpm.author_agent_id.clone(), - timestamp: gpm.timestamp, - channel: poll_channel_name.clone(), - thread_root: None, - broadcast: false, - is_deleted: false, - reply_count: 0, - reactions: HashMap::new(), - }; + let chat = signed_public_chat_message(gpm, &poll_channel_name, key); let persist_msg = chat.clone(); - let mut inserted = false; - messages.with_mut(|msgs| { - if !msgs.iter().any(|m| m.id == chat.id) { - let pos = msgs.partition_point(|m| m.timestamp <= chat.timestamp); - msgs.insert(pos, chat); - if msgs.len() > MAX_MESSAGES { - msgs.drain(..msgs.len() - MAX_MESSAGES); - } - inserted = true; - } - }); + let inserted = insert_signal_message(&mut messages, chat); if inserted { x0x_contract::append_channel_history( &poll_group_id, @@ -558,7 +587,7 @@ pub fn ChannelChatView( } } // Push path covers real-time; the poll is a backstop. - tokio::time::sleep(std::time::Duration::from_secs(30)).await; + crate::poll_sleep(std::time::Duration::from_secs(30)).await; } } }); @@ -573,24 +602,41 @@ pub fn ChannelChatView( let history_group_id = history_group_id.clone(); let history_channel_name = history_channel_name.clone(); async move { - let history = + let mut history = x0x_contract::load_channel_history(&history_group_id, &history_channel_name).await; - messages.set(history); + let client = communitas_x0x_client::X0xClient::new(); + if let Ok(info) = client.get_group(&history_group_id).await + && info.policy.is_some_and(|policy| { + matches!( + policy.confidentiality, + communitas_x0x_client::GroupConfidentiality::SignedPublic + ) + }) + && let Ok(list) = client.get_group_public_messages(&history_group_id).await + { + for gpm in &list { + let key = signed_public_key(gpm); + seen_signed.write().insert(key.clone()); + let chat = signed_public_chat_message(gpm, &history_channel_name, key); + insert_sorted_unique_message(&mut history, chat); + } + } + set_signal_if_changed(&mut messages, history); } }); // ----- discovered agents ----- use_future(move || async move { - let client = shared_client.read().clone(); + let client = shared_client.peek().clone(); if let Ok(agents) = client.discovered_agents().await { let ids: HashSet = agents.into_iter().map(|a| a.agent_id).collect(); - discovered_agent_ids.set(ids); + set_signal_if_changed(&mut discovered_agent_ids, ids); } }); // ----- contacts for @mention ----- use_future(move || async move { - let client = shared_client.read().clone(); + let client = shared_client.peek().clone(); if let Ok(contacts) = client.list_contacts().await { let candidates = contacts .into_iter() @@ -604,7 +650,7 @@ pub fn ChannelChatView( .unwrap_or_else(|| x0x_contract::fallback_sender_name(&c.agent_id)), }) .collect::>(); - mention_candidates.set(candidates); + set_signal_if_changed(&mut mention_candidates, candidates); } }); @@ -613,9 +659,9 @@ pub fn ChannelChatView( use_effect(move || { if let Some(bump_signal) = reply_count_bump && let Some((parent_id, generation)) = bump_signal() - && generation > last_bump_gen() + && generation > *last_bump_gen.peek() { - last_bump_gen.set(generation); + set_signal_if_changed(&mut last_bump_gen, generation); messages.with_mut(|msgs| { if let Some(msg) = msgs.iter_mut().find(|m| m.id == parent_id) { msg.reply_count += 1; @@ -629,6 +675,9 @@ pub fn ChannelChatView( // and re-runs this effect every time new messages arrive. use_effect(move || { let _count = messages.read().len(); // creates the signal subscription + if !chat_auto_scroll_enabled() { + return; + } let _ = document::eval(&format!( r#" var el = document.getElementById('{CHAT_MESSAGES_ID}'); @@ -650,190 +699,215 @@ pub fn ChannelChatView( let group_id = ws_group_id.clone(); let channel_name = ws_channel_name.clone(); async move { - let ws = match X0xWebSocket::connect().await { - Ok(ws) => { - if let Err(e) = ws.subscribe(vec![topic.clone()]) { - error!(target: "ui.channel_chat", "Failed to subscribe to {topic}: {e}"); - return; - } - info!(target: "ui.channel_chat", "Subscribed to channel topic: {topic}"); - ws_connected.set(true); - ws - } - Err(e) => { - warn!(target: "ui.channel_chat", "WebSocket connection failed: {e}"); - return; - } - }; - - let mut ws = ws; + if !crate::live_streams_enabled() { + return; + } - while let Some(inbound) = ws.recv().await { - match inbound { - communitas_x0x_client::WsInbound::Message { - topic: msg_topic, - payload, - .. - } => { - if msg_topic != topic { + let mut retry_delay = std::time::Duration::from_secs(1); + loop { + let ws = match X0xWebSocket::connect().await { + Ok(ws) => { + if let Err(e) = ws.subscribe(vec![topic.clone()]) { + error!(target: "ui.channel_chat", "Failed to subscribe to {topic}: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); continue; } - let decoded = match base64::engine::general_purpose::STANDARD - .decode(&payload) - { - Ok(b) => b, - Err(e) => { - warn!(target: "ui.channel_chat", "Failed to decode payload: {e}"); + info!(target: "ui.channel_chat", "Subscribed to channel topic: {topic}"); + ws_connected.set(true); + retry_delay = std::time::Duration::from_secs(1); + ws + } + Err(e) => { + warn!(target: "ui.channel_chat", "WebSocket connection failed: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + }; + + let mut ws = ws; + + while let Some(inbound) = ws.recv().await { + match inbound { + communitas_x0x_client::WsInbound::Message { + topic: msg_topic, + payload, + .. + } => { + if msg_topic != topic { continue; } - }; + let decoded = match base64::engine::general_purpose::STANDARD + .decode(&payload) + { + Ok(b) => b, + Err(e) => { + warn!(target: "ui.channel_chat", "Failed to decode payload: {e}"); + continue; + } + }; - match serde_json::from_slice::(&decoded) { - Ok(payload) => match payload.effective_type() { - MSG_TYPE_CHAT | MSG_TYPE_MESSAGE | "" => { - if let Some(msg) = payload.into_chat_message() { - let history_msg = msg.clone(); - messages.with_mut(|msgs| { - if !msgs.iter().any(|m| m.id == history_msg.id) { - let pos = msgs.partition_point(|m| { - m.timestamp <= msg.timestamp - }); - msgs.insert(pos, msg); - if msgs.len() > MAX_MESSAGES { - msgs.drain(..msgs.len() - MAX_MESSAGES); + match serde_json::from_slice::(&decoded) { + Ok(payload) => match payload.effective_type() { + MSG_TYPE_CHAT | MSG_TYPE_MESSAGE | "" => { + if let Some(msg) = payload.into_chat_message() { + let history_msg = msg.clone(); + messages.with_mut(|msgs| { + if !msgs.iter().any(|m| m.id == history_msg.id) { + let pos = msgs.partition_point(|m| { + m.timestamp <= msg.timestamp + }); + msgs.insert(pos, msg); + if msgs.len() > MAX_MESSAGES { + msgs.drain(..msgs.len() - MAX_MESSAGES); + } } - } - }); - x0x_contract::append_channel_history( - &group_id, - &channel_name, - &history_msg, - ) - .await; + }); + x0x_contract::append_channel_history( + &group_id, + &channel_name, + &history_msg, + ) + .await; + } } - } - MSG_TYPE_EDIT => { - if let Some((message_id, sender_id, new_text)) = - payload.edit_fields() - { - messages.with_mut(|msgs| { - if let Some(m) = - msgs.iter_mut().find(|m| m.id == message_id) - && m.sender_id == sender_id - { - m.text = new_text; - } - }); + MSG_TYPE_EDIT => { + if let Some((message_id, sender_id, new_text)) = + payload.edit_fields() + { + messages.with_mut(|msgs| { + if let Some(m) = + msgs.iter_mut().find(|m| m.id == message_id) + && m.sender_id == sender_id + { + m.text = new_text; + } + }); + } } - } - MSG_TYPE_DELETE => { - if let Some((message_id, sender_id)) = payload.delete_fields() { - messages.with_mut(|msgs| { - if let Some(m) = - msgs.iter_mut().find(|m| m.id == message_id) - && m.sender_id == sender_id - { - m.is_deleted = true; - } - }); + MSG_TYPE_DELETE => { + if let Some((message_id, sender_id)) = + payload.delete_fields() + { + messages.with_mut(|msgs| { + if let Some(m) = + msgs.iter_mut().find(|m| m.id == message_id) + && m.sender_id == sender_id + { + m.is_deleted = true; + } + }); + } } - } - MSG_TYPE_TYPING => { - if let Some((sender_id, sender_name)) = payload.typing_fields() - { - let now = now_secs(); - let should_update = typing_users - .read() - .get(&sender_id) - .map(|entry| { - now.saturating_sub(entry.last_seen_secs) - >= TYPING_INBOUND_RATE_LIMIT_SECS - }) - .unwrap_or(true); - if should_update { - typing_users.with_mut(|map| { - map.retain(|_, entry| { + MSG_TYPE_TYPING => { + if let Some((sender_id, sender_name)) = + payload.typing_fields() + { + let now = now_secs(); + let should_update = typing_users + .read() + .get(&sender_id) + .map(|entry| { now.saturating_sub(entry.last_seen_secs) - <= TYPING_EXPIRY_SECS + >= TYPING_INBOUND_RATE_LIMIT_SECS + }) + .unwrap_or(true); + if should_update { + typing_users.with_mut(|map| { + map.retain(|_, entry| { + now.saturating_sub(entry.last_seen_secs) + <= TYPING_EXPIRY_SECS + }); + map.insert( + sender_id, + TypingEntry { + display_name: sender_name, + last_seen_secs: now, + }, + ); }); - map.insert( - sender_id, - TypingEntry { - display_name: sender_name, - last_seen_secs: now, - }, - ); - }); + } } } - } - MSG_TYPE_REACTION => { - if let Some((message_id, emoji, action, sender_id)) = - payload.reaction_fields() - { - let own_agent_id = own_agent_id().unwrap_or_default(); - if sender_id == own_agent_id { - continue; + MSG_TYPE_REACTION => { + if let Some((message_id, emoji, action, sender_id)) = + payload.reaction_fields() + { + let own_agent_id = own_agent_id().unwrap_or_default(); + if sender_id == own_agent_id { + continue; + } + messages.with_mut(|msgs| { + if let Some(m) = + msgs.iter_mut().find(|m| m.id == message_id) + { + match action.as_str() { + "add" => { + *m.reactions + .entry(emoji) + .or_insert(0) += 1; + } + "remove" => { + let current = m + .reactions + .get(&emoji) + .copied() + .unwrap_or(0); + if current <= 1 { + m.reactions.remove(&emoji); + } else { + m.reactions + .insert(emoji, current - 1); + } + } + _ => {} + } + } + }); } - messages.with_mut(|msgs| { - if let Some(m) = - msgs.iter_mut().find(|m| m.id == message_id) - { + } + MSG_TYPE_PIN => { + if let Some((message_id, action)) = payload.pin_fields() { + pinned_message_ids.with_mut(|pins| { match action.as_str() { - "add" => { - *m.reactions.entry(emoji).or_insert(0) += 1; - } - "remove" => { - let current = m - .reactions - .get(&emoji) - .copied() - .unwrap_or(0); - if current <= 1 { - m.reactions.remove(&emoji); - } else { - m.reactions.insert(emoji, current - 1); - } + "pin" if !pins.contains(&message_id) => { + pins.push(message_id); } + "unpin" => pins.retain(|id| id != &message_id), _ => {} } - } - }); + }); + } } - } - MSG_TYPE_PIN => { - if let Some((message_id, action)) = payload.pin_fields() { - pinned_message_ids.with_mut(|pins| match action.as_str() { - "pin" if !pins.contains(&message_id) => { - pins.push(message_id); - } - "unpin" => pins.retain(|id| id != &message_id), - _ => {} - }); + other => { + warn!(target: "ui.channel_chat", "Unknown inbound channel event type: {other}"); } + }, + Err(e) => { + warn!(target: "ui.channel_chat", "Failed to parse inbound message: {e}"); } - other => { - warn!(target: "ui.channel_chat", "Unknown inbound channel event type: {other}"); - } - }, - Err(e) => { - warn!(target: "ui.channel_chat", "Failed to parse inbound message: {e}"); } } + communitas_x0x_client::WsInbound::Error { message } => { + error!(target: "ui.channel_chat", "WebSocket error: {message}"); + } + _ => {} } - communitas_x0x_client::WsInbound::Error { message } => { - error!(target: "ui.channel_chat", "WebSocket error: {message}"); - } - _ => {} } - } - ws_connected.set(false); + ws_connected.set(false); + warn!(target: "ui.channel_chat", "WebSocket closed; reconnecting"); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + } } }); use_future(move || async move { - let client = shared_client.read().clone(); + let client = shared_client.peek().clone(); if let Ok(agent) = client.agent().await { let fallback_name = agent .user_id @@ -848,8 +922,8 @@ pub fn ChannelChatView( .filter(|v| !v.trim().is_empty()) .unwrap_or(fallback_name); - own_agent_id.set(Some(agent.agent_id)); - own_sender_name.set(Some(display_name)); + set_signal_if_changed(&mut own_agent_id, Some(agent.agent_id)); + set_signal_if_changed(&mut own_sender_name, Some(display_name)); } }); @@ -949,17 +1023,25 @@ pub fn ChannelChatView( // be authoritative. if !is_signed_public() { messages.with_mut(|msgs| { - if !msgs.iter().any(|m| m.id == msg.id) { - let pos = - msgs.partition_point(|m| m.timestamp <= msg.timestamp); - msgs.insert(pos, msg.clone()); - if msgs.len() > MAX_MESSAGES { - msgs.drain(..msgs.len() - MAX_MESSAGES); - } - } + insert_sorted_unique_message(msgs, msg.clone()); }); x0x_contract::append_channel_history(&group_id, &channel_name, &msg) .await; + } else if let Ok(list) = client.get_group_public_messages(&group_id).await { + for gpm in &list { + let key = signed_public_key(gpm); + seen_signed.write().insert(key.clone()); + let chat = signed_public_chat_message(gpm, &channel_name, key); + let persist_msg = chat.clone(); + if insert_signal_message(&mut messages, chat) { + x0x_contract::append_channel_history( + &group_id, + &channel_name, + &persist_msg, + ) + .await; + } + } } } Err(e) => { diff --git a/communitas-dioxus/src/components/channel_sidebar.rs b/communitas-dioxus/src/components/channel_sidebar.rs index b6e5fb736..197a949a0 100644 --- a/communitas-dioxus/src/components/channel_sidebar.rs +++ b/communitas-dioxus/src/components/channel_sidebar.rs @@ -38,9 +38,22 @@ pub struct SelectedChannel { pub meta: Option, } +fn set_signal_if_changed(signal: &mut Signal, next: T) { + let changed = { + let current = signal.peek(); + *current != next + }; + if changed { + signal.set(next); + } +} + /// Channel sidebar showing spaces and their channels. #[component] pub fn ChannelSidebar( + /// Restrict the channel list to one active space. + #[props(default)] + active_space_id: Option, /// The currently selected channel, if any. #[props(default)] selected: Option, @@ -62,20 +75,29 @@ pub fn ChannelSidebar( use_signal(std::collections::HashSet::new); let selected_for_load = selected.clone(); + let active_space_id_for_load = active_space_id.clone(); let on_select_for_load = on_select; use_future(move || { let refresh_key = refresh_key; let selected = selected_for_load.clone(); + let active_space_id = active_space_id_for_load.clone(); let on_select = on_select_for_load; async move { let _ = refresh_key; - loading.set(true); - error_msg.set(None); + set_signal_if_changed(&mut loading, true); + set_signal_if_changed(&mut error_msg, None); let client = X0xClient::new(); - match load_spaces(&client).await { + match load_spaces(&client, active_space_id.as_deref()).await { Ok(loaded_spaces) => { - if selected.is_none() + let should_auto_select = match (&selected, &active_space_id) { + (None, _) => true, + (Some(selected), Some(active_space_id)) => { + selected.group_id != *active_space_id + } + (Some(_), None) => false, + }; + if should_auto_select && let Some(first_space) = loaded_spaces.first() && let Some(first_channel) = first_space.channels.first() { @@ -86,15 +108,15 @@ pub fn ChannelSidebar( meta: Some(first_channel.clone()), }); } - spaces.set(loaded_spaces) + set_signal_if_changed(&mut spaces, loaded_spaces); } Err(err) => { error!(target: "ui.channel_sidebar", "Failed to load spaces: {err}"); - error_msg.set(Some(err)); + set_signal_if_changed(&mut error_msg, Some(err)); } } - loading.set(false); + set_signal_if_changed(&mut loading, false); } }); @@ -263,7 +285,10 @@ fn fallback_group_info(summary: &GroupSummary) -> GroupInfo { } } -async fn load_spaces(client: &X0xClient) -> Result, String> { +async fn load_spaces( + client: &X0xClient, + active_space_id: Option<&str>, +) -> Result, String> { let groups = client .list_groups() .await @@ -271,7 +296,10 @@ async fn load_spaces(client: &X0xClient) -> Result, String> { let mut loaded_spaces = Vec::new(); - for group in groups { + for group in groups.into_iter().filter(|group| match active_space_id { + Some(active_space_id) => group.group_id == active_space_id, + None => true, + }) { let group_info = match client.get_group(&group.group_id).await { Ok(group_info) => group_info, Err(err) => { diff --git a/communitas-dioxus/src/components/daemon_status.rs b/communitas-dioxus/src/components/daemon_status.rs index 519cf85f6..1e753f1bb 100644 --- a/communitas-dioxus/src/components/daemon_status.rs +++ b/communitas-dioxus/src/components/daemon_status.rs @@ -18,7 +18,7 @@ //! } //! ``` -use communitas_x0x_client::{DaemonManager, DaemonState, X0xClient}; +use communitas_x0x_client::{DaemonManager, DaemonState, X0xClient, is_running_health_status}; use dioxus::prelude::*; use tracing::warn; @@ -104,7 +104,7 @@ pub fn DaemonStatusBar() -> Element { loop { match client.health().await { Ok(health) => { - let daemon_state = if health.status == "healthy" || health.status == "running" { + let daemon_state = if is_running_health_status(&health.status) { DaemonState::Running } else { DaemonState::Degraded @@ -132,7 +132,7 @@ pub fn DaemonStatusBar() -> Element { } } - tokio::time::sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; } }); diff --git a/communitas-dioxus/src/components/dashboard.rs b/communitas-dioxus/src/components/dashboard.rs index 596f4afd4..a323d459e 100644 --- a/communitas-dioxus/src/components/dashboard.rs +++ b/communitas-dioxus/src/components/dashboard.rs @@ -119,7 +119,7 @@ pub fn Dashboard() -> Element { data.set(d); loading.set(false); - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; } }); diff --git a/communitas-dioxus/src/components/discover_view.rs b/communitas-dioxus/src/components/discover_view.rs index b863d74ec..8d3205308 100644 --- a/communitas-dioxus/src/components/discover_view.rs +++ b/communitas-dioxus/src/components/discover_view.rs @@ -134,7 +134,7 @@ pub fn DiscoverView() -> Element { move |_: UnboundedReceiver<()>| async move { let client = X0xClient::new(); loop { - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; let current_mode = mode(); let current_query = query().trim().to_string(); let mut seq = fetch_seq; diff --git a/communitas-dioxus/src/components/dm_view.rs b/communitas-dioxus/src/components/dm_view.rs index af6a17ae2..eb3e20558 100644 --- a/communitas-dioxus/src/components/dm_view.rs +++ b/communitas-dioxus/src/components/dm_view.rs @@ -144,14 +144,18 @@ pub fn DmView( match client.direct_connections().await { Ok(connections) => { let is_connected = connections.iter().any(|c| c.agent_id == peer_id); - peer_connected.set(is_connected); + if *peer_connected.peek() != is_connected { + peer_connected.set(is_connected); + } } Err(e) => { warn!(target: "ui.dm_view", "Failed to check connections: {e}"); - peer_connected.set(false); + if *peer_connected.peek() { + peer_connected.set(false); + } } } - tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(5)).await; } } }); @@ -185,59 +189,76 @@ pub fn DmView( use_coroutine(move |_: UnboundedReceiver<()>| { let peer_id = ws_peer_id.clone(); async move { - let mut ws = match X0xWebSocket::connect_direct().await { - Ok(ws) => { - info!(target: "ui.dm_view", "Connected to /ws/direct for DMs"); - ws_connected.set(true); - ws - } - Err(e) => { - warn!(target: "ui.dm_view", "WebSocket /ws/direct connection failed: {e}"); - return; - } - }; - - while let Some(inbound) = ws.recv().await { - match inbound { - communitas_x0x_client::WsInbound::DirectMessage { - sender, payload, .. - } => { - // Only show messages from our conversation peer - if sender != peer_id { - continue; - } + if !crate::live_streams_enabled() { + return; + } + + let mut retry_delay = std::time::Duration::from_secs(1); + loop { + let mut ws = match X0xWebSocket::connect_direct().await { + Ok(ws) => { + info!(target: "ui.dm_view", "Connected to /ws/direct for DMs"); + ws_connected.set(true); + retry_delay = std::time::Duration::from_secs(1); + ws + } + Err(e) => { + warn!(target: "ui.dm_view", "WebSocket /ws/direct connection failed: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + }; + + while let Some(inbound) = ws.recv().await { + match inbound { + communitas_x0x_client::WsInbound::DirectMessage { + sender, payload, .. + } => { + // Only show messages from our conversation peer + if sender != peer_id { + continue; + } - match base64::engine::general_purpose::STANDARD.decode(&payload) { - Ok(bytes) => match decode_direct_message_payload(&bytes, &sender) { - Ok(msg) => { - let history_msg = msg.clone(); - let history_peer = peer_id.clone(); - messages.with_mut(|msgs| { - if !msgs.iter().any(|m| m.id == history_msg.id) { - msgs.push(msg); - msgs.sort_by_key(|m| m.timestamp); - } - }); - x0x_contract::append_dm_history(&history_peer, &history_msg) + match base64::engine::general_purpose::STANDARD.decode(&payload) { + Ok(bytes) => match decode_direct_message_payload(&bytes, &sender) { + Ok(msg) => { + let history_msg = msg.clone(); + let history_peer = peer_id.clone(); + messages.with_mut(|msgs| { + if !msgs.iter().any(|m| m.id == history_msg.id) { + msgs.push(msg); + msgs.sort_by_key(|m| m.timestamp); + } + }); + x0x_contract::append_dm_history( + &history_peer, + &history_msg, + ) .await; - } + } + Err(e) => { + warn!(target: "ui.dm_view", "Failed to parse DM payload: {e}"); + } + }, Err(e) => { - warn!(target: "ui.dm_view", "Failed to parse DM payload: {e}"); + warn!(target: "ui.dm_view", "Failed to decode DM base64: {e}"); } - }, - Err(e) => { - warn!(target: "ui.dm_view", "Failed to decode DM base64: {e}"); } } + communitas_x0x_client::WsInbound::Error { message } => { + error!(target: "ui.dm_view", "WebSocket error: {message}"); + } + _ => {} } - communitas_x0x_client::WsInbound::Error { message } => { - error!(target: "ui.dm_view", "WebSocket error: {message}"); - } - _ => {} } - } - ws_connected.set(false); + ws_connected.set(false); + warn!(target: "ui.dm_view", "WebSocket /ws/direct closed; reconnecting"); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + } } }); @@ -288,7 +309,10 @@ pub fn DmView( match serde_json::to_vec(&wire) { Ok(json_bytes) => { let client = X0xClient::new(); - if let Err(e) = client.send_direct(&peer_id, &json_bytes).await { + if let Err(e) = client + .send_direct_with_gossip_ack(&peer_id, &json_bytes, true) + .await + { error!(target: "ui.dm_view", "Failed to send DM to {peer_id}: {e}"); } else { info!(target: "ui.dm_view", "DM sent to {peer_id}"); diff --git a/communitas-dioxus/src/components/feed_view.rs b/communitas-dioxus/src/components/feed_view.rs index a99320c30..20372ab86 100644 --- a/communitas-dioxus/src/components/feed_view.rs +++ b/communitas-dioxus/src/components/feed_view.rs @@ -113,7 +113,7 @@ pub fn FeedView(props: FeedViewProps) -> Element { let mut posts: Signal> = use_signal(Vec::new); let mut composer_text = use_signal(String::new); let mut posting = use_signal(|| false); - let mut ws_connected = use_signal(|| false); + let mut ws_connected = use_signal(|| !crate::live_streams_enabled()); // Load history on mount let history_group_id = group_id.clone(); @@ -149,63 +149,79 @@ pub fn FeedView(props: FeedViewProps) -> Element { use_coroutine(move |_: UnboundedReceiver<()>| { let group_id = ws_group_id.clone(); async move { - let topic = feed_topic(&group_id); + if !crate::live_streams_enabled() { + return; + } - let ws = match X0xWebSocket::connect().await { - Ok(ws) => { - if let Err(e) = ws.subscribe(vec![topic.clone()]) { - error!(target: "ui.feed", "Failed to subscribe to feed topic: {e}"); - return; + let topic = feed_topic(&group_id); + let mut retry_delay = std::time::Duration::from_secs(1); + + loop { + let mut ws = match X0xWebSocket::connect().await { + Ok(ws) => { + if let Err(e) = ws.subscribe(vec![topic.clone()]) { + error!(target: "ui.feed", "Failed to subscribe to feed topic: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + info!(target: "ui.feed", "Subscribed to feed topic: {topic}"); + ws_connected.set(true); + retry_delay = std::time::Duration::from_secs(1); + ws } - info!(target: "ui.feed", "Subscribed to feed topic: {topic}"); - ws_connected.set(true); - ws - } - Err(e) => { - warn!(target: "ui.feed", "WebSocket connect failed: {e}"); - return; - } - }; - - let mut ws = ws; - while let Some(inbound) = ws.recv().await { - match inbound { - communitas_x0x_client::WsInbound::Message { - topic: msg_topic, - payload, - .. - } => { - if msg_topic == topic - && let Ok(bytes) = - base64::engine::general_purpose::STANDARD.decode(&payload) - { - match serde_json::from_slice::(&bytes) { - Ok(post) => { - let save_post = post.clone(); - posts.with_mut(|list| { - if !list.iter().any(|p| p.id == save_post.id) { - list.insert(0, save_post); - if list.len() > FEED_LIMIT { - list.truncate(FEED_LIMIT); + Err(e) => { + warn!(target: "ui.feed", "WebSocket connect failed: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + }; + + while let Some(inbound) = ws.recv().await { + match inbound { + communitas_x0x_client::WsInbound::Message { + topic: msg_topic, + payload, + .. + } => { + if msg_topic == topic + && let Ok(bytes) = + base64::engine::general_purpose::STANDARD.decode(&payload) + { + match serde_json::from_slice::(&bytes) { + Ok(post) => { + let save_post = post.clone(); + posts.with_mut(|list| { + if !list.iter().any(|p| p.id == save_post.id) { + list.insert(0, save_post); + if list.len() > FEED_LIMIT { + list.truncate(FEED_LIMIT); + } } - } - }); - save_feed_history(&group_id, &posts()).await; - } - Err(e) => { - warn!(target: "ui.feed", "Failed to parse feed post: {e}"); + }); + save_feed_history(&group_id, &posts()).await; + } + Err(e) => { + warn!(target: "ui.feed", "Failed to parse feed post: {e}"); + } } } } + communitas_x0x_client::WsInbound::Error { message } => { + error!(target: "ui.feed", "WebSocket error: {message}"); + } + _ => {} } - communitas_x0x_client::WsInbound::Error { message } => { - error!(target: "ui.feed", "WebSocket error: {message}"); - } - _ => {} } - } - ws_connected.set(false); + ws_connected.set(false); + warn!(target: "ui.feed", "WebSocket closed; reconnecting"); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + } } }); diff --git a/communitas-dioxus/src/components/files_view.rs b/communitas-dioxus/src/components/files_view.rs index 0723f1cf7..bba710279 100644 --- a/communitas-dioxus/src/components/files_view.rs +++ b/communitas-dioxus/src/components/files_view.rs @@ -21,6 +21,45 @@ fn short_id(id: &str) -> String { } } +fn same_transfers(left: &[FileTransfer], right: &[FileTransfer]) -> bool { + left.len() == right.len() + && left.iter().zip(right).all(|(left, right)| { + left.transfer_id == right.transfer_id + && left.direction == right.direction + && left.remote_agent_id == right.remote_agent_id + && left.filename == right.filename + && left.total_size == right.total_size + && left.bytes_transferred == right.bytes_transferred + && left.status == right.status + && left.sha256 == right.sha256 + && left.error == right.error + && left.started_at == right.started_at + }) +} + +async fn refresh_transfers( + mut transfers: Signal>, + mut error: Signal>, + mut loading: Signal, +) { + let client = X0xClient::new(); + match client.transfers().await { + Ok(list) => { + transfers.set(list); + if error.peek().is_some() { + error.set(None); + } + } + Err(e) => { + warn!(target: "ui.files", "failed to list transfers: {e}"); + error.set(Some(format!("{e}"))); + } + } + if *loading.peek() { + loading.set(false); + } +} + /// Props for the files view. #[derive(Props, Clone, PartialEq)] pub struct FilesViewProps { @@ -42,20 +81,29 @@ pub fn FilesView(props: FilesViewProps) -> Element { let client = X0xClient::new(); loop { - let _key = *refresh_key.read(); + let _key = *refresh_key.peek(); match client.transfers().await { Ok(list) => { - transfers.set(list); - error.set(None); + if !same_transfers(transfers.peek().as_slice(), list.as_slice()) { + transfers.set(list); + } + if error.peek().is_some() { + error.set(None); + } } Err(e) => { warn!(target: "ui.files", "failed to list transfers: {e}"); - error.set(Some(format!("{e}"))); + let next_error = Some(format!("{e}")); + if error.peek().as_ref() != next_error.as_ref() { + error.set(next_error); + } } } - loading.set(false); + if *loading.peek() { + loading.set(false); + } - tokio::time::sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; } }); @@ -126,7 +174,7 @@ pub fn FilesView(props: FilesViewProps) -> Element { } // Send file form - {send_file_form(refresh_key)} + {send_file_form(transfers, error, loading, refresh_key)} // Incoming files requiring action if !incoming_pending.is_empty() { @@ -186,6 +234,7 @@ pub fn FilesView(props: FilesViewProps) -> Element { if let Err(e) = client.accept_file(&tid).await { warn!(target: "ui.files", "failed to accept: {e}"); } + refresh_transfers(transfers, error, loading).await; refresh_key.set(refresh_key() + 1); }); } @@ -209,6 +258,7 @@ pub fn FilesView(props: FilesViewProps) -> Element { if let Err(e) = client.reject_file(&tid, None).await { warn!(target: "ui.files", "failed to reject: {e}"); } + refresh_transfers(transfers, error, loading).await; refresh_key.set(refresh_key() + 1); }); } @@ -331,7 +381,12 @@ pub fn FilesView(props: FilesViewProps) -> Element { } /// Send file form — agent ID + file path inputs. -fn send_file_form(mut refresh_key: Signal) -> Element { +fn send_file_form( + transfers: Signal>, + error: Signal>, + loading: Signal, + mut refresh_key: Signal, +) -> Element { let mut agent_id_input = use_signal(String::new); let mut file_path_input = use_signal(String::new); let mut sending = use_signal(|| false); @@ -420,7 +475,23 @@ fn send_file_form(mut refresh_key: Signal) -> Element { spawn(async move { let path = std::path::Path::new(&file_path); - let meta = match tokio::fs::metadata(path).await { + let canonical_path = match tokio::fs::canonicalize(path).await { + Ok(path) => path, + Err(e) => { + send_error.set(Some(format!("Cannot read file: {e}"))); + sending.set(false); + return; + } + }; + let source_path = match canonical_path.to_str() { + Some(path) => path.to_owned(), + None => { + send_error.set(Some("File path must be valid UTF-8".into())); + sending.set(false); + return; + } + }; + let meta = match tokio::fs::metadata(&canonical_path).await { Ok(m) => m, Err(e) => { send_error.set(Some(format!("Cannot read file: {e}"))); @@ -435,7 +506,7 @@ fn send_file_form(mut refresh_key: Signal) -> Element { .unwrap_or_else(|| "file".to_string()); // Compute SHA-256 - let data = match tokio::fs::read(path).await { + let data = match tokio::fs::read(&canonical_path).await { Ok(d) => d, Err(e) => { send_error.set(Some(format!("Failed to read file: {e}"))); @@ -448,7 +519,7 @@ fn send_file_form(mut refresh_key: Signal) -> Element { let client = X0xClient::new(); match client - .send_file(&agent_id, &filename, size, &sha256, Some(&file_path)) + .send_file(&agent_id, &filename, size, &sha256, Some(&source_path)) .await { Ok(transfer_id) => { @@ -462,6 +533,7 @@ fn send_file_form(mut refresh_key: Signal) -> Element { ))); agent_id_input.set(String::new()); file_path_input.set(String::new()); + refresh_transfers(transfers, error, loading).await; refresh_key.set(refresh_key() + 1); } Err(e) => send_error.set(Some(format!("Send failed: {e}"))), diff --git a/communitas-dioxus/src/components/local_x0x_profile_view.rs b/communitas-dioxus/src/components/local_x0x_profile_view.rs index 29a5b00a3..136c6eaaf 100644 --- a/communitas-dioxus/src/components/local_x0x_profile_view.rs +++ b/communitas-dioxus/src/components/local_x0x_profile_view.rs @@ -85,7 +85,7 @@ async fn load_local_x0x_profile() -> LocalX0xProfile { Err(err) => warn!(target: "ui.local_x0x_profile", "failed to load /agent: {err}"), } - match client.agent_card(None, Some(false)).await { + match client.agent_card_for_local_share(None, Some(false)).await { Ok(card_resp) => { let card = card_resp.card; if !card.display_name.trim().is_empty() { diff --git a/communitas-dioxus/src/components/network_view.rs b/communitas-dioxus/src/components/network_view.rs index 4f5730bb6..db2d3f777 100644 --- a/communitas-dioxus/src/components/network_view.rs +++ b/communitas-dioxus/src/components/network_view.rs @@ -167,7 +167,7 @@ pub fn NetworkView() -> Element { data.set(d); loading.set(false); - tokio::time::sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; } }); diff --git a/communitas-dioxus/src/components/offline.rs b/communitas-dioxus/src/components/offline.rs index 491851a12..4f0197994 100644 --- a/communitas-dioxus/src/components/offline.rs +++ b/communitas-dioxus/src/components/offline.rs @@ -844,7 +844,7 @@ pub fn Toast(props: ToastProps) -> Element { let toast_id = toast_id_for_timer.clone(); async move { if auto_dismiss { - tokio::time::sleep(std::time::Duration::from_millis(duration as u64)).await; + crate::ui_sleep(std::time::Duration::from_millis(duration as u64)).await; on_dismiss_timer.call(toast_id); } } diff --git a/communitas-dioxus/src/components/onboarding_gate.rs b/communitas-dioxus/src/components/onboarding_gate.rs index 7852a73cc..fbe683804 100644 --- a/communitas-dioxus/src/components/onboarding_gate.rs +++ b/communitas-dioxus/src/components/onboarding_gate.rs @@ -78,13 +78,7 @@ pub fn OnboardingGate(children: Element) -> Element { // On mount, check daemon state once. use_future(move || async move { - let manager = DaemonManager::new(); - let state = manager.state().await; - match state { - DaemonState::Running | DaemonState::Degraded => gate.set(GateState::Ready), - DaemonState::NotRunning => gate.set(GateState::NotRunning), - DaemonState::NotInstalled => gate.set(GateState::NotInstalled), - } + gate.set(check_daemon_gate_state().await); }); match gate() { @@ -115,7 +109,7 @@ pub fn OnboardingGate(children: Element) -> Element { gate.set(GateState::Ready); } else { gate.set(GateState::Failed( - "x0x started but did not become healthy within 15 seconds. Try restarting Communitas.".to_string(), + "x0x started but did not become healthy within 30 seconds. Try restarting Communitas.".to_string(), )); } } @@ -193,7 +187,12 @@ pub fn OnboardingGate(children: Element) -> Element { OnboardingScreen { FailedView { error: err_msg, - on_retry: move |_| gate.set(GateState::Checking), + on_retry: move |_| { + gate.set(GateState::Checking); + spawn(async move { + gate.set(check_daemon_gate_state().await); + }); + }, } } } @@ -202,7 +201,12 @@ pub fn OnboardingGate(children: Element) -> Element { GateState::Cancelled => rsx! { OnboardingScreen { CancelledView { - on_try_again: move |_| gate.set(GateState::Checking), + on_try_again: move |_| { + gate.set(GateState::Checking); + spawn(async move { + gate.set(check_daemon_gate_state().await); + }); + }, on_quit: move |_| quit_app(), } } @@ -212,6 +216,16 @@ pub fn OnboardingGate(children: Element) -> Element { // ── Helper ─────────────────────────────────────────────────────────────────── +/// Probe the x0x daemon and convert the lifecycle result into a UI state. +async fn check_daemon_gate_state() -> GateState { + let manager = DaemonManager::new(); + match manager.state().await { + DaemonState::Running | DaemonState::Degraded => GateState::Ready, + DaemonState::NotRunning => GateState::NotRunning, + DaemonState::NotInstalled => GateState::NotInstalled, + } +} + /// Poll the x0xd health endpoint until it responds successfully or the /// `timeout_secs` deadline elapses. Returns `true` if healthy. /// @@ -230,7 +244,7 @@ async fn poll_until_healthy(timeout_secs: u64) -> bool { if std::time::Instant::now() >= deadline { return false; } - tokio::time::sleep(tokio::time::Duration::from_millis(POLL_INTERVAL_MS)).await; + crate::poll_sleep(tokio::time::Duration::from_millis(POLL_INTERVAL_MS)).await; } } diff --git a/communitas-dioxus/src/components/people_view.rs b/communitas-dioxus/src/components/people_view.rs index b76b30599..b172f8f55 100644 --- a/communitas-dioxus/src/components/people_view.rs +++ b/communitas-dioxus/src/components/people_view.rs @@ -73,6 +73,53 @@ fn relative_time(last_seen_secs: u64) -> String { } } +fn same_contacts(left: &[Contact], right: &[Contact]) -> bool { + left.len() == right.len() + && left.iter().zip(right).all(|(left, right)| { + left.agent_id == right.agent_id + && left.trust_level == right.trust_level + && left.label == right.label + && left.added_at == right.added_at + && left.last_seen == right.last_seen + && left.identity_type == right.identity_type + }) +} + +fn same_discovered_agents(left: &[DiscoveredAgent], right: &[DiscoveredAgent]) -> bool { + left.len() == right.len() + && left.iter().zip(right).all(|(left, right)| { + left.agent_id == right.agent_id + && left.machine_id == right.machine_id + && left.user_id == right.user_id + && left.addresses == right.addresses + && left.announced_at == right.announced_at + && left.last_seen == right.last_seen + }) +} + +async fn refresh_contacts( + client: &X0xClient, + mut contacts: Signal>, + mut error: Signal>, + mut loading: Signal, +) { + match client.list_contacts().await { + Ok(list) => { + contacts.set(list); + if error.peek().is_some() { + error.set(None); + } + } + Err(e) => { + warn!(target: "ui.people", "failed to list contacts: {e}"); + error.set(Some(format!("Failed to load contacts: {e}"))); + } + } + if *loading.peek() { + loading.set(false); + } +} + /// People view component. #[component] pub fn PeopleView() -> Element { @@ -101,20 +148,29 @@ pub fn PeopleView() -> Element { let client = X0xClient::new(); loop { - let _key = *refresh_key.read(); // reactive dependency + let _key = *refresh_key.peek(); match client.list_contacts().await { Ok(list) => { - contacts.set(list); - error.set(None); + if !same_contacts(contacts.peek().as_slice(), list.as_slice()) { + contacts.set(list); + } + if error.peek().is_some() { + error.set(None); + } } Err(e) => { warn!(target: "ui.people", "failed to list contacts: {e}"); - error.set(Some(format!("Failed to load contacts: {e}"))); + let next_error = Some(format!("Failed to load contacts: {e}")); + if error.peek().as_ref() != next_error.as_ref() { + error.set(next_error); + } } } - loading.set(false); + if *loading.peek() { + loading.set(false); + } - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; } }); @@ -124,14 +180,21 @@ pub fn PeopleView() -> Element { loop { match client.discovered_agents().await { Ok(agents) => { - discovered_agents.set(agents); + if !same_discovered_agents( + discovered_agents.peek().as_slice(), + agents.as_slice(), + ) { + discovered_agents.set(agents); + } } Err(e) => { warn!(target: "ui.people", "failed to list discovered agents: {e}"); } } - agents_loading.set(false); - tokio::time::sleep(tokio::time::Duration::from_secs(AGENT_REFRESH_SECS)).await; + if *agents_loading.peek() { + agents_loading.set(false); + } + crate::poll_sleep(tokio::time::Duration::from_secs(AGENT_REFRESH_SECS)).await; } }); @@ -256,6 +319,7 @@ pub fn PeopleView() -> Element { Ok(resp) => { info!(target: "ui.people", "imported agent card: {}", resp.agent_id); import_input.set(String::new()); + refresh_contacts(&client, contacts, error, loading).await; refresh_key.set(refresh_key() + 1); } Err(e) => { @@ -325,6 +389,7 @@ pub fn PeopleView() -> Element { Ok(_) => { add_agent_id.set(String::new()); add_label.set(String::new()); + refresh_contacts(&client, contacts, error, loading).await; refresh_key.set(refresh_key() + 1); } Err(e) => add_error.set(Some(format!("{e}"))), @@ -585,6 +650,8 @@ pub fn PeopleView() -> Element { let client = X0xClient::new(); if let Err(e) = client.set_trust(&aid, level).await { warn!(target: "ui.people", "failed to set trust: {e}"); + } else { + refresh_contacts(&client, contacts, error, loading).await; } refresh_key.set(refresh_key() + 1); }); @@ -741,6 +808,8 @@ pub fn PeopleView() -> Element { let client = X0xClient::new(); if let Err(e) = client.add_contact(&aid, TrustLevel::Known, None).await { warn!(target: "ui.people", "failed to add discovered agent as contact: {e}"); + } else { + refresh_contacts(&client, contacts, error, loading).await; } refresh_key.set(refresh_key() + 1); }); diff --git a/communitas-dioxus/src/components/settings_view.rs b/communitas-dioxus/src/components/settings_view.rs index 498b72b7d..7bbf23d8b 100644 --- a/communitas-dioxus/src/components/settings_view.rs +++ b/communitas-dioxus/src/components/settings_view.rs @@ -6,6 +6,7 @@ use std::time::Duration; use communitas_x0x_client::X0xClient; use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::tokens::{colors, radius, spacing, typography}; @@ -34,6 +35,61 @@ fn format_uptime(secs: u64) -> String { const SETTINGS_LOAD_TIMEOUT_SECS: u64 = 3; +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct LocalProfileSettings { + display_name: String, +} + +fn profile_settings_path() -> std::path::PathBuf { + if let Some(base) = std::env::var_os("COMMUNITAS_X0X_SETTINGS_DIR") { + std::path::PathBuf::from(base).join("profile-settings.json") + } else { + dirs::data_local_dir() + .or_else(dirs::data_dir) + .or_else(dirs::home_dir) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join("communitas") + .join("profile-settings.json") + } +} + +async fn load_saved_display_name() -> Option { + let path = profile_settings_path(); + match tokio::fs::read(&path).await { + Ok(bytes) => match serde_json::from_slice::(&bytes) { + Ok(settings) => { + let name = settings.display_name.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } + } + Err(err) => { + warn!(target: "ui.settings", "failed to parse profile settings {}: {err}", path.display()); + None + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => None, + Err(err) => { + warn!(target: "ui.settings", "failed to read profile settings {}: {err}", path.display()); + None + } + } +} + +async fn save_display_name(display_name: &str) -> Result<(), std::io::Error> { + let path = profile_settings_path(); + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let bytes = serde_json::to_vec_pretty(&LocalProfileSettings { + display_name: display_name.to_string(), + }) + .map_err(std::io::Error::other)?; + tokio::fs::write(path, bytes).await +} + /// Settings page component. #[component] pub fn SettingsView() -> Element { @@ -57,16 +113,32 @@ pub fn SettingsView() -> Element { use_future(move || async move { let client = X0xClient::new(); let timeout = Duration::from_secs(SETTINGS_LOAD_TIMEOUT_SECS); + let saved_display_name = load_saved_display_name().await; + + if let Some(ref name) = saved_display_name { + display_name.set(name.clone()); + display_name_original.set(name.clone()); + } - if let Ok(Ok(card_resp)) = - tokio::time::timeout(timeout, client.agent_card(None, Some(false))).await + if let Ok(Ok(card_resp)) = tokio::time::timeout( + timeout, + client.agent_card(saved_display_name.as_deref(), Some(false)), + ) + .await { - display_name.set(card_resp.card.display_name.clone()); - display_name_original.set(card_resp.card.display_name); + let name = saved_display_name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or(card_resp.card.display_name); + display_name.set(name.clone()); + display_name_original.set(name); } - if let Ok(Ok(card_resp)) = - tokio::time::timeout(timeout, client.agent_card(None, Some(true))).await + if let Ok(Ok(card_resp)) = tokio::time::timeout( + timeout, + client.agent_card_for_local_share(saved_display_name.as_deref(), Some(true)), + ) + .await { agent_card_link.set(Some(card_resp.link)); } @@ -212,12 +284,24 @@ pub fn SettingsView() -> Element { spawn(async move { let client = X0xClient::new(); - // Generate a new card with the updated name - match client.agent_card(Some(&new_name), Some(false)).await { - Ok(_) => { + match client + .agent_card_for_local_share(Some(&new_name), Some(true)) + .await + { + Ok(resp) => { info!(target: "ui.settings", "display name updated to: {new_name}"); - display_name_original.set(new_name); - save_success.set(true); + let saved_name = resp.card.display_name.clone(); + match save_display_name(&saved_name).await { + Ok(()) => { + display_name.set(saved_name.clone()); + display_name_original.set(saved_name); + agent_card_link.set(Some(resp.link)); + save_success.set(true); + } + Err(err) => { + save_error.set(Some(format!("Failed to persist display name: {err}"))); + } + } } Err(e) => { save_error.set(Some(format!("{e}"))); @@ -262,11 +346,17 @@ pub fn SettingsView() -> Element { div { style: format!( "font-family: {}; font-size: {}; color: {}; \ - word-break: break-all; margin-bottom: {};", + word-break: break-all; margin-bottom: {}; max-height: 160px; \ + overflow-y: auto; background: {}; border: 1px solid {}; \ + border-radius: {}; padding: {}; line-height: 1.45;", typography::FONT_MONO, typography::TEXT_XS, colors::TEXT_SECONDARY, spacing::SM, + colors::SURFACE_BG, + colors::BORDER_DEFAULT, + radius::MD, + spacing::SM, ), "{link}" } @@ -280,9 +370,15 @@ pub fn SettingsView() -> Element { disabled: generating_card(), onclick: move |_| { generating_card.set(true); + let current_name = display_name().trim().to_string(); spawn(async move { let client = X0xClient::new(); - match client.agent_card(None, Some(true)).await { + let display_name = if current_name.is_empty() { + None + } else { + Some(current_name.as_str()) + }; + match client.agent_card_for_local_share(display_name, Some(true)).await { Ok(resp) => { agent_card_link.set(Some(resp.link)); } @@ -311,7 +407,7 @@ pub fn SettingsView() -> Element { copy_text(link); card_copied.set(true); spawn(async move { - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + crate::ui_sleep(tokio::time::Duration::from_secs(2)).await; card_copied.set(false); }); } diff --git a/communitas-dioxus/src/components/space_admin.rs b/communitas-dioxus/src/components/space_admin.rs index f80f13da5..ae7f381b1 100644 --- a/communitas-dioxus/src/components/space_admin.rs +++ b/communitas-dioxus/src/components/space_admin.rs @@ -41,6 +41,50 @@ pub struct SpaceAdminProps { pub space_id: String, } +fn set_signal_if_changed(signal: &mut Signal, next: T) { + let changed = { + let current = signal.peek(); + *current != next + }; + if changed { + signal.set(next); + } +} + +fn set_group_info_if_changed(signal: &mut Signal>, next: GroupInfo) { + let changed = { + let current = signal.peek(); + match &*current { + Some(current) => !group_info_same(current, &next), + None => true, + } + }; + if changed { + signal.set(Some(next)); + } +} + +fn group_info_same(left: &GroupInfo, right: &GroupInfo) -> bool { + left.group_id == right.group_id + && left.name == right.name + && left.description == right.description + && left.creator == right.creator + && left.created_at == right.created_at + && left.member_count == right.member_count + && left.chat_topic == right.chat_topic + && left.metadata_topic == right.metadata_topic + && left.policy == right.policy + && left.members.len() == right.members.len() + && left + .members + .iter() + .zip(&right.members) + .all(|(left_member, right_member)| { + left_member.agent_id == right_member.agent_id + && left_member.display_name == right_member.display_name + }) +} + /// Top-level admin panel rendered under the space's Manage tab. #[component] pub fn SpaceAdminPanel(props: SpaceAdminProps) -> Element { @@ -64,20 +108,23 @@ pub fn SpaceAdminPanel(props: SpaceAdminProps) -> Element { let client = X0xClient::new(); loop { match client.agent().await { - Ok(a) => caller_agent_id.set(Some(a.agent_id)), + Ok(a) => set_signal_if_changed(&mut caller_agent_id, Some(a.agent_id)), Err(e) => warn!(target: "ui.space_admin", "agent fetch failed: {e}"), } match client.get_group(&space_id).await { Ok(info) => { - group_info.set(Some(info)); - last_error.set(None); + set_group_info_if_changed(&mut group_info, info); + set_signal_if_changed(&mut last_error, None); } Err(e) => { warn!(target: "ui.space_admin", "get_group failed: {e}"); - last_error.set(Some(format!("Failed to load group: {e}"))); + set_signal_if_changed( + &mut last_error, + Some(format!("Failed to load group: {e}")), + ); } } - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; } } }); @@ -88,9 +135,9 @@ pub fn SpaceAdminPanel(props: SpaceAdminProps) -> Element { let client = X0xClient::new(); loop { if let Ok(list) = client.list_named_group_members(&space_id).await { - members.set(list); + set_signal_if_changed(&mut members, list); } - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; } } }); @@ -102,10 +149,10 @@ pub fn SpaceAdminPanel(props: SpaceAdminProps) -> Element { loop { // 403 on non-admin is expected; swallow it. match client.list_join_requests(&space_id).await { - Ok(list) => requests.set(list), - Err(_) => requests.set(Vec::new()), + Ok(list) => set_signal_if_changed(&mut requests, list), + Err(_) => set_signal_if_changed(&mut requests, Vec::new()), } - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)).await; } } }); @@ -338,26 +385,25 @@ fn StatePanel(props: StatePanelProps) -> Element { async move { let client = X0xClient::new(); loop { - // chain_rev() is read to participate in reactivity; - // a bump from seal/withdraw triggers the next poll - // sooner via the inline re-fetch in those handlers. - let _ = chain_rev(); + // Keep the revision marker available to action handlers without + // subscribing this long-lived coroutine to signal wakes. + let _ = *chain_rev.peek(); match client.get_group_state(&space_id).await { Ok(state) => { - chain.set(Some(state)); - chain_error.set(None); + set_signal_if_changed(&mut chain, Some(state)); + set_signal_if_changed(&mut chain_error, None); } Err(e) => { let msg = format!("{e}"); // 403 is normal for non-members; silence it. if !msg.contains("not a member") { warn!(target: "ui.space_admin", "state fetch failed: {msg}"); - chain_error.set(Some(msg)); + set_signal_if_changed(&mut chain_error, Some(msg)); } - chain.set(None); + set_signal_if_changed(&mut chain, None); } } - tokio::time::sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)) + crate::poll_sleep(tokio::time::Duration::from_secs(REFRESH_INTERVAL_SECS)) .await; } } diff --git a/communitas-dioxus/src/components/space_view.rs b/communitas-dioxus/src/components/space_view.rs index b161de8a4..e5c4c00f6 100644 --- a/communitas-dioxus/src/components/space_view.rs +++ b/communitas-dioxus/src/components/space_view.rs @@ -57,6 +57,7 @@ pub fn SpaceView(props: SpaceViewProps) -> Element { let mut thread_message = use_signal(|| Option::::None); let reply_count_bump = use_signal(|| Option::<(String, u64)>::None); let mut channel_sidebar_refresh = use_signal(|| 0u64); + let mut last_space_id = use_signal(|| props.space_id.clone()); let mut create_channel_group_id = use_signal(|| Option::::None); let mut create_channel_name = use_signal(String::new); let mut create_channel_description = use_signal(String::new); @@ -65,6 +66,19 @@ pub fn SpaceView(props: SpaceViewProps) -> Element { let current_tab = *active_tab.read(); let space_id = props.space_id.clone(); + { + let active_space_id = space_id.clone(); + use_effect(move || { + if *last_space_id.peek() != active_space_id { + last_space_id.set(active_space_id.clone()); + selected_channel.set(None); + thread_message.set(None); + channel_sidebar_refresh.with_mut(|version| { + *version = version.saturating_add(1); + }); + } + }); + } let container_style = format!( "display: flex; flex-direction: column; height: 100%; overflow: hidden; \ @@ -104,6 +118,11 @@ pub fn SpaceView(props: SpaceViewProps) -> Element { }; let content_style = "flex: 1; overflow: hidden; display: flex;"; + let chat_pane_style = if current_tab == SpaceTab::Chat { + "flex: 1; overflow: hidden; display: flex;" + } else { + "display: none;" + }; rsx! { div { @@ -177,124 +196,126 @@ pub fn SpaceView(props: SpaceViewProps) -> Element { style: "{content_style}", role: "tabpanel", - match current_tab { - SpaceTab::Chat => rsx! { - // Channel sidebar + chat - div { - style: "width: 220px; flex-shrink: 0; border-right: 1px solid rgba(37, 41, 64, 0.8);", - components::ChannelSidebar { - refresh_key: channel_sidebar_refresh(), - selected: selected_channel(), - on_select: move |ch: components::channel_sidebar::SelectedChannel| { - selected_channel.set(Some(ch)); - thread_message.set(None); - }, - on_create_channel: move |group_id: String| { - create_channel_group_id.set(Some(group_id)); - create_channel_name.set(String::new()); - create_channel_description.set(String::new()); - create_channel_error.set(None); - }, - on_open_identity: move |_| {}, - } + div { + style: "{chat_pane_style}", + div { + style: "width: 220px; flex-shrink: 0; border-right: 1px solid rgba(37, 41, 64, 0.8);", + components::ChannelSidebar { + active_space_id: Some(space_id.clone()), + refresh_key: channel_sidebar_refresh(), + selected: selected_channel(), + on_select: move |ch: components::channel_sidebar::SelectedChannel| { + selected_channel.set(Some(ch)); + thread_message.set(None); + }, + on_create_channel: move |group_id: String| { + create_channel_group_id.set(Some(group_id)); + create_channel_name.set(String::new()); + create_channel_description.set(String::new()); + create_channel_error.set(None); + }, + on_open_identity: move |_| {}, } + } - div { - style: "flex: 1; display: flex; overflow: hidden;", - - if let Some(channel) = selected_channel() { - div { - style: "flex: 1; display: flex; flex-direction: column; overflow: hidden;", - components::ChannelChatView { - key: "{channel.group_id}:{channel.channel_name}", - channel: channel.clone(), - on_open_thread: move |msg: models::channel::ChatMessage| { - thread_message.set(Some(msg)); - }, - reply_count_bump: reply_count_bump, - } + div { + style: "flex: 1; display: flex; overflow: hidden;", + + if let Some(channel) = selected_channel() { + div { + style: "flex: 1; display: flex; flex-direction: column; overflow: hidden;", + components::ChannelChatView { + key: "{channel.group_id}:{channel.channel_name}", + channel: channel.clone(), + on_open_thread: move |msg: models::channel::ChatMessage| { + thread_message.set(Some(msg)); + }, + reply_count_bump: reply_count_bump, } + } - if let Some(ref parent_msg) = thread_message() { - if let Some(ref ch) = selected_channel() { - components::ThreadPanel { - key: "{ch.group_id}:{ch.channel_name}:{parent_msg.id}", - parent_message: parent_msg.clone(), - channel: ch.clone(), - on_close: move |_| { - thread_message.set(None); - }, - reply_count_bump: reply_count_bump, - } + if let Some(ref parent_msg) = thread_message() { + if let Some(ref ch) = selected_channel() { + components::ThreadPanel { + key: "{ch.group_id}:{ch.channel_name}:{parent_msg.id}", + parent_message: parent_msg.clone(), + channel: ch.clone(), + on_close: move |_| { + thread_message.set(None); + }, + reply_count_bump: reply_count_bump, } } - } else { - div { - style: format!( - "flex: 1; display: flex; align-items: center; justify-content: center; \ - color: {};", - colors::TEXT_MUTED, - ), - "Select a channel to start chatting" - } + } + } else { + div { + style: format!( + "flex: 1; display: flex; align-items: center; justify-content: center; \ + color: {};", + colors::TEXT_MUTED, + ), + "Select a channel to start chatting" } } + } - // Create channel modal - if let Some(group_id) = create_channel_group_id() { - components::CreateChannelModal { - channel_name: create_channel_name(), - channel_description: create_channel_description(), - submitting: create_channel_submitting(), - error: create_channel_error(), - on_name_change: move |value: String| create_channel_name.set(value), - on_description_change: move |value: String| create_channel_description.set(value), - on_cancel: move |_| { + if let Some(group_id) = create_channel_group_id() { + components::CreateChannelModal { + channel_name: create_channel_name(), + channel_description: create_channel_description(), + submitting: create_channel_submitting(), + error: create_channel_error(), + on_name_change: move |value: String| create_channel_name.set(value), + on_description_change: move |value: String| create_channel_description.set(value), + on_cancel: move |_| { + if create_channel_submitting() { return; } + create_channel_group_id.set(None); + create_channel_error.set(None); + }, + on_create: { + let _space_id = space_id.clone(); + move |_| { if create_channel_submitting() { return; } - create_channel_group_id.set(None); + + let group_id = group_id.clone(); + let raw_name = create_channel_name(); + let raw_description = create_channel_description(); + + create_channel_submitting.set(true); create_channel_error.set(None); - }, - on_create: { - let _space_id = space_id.clone(); - move |_| { - if create_channel_submitting() { return; } - - let group_id = group_id.clone(); - let raw_name = create_channel_name(); - let raw_description = create_channel_description(); - - create_channel_submitting.set(true); - create_channel_error.set(None); - - spawn(async move { - match crate::create_channel(&group_id, &raw_name, &raw_description).await { - Ok(channel) => { - info!( - target: "ui.space", - "Created channel {} in group {}", - channel.name, group_id - ); - selected_channel.set(Some( - components::channel_sidebar::SelectedChannel { - group_id: group_id.clone(), - channel_name: channel.name.clone(), - topic: channel.topic.clone(), - meta: Some(channel), - }, - )); - thread_message.set(None); - channel_sidebar_refresh.set(channel_sidebar_refresh() + 1); - create_channel_group_id.set(None); - } - Err(err) => create_channel_error.set(Some(err)), + + spawn(async move { + match crate::create_channel(&group_id, &raw_name, &raw_description).await { + Ok(channel) => { + info!( + target: "ui.space", + "Created channel {} in group {}", + channel.name, group_id + ); + selected_channel.set(Some( + components::channel_sidebar::SelectedChannel { + group_id: group_id.clone(), + channel_name: channel.name.clone(), + topic: channel.topic.clone(), + meta: Some(channel), + }, + )); + thread_message.set(None); + channel_sidebar_refresh.set(channel_sidebar_refresh() + 1); + create_channel_group_id.set(None); } - create_channel_submitting.set(false); - }); - } - }, - } + Err(err) => create_channel_error.set(Some(err)), + } + create_channel_submitting.set(false); + }); + } + }, } - }, + } + } + + match current_tab { + SpaceTab::Chat => rsx! {}, SpaceTab::Board => { let gid = space_id.clone(); diff --git a/communitas-dioxus/src/components/status_bar.rs b/communitas-dioxus/src/components/status_bar.rs index a73f3a689..597bebe5e 100644 --- a/communitas-dioxus/src/components/status_bar.rs +++ b/communitas-dioxus/src/components/status_bar.rs @@ -2,7 +2,7 @@ //! Bottom status bar showing daemon connectivity, peer count, and agent identity. -use communitas_x0x_client::{DaemonManager, DaemonState, X0xClient}; +use communitas_x0x_client::{DaemonManager, DaemonState, X0xClient, is_running_health_status}; use dioxus::prelude::*; use crate::tokens::{colors, radius, spacing, typography}; @@ -54,33 +54,55 @@ pub fn StatusBar() -> Element { loop { match client.health().await { Ok(health) => { - let state = if health.status == "healthy" || health.status == "running" { + let state = if is_running_health_status(&health.status) { DaemonState::Running } else { DaemonState::Degraded }; - daemon_state.set(state); - peers.set(health.peers); - version.set(Some(health.version.clone())); + if *daemon_state.peek() != state { + daemon_state.set(state); + } + if *peers.peek() != health.peers { + peers.set(health.peers); + } + let next_version = Some(health.version.clone()); + if version.peek().as_ref() != next_version.as_ref() { + version.set(next_version); + } match client.agent().await { - Ok(identity) => agent_id.set(Some(identity.agent_id)), + Ok(identity) => { + let next_agent_id = Some(identity.agent_id); + if agent_id.peek().as_ref() != next_agent_id.as_ref() { + agent_id.set(next_agent_id); + } + } Err(e) => { warn!(target: "ui.status_bar", "failed to fetch agent: {e}"); - agent_id.set(None); + if agent_id.peek().is_some() { + agent_id.set(None); + } } } } Err(_) => { let state = manager.state().await; - daemon_state.set(state); - agent_id.set(None); - peers.set(0); - version.set(None); + if *daemon_state.peek() != state { + daemon_state.set(state); + } + if agent_id.peek().is_some() { + agent_id.set(None); + } + if *peers.peek() != 0 { + peers.set(0); + } + if version.peek().is_some() { + version.set(None); + } } } - tokio::time::sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(POLL_INTERVAL_SECS)).await; } }); @@ -153,7 +175,7 @@ pub fn StatusBar() -> Element { format!("navigator.clipboard.writeText(\"{escaped}\").catch(()=>{{}});",); let _ = dioxus::document::eval(&script); copied.set(true); - tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + crate::ui_sleep(tokio::time::Duration::from_secs(2)).await; copied.set(false); }); } diff --git a/communitas-dioxus/src/components/swarm_view.rs b/communitas-dioxus/src/components/swarm_view.rs index 3b7544449..23d8e10bf 100644 --- a/communitas-dioxus/src/components/swarm_view.rs +++ b/communitas-dioxus/src/components/swarm_view.rs @@ -104,7 +104,7 @@ pub fn SwarmView(props: SwarmViewProps) -> Element { let mut task_desc = use_signal(String::new); let mut task_caps = use_signal(String::new); let mut posting = use_signal(|| false); - let mut ws_connected = use_signal(|| false); + let mut ws_connected = use_signal(|| !crate::live_streams_enabled()); // Agent identity let mut own_agent_id = use_signal(|| Option::::None); @@ -130,59 +130,76 @@ pub fn SwarmView(props: SwarmViewProps) -> Element { use_coroutine(move |_: UnboundedReceiver<()>| { let group_id = ws_group_id.clone(); async move { + if !crate::live_streams_enabled() { + return; + } + let tasks_topic = swarm_tasks_topic(&group_id); let results_topic = swarm_results_topic(&group_id); + let mut retry_delay = std::time::Duration::from_secs(1); - let ws = match X0xWebSocket::connect().await { - Ok(ws) => { - if let Err(e) = ws.subscribe(vec![tasks_topic.clone(), results_topic.clone()]) { - error!(target: "ui.swarm", "Failed to subscribe to swarm topics: {e}"); - return; - } - info!(target: "ui.swarm", "Subscribed to swarm topics for group {group_id}"); - ws_connected.set(true); - ws - } - Err(e) => { - warn!(target: "ui.swarm", "WebSocket connect failed: {e}"); - return; - } - }; - - let mut ws = ws; - while let Some(inbound) = ws.recv().await { - match inbound { - communitas_x0x_client::WsInbound::Message { - topic: _, payload, .. - } => { - if let Ok(bytes) = - base64::engine::general_purpose::STANDARD.decode(&payload) + loop { + let mut ws = match X0xWebSocket::connect().await { + Ok(ws) => { + if let Err(e) = + ws.subscribe(vec![tasks_topic.clone(), results_topic.clone()]) { - match serde_json::from_slice::(&bytes) { - Ok(evt) => { - events.with_mut(|list| { - if !list - .iter() - .any(|e| e.task_id == evt.task_id && e.kind == evt.kind) - { - list.insert(0, evt); - } - }); - } - Err(e) => { - warn!(target: "ui.swarm", "Failed to parse swarm event: {e}"); + error!(target: "ui.swarm", "Failed to subscribe to swarm topics: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + info!(target: "ui.swarm", "Subscribed to swarm topics for group {group_id}"); + ws_connected.set(true); + retry_delay = std::time::Duration::from_secs(1); + ws + } + Err(e) => { + warn!(target: "ui.swarm", "WebSocket connect failed: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + }; + + while let Some(inbound) = ws.recv().await { + match inbound { + communitas_x0x_client::WsInbound::Message { + topic: _, payload, .. + } => { + if let Ok(bytes) = + base64::engine::general_purpose::STANDARD.decode(&payload) + { + match serde_json::from_slice::(&bytes) { + Ok(evt) => { + events.with_mut(|list| { + if !list.iter().any(|e| { + e.task_id == evt.task_id && e.kind == evt.kind + }) { + list.insert(0, evt); + } + }); + } + Err(e) => { + warn!(target: "ui.swarm", "Failed to parse swarm event: {e}"); + } } } } + communitas_x0x_client::WsInbound::Error { message } => { + error!(target: "ui.swarm", "WebSocket error: {message}"); + } + _ => {} } - communitas_x0x_client::WsInbound::Error { message } => { - error!(target: "ui.swarm", "WebSocket error: {message}"); - } - _ => {} } - } - ws_connected.set(false); + ws_connected.set(false); + warn!(target: "ui.swarm", "WebSocket closed; reconnecting"); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + } } }); diff --git a/communitas-dioxus/src/components/thread_panel.rs b/communitas-dioxus/src/components/thread_panel.rs index 15f999e18..06154d5a4 100644 --- a/communitas-dioxus/src/components/thread_panel.rs +++ b/communitas-dioxus/src/components/thread_panel.rs @@ -67,53 +67,74 @@ pub fn ThreadPanel( let group_id = ws_group_id.clone(); let parent_msg_id = ws_parent_msg_id.clone(); async move { - let ws = match X0xWebSocket::connect().await { - Ok(ws) => { - if let Err(e) = ws.subscribe(vec![topic.clone()]) { - error!(target: "ui.thread_panel", "Failed to subscribe to thread: {e}"); - return; - } - ws_connected.set(true); - ws - } - Err(e) => { - warn!(target: "ui.thread_panel", "WebSocket connection failed: {e}"); - return; - } - }; - - let mut ws = ws; - - while let Some(inbound) = ws.recv().await { - if let communitas_x0x_client::WsInbound::Message { - topic: msg_topic, - payload, - .. - } = inbound - && msg_topic == topic - && let Ok(bytes) = - base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &payload) - && let Ok(msg) = serde_json::from_slice::(&bytes) - { - let history_msg = msg.clone(); - replies.with_mut(|r| { - if !r.iter().any(|m| m.id == history_msg.id) { - r.push(msg); - r.sort_by_key(|m| m.timestamp); + if !crate::live_streams_enabled() { + return; + } + + let mut retry_delay = std::time::Duration::from_secs(1); + loop { + let mut ws = match X0xWebSocket::connect().await { + Ok(ws) => { + if let Err(e) = ws.subscribe(vec![topic.clone()]) { + error!(target: "ui.thread_panel", "Failed to subscribe to thread: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; } - }); - x0x_contract::append_thread_history(&group_id, &parent_msg_id, &history_msg) + ws_connected.set(true); + retry_delay = std::time::Duration::from_secs(1); + ws + } + Err(e) => { + warn!(target: "ui.thread_panel", "WebSocket connection failed: {e}"); + ws_connected.set(false); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + continue; + } + }; + + while let Some(inbound) = ws.recv().await { + if let communitas_x0x_client::WsInbound::Message { + topic: msg_topic, + payload, + .. + } = inbound + && msg_topic == topic + && let Ok(bytes) = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &payload, + ) + && let Ok(msg) = serde_json::from_slice::(&bytes) + { + let history_msg = msg.clone(); + replies.with_mut(|r| { + if !r.iter().any(|m| m.id == history_msg.id) { + r.push(msg); + r.sort_by_key(|m| m.timestamp); + } + }); + x0x_contract::append_thread_history( + &group_id, + &parent_msg_id, + &history_msg, + ) .await; - // Notify channel view of new reply count - if let Some(mut bump) = reply_count_bump { - let next_gen = bump_generation() + 1; - bump_generation.set(next_gen); - bump.set(Some((parent_msg_id.clone(), next_gen))); + // Notify channel view of new reply count + if let Some(mut bump) = reply_count_bump { + let next_gen = bump_generation() + 1; + bump_generation.set(next_gen); + bump.set(Some((parent_msg_id.clone(), next_gen))); + } } } - } - ws_connected.set(false); + ws_connected.set(false); + warn!(target: "ui.thread_panel", "WebSocket closed; reconnecting"); + crate::ui_sleep(retry_delay).await; + retry_delay = (retry_delay * 2).min(std::time::Duration::from_secs(30)); + } } }); diff --git a/communitas-dioxus/src/components/toast_system.rs b/communitas-dioxus/src/components/toast_system.rs index 7665cd8c4..3667015ec 100644 --- a/communitas-dioxus/src/components/toast_system.rs +++ b/communitas-dioxus/src/components/toast_system.rs @@ -267,7 +267,7 @@ pub fn ToastItem(props: ToastItemProps) -> Element { } #[cfg(not(target_arch = "wasm32"))] { - tokio::time::sleep(std::time::Duration::from_millis(ms)).await; + crate::ui_sleep(std::time::Duration::from_millis(ms)).await; } if !dismissed() { dismissed.set(true); diff --git a/communitas-dioxus/src/e2e_test_mode.rs b/communitas-dioxus/src/e2e_test_mode.rs index f25422b7f..289eff0d0 100644 --- a/communitas-dioxus/src/e2e_test_mode.rs +++ b/communitas-dioxus/src/e2e_test_mode.rs @@ -12,10 +12,14 @@ use std::io::{self, BufRead, Write}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; use communitas_x0x_client::{ - GroupPolicyPreset, GroupRole, TrustLevel, UpdateGroupPolicyRequest, X0xClient, + GroupPolicyPreset, GroupRole, TrustLevel, UpdateGroupPolicyRequest, WsInbound, X0xClient, + X0xWebSocket, }; use serde_json::{Value, json}; +use tokio::time::timeout; /// Run the line-delimited JSON E2E command loop. pub async fn run() -> Result<(), Box> { @@ -88,6 +92,7 @@ async fn handle_operation(op: &str, command: &Value) -> Result { "trust.evaluate" => trust_evaluate(&client, command).await, "connectivity.discover_agents" => connectivity_discover_agents(&client).await, "connectivity.four_word_bootstrap" => connectivity_four_word_bootstrap(&client).await, + "messaging.pubsub_roundtrip" => messaging_pubsub_roundtrip(&client).await, "groups.policy" => groups_policy(&client, command).await, "groups.discover" => groups_discover(&client).await, "kv.create_list" => kv_create_list(&client).await, @@ -291,6 +296,73 @@ async fn connectivity_four_word_bootstrap(client: &X0xClient) -> Result Result { + let mut ws = X0xWebSocket::connect() + .await + .map_err(|err| format!("connect /ws: {err}"))?; + let topic = unique_suffix("dioxus-pubsub"); + let message = format!("hello-{topic}"); + + let connected = timeout(Duration::from_secs(5), ws.recv()) + .await + .map_err(|_| "timed out waiting for /ws connected frame".to_string())? + .ok_or_else(|| "websocket closed before connected frame".to_string())?; + if !matches!(connected, WsInbound::Connected { .. }) { + return Err(format!("expected connected frame, got {connected:?}")); + } + + ws.subscribe(vec![topic.clone()]) + .map_err(|err| format!("subscribe {topic}: {err}"))?; + + loop { + let inbound = timeout(Duration::from_secs(5), ws.recv()) + .await + .map_err(|_| format!("timed out waiting for subscribed frame on {topic}"))? + .ok_or_else(|| "websocket closed before subscribed frame".to_string())?; + match inbound { + WsInbound::Subscribed { topics } if topics.contains(&topic) => break, + WsInbound::Connected { .. } => continue, + _ => continue, + } + } + + client + .publish(&topic, message.as_bytes()) + .await + .map_err(|err| format!("publish {topic}: {err}"))?; + + loop { + let inbound = timeout(Duration::from_secs(10), ws.recv()) + .await + .map_err(|_| format!("timed out waiting for message on {topic}"))? + .ok_or_else(|| "websocket closed before message frame".to_string())?; + match inbound { + WsInbound::Message { + topic: inbound_topic, + payload, + .. + } if inbound_topic == topic => { + let decoded = BASE64 + .decode(payload.as_bytes()) + .map_err(|err| format!("decode payload: {err}"))?; + let echoed = String::from_utf8(decoded) + .map_err(|err| format!("payload was not UTF-8: {err}"))?; + if echoed != message { + return Err(format!( + "expected echoed payload {message:?}, got {echoed:?}" + )); + } + return Ok(json!({ + "topic": topic, + "payload": echoed, + "stream": "ws", + })); + } + _ => continue, + } + } +} + async fn groups_policy(client: &X0xClient, command: &Value) -> Result { let member_agent_id = required_str(command, "member_agent_id")?; let group_name = unique_suffix("dioxus-policy"); @@ -464,19 +536,25 @@ async fn upgrade_check(client: &X0xClient) -> Result { } async fn upgrade_apply(client: &X0xClient) -> Result { - let response = client - .apply_upgrade() - .await - .map_err(|err| err.to_string())?; - Ok(json!({ - "http_status": 200, - "body": { - "ok": true, - "applied": response.applied, - "version": response.version, - "reason": response.reason, - }, - })) + match client.apply_upgrade().await { + Ok(response) => Ok(json!({ + "http_status": 200, + "body": { + "ok": true, + "applied": response.applied, + "version": response.version, + "reason": response.reason, + }, + })), + Err(client_error) => { + let raw = raw_x0x_request(reqwest::Method::POST, "/upgrade/apply").await?; + Ok(json!({ + "http_status": raw.http_status, + "client_error": client_error.to_string(), + "body": raw.body, + })) + } + } } struct RawX0xResponse { diff --git a/communitas-dioxus/src/hooks/use_presence.rs b/communitas-dioxus/src/hooks/use_presence.rs index a69e3e0f1..3a9a9f4f7 100644 --- a/communitas-dioxus/src/hooks/use_presence.rs +++ b/communitas-dioxus/src/hooks/use_presence.rs @@ -30,14 +30,17 @@ pub fn use_presence() -> Signal> { loop { match client.presence().await { Ok(agents) => { - online.set(agents.into_iter().collect()); + let next_online = agents.into_iter().collect(); + if *online.peek() != next_online { + online.set(next_online); + } } Err(e) => { // Best-effort — silently ignore like Swift warn!(target: "ui.presence", "presence poll failed: {e}"); } } - tokio::time::sleep(tokio::time::Duration::from_secs(PRESENCE_POLL_SECS)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(PRESENCE_POLL_SECS)).await; } }); diff --git a/communitas-dioxus/src/main.rs b/communitas-dioxus/src/main.rs index 0c7a290aa..e31ab7c6a 100644 --- a/communitas-dioxus/src/main.rs +++ b/communitas-dioxus/src/main.rs @@ -2,6 +2,7 @@ #![allow(non_snake_case)] #![allow(clippy::print_stderr)] // eprintln! used for bootstrap errors before logger init +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] mod components; pub mod contrast; @@ -193,7 +194,103 @@ async fn main() { } info!("starting Communitas (Deep Space Operations Console)"); - dioxus::launch(App); + dioxus::LaunchBuilder::desktop() + .with_cfg(desktop_config()) + .launch(App); +} + +fn desktop_config() -> dioxus::desktop::Config { + let window_share_deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + let window = dioxus::desktop::WindowBuilder::new() + .with_title("CommunitasDioxus") + .with_inner_size(dioxus::desktop::LogicalSize::new(1200.0, 780.0)) + .with_position(dioxus::desktop::LogicalPosition::new(220.0, 80.0)) + .with_focused(true) + .with_visible(true); + + dioxus::desktop::Config::new() + .with_window(window) + .with_background_color((10, 12, 20, 255)) + .with_on_window(|window, _dom| configure_platform_window(window.as_ref())) + .with_custom_event_handler(move |event, _target| { + configure_platform_windows_after_startup(event, window_share_deadline); + }) +} + +#[cfg(target_os = "macos")] +fn configure_platform_window(window: &dioxus::desktop::tao::window::Window) { + use dioxus::desktop::tao::platform::macos::WindowExtMacOS; + use objc2_app_kit::NSWindow; + + let ns_window = window.ns_window() as *mut NSWindow; + if ns_window.is_null() { + warn!(target: "ui.window", "macOS window hook received a null NSWindow pointer"); + return; + } + + unsafe { + configure_ns_window(&*ns_window, true); + } + + window.set_visible(true); + window.request_redraw(); +} + +#[cfg(target_os = "macos")] +fn configure_platform_windows_after_startup( + event: &dioxus::desktop::tao::event::Event<'_, T>, + deadline: std::time::Instant, +) { + use dioxus::desktop::tao::event::Event; + use objc2::MainThreadMarker; + use objc2_app_kit::NSApplication; + + if std::time::Instant::now() > deadline { + return; + } + + if !matches!( + event, + Event::NewEvents(_) + | Event::Resumed + | Event::MainEventsCleared + | Event::RedrawEventsCleared + | Event::Reopen { .. } + ) { + return; + } + + let Some(marker) = MainThreadMarker::new() else { + return; + }; + let app = NSApplication::sharedApplication(marker); + for ns_window in app.windows().iter() { + configure_ns_window(&ns_window, false); + } +} + +#[cfg(target_os = "macos")] +fn configure_ns_window(ns_window: &objc2_app_kit::NSWindow, bring_to_front: bool) { + use objc2_app_kit::NSWindowSharingType; + + ns_window.setSharingType(NSWindowSharingType::ReadOnly); + ns_window.setCanHide(false); + if bring_to_front && ns_window.canBecomeKeyWindow() { + ns_window.makeKeyAndOrderFront(None); + } +} + +#[cfg(not(target_os = "macos"))] +fn configure_platform_window(window: &dioxus::desktop::tao::window::Window) { + window.set_visible(true); + window.request_redraw(); +} + +#[cfg(not(target_os = "macos"))] +fn configure_platform_windows_after_startup( + _event: &dioxus::desktop::tao::event::Event<'_, T>, + _deadline: std::time::Instant, +) { } // ── Global CSS ────────────────────────────────────────────────────────────── @@ -241,6 +338,44 @@ body { background-color: #0a0c14; color: #e4e6f0; font-family: system-ui, -apple ::-webkit-scrollbar-thumb:hover { background: #353a52; } "#; +pub(crate) async fn ui_sleep(duration: std::time::Duration) { + futures_timer::Delay::new(duration).await; +} + +pub(crate) async fn poll_sleep(duration: std::time::Duration) { + if !polling_timers_enabled() { + std::future::pending::<()>().await; + } + ui_sleep(duration).await; +} + +pub(crate) fn live_streams_enabled() -> bool { + live_streams_enabled_for_env(std::env::var_os("COMMUNITAS_DIOXUS_DISABLE_LIVE_WS").is_some()) +} + +fn live_streams_enabled_for_env(disable_live_ws: bool) -> bool { + !disable_live_ws +} + +fn polling_timers_enabled() -> bool { + polling_timers_enabled_for_env( + cfg!(target_os = "macos"), + std::env::var_os("COMMUNITAS_DIOXUS_DISABLE_POLLS").is_some(), + std::env::var_os("COMMUNITAS_DIOXUS_ENABLE_POLLS").is_some(), + ) +} + +fn polling_timers_enabled_for_env( + _is_macos: bool, + disable_polls: bool, + _enable_polls: bool, +) -> bool { + if disable_polls { + return false; + } + true +} + // ── App component ─────────────────────────────────────────────────────────── #[component] @@ -305,8 +440,16 @@ fn AppShell(children: Element) -> Element { loop { match client.health().await { - Ok(_) => connected.set(true), - Err(_) => connected.set(false), + Ok(_) => { + if !*connected.peek() { + connected.set(true); + } + } + Err(_) => { + if *connected.peek() { + connected.set(false); + } + } } if let Ok(agent) = client.agent().await { @@ -322,15 +465,25 @@ fn AppShell(children: Element) -> Element { .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| format!("agent:{short_agent}")); - agent_id.set(Some(agent.agent_id.clone())); - identity_secondary.set(Some(secondary)); + let next_agent_id = Some(agent.agent_id.clone()); + if agent_id.peek().as_ref() != next_agent_id.as_ref() { + agent_id.set(next_agent_id); + } + + let next_secondary = Some(secondary); + if identity_secondary.peek().as_ref() != next_secondary.as_ref() { + identity_secondary.set(next_secondary); + } - match client.agent_card(None, Some(false)).await { + let next_label = match client.agent_card(None, Some(false)).await { Ok(card_resp) if !card_resp.card.display_name.trim().is_empty() => { - identity_label.set(card_resp.card.display_name.trim().to_string()); + card_resp.card.display_name.trim().to_string() } - Ok(_) => identity_label.set(fallback_label), - Err(_) => identity_label.set(fallback_label), + Ok(_) => fallback_label, + Err(_) => fallback_label, + }; + if identity_label.peek().as_str() != next_label.as_str() { + identity_label.set(next_label); } } @@ -343,7 +496,9 @@ fn AppShell(children: Element) -> Element { member_count: g.member_count.unwrap_or(0), }) .collect(); - groups.set(entries); + if groups.peek().as_slice() != entries.as_slice() { + groups.set(entries); + } } let online_agents = client.presence().await.unwrap_or_default(); @@ -358,14 +513,19 @@ fn AppShell(children: Element) -> Element { online: online_agents.iter().any(|id| id == &c.agent_id), }) .collect(); - contacts.set(entries); + if contacts.peek().as_slice() != entries.as_slice() { + contacts.set(entries); + } } if let Ok(agents) = client.discovered_agents().await { - discovered_agent_count_sidebar.set(agents.len()); + let next_count = agents.len(); + if *discovered_agent_count_sidebar.peek() != next_count { + discovered_agent_count_sidebar.set(next_count); + } } - tokio::time::sleep(tokio::time::Duration::from_secs(8)).await; + crate::poll_sleep(tokio::time::Duration::from_secs(8)).await; } }); @@ -625,6 +785,7 @@ fn SpaceView(space_id: String) -> Element { rsx! { AppShell { components::SpaceView { + key: "{space_id}:chat", space_id: space_id, } } @@ -636,6 +797,7 @@ fn SpaceTab(space_id: String, tab: String) -> Element { rsx! { AppShell { components::SpaceView { + key: "{space_id}:{tab}", space_id: space_id, initial_tab: Some(tab), } @@ -980,4 +1142,31 @@ mod tests { let route = Route::People {}; assert_eq!(format!("{route:?}"), "People"); } + + #[test] + fn polling_timers_run_by_default_off_macos() { + assert!(polling_timers_enabled_for_env(false, false, false)); + } + + #[test] + fn polling_timers_run_by_default_on_macos() { + assert!(polling_timers_enabled_for_env(true, false, false)); + assert!(polling_timers_enabled_for_env(true, false, true)); + } + + #[test] + fn polling_timers_disable_override_wins() { + assert!(!polling_timers_enabled_for_env(false, true, true)); + assert!(!polling_timers_enabled_for_env(true, true, true)); + } + + #[test] + fn live_streams_run_by_default() { + assert!(live_streams_enabled_for_env(false)); + } + + #[test] + fn live_streams_disable_override_wins() { + assert!(!live_streams_enabled_for_env(true)); + } } diff --git a/communitas-dioxus/src/pages.rs b/communitas-dioxus/src/pages.rs index b55a7346f..323ac3a93 100644 --- a/communitas-dioxus/src/pages.rs +++ b/communitas-dioxus/src/pages.rs @@ -1432,7 +1432,7 @@ fn EntityDocsContent(entity: UnifiedEntity) -> Element { use_effect(move || { spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(300)).await; + crate::ui_sleep(std::time::Duration::from_millis(300)).await; docs_loading.set(false); }); }); @@ -3406,7 +3406,7 @@ pub fn EntityDocsPageV2(entity: UnifiedEntity) -> Element { // In production, this would use DriveService to list /docs directory spawn(async move { // Simulated delay - tokio::time::sleep(std::time::Duration::from_millis(300)).await; + crate::ui_sleep(std::time::Duration::from_millis(300)).await; docs.set(vec![]); docs_loading.set(false); }); @@ -4551,7 +4551,7 @@ pub fn CreateEntityModal( // TODO: Call DirectoryService to create the entity // For now, simulate creation with a fake ID spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + crate::ui_sleep(std::time::Duration::from_millis(500)).await; // Simulate success - use timestamp for unique ID let fake_id = format!( @@ -4890,7 +4890,7 @@ pub fn InviteMemberModal( // TODO: Call DirectoryService to invite member spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + crate::ui_sleep(std::time::Duration::from_millis(500)).await; // Simulate success tracing::info!("Invited member with four-words: {}", words); @@ -5218,7 +5218,7 @@ pub fn EditEntityModal( // TODO: Call DirectoryService to update the entity spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + crate::ui_sleep(std::time::Duration::from_millis(500)).await; tracing::info!("Updated entity: {} ({})", name_value, entity_id_for_spawn); @@ -5248,7 +5248,7 @@ pub fn EditEntityModal( // TODO: Call DirectoryService to update the entity spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + crate::ui_sleep(std::time::Duration::from_millis(500)).await; tracing::info!("Updated entity: {} ({})", name_value, entity_id_for_spawn); diff --git a/communitas-dioxus/tests/accessibility.rs b/communitas-dioxus/tests/accessibility.rs index c572e24a7..46ad635af 100644 --- a/communitas-dioxus/tests/accessibility.rs +++ b/communitas-dioxus/tests/accessibility.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Accessibility tests for Communitas Dioxus components. //! //! These tests verify WCAG 2.1 AA compliance across the application: diff --git a/communitas-dioxus/tests/e2e.sh b/communitas-dioxus/tests/e2e.sh index 3c26b2ad8..041cd6a92 100755 --- a/communitas-dioxus/tests/e2e.sh +++ b/communitas-dioxus/tests/e2e.sh @@ -41,6 +41,7 @@ TESTS=( e2e_identity e2e_trust_contacts e2e_connectivity + e2e_messaging e2e_groups e2e_kv_store e2e_presence diff --git a/communitas-dioxus/tests/e2e/Cargo.lock b/communitas-dioxus/tests/e2e/Cargo.lock index 861b01628..21c1207c2 100644 --- a/communitas-dioxus/tests/e2e/Cargo.lock +++ b/communitas-dioxus/tests/e2e/Cargo.lock @@ -3,274 +3,188 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "anyhow" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "aead" -version = "0.5.2" +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "aes" -version = "0.8.4" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "aes-gcm" -version = "0.10.3" +name = "bitflags" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" -dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", -] +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] -name = "aho-corasick" -version = "1.1.4" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "memchr", + "generic-array", ] [[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" +name = "bumpalo" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] -name = "anstream" -version = "1.0.0" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] -name = "anstyle" -version = "1.0.14" +name = "bytes" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] -name = "anstyle-parse" -version = "1.0.0" +name = "cc" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ - "utf8parse", + "find-msvc-tools", + "shlex", ] [[package]] -name = "anstyle-query" -version = "1.1.5" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +name = "communitas-dioxus-e2e" +version = "0.0.0" dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", + "anyhow", + "communitas-x0x-client", + "serde_json", + "tempfile", + "tokio", ] [[package]] -name = "ant-quic" -version = "0.27.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec518faf7b0879b7ca930cb8de46ca33119805b28c8e5a1852984af592032af7" +name = "communitas-x0x-client" +version = "0.12.4" dependencies = [ - "anyhow", - "async-trait", - "aws-lc-rs", - "blake3", - "bytes", - "chrono", - "clap", - "core-foundation 0.9.4", - "dashmap", - "dirs", + "base64", + "dirs-next", "futures-util", "hex", - "igd-next", - "indexmap", - "keyring", - "libc", - "lru-slab", - "mdns-sd", - "nix", - "once_cell", - "parking_lot", - "pin-project-lite", - "quinn-udp", - "rand 0.8.6", - "rcgen", - "regex", "reqwest", - "rustc-hash", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-platform-verifier", - "rustls-post-quantum", - "saorsa-pqc 0.5.1", "serde", "serde_json", - "serde_yaml", "sha2", - "slab", - "socket2 0.5.10", - "system-configuration 0.6.1", - "thiserror 2.0.18", - "time", - "tinyvec", + "thiserror", "tokio", - "tokio-util", + "tokio-tungstenite", "tracing", - "tracing-subscriber", - "unicode-width 0.2.0", - "uuid", - "windows", - "x25519-dalek", - "zeroize", ] [[package]] -name = "anyhow" -version = "1.0.102" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] [[package]] -name = "arbitrary" -version = "1.4.2" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "derive_arbitrary", + "core-foundation-sys", + "libc", ] [[package]] -name = "arrayref" -version = "0.3.9" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "arrayvec" -version = "0.7.6" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] [[package]] -name = "asn1-rs" -version = "0.7.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "asn1-rs-derive", - "asn1-rs-impl", - "displaydoc", - "nom", - "num-traits", - "rusticata-macros", - "thiserror 2.0.18", - "time", + "generic-array", + "typenum", ] [[package]] -name = "asn1-rs-derive" -version = "0.6.0" +name = "data-encoding" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] -name = "asn1-rs-impl" -version = "0.2.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "proc-macro2", - "quote", - "syn", + "block-buffer", + "crypto-common", ] [[package]] -name = "async-stream" -version = "0.3.6" +name = "dirs-next" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", + "cfg-if", + "dirs-sys-next", ] [[package]] -name = "async-stream-impl" -version = "0.3.6" +name = "dirs-sys-next" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "libc", + "redox_users", + "winapi", ] [[package]] -name = "async-trait" -version = "0.1.89" +name = "displaydoc" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", @@ -278,2737 +192,719 @@ dependencies = [ ] [[package]] -name = "atomic-polyfill" -version = "1.0.3" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "critical-section", + "cfg-if", ] [[package]] -name = "atomic-waker" -version = "1.1.2" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "attohttpc" -version = "0.30.1" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "base64", - "http 1.4.0", - "log", - "url", + "libc", + "windows-sys 0.61.2", ] [[package]] -name = "autocfg" -version = "1.5.0" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "aws-lc-rs" -version = "1.16.3" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" -dependencies = [ - "aws-lc-sys", - "zeroize", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "aws-lc-sys" -version = "0.40.0" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] -name = "axum" -version = "0.7.9" +name = "foldhash" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "base64", - "bytes", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] -name = "axum-core" -version = "0.4.5" +name = "foreign-types" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", + "foreign-types-shared", ] [[package]] -name = "axum-extra" -version = "0.9.6" +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "axum", - "axum-core", - "bytes", - "fastrand", - "futures-util", - "headers", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "mime", - "multer", - "pin-project-lite", - "serde", - "tower", - "tower-layer", - "tower-service", + "percent-encoding", ] [[package]] -name = "base16ct" -version = "0.2.0" +name = "futures-channel" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] [[package]] -name = "base64" -version = "0.22.1" +name = "futures-core" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] -name = "base64ct" -version = "1.8.3" +name = "futures-io" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] -name = "bincode" -version = "1.3.3" +name = "futures-macro" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ - "serde", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "bit-set" -version = "0.8.0" +name = "futures-sink" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] -name = "bit-vec" -version = "0.8.0" +name = "futures-task" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] -name = "bitflags" -version = "2.11.1" +name = "futures-util" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] [[package]] -name = "bitvec" -version = "1.0.1" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "typenum", + "version_check", ] [[package]] -name = "blake3" -version = "1.8.5" +name = "getrandom" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "arrayref", - "arrayvec", - "cc", "cfg-if", - "constant_time_eq 0.4.2", - "cpufeatures 0.3.0", + "libc", + "wasi", ] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "getrandom" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ - "generic-array", + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", ] [[package]] -name = "bs58" -version = "0.5.1" +name = "h2" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ - "tinyvec", + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] -name = "bumpalo" -version = "3.20.2" +name = "hashbrown" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] [[package]] -name = "bytemuck" -version = "1.25.0" +name = "hashbrown" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] -name = "byteorder" -version = "1.5.0" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "bytes" -version = "1.11.1" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "bzip2" -version = "0.5.2" +name = "http" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ - "bzip2-sys", + "bytes", + "itoa", ] [[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +name = "http-body" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ - "cc", - "pkg-config", + "bytes", + "http", ] [[package]] -name = "cc" -version = "1.2.61" +name = "http-body-util" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "cesu8" -version = "1.1.0" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "cfg-if" -version = "1.0.4" +name = "hyper" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] [[package]] -name = "cfg_aliases" -version = "0.2.1" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] [[package]] -name = "chacha20" -version = "0.9.1" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] -name = "chacha20" -version = "0.10.0" +name = "hyper-util" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", ] [[package]] -name = "chacha20poly1305" -version = "0.10.1" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "chrono" -version = "0.4.44" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "ciborium" -version = "0.2.2" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "ciborium-io", - "ciborium-ll", - "serde", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "ciborium-io" -version = "0.2.2" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "ciborium-ll" -version = "0.2.2" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "ciborium-io", - "half", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "cipher" -version = "0.4.4" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "clap" -version = "4.6.1" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "clap_builder", - "clap_derive", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "clap_builder" -version = "4.6.0" +name = "id-arena" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" [[package]] -name = "clap_derive" -version = "4.6.1" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "cc", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "cobs" -version = "0.3.0" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "thiserror 2.0.18", + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", ] [[package]] -name = "colorchoice" -version = "1.0.5" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "combine" -version = "4.6.7" +name = "iri-string" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ - "bytes", "memchr", + "serde", ] [[package]] -name = "communitas-dioxus-e2e" -version = "0.0.0" -dependencies = [ - "anyhow", - "communitas-x0x-client", - "serde_json", - "tokio", - "x0x-test-harness", -] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "communitas-x0x-client" -version = "0.12.2" +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" dependencies = [ - "base64", - "dirs-next", + "cfg-if", "futures-util", - "hex", - "reqwest", - "serde", - "serde_json", - "sha2", - "thiserror 1.0.69", - "tokio", - "tokio-tungstenite", - "tracing", + "once_cell", + "wasm-bindgen", ] [[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - -[[package]] -name = "constant_time_eq" -version = "0.3.1" +name = "leb128fmt" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] -name = "constant_time_eq" -version = "0.4.2" +name = "libc" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] -name = "convert_case" -version = "0.10.0" +name = "libredox" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "unicode-segmentation", + "libc", ] [[package]] -name = "coolor" -version = "1.1.0" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3" -dependencies = [ - "crossterm 0.29.0", -] +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "core-foundation" -version = "0.9.4" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "core-foundation" -version = "0.10.1" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "core-foundation-sys", - "libc", + "scopeguard", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "log" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "memchr" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] -name = "cpufeatures" -version = "0.3.0" +name = "mime" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] -name = "crc" -version = "3.4.0" +name = "mio" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ - "crc-catalog", + "libc", + "wasi", + "windows-sys 0.61.2", ] [[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32fast" -version = "1.5.0" +name = "native-tls" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ - "cfg-if", + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", ] [[package]] -name = "critical-section" -version = "1.2.0" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "crokey" -version = "1.4.0" +name = "openssl" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ - "crokey-proc_macros", - "crossterm 0.29.0", + "bitflags", + "cfg-if", + "foreign-types", + "libc", "once_cell", - "serde", - "strict", + "openssl-macros", + "openssl-sys", ] [[package]] -name = "crokey-proc_macros" -version = "1.4.0" +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "crossterm 0.29.0", "proc-macro2", "quote", - "strict", "syn", ] [[package]] -name = "crossbeam" -version = "0.8.4" +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" -dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-epoch", - "crossbeam-queue", - "crossbeam-utils", -] +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "crossbeam-channel" -version = "0.5.15" +name = "openssl-sys" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ - "crossbeam-utils", + "cc", + "libc", + "pkg-config", + "vcpkg", ] [[package]] -name = "crossbeam-deque" -version = "0.8.6" +name = "parking_lot" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", + "lock_api", + "parking_lot_core", ] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "crossbeam-utils", + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", ] [[package]] -name = "crossbeam-queue" -version = "0.3.12" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "crossterm" -version = "0.28.1" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" -dependencies = [ - "bitflags", - "crossterm_winapi", - "mio", - "parking_lot", - "rustix 0.38.44", - "signal-hook", - "signal-hook-mio", - "winapi", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "crossterm" -version = "0.29.0" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "bitflags", - "crossterm_winapi", - "derive_more", - "document-features", - "mio", - "parking_lot", - "rustix 1.1.4", - "signal-hook", - "signal-hook-mio", - "winapi", + "zerovec", ] [[package]] -name = "crossterm_winapi" -version = "0.9.1" +name = "ppv-lite86" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "winapi", + "zerocopy", ] [[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "proc-macro2", + "syn", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "proc-macro2" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", + "unicode-ident", ] [[package]] -name = "ctr" -version = "0.9.2" +name = "quote" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ - "cipher", + "proc-macro2", ] [[package]] -name = "curve25519-dalek" -version = "4.1.3" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "deflate64" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "der-parser" -version = "10.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" -dependencies = [ - "asn1-rs", - "displaydoc", - "nom", - "num-bigint", - "num-traits", - "rusticata-macros", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", - "subtle", -] - -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.48.0", -] - -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "document-features" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" -dependencies = [ - "litrs", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "rand_core 0.6.4", - "serde", - "sha2", - "subtle", - "zeroize", -] - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest", - "ff", - "generic-array", - "group", - "hkdf", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "env_home" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fips203" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8bdb6454f692ca2a2b45cd554c6828c639d7f9c968cf83a678899ec4443a280" -dependencies = [ - "rand_core 0.6.4", - "sha3", - "subtle", - "zeroize", -] - -[[package]] -name = "fips204" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9fb5a367b9846933e271a3c2a992930743f82ae5e8cb7faa780715a80fa0b15" -dependencies = [ - "rand_core 0.6.4", - "sha2", - "sha3", - "zeroize", -] - -[[package]] -name = "fips205" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f5626bf5534df4ebdbd2536465d7eaa8a9dc2cdeb7e036e0ecf291dcc80ffb6" -dependencies = [ - "rand_core 0.6.4", - "sha2", - "sha3", - "zeroize", -] - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "four-word-networking" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf6a3c16b04f592bdb362f5fe306000bdc416f4616a0528ec0513bff199f589" -dependencies = [ - "bitvec", - "bs58", - "clap", - "crossterm 0.28.1", - "hex", - "once_cell", - "rand 0.8.6", - "serde", - "serde_json", - "sha2", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "fs2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - -[[package]] -name = "futures-macro" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasip2", - "wasip3", -] - -[[package]] -name = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http 1.4.0", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy", -] - -[[package]] -name = "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" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" - -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http 1.4.0", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http 1.4.0", -] - -[[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", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "hpke" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65d16b699dd1a1fa2d851c970b0c971b388eeeb40f744252b8de48860980c8f" -dependencies = [ - "aead", - "aes-gcm", - "chacha20poly1305", - "digest", - "generic-array", - "hkdf", - "hmac", - "p256", - "rand_core 0.9.5", - "sha2", - "subtle", - "x25519-dalek", - "zeroize", -] - -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http 1.4.0", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http 1.4.0", - "http-body 1.0.1", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http 1.4.0", - "http-body 1.0.1", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http 1.4.0", - "hyper 1.9.0", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.3", - "system-configuration 0.7.0", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core 0.62.2", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "if-addrs" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - -[[package]] -name = "if-addrs" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "igd-next" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bac9a3c8278f43b4cd8463380f4a25653ac843e5b177e1d3eaf849cc9ba10d4d" -dependencies = [ - "attohttpc", - "bytes", - "futures", - "http 1.4.0", - "http-body-util", - "hyper 1.9.0", - "hyper-util", - "log", - "rand 0.10.1", - "tokio", - "url", - "xmltree", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.0", - "serde", - "serde_core", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[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-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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.97" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" -dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "keccak" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" -dependencies = [ - "cpufeatures 0.2.17", -] - -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "log", - "zeroize", -] - -[[package]] -name = "lazy-regex" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" -dependencies = [ - "lazy-regex-proc_macros", - "once_cell", - "regex", -] - -[[package]] -name = "lazy-regex-proc_macros" -version = "3.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" -dependencies = [ - "proc-macro2", - "quote", - "regex", - "syn", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libredox" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" -dependencies = [ - "bitflags", - "libc", - "plain", - "redox_syscall 0.7.4", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "litrs" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] - -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - -[[package]] -name = "mdns-sd" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2bb8ce26633738d98ffcef71ec58bff967c6675be50229823c2835f6316e67e" -dependencies = [ - "fastrand", - "flume", - "if-addrs 0.15.0", - "log", - "mio", - "socket-pktinfo", - "socket2 0.6.3", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "minimad" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9c5d708226d186590a7b6d4a9780e2bdda5f689e0d58cd17012a298efd745d2" -dependencies = [ - "once_cell", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http 1.4.0", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_threads" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" -dependencies = [ - "libc", -] - -[[package]] -name = "oid-registry" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" -dependencies = [ - "asn1-rs", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl" -version = "0.10.78" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "elliptic-curve", - "primeorder", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", -] - -[[package]] -name = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", -] - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[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 = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "serde", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proptest" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" -dependencies = [ - "bit-set", - "bit-vec", - "bitflags", - "num-traits", - "rand 0.9.4", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", -] - -[[package]] -name = "quick-error" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.3", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.4", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.3", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" @@ -3017,29 +913,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" -dependencies = [ - "chacha20 0.10.0", - "getrandom 0.4.2", - "rand_core 0.10.1", + "rand_chacha", + "rand_core", ] [[package]] @@ -3049,17 +924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -3071,121 +936,25 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - -[[package]] -name = "rcgen" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" -dependencies = [ - "pem", - "ring", - "rustls-pki-types", - "time", - "x509-parser", - "yasna", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", + "bitflags", ] [[package]] -name = "regex-syntax" -version = "0.8.10" +name = "redox_users" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] [[package]] name = "reqwest" @@ -3199,10 +968,10 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.9.0", + "hyper", "hyper-rustls", "hyper-tls", "hyper-util", @@ -3212,8 +981,6 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", - "quinn", - "rustls", "rustls-pki-types", "serde", "serde_json", @@ -3221,7 +988,6 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3231,7 +997,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", ] [[package]] @@ -3241,521 +1006,71 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rusticata-macros" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" -dependencies = [ - "nom", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "aws-lc-rs", - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-post-quantum" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0da3cd9229bac4fae1f589c8f875b3c891a058ddaa26eb3bde16b5e43dc174ce" -dependencies = [ - "aws-lc-rs", - "rustls", - "rustls-webpki", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "rusty-fork" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" -dependencies = [ - "fnv", - "quick-error", - "tempfile", - "wait-timeout", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "saorsa-gossip-coordinator" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "250eee6bc9da01622a7e8aa2152ae57dbd40694a639a6c732b610f5f8655702d" -dependencies = [ - "ant-quic", - "anyhow", - "blake3", - "bytes", - "ciborium", - "fs2", - "lru", - "postcard", - "rand 0.8.6", - "saorsa-gossip-types", - "saorsa-pqc 0.5.1", - "serde", - "serde_bytes", - "tokio", - "tracing", -] - -[[package]] -name = "saorsa-gossip-crdt-sync" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3ee9f2a770c6fa276c87d6def82712197ad6d8d1529bcd7c8453f8234a2d975" -dependencies = [ - "anyhow", - "blake3", - "saorsa-gossip-transport", - "saorsa-gossip-types", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "saorsa-gossip-groups" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a789171ddb5d37b164ed9b71146aa2cbe4619c91c317df95e241405407ab93" -dependencies = [ - "anyhow", - "blake3", - "saorsa-gossip-types", - "saorsa-pqc 0.5.1", - "serde", -] - -[[package]] -name = "saorsa-gossip-identity" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2682f2803c8fcc3a625da97daee7fdf7454dae29c6a891371b3f718e74240623" -dependencies = [ - "anyhow", - "blake3", - "postcard", - "saorsa-gossip-types", - "saorsa-pqc 0.5.1", - "serde", - "tokio", -] - -[[package]] -name = "saorsa-gossip-membership" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed3cf1f59127c63850343a280eb199b85f87b3766918abacd065d2542c3d9e3" -dependencies = [ - "anyhow", - "async-trait", - "bytes", - "postcard", - "rand 0.8.6", - "saorsa-gossip-transport", - "saorsa-gossip-types", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "saorsa-gossip-presence" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa063d4e44b98f2e7c7980c7ab2b106f3456aac577fc2f9a3e766b075f20a501" -dependencies = [ - "anyhow", - "async-trait", - "blake3", - "bytes", - "postcard", - "rand 0.8.6", - "saorsa-gossip-groups", - "saorsa-gossip-identity", - "saorsa-gossip-transport", - "saorsa-gossip-types", - "saorsa-pqc 0.5.1", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "saorsa-gossip-pubsub" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b3ffcba3448412b300e4c23f2d3c7a00037e660e6212ebc70be9c710a557409" -dependencies = [ - "anyhow", - "async-trait", - "blake3", - "bytes", - "lru", - "postcard", - "saorsa-gossip-identity", - "saorsa-gossip-membership", - "saorsa-gossip-transport", - "saorsa-gossip-types", - "serde", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "saorsa-gossip-rendezvous" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96f000ce06b2a613979b96ac67ff2a76010d36f3f43bfbc4cc2fb9c275f2a58f" -dependencies = [ - "anyhow", - "blake3", - "bytes", - "ciborium", - "saorsa-gossip-types", - "saorsa-pqc 0.5.1", - "serde", - "tokio", -] - -[[package]] -name = "saorsa-gossip-runtime" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f31f992881c04f0f2e24d9f2ccc3ba7b9eea46ab48daea52dac0a506e189472" -dependencies = [ - "anyhow", - "async-trait", - "blake3", - "bytes", - "ciborium", - "rand 0.8.6", - "saorsa-gossip-coordinator", - "saorsa-gossip-groups", - "saorsa-gossip-identity", - "saorsa-gossip-membership", - "saorsa-gossip-presence", - "saorsa-gossip-pubsub", - "saorsa-gossip-rendezvous", - "saorsa-gossip-transport", - "saorsa-gossip-types", - "serde", - "tokio", - "tracing", -] - -[[package]] -name = "saorsa-gossip-transport" -version = "0.5.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aeb5220e870763862ceb548926a5b708bef55df628083550c9a53871447e568" -dependencies = [ - "ant-quic", - "anyhow", - "async-trait", - "bytes", - "dirs", - "futures", - "postcard", - "saorsa-gossip-types", - "serde", - "thiserror 2.0.18", - "tokio", - "tokio-util", - "tracing", - "uuid", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", ] [[package]] -name = "saorsa-gossip-types" -version = "0.5.23" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ff8ae3941bbdf5c3ec5be5bb4e86554b8d100cb6a4944fc7094cb812b0346a" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "anyhow", - "blake3", - "bytes", - "hex", - "postcard", - "serde", - "sha2", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] -name = "saorsa-mls" -version = "0.3.5" +name = "rustls" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14d51eeaea1d4c75374d7e3ec2bd171316b11bc66ad6a4edab695e5d272e1306" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "anyhow", - "async-trait", - "bytes", - "chacha20poly1305", - "chrono", - "dashmap", - "hex", - "lazy_static", - "parking_lot", - "postcard", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "saorsa-pqc 0.4.2", - "serde", - "serde_json", + "once_cell", + "rustls-pki-types", + "rustls-webpki", "subtle", - "thiserror 1.0.69", - "tokio", - "uuid", "zeroize", - "zeroize_derive", ] [[package]] -name = "saorsa-pqc" -version = "0.4.2" +name = "rustls-pki-types" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d4bae22bfc65b379efcaae0c9ec5075916a79c05e97d595a4b78fb8ff6545b" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "aead", - "aes-gcm", - "anyhow", - "blake3", - "bytes", - "chacha20poly1305", - "curve25519-dalek", - "ed25519-dalek", - "fips203", - "fips204", - "fips205", - "futures", - "hkdf", - "hmac", - "hpke", - "libc", - "log", - "pbkdf2", - "postcard", - "rand 0.8.6", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "rayon", - "serde", - "serde_json", - "sha2", - "sha3", - "subtle", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", - "wide", - "x25519-dalek", "zeroize", ] [[package]] -name = "saorsa-pqc" -version = "0.5.1" +name = "rustls-webpki" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2ed2e429e5673763625ca5285bcbf15270fa16918cb5e63fc95ababacbb1a4c" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aead", - "aes-gcm", - "anyhow", - "blake3", - "bytes", - "chacha20poly1305", - "fips203", - "fips204", - "fips205", - "futures", - "hkdf", - "hmac", - "hpke", - "libc", - "log", - "pbkdf2", - "postcard", - "rand 0.8.6", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "rayon", - "serde", - "serde_json", - "sha2", - "sha3", - "subtle", - "thiserror 2.0.18", - "time", - "tokio", - "tracing", - "wide", - "zeroize", + "ring", + "rustls-pki-types", + "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "schannel" version = "0.1.29" @@ -3771,19 +1086,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -3823,16 +1125,6 @@ dependencies = [ "serde_derive", ] -[[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" @@ -3866,26 +1158,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3898,19 +1170,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha1" version = "0.10.6" @@ -3918,7 +1177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", + "cpufeatures", "digest", ] @@ -3929,27 +1188,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest", -] - -[[package]] -name = "sha3" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" -dependencies = [ + "cpufeatures", "digest", - "keccak", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", ] [[package]] @@ -3958,27 +1198,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - -[[package]] -name = "signal-hook-mio" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" -dependencies = [ - "libc", - "mio", - "signal-hook", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -3989,21 +1208,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -4016,27 +1220,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket-pktinfo" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" -dependencies = [ - "libc", - "socket2 0.6.3", - "windows-sys 0.60.2", -] - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.3" @@ -4047,43 +1230,12 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "strict" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" @@ -4121,17 +1273,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - [[package]] name = "system-configuration" version = "0.7.0" @@ -4153,23 +1294,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -4179,33 +1303,7 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "termimad" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22117210909e9dfff30a558f554c7fb3edb198ef614e7691386785fb7679677c" -dependencies = [ - "coolor", - "crokey", - "crossbeam", - "lazy-regex", - "minimad", - "serde", - "thiserror 1.0.69", - "unicode-width 0.1.14", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] @@ -4215,80 +1313,18 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "libc", - "num-conv", - "num_threads", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "num-conv", - "time-core", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -4301,21 +1337,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.1" @@ -4328,7 +1349,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -4364,18 +1385,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", - "tokio-util", -] - [[package]] name = "tokio-tungstenite" version = "0.24.0" @@ -4403,47 +1412,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", -] - -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "tower" version = "0.5.3" @@ -4457,7 +1425,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -4469,14 +1436,13 @@ dependencies = [ "bitflags", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -4497,7 +1463,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4521,50 +1486,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "time", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", ] [[package]] @@ -4582,13 +1503,13 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http", "httparse", "log", "native-tls", - "rand 0.8.6", + "rand", "sha1", - "thiserror 1.0.69", + "thiserror", "utf-8", ] @@ -4598,58 +1519,18 @@ version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "unicode-segmentation" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "unicode-xid" 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", - "subtle", -] - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -4680,30 +1561,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "serde_core", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "vcpkg" version = "0.2.15" @@ -4716,25 +1573,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "wait-timeout" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" -dependencies = [ - "libc", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -4880,56 +1718,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webpki-roots" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "which" -version = "7.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" -dependencies = [ - "either", - "env_home", - "rustix 1.1.4", - "winsafe", -] - -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch", -] - [[package]] name = "winapi" version = "0.3.9" @@ -4946,198 +1734,54 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" -dependencies = [ - "windows-core 0.58.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" -dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-implement" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.58.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" -dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -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", -] +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-registry" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-targets 0.48.5", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] -name = "windows-sys" -version = "0.52.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] -name = "windows-sys" -version = "0.59.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-targets 0.52.6", + "windows-link", ] [[package]] name = "windows-sys" -version = "0.60.2" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5149,264 +1793,70 @@ 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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -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.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -5507,165 +1957,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "x0x" -version = "0.19.8" -dependencies = [ - "ant-quic", - "anyhow", - "async-stream", - "async-trait", - "axum", - "axum-extra", - "base64", - "bincode", - "blake3", - "bytes", - "chacha20poly1305", - "clap", - "dashmap", - "dirs", - "flate2", - "four-word-networking", - "futures", - "hex", - "hyper 0.14.32", - "if-addrs 0.13.4", - "postcard", - "rand 0.8.6", - "reqwest", - "saorsa-gossip-coordinator", - "saorsa-gossip-crdt-sync", - "saorsa-gossip-groups", - "saorsa-gossip-identity", - "saorsa-gossip-membership", - "saorsa-gossip-presence", - "saorsa-gossip-pubsub", - "saorsa-gossip-rendezvous", - "saorsa-gossip-runtime", - "saorsa-gossip-transport", - "saorsa-gossip-types", - "saorsa-mls", - "saorsa-pqc 0.5.1", - "semver", - "serde", - "serde_json", - "sha2", - "tar", - "termimad", - "terminal_size", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "toml", - "tower-http", - "tracing", - "tracing-subscriber", - "uuid", - "which", - "zeroize", - "zip", -] - -[[package]] -name = "x0x-test-harness" -version = "0.1.0" -dependencies = [ - "dirs", - "futures", - "proptest", - "rand 0.8.6", - "reqwest", - "serde", - "serde_json", - "tempfile", - "tokio", - "tokio-tungstenite", - "x0x", -] - -[[package]] -name = "x25519-dalek" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" -dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", - "zeroize", -] - -[[package]] -name = "x509-parser" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" -dependencies = [ - "asn1-rs", - "data-encoding", - "der-parser", - "lazy_static", - "nom", - "oid-registry", - "ring", - "rusticata-macros", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.4", -] - -[[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 = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - -[[package]] -name = "yasna" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" -dependencies = [ - "time", -] - [[package]] name = "yoke" version = "0.8.2" @@ -5735,20 +2026,6 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zerotrie" @@ -5783,78 +2060,8 @@ dependencies = [ "syn", ] -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "aes", - "arbitrary", - "bzip2", - "constant_time_eq 0.3.1", - "crc32fast", - "crossbeam-utils", - "deflate64", - "displaydoc", - "flate2", - "getrandom 0.3.4", - "hmac", - "indexmap", - "lzma-rs", - "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.18", - "time", - "xz2", - "zeroize", - "zopfli", - "zstd", -] - [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zopfli" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/communitas-dioxus/tests/e2e/Cargo.toml b/communitas-dioxus/tests/e2e/Cargo.toml index 502f92136..963927ae4 100644 --- a/communitas-dioxus/tests/e2e/Cargo.toml +++ b/communitas-dioxus/tests/e2e/Cargo.toml @@ -21,7 +21,7 @@ anyhow = "1" communitas-x0x-client = { path = "../../../communitas-x0x-client" } serde_json = "1" tokio = { version = "1", features = ["full"] } -x0x-test-harness = { path = "../../../../x0x/tests/harness" } +tempfile = "3" [[test]] name = "e2e_identity" @@ -35,6 +35,10 @@ path = "trust_contacts.rs" name = "e2e_connectivity" path = "connectivity.rs" +[[test]] +name = "e2e_messaging" +path = "messaging.rs" + [[test]] name = "e2e_groups" path = "groups.rs" diff --git a/communitas-dioxus/tests/e2e/harness.rs b/communitas-dioxus/tests/e2e/harness.rs index 6369bb149..bd13a7720 100644 --- a/communitas-dioxus/tests/e2e/harness.rs +++ b/communitas-dioxus/tests/e2e/harness.rs @@ -7,8 +7,9 @@ //! isolated `x0xd` daemons, then launches the Communitas Dioxus binary in its //! feature-gated headless JSON driver mode pointed at daemon A. +use std::fs::{self, File}; use std::io::{BufRead, BufReader, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::mpsc::{self, Receiver}; use std::time::Duration; @@ -16,9 +17,10 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use communitas_x0x_client::X0xClient; use serde_json::{Value, json}; -use x0x_test_harness::daemon::DaemonFixture; +use tempfile::TempDir; const DRIVER_TIMEOUT: Duration = Duration::from_secs(45); +const DAEMON_STARTUP_TIMEOUT: Duration = Duration::from_secs(45); /// Two-daemon Dioxus parity fixture. pub struct ParityHarness { @@ -34,8 +36,8 @@ pub struct ParityHarness { impl ParityHarness { /// Start two isolated daemons and the headless Dioxus test driver. pub async fn start(prefix: &str) -> Result { - let primary = DaemonFixture::start(&format!("{prefix}-a")).await; - let secondary = DaemonFixture::start(&format!("{prefix}-b")).await; + let primary = DaemonFixture::start(&format!("{prefix}-a")).await?; + let secondary = DaemonFixture::start(&format!("{prefix}-b")).await?; let api_base = primary.url(""); let token = primary.api_token().to_string(); let mut app = DioxusDriver::start(&api_base, &token)?; @@ -62,6 +64,152 @@ impl ParityHarness { } } +/// Isolated x0xd daemon fixture for Communitas Dioxus E2E tests. +pub struct DaemonFixture { + process: Child, + api_addr: String, + api_token: String, + tempdir: TempDir, + stdout_log: PathBuf, + stderr_log: PathBuf, +} + +impl DaemonFixture { + /// Start a fresh daemon, preferring `X0XD_BIN` when supplied by CI or scripts. + pub async fn start(_prefix: &str) -> Result { + let binary = find_x0xd_binary()?; + let tempdir = TempDir::new().context("create daemon temp dir")?; + let config_path = tempdir.path().join("config.toml"); + let config = format!( + "bind_address = \"0.0.0.0:0\"\napi_address = \"127.0.0.1:0\"\ndata_dir = \"{}\"\nlog_level = \"warn\"\nbootstrap_peers = []\n", + tempdir.path().display(), + ); + fs::write(&config_path, config).context("write daemon config")?; + + let stdout_log = tempdir.path().join("daemon.stdout.log"); + let stderr_log = tempdir.path().join("daemon.stderr.log"); + let stdout = File::create(&stdout_log).context("create daemon stdout log")?; + let stderr = File::create(&stderr_log).context("create daemon stderr log")?; + let process = Command::new(&binary) + .arg("--config") + .arg(&config_path) + .arg("--skip-update-check") + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn() + .with_context(|| format!("start x0xd at {}", binary.display()))?; + + let mut fixture = Self { + process, + api_addr: String::new(), + api_token: String::new(), + tempdir, + stdout_log, + stderr_log, + }; + fixture.wait_for_startup().await?; + Ok(fixture) + } + + async fn wait_for_startup(&mut self) -> Result<()> { + let port_file = self.port_file(); + let deadline = tokio::time::Instant::now() + DAEMON_STARTUP_TIMEOUT; + self.api_addr = loop { + if let Some(status) = self + .process + .try_wait() + .context("poll x0xd startup status")? + { + bail!( + "x0xd exited before writing api.port with status {status}\nstdout:\n{}\nstderr:\n{}", + read_log_excerpt(&self.stdout_log), + read_log_excerpt(&self.stderr_log) + ); + } + if tokio::time::Instant::now() > deadline { + bail!( + "timeout waiting for x0xd api.port at {}\nstdout:\n{}\nstderr:\n{}", + port_file.display(), + read_log_excerpt(&self.stdout_log), + read_log_excerpt(&self.stderr_log) + ); + } + if let Ok(addr) = fs::read_to_string(&port_file) { + let trimmed = addr.trim(); + if let Ok(addr) = trimmed.parse::() { + break addr.to_string(); + } + if let Ok(port) = trimmed.parse::() { + break format!("127.0.0.1:{port}"); + } + } + tokio::time::sleep(Duration::from_millis(200)).await; + }; + + let client = X0xClient::with_base_url(&self.url("")); + let deadline = tokio::time::Instant::now() + DAEMON_STARTUP_TIMEOUT; + loop { + if tokio::time::Instant::now() > deadline { + bail!( + "timeout waiting for x0xd health at {}\nstdout:\n{}\nstderr:\n{}", + self.url("/health"), + read_log_excerpt(&self.stdout_log), + read_log_excerpt(&self.stderr_log) + ); + } + if client.health().await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + + let token_file = self.token_file(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + self.api_token = loop { + if let Ok(token) = fs::read_to_string(&token_file) { + let token = token.trim().to_owned(); + if !token.is_empty() { + break token; + } + } + if tokio::time::Instant::now() > deadline { + bail!( + "timeout waiting for x0xd api-token at {}", + token_file.display() + ); + } + tokio::time::sleep(Duration::from_millis(100)).await; + }; + + Ok(()) + } + + /// Full HTTP URL for `path`. + pub fn url(&self, path: &str) -> String { + format!("http://{}{}", self.api_addr, path) + } + + /// Raw API token. + pub fn api_token(&self) -> &str { + &self.api_token + } + + fn port_file(&self) -> PathBuf { + self.tempdir.path().join("api.port") + } + + fn token_file(&self) -> PathBuf { + self.tempdir.path().join("api-token") + } +} + +impl Drop for DaemonFixture { + fn drop(&mut self) { + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + /// Headless Dioxus binary driver. pub struct DioxusDriver { child: Child, @@ -181,3 +329,44 @@ fn dioxus_binary_path() -> Result { path.push(format!("communitas-dioxus{}", std::env::consts::EXE_SUFFIX)); Ok(path) } + +fn find_x0xd_binary() -> Result { + let mut candidates = Vec::new(); + if let Ok(path) = std::env::var("X0XD_BIN") { + candidates.push(PathBuf::from(path)); + } + if let Ok(dir) = std::env::var("X0X_DIR") { + let dir = PathBuf::from(dir); + candidates.push(dir.join("target/release/x0xd")); + candidates.push(dir.join("target/debug/x0xd")); + } + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace_dir = manifest_dir + .parent() + .and_then(Path::parent) + .and_then(Path::parent) + .and_then(Path::parent) + .context("resolve Communitas workspace from E2E manifest dir")?; + candidates.push(workspace_dir.join("../x0x/target/release/x0xd")); + candidates.push(workspace_dir.join("../x0x/target/debug/x0xd")); + candidates.push(std::env::current_dir()?.join("target/release/x0xd")); + candidates.push(std::env::current_dir()?.join("target/debug/x0xd")); + + for candidate in candidates { + if candidate.is_file() { + return Ok(candidate); + } + } + + bail!("could not locate x0xd; build x0x or set X0XD_BIN") +} + +fn read_log_excerpt(path: &Path) -> String { + let Ok(contents) = fs::read_to_string(path) else { + return "(unreadable)".to_owned(); + }; + let mut lines: Vec<&str> = contents.lines().rev().take(40).collect(); + lines.reverse(); + lines.join("\n") +} diff --git a/communitas-dioxus/tests/e2e/messaging.rs b/communitas-dioxus/tests/e2e/messaging.rs new file mode 100644 index 000000000..ca077234a --- /dev/null +++ b/communitas-dioxus/tests/e2e/messaging.rs @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Dioxus parity tests for live messaging cells. + +mod harness; + +use anyhow::Result; +use serde_json::json; + +use harness::{ParityHarness, ensure_ok, string_field}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "live x0xd + Dioxus binary E2E harness"] +async fn dioxus_parity_messaging_publish_receives_ws_payload() -> Result<()> { + let mut harness = ParityHarness::start("messaging-pubsub").await?; + let response = harness + .app + .command(json!({ "op": "messaging.pubsub_roundtrip" }))?; + ensure_ok(&response)?; + let stream = string_field(&response, "stream")?; + let topic = string_field(&response, "topic")?; + let payload = string_field(&response, "payload")?; + assert_eq!(stream, "ws"); + assert!(topic.starts_with("dioxus-pubsub-")); + assert_eq!(payload, format!("hello-{topic}")); + Ok(()) +} diff --git a/communitas-dioxus/tests/offline_ux_integration.rs b/communitas-dioxus/tests/offline_ux_integration.rs index 8a4c5bb04..94fc3b8d7 100644 --- a/communitas-dioxus/tests/offline_ux_integration.rs +++ b/communitas-dioxus/tests/offline_ux_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for offline UX components in Communitas Dioxus. //! //! These tests verify the offline-first UX behavior including: diff --git a/communitas-kanban/src/lib.rs b/communitas-kanban/src/lib.rs index 05390aa0a..d7fdffd15 100644 --- a/communitas-kanban/src/lib.rs +++ b/communitas-kanban/src/lib.rs @@ -44,7 +44,11 @@ //! ``` #![forbid(unsafe_code)] -#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +#![cfg_attr( + not(test), + deny(clippy::unwrap_used, clippy::expect_used, clippy::panic) +)] +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] #![warn(missing_docs, clippy::missing_docs_in_private_items)] mod analytics; diff --git a/communitas-kanban/tests/integration_tests.rs b/communitas-kanban/tests/integration_tests.rs index ca04c1405..b7662e246 100644 --- a/communitas-kanban/tests/integration_tests.rs +++ b/communitas-kanban/tests/integration_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the Kanban system. //! //! These tests verify complete workflows and interactions between components. diff --git a/communitas-kanban/tests/sync_tests.rs b/communitas-kanban/tests/sync_tests.rs index 36ca2d9e1..9c80bf02f 100644 --- a/communitas-kanban/tests/sync_tests.rs +++ b/communitas-kanban/tests/sync_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Sync tests for the Kanban CRDT system. //! //! These tests verify that concurrent edits merge correctly and data diff --git a/communitas-ui-service/src/lib.rs b/communitas-ui-service/src/lib.rs index 3842a316a..193a36146 100644 --- a/communitas-ui-service/src/lib.rs +++ b/communitas-ui-service/src/lib.rs @@ -2,6 +2,8 @@ //! Shared Rust service layer consumed by all Communitas UI surfaces (Dioxus + MCP). +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + pub mod audit; pub mod auth; pub mod canvas; diff --git a/communitas-ui-service/tests/accessibility_integration.rs b/communitas-ui-service/tests/accessibility_integration.rs index da739b3d0..2a44139ce 100644 --- a/communitas-ui-service/tests/accessibility_integration.rs +++ b/communitas-ui-service/tests/accessibility_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Accessibility integration tests for UI service components. //! //! These tests verify that accessibility features are properly integrated diff --git a/communitas-ui-service/tests/auth_e2e.rs b/communitas-ui-service/tests/auth_e2e.rs index e63da8334..3865a3eb7 100644 --- a/communitas-ui-service/tests/auth_e2e.rs +++ b/communitas-ui-service/tests/auth_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end tests for authentication flows. //! //! These tests verify complete authentication workflows through the AuthService layer, diff --git a/communitas-ui-service/tests/auth_hardening.rs b/communitas-ui-service/tests/auth_hardening.rs index 0a418d8cc..2b36c2ef6 100644 --- a/communitas-ui-service/tests/auth_hardening.rs +++ b/communitas-ui-service/tests/auth_hardening.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for Phase 6.1 Auth Hardening features. //! //! Tests cover: diff --git a/communitas-ui-service/tests/auth_integration.rs b/communitas-ui-service/tests/auth_integration.rs index 816aef1de..dd910c686 100644 --- a/communitas-ui-service/tests/auth_integration.rs +++ b/communitas-ui-service/tests/auth_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for identity recovery flows. //! //! These tests verify the full recovery lifecycle including: diff --git a/communitas-ui-service/tests/canvas_e2e.rs b/communitas-ui-service/tests/canvas_e2e.rs index d7b3863ed..494d1d10f 100644 --- a/communitas-ui-service/tests/canvas_e2e.rs +++ b/communitas-ui-service/tests/canvas_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end tests for canvas collaboration workflows. //! //! These tests verify complete canvas workflows through the CanvasService layer, diff --git a/communitas-ui-service/tests/canvas_integration.rs b/communitas-ui-service/tests/canvas_integration.rs index 7fb952a80..80d7e3878 100644 --- a/communitas-ui-service/tests/canvas_integration.rs +++ b/communitas-ui-service/tests/canvas_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the canvas service using real CommunitasApp. //! //! These tests verify the full canvas flow including element operations, diff --git a/communitas-ui-service/tests/contacts_test.rs b/communitas-ui-service/tests/contacts_test.rs index af97af2cc..1f9565327 100644 --- a/communitas-ui-service/tests/contacts_test.rs +++ b/communitas-ui-service/tests/contacts_test.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the ContactsService. //! //! These tests verify the contacts service layer works correctly diff --git a/communitas-ui-service/tests/drive_e2e.rs b/communitas-ui-service/tests/drive_e2e.rs index c1baf5753..ec0d6c0f3 100644 --- a/communitas-ui-service/tests/drive_e2e.rs +++ b/communitas-ui-service/tests/drive_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end tests for drive/file operations with parity verification. //! //! Tests cover: diff --git a/communitas-ui-service/tests/drive_integration.rs b/communitas-ui-service/tests/drive_integration.rs index c68513839..b6f9e3e89 100644 --- a/communitas-ui-service/tests/drive_integration.rs +++ b/communitas-ui-service/tests/drive_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the drive service using real CommunitasApp. //! //! These tests verify the full drive flow including file operations, diff --git a/communitas-ui-service/tests/drive_resume_integration.rs b/communitas-ui-service/tests/drive_resume_integration.rs index ec7b46b2a..39919686b 100644 --- a/communitas-ui-service/tests/drive_resume_integration.rs +++ b/communitas-ui-service/tests/drive_resume_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the drive resume flow. //! //! These tests verify the complete resume functionality including: diff --git a/communitas-ui-service/tests/edge_case_e2e.rs b/communitas-ui-service/tests/edge_case_e2e.rs index 7485ead6d..e3ac89d0b 100644 --- a/communitas-ui-service/tests/edge_case_e2e.rs +++ b/communitas-ui-service/tests/edge_case_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end edge case tests for the Communitas UI services. //! //! These tests verify graceful handling of edge cases like empty lists, diff --git a/communitas-ui-service/tests/kanban_e2e.rs b/communitas-ui-service/tests/kanban_e2e.rs index 5698bf6f7..bbc330396 100644 --- a/communitas-ui-service/tests/kanban_e2e.rs +++ b/communitas-ui-service/tests/kanban_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end tests for kanban board workflows. //! //! These tests verify complete kanban board workflows through the KanbanService layer, diff --git a/communitas-ui-service/tests/kanban_integration.rs b/communitas-ui-service/tests/kanban_integration.rs index 12b022fd1..881d9c44f 100644 --- a/communitas-ui-service/tests/kanban_integration.rs +++ b/communitas-ui-service/tests/kanban_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the kanban service using real CommunitasApp. //! //! These tests verify the full kanban flow including board/column/card CRUD, diff --git a/communitas-ui-service/tests/messaging_e2e.rs b/communitas-ui-service/tests/messaging_e2e.rs index 0eea5d2ad..f30d51036 100644 --- a/communitas-ui-service/tests/messaging_e2e.rs +++ b/communitas-ui-service/tests/messaging_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end tests for messaging workflows. //! //! Tests cover: diff --git a/communitas-ui-service/tests/messaging_integration.rs b/communitas-ui-service/tests/messaging_integration.rs index 09df28ffd..a51c3474a 100644 --- a/communitas-ui-service/tests/messaging_integration.rs +++ b/communitas-ui-service/tests/messaging_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for the messaging service using real CommunitasApp. //! //! These tests verify the full messaging flow including send, edit, delete, diff --git a/communitas-ui-service/tests/offline_e2e.rs b/communitas-ui-service/tests/offline_e2e.rs index f460f935c..7745931d8 100644 --- a/communitas-ui-service/tests/offline_e2e.rs +++ b/communitas-ui-service/tests/offline_e2e.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! End-to-end offline sync tests for the Communitas UI services. //! //! These tests verify graceful handling of offline scenarios including: diff --git a/communitas-ui-service/tests/property_tests.rs b/communitas-ui-service/tests/property_tests.rs index ece4a5a85..eb725b90a 100644 --- a/communitas-ui-service/tests/property_tests.rs +++ b/communitas-ui-service/tests/property_tests.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Property-based tests for the Communitas UI services. //! //! These tests use proptest to verify invariants hold across random inputs. diff --git a/communitas-ui-service/tests/stress.rs b/communitas-ui-service/tests/stress.rs index 13e763245..e8d0faac1 100644 --- a/communitas-ui-service/tests/stress.rs +++ b/communitas-ui-service/tests/stress.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Stress tests for the Communitas UI services. //! //! These tests are marked `#[ignore]` and should be run in CI nightly builds diff --git a/communitas-ui-service/tests/sync_state_integration.rs b/communitas-ui-service/tests/sync_state_integration.rs index bba781cc7..d1e7ff081 100644 --- a/communitas-ui-service/tests/sync_state_integration.rs +++ b/communitas-ui-service/tests/sync_state_integration.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Integration tests for sync state transitions in the Communitas UI services. //! //! These tests verify the offline-first UX behavior including: diff --git a/communitas-x0x-client/src/client.rs b/communitas-x0x-client/src/client.rs index 653667804..3f8c513d4 100644 --- a/communitas-x0x-client/src/client.rs +++ b/communitas-x0x-client/src/client.rs @@ -40,6 +40,10 @@ impl X0xClient { /// found, preserving backward compatibility for environments where x0xd has /// not yet written its config. pub fn new() -> Self { + if let Some(client) = Self::from_env_override() { + return client; + } + match crate::config::discover() { Ok(cfg) => Self::from_config(cfg), Err(e) => { @@ -61,6 +65,21 @@ impl X0xClient { Self { base_url, client } } + fn from_env_override() -> Option { + let base_url = std::env::var("X0X_API_BASE").ok()?; + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return None; + } + + match std::env::var("X0X_API_TOKEN") { + Ok(token) if !token.trim().is_empty() => { + Some(Self::with_base_url_and_token(trimmed, token.trim())) + } + _ => Some(Self::with_base_url(trimmed)), + } + } + /// Discover the x0x daemon config and create an authenticated client. /// /// # Errors @@ -110,6 +129,24 @@ impl X0xClient { format!("{}{}", self.base_url, path) } + fn path_component(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(char::from(byte)) + } + _ => { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0F)])); + } + } + } + encoded + } + /// Parse a daemon response, handling x0x's flattened `{"ok":true,...}` payloads. async fn parse(&self, resp: reqwest::Response) -> Result { let status = resp.status(); @@ -238,6 +275,30 @@ impl X0xClient { &self, display_name: Option<&str>, include_groups: Option, + ) -> Result { + self.agent_card_query(display_name, include_groups, None) + .await + } + + /// Generate an agent card suitable for nearby/local app pairing. + /// + /// This keeps the base `agent_card` method aligned with x0xd's default + /// privacy behavior while allowing local UI share flows to include LAN + /// addresses needed for first contact on a local network. + pub async fn agent_card_for_local_share( + &self, + display_name: Option<&str>, + include_groups: Option, + ) -> Result { + self.agent_card_query(display_name, include_groups, Some(true)) + .await + } + + async fn agent_card_query( + &self, + display_name: Option<&str>, + include_groups: Option, + include_local_addresses: Option, ) -> Result { let mut query: Vec<(String, String)> = Vec::new(); if let Some(name) = display_name { @@ -246,6 +307,9 @@ impl X0xClient { if let Some(include) = include_groups { query.push(("include_groups".to_owned(), include.to_string())); } + if let Some(include) = include_local_addresses { + query.push(("include_local_addresses".to_owned(), include.to_string())); + } let resp = self .client @@ -572,10 +636,25 @@ impl X0xClient { } /// Send a direct (point-to-point) message. + /// + /// Communitas user-facing DMs require the x0x 0.22 recipient inbox gossip + /// ACK so send failures surface instead of silently dropping receive paths. pub async fn send_direct(&self, agent_id: &str, payload: &[u8]) -> Result<()> { + self.send_direct_with_gossip_ack(agent_id, payload, true) + .await + } + + /// Send a direct message with explicit gossip-inbox ACK behavior. + pub async fn send_direct_with_gossip_ack( + &self, + agent_id: &str, + payload: &[u8], + require_gossip_ack: bool, + ) -> Result<()> { let req = DirectSendRequest { agent_id: agent_id.to_owned(), payload: BASE64.encode(payload), + require_gossip_ack: Some(require_gossip_ack), }; self.post_ok("/direct/send", &req).await } @@ -959,6 +1038,7 @@ impl X0xClient { /// List tasks in a task list. pub async fn list_tasks(&self, list_id: &str) -> Result> { + let list_id = Self::path_component(list_id); let resp = self .client .get(self.url(&format!("/task-lists/{list_id}/tasks"))) @@ -979,6 +1059,7 @@ impl X0xClient { title: title.to_owned(), description: description.map(str::to_owned), }; + let list_id = Self::path_component(list_id); self.post_ok(&format!("/task-lists/{list_id}/tasks"), &req) .await } @@ -988,6 +1069,8 @@ impl X0xClient { let req = UpdateTaskRequest { action: "claim".to_owned(), }; + let list_id = Self::path_component(list_id); + let task_id = Self::path_component(task_id); self.request_ok( self.client .patch(self.url(&format!("/task-lists/{list_id}/tasks/{task_id}"))) @@ -1001,6 +1084,8 @@ impl X0xClient { let req = UpdateTaskRequest { action: "complete".to_owned(), }; + let list_id = Self::path_component(list_id); + let task_id = Self::path_component(task_id); self.request_ok( self.client .patch(self.url(&format!("/task-lists/{list_id}/tasks/{task_id}"))) @@ -1028,6 +1113,7 @@ impl X0xClient { /// Join an existing store by its topic. pub async fn join_store(&self, store_id: &str) -> Result<()> { + let store_id = Self::path_component(store_id); self.post_ok(&format!("/stores/{store_id}/join"), &serde_json::json!({})) .await } @@ -1041,6 +1127,7 @@ impl X0xClient { /// List keys in a store. pub async fn list_keys(&self, store_id: &str) -> Result> { + let store_id = Self::path_component(store_id); let resp = self .client .get(self.url(&format!("/stores/{store_id}/keys"))) @@ -1062,6 +1149,8 @@ impl X0xClient { value: BASE64.encode(value), content_type: content_type.map(str::to_owned), }; + let store_id = Self::path_component(store_id); + let key = Self::path_component(key); self.request_ok( self.client .put(self.url(&format!("/stores/{store_id}/{key}"))) @@ -1072,6 +1161,8 @@ impl X0xClient { /// Get a raw store entry, including its base64 value and metadata. pub async fn get(&self, store_id: &str, key: &str) -> Result { + let store_id = Self::path_component(store_id); + let key = Self::path_component(key); let resp = self .client .get(self.url(&format!("/stores/{store_id}/{key}"))) @@ -1082,6 +1173,8 @@ impl X0xClient { /// Delete a key from a store. pub async fn delete_key(&self, store_id: &str, key: &str) -> Result<()> { + let store_id = Self::path_component(store_id); + let key = Self::path_component(key); self.delete_ok(&format!("/stores/{store_id}/{key}")).await } @@ -1379,18 +1472,34 @@ impl X0xClient { group_id: &str, agent_id: &str, display_name: Option<&str>, + ) -> Result<()> { + self.add_named_group_member_with_treekem_key_package(group_id, agent_id, display_name, None) + .await + } + + /// `POST /groups/:id/members` — add a member directly (admin+), supplying + /// TreeKEM key-package material when adding to a TreeKEM-secure group. + pub async fn add_named_group_member_with_treekem_key_package( + &self, + group_id: &str, + agent_id: &str, + display_name: Option<&str>, + treekem_key_package_b64: Option<&str>, ) -> Result<()> { #[derive(serde::Serialize)] struct Body<'a> { agent_id: &'a str, #[serde(skip_serializing_if = "Option::is_none")] display_name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + treekem_key_package_b64: Option<&'a str>, } self.post_ok( &format!("/groups/{group_id}/members"), &Body { agent_id, display_name, + treekem_key_package_b64, }, ) .await @@ -1585,7 +1694,7 @@ impl X0xClient { group_id: &str, body: &str, kind: Option<&str>, - ) -> Result { + ) -> Result { let req = SendGroupMessageRequest { body: body.to_owned(), kind: kind.map(str::to_owned), @@ -1736,3 +1845,26 @@ impl Default for X0xClient { Self::new() } } + +#[cfg(test)] +mod tests { + use super::X0xClient; + + #[test] + fn path_component_leaves_unreserved_characters() { + assert_eq!(X0xClient::path_component("abc-XYZ_012.~"), "abc-XYZ_012.~"); + } + + #[test] + fn path_component_encodes_reserved_characters() { + assert_eq!( + X0xClient::path_component("x0x.group.abc.board/tasks#1"), + "x0x.group.abc.board%2Ftasks%231" + ); + } + + #[test] + fn path_component_encodes_utf8_bytes() { + assert_eq!(X0xClient::path_component("cafe\u{301}"), "cafe%CC%81"); + } +} diff --git a/communitas-x0x-client/src/daemon.rs b/communitas-x0x-client/src/daemon.rs index a10e89c3c..f7f7d2b46 100644 --- a/communitas-x0x-client/src/daemon.rs +++ b/communitas-x0x-client/src/daemon.rs @@ -6,7 +6,7 @@ //! starting, stopping, and optionally auto-starting the daemon. It does not //! assume undocumented direct `x0xd` process management semantics. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use std::time::Duration; @@ -26,6 +26,9 @@ const SUBPROCESS_TIMEOUT_SECS: u64 = 30; /// Maximum time to wait for the installer (`curl -sfL https://x0x.md | sh`) (seconds). const INSTALL_TIMEOUT_SECS: u64 = 120; +/// Maximum time to wait for a daemon health probe while deciding onboarding state. +const HEALTH_PROBE_TIMEOUT_SECS: u64 = 5; + /// The current state of the x0xd daemon. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DaemonState { @@ -63,15 +66,20 @@ impl DaemonManager { /// Check the current state of the daemon. pub async fn state(&self) -> DaemonState { // First check if we can reach the daemon. - match self.client.health().await { - Ok(health) => { - if health.status == "healthy" || health.status == "running" { + match tokio::time::timeout( + Duration::from_secs(HEALTH_PROBE_TIMEOUT_SECS), + self.client.health(), + ) + .await + { + Ok(Ok(health)) => { + if is_running_health_status(&health.status) { DaemonState::Running } else { DaemonState::Degraded } } - Err(_) => { + Ok(Err(_)) | Err(_) => { // Daemon not reachable. Check if binary is installed. if Self::is_installed() { DaemonState::NotRunning @@ -265,15 +273,7 @@ impl DaemonManager { } } let home = dirs_next::home_dir().unwrap_or_default(); - [ - PathBuf::from("/usr/local/bin/x0x"), - PathBuf::from("/opt/homebrew/bin/x0x"), - PathBuf::from("/opt/zerobrew/bin/x0x"), - home.join(".cargo/bin/x0x"), - home.join(".x0x/bin/x0x"), - ] - .into_iter() - .find(|p| p.exists()) + locate_installed_binary_from_candidates(installed_binary_candidates(&home)) } async fn fetch_release_manifest(url: &str) -> Result { @@ -322,6 +322,29 @@ impl DaemonManager { } } +fn installed_binary_candidates(home: &Path) -> [PathBuf; 6] { + [ + PathBuf::from("/usr/local/bin/x0x"), + PathBuf::from("/opt/homebrew/bin/x0x"), + PathBuf::from("/opt/zerobrew/bin/x0x"), + home.join(".local/bin/x0x"), + home.join(".cargo/bin/x0x"), + home.join(".x0x/bin/x0x"), + ] +} + +/// x0xd has used a few success strings across release lines. +pub fn is_running_health_status(status: &str) -> bool { + matches!(status, "healthy" | "running" | "ok") +} + +fn locate_installed_binary_from_candidates(candidates: I) -> Option +where + I: IntoIterator, +{ + candidates.into_iter().find(|p| p.exists()) +} + impl Default for DaemonManager { fn default() -> Self { Self::new() @@ -395,3 +418,44 @@ async fn run_with_timeout( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installed_binary_candidates_include_user_local_bin() { + let home = Path::new("/tmp/communitas-test-home"); + let candidates = installed_binary_candidates(home); + + assert!( + candidates.contains(&home.join(".local/bin/x0x")), + "~/.local/bin/x0x must be treated as an installed x0x CLI" + ); + } + + #[test] + fn running_health_status_accepts_all_success_spellings() { + assert!(is_running_health_status("healthy")); + assert!(is_running_health_status("running")); + assert!(is_running_health_status("ok")); + assert!(!is_running_health_status("degraded")); + } + + #[test] + fn locate_installed_binary_from_candidates_finds_existing_user_binary() { + let temp = tempfile::tempdir().expect("temp dir"); + let binary = temp.path().join(".local/bin/x0x"); + let parent = binary.parent().expect("binary parent"); + std::fs::create_dir_all(parent).expect("create x0x bin dir"); + std::fs::write(&binary, b"#!/bin/sh\n").expect("write x0x marker"); + + let found = locate_installed_binary_from_candidates([ + temp.path().join("missing/bin/x0x"), + binary.clone(), + temp.path().join(".cargo/bin/x0x"), + ]); + + assert_eq!(found, Some(binary)); + } +} diff --git a/communitas-x0x-client/src/lib.rs b/communitas-x0x-client/src/lib.rs index feeb5b183..5bb519ba6 100644 --- a/communitas-x0x-client/src/lib.rs +++ b/communitas-x0x-client/src/lib.rs @@ -31,6 +31,8 @@ //! # } //! ``` +#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))] + pub mod client; pub mod config; pub mod daemon; @@ -41,7 +43,7 @@ pub mod websocket; pub use client::X0xClient; pub use config::{X0xConfig, discover as discover_x0x_config}; -pub use daemon::{DaemonManager, DaemonState}; +pub use daemon::{DaemonManager, DaemonState, is_running_health_status}; pub use error::{Result, X0xError}; pub use sse::{SseFrame, X0xSseStream}; pub use types::*; diff --git a/communitas-x0x-client/src/sse.rs b/communitas-x0x-client/src/sse.rs index 5434b8ec9..a29059163 100644 --- a/communitas-x0x-client/src/sse.rs +++ b/communitas-x0x-client/src/sse.rs @@ -38,6 +38,10 @@ pub struct X0xSseStream { impl X0xSseStream { /// Connect to the daemon's general SSE endpoint (`/events`). pub async fn connect() -> Result { + if let Some((url, token)) = sse_url_from_env("/events") { + return Self::connect_with_optional_token(&url, token.as_deref()).await; + } + match crate::config::discover() { Ok(cfg) => Self::connect_with_config(&cfg, "/events").await, Err(e) => { @@ -51,6 +55,10 @@ impl X0xSseStream { /// Connect to the daemon's direct-message SSE endpoint (`/direct/events`). pub async fn connect_direct() -> Result { + if let Some((url, token)) = sse_url_from_env("/direct/events") { + return Self::connect_with_optional_token(&url, token.as_deref()).await; + } + match crate::config::discover() { Ok(cfg) => Self::connect_with_config(&cfg, "/direct/events").await, Err(e) => { @@ -64,6 +72,10 @@ impl X0xSseStream { /// Connect to the daemon's presence SSE endpoint (`/presence/events`). pub async fn connect_presence() -> Result { + if let Some((url, token)) = sse_url_from_env("/presence/events") { + return Self::connect_with_optional_token(&url, token.as_deref()).await; + } + match crate::config::discover() { Ok(cfg) => Self::connect_with_config(&cfg, "/presence/events").await, Err(e) => { @@ -77,6 +89,10 @@ impl X0xSseStream { /// Connect to the daemon's peer-lifecycle SSE endpoint (`/peers/events`). pub async fn connect_peer_events() -> Result { + if let Some((url, token)) = sse_url_from_env("/peers/events") { + return Self::connect_with_optional_token(&url, token.as_deref()).await; + } + match crate::config::discover() { Ok(cfg) => Self::connect_with_config(&cfg, "/peers/events").await, Err(e) => { @@ -106,6 +122,13 @@ impl X0xSseStream { Self::connect_with_client(client, &url).await } + async fn connect_with_optional_token(url: &str, token: Option<&str>) -> Result { + match token { + Some(token) => Self::connect_with_client(authenticated_client(token)?, url).await, + None => Self::connect_to(url).await, + } + } + async fn connect_with_client(client: reqwest::Client, url: &str) -> Result { let resp = client .get(url) @@ -165,6 +188,31 @@ fn authenticated_client(token: &str) -> Result { .map_err(X0xError::Http) } +fn sse_url_from_env(path: &str) -> Option<(String, Option)> { + let base_url = std::env::var("X0X_API_BASE").ok()?; + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + let url = if trimmed.starts_with("http://") || trimmed.starts_with("https://") { + format!("{trimmed}{path}") + } else if let Some(address) = trimmed.strip_prefix("ws://") { + format!("http://{address}{path}") + } else if let Some(address) = trimmed.strip_prefix("wss://") { + format!("https://{address}{path}") + } else { + format!("http://{trimmed}{path}") + }; + + let token = match std::env::var("X0X_API_TOKEN") { + Ok(token) if !token.trim().is_empty() => Some(token.trim().to_owned()), + _ => None, + }; + + Some((url, token)) +} + fn take_next_frame(buffer: &mut String) -> Option { let pos = buffer.find("\n\n")?; let frame = buffer[..pos].to_owned(); diff --git a/communitas-x0x-client/src/types.rs b/communitas-x0x-client/src/types.rs index a042ae306..8bdf380b7 100644 --- a/communitas-x0x-client/src/types.rs +++ b/communitas-x0x-client/src/types.rs @@ -545,6 +545,11 @@ pub struct ConnectMachineResponse { pub struct DirectSendRequest { pub agent_id: String, pub payload: String, // base64 + /// x0x 0.22+ defaults `/direct/send` to waiting for the recipient inbox + /// gossip ACK. Keep this explicit at app call sites so fire-and-forget + /// sends can opt out deliberately. + #[serde(skip_serializing_if = "Option::is_none")] + pub require_gossip_ack: Option, } /// A direct connection from `GET /direct/connections`. @@ -1625,6 +1630,16 @@ pub struct SendGroupMessageRequest { pub kind: Option, } +/// Response from `POST /groups/:id/send`. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct GroupPublicSendResponse { + pub group_id: String, + pub topic: String, + #[serde(default)] + pub fallback_topic: Option, + pub timestamp: u64, +} + /// Response from `GET /groups/:id/messages`. #[derive(Debug, Clone, Deserialize)] pub struct GroupMessagesResponse { @@ -1833,8 +1848,11 @@ pub struct SecureEncryptRequest { #[derive(Debug, Clone, Deserialize)] pub struct SecureEncryptResponse { pub ciphertext_b64: String, - pub nonce_b64: String, + #[serde(default)] + pub nonce_b64: Option, pub secret_epoch: u64, + #[serde(default)] + pub secure_plane: Option, } /// Request body for `POST /groups/:id/secure/decrypt`. @@ -1848,7 +1866,12 @@ pub struct SecureDecryptRequest { /// Response from `POST /groups/:id/secure/decrypt`. #[derive(Debug, Clone, Deserialize)] pub struct SecureDecryptResponse { + #[serde(alias = "payload_b64")] pub plaintext_b64: String, + #[serde(default)] + pub secret_epoch: Option, + #[serde(default)] + pub secure_plane: Option, } /// Request body for `POST /groups/:id/secure/reseal`. diff --git a/communitas-x0x-client/src/websocket.rs b/communitas-x0x-client/src/websocket.rs index c20f5dba3..5069bda78 100644 --- a/communitas-x0x-client/src/websocket.rs +++ b/communitas-x0x-client/src/websocket.rs @@ -13,6 +13,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio::sync::mpsc; +use tokio::task::JoinHandle; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; @@ -32,6 +33,8 @@ use crate::types::{WsInbound, WsOutbound}; pub struct X0xWebSocket { tx: mpsc::UnboundedSender, rx: mpsc::UnboundedReceiver, + send_task: JoinHandle<()>, + receive_task: JoinHandle<()>, } impl X0xWebSocket { @@ -41,6 +44,10 @@ impl X0xWebSocket { /// files. Falls back to `ws://127.0.0.1:12700/ws` with no token if the /// config files are not found. pub async fn connect() -> Result { + if let Some(url) = websocket_url_from_env("/ws") { + return Self::connect_to(&url).await; + } + match crate::config::discover() { Ok(cfg) => Self::connect_with_config(&cfg, "/ws").await, Err(e) => { @@ -58,6 +65,10 @@ impl X0xWebSocket { /// files. Falls back to `ws://127.0.0.1:12700/ws/direct` with no token if /// the config files are not found. pub async fn connect_direct() -> Result { + if let Some(url) = websocket_url_from_env("/ws/direct") { + return Self::connect_to(&url).await; + } + match crate::config::discover() { Ok(cfg) => Self::connect_with_config(&cfg, "/ws/direct").await, Err(e) => { @@ -100,7 +111,7 @@ impl X0xWebSocket { let (inbound_tx, inbound_rx) = mpsc::unbounded_channel::(); // Send task: forward outbound messages to the WebSocket. - tokio::spawn(async move { + let send_task = tokio::spawn(async move { while let Some(msg) = outbound_rx.recv().await { let json = match serde_json::to_string(&msg) { Ok(j) => j, @@ -116,7 +127,7 @@ impl X0xWebSocket { }); // Receive task: forward inbound WebSocket messages to the channel. - tokio::spawn(async move { + let receive_task = tokio::spawn(async move { while let Some(Ok(msg)) = ws_source.next().await { let text = match msg { Message::Text(t) => t.to_string(), @@ -139,6 +150,8 @@ impl X0xWebSocket { Ok(Self { tx: outbound_tx, rx: inbound_rx, + send_task, + receive_task, }) } @@ -198,3 +211,33 @@ impl X0xWebSocket { self.rx.recv().await } } + +impl Drop for X0xWebSocket { + fn drop(&mut self) { + self.send_task.abort(); + self.receive_task.abort(); + } +} + +fn websocket_url_from_env(path: &str) -> Option { + let base_url = std::env::var("X0X_API_BASE").ok()?; + let trimmed = base_url.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return None; + } + + let url = if let Some(address) = trimmed.strip_prefix("https://") { + format!("wss://{address}{path}") + } else if let Some(address) = trimmed.strip_prefix("http://") { + format!("ws://{address}{path}") + } else if trimmed.starts_with("ws://") || trimmed.starts_with("wss://") { + format!("{trimmed}{path}") + } else { + format!("ws://{trimmed}{path}") + }; + + match std::env::var("X0X_API_TOKEN") { + Ok(token) if !token.trim().is_empty() => Some(format!("{url}?token={}", token.trim())), + _ => Some(url), + } +} diff --git a/communitas-x0x-client/tests/client_coverage.rs b/communitas-x0x-client/tests/client_coverage.rs index dbb18a971..c55dbb669 100644 --- a/communitas-x0x-client/tests/client_coverage.rs +++ b/communitas-x0x-client/tests/client_coverage.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! API coverage guardian for the communitas x0x client. //! //! Every public method on X0xClient and X0xWebSocket MUST have an entry @@ -8,6 +10,7 @@ //! //! Run: cargo nextest run -p communitas-x0x-client --test client_coverage +use communitas_x0x_client::DirectSendRequest; use std::collections::HashSet; // ── Every public method on X0xClient that wraps an x0x API endpoint ──── @@ -23,6 +26,7 @@ const COVERED_REST: &[(&str, &str, &str)] = &[ ("agent_user_id", "GET", "/agent/user-id"), ("agent_sign", "POST", "/agent/sign"), ("agent_card", "GET", "/agent/card"), + ("agent_card_for_local_share", "GET", "/agent/card"), ("import_agent_card", "POST", "/agent/card/import"), ("introduction", "GET", "/introduction"), ("ws_sessions", "GET", "/ws/sessions"), @@ -67,6 +71,7 @@ const COVERED_REST: &[(&str, &str, &str)] = &[ ("exec_cancel", "POST", "/exec/cancel"), ("exec_sessions", "GET", "/exec/sessions"), ("send_direct", "POST", "/direct/send"), + ("send_direct_with_gossip_ack", "POST", "/direct/send"), ("direct_connections", "GET", "/direct/connections"), // Contacts ("list_contacts", "GET", "/contacts"), @@ -123,6 +128,11 @@ const COVERED_REST: &[(&str, &str, &str)] = &[ ("update_group_policy", "PATCH", "/groups/:id/policy"), ("list_named_group_members", "GET", "/groups/:id/members"), ("add_named_group_member", "POST", "/groups/:id/members"), + ( + "add_named_group_member_with_treekem_key_package", + "POST", + "/groups/:id/members", + ), ( "remove_named_group_member", "DELETE", @@ -289,14 +299,20 @@ fn no_duplicate_endpoints() { } // Allow intentional shared endpoints: // - announce / announce_with_options share POST /announce + // - agent_card / agent_card_for_local_share share GET /agent/card // - claim_task / complete_task share PATCH /task-lists/:id/tasks/:tid (different action param) // - create_group / create_group_with_preset share POST /groups (preset-aware overload) + // - add_named_group_member_with_treekem_key_package extends POST /groups/:id/members + // - send_direct_with_gossip_ack makes POST /direct/send ACK intent explicit let real_dupes: Vec<_> = dupes .iter() .filter(|d| { !d.contains("announce") + && !d.contains("agent_card_for_local_share") && !d.contains("task") && !d.contains("create_group_with_preset") + && !d.contains("add_named_group_member_with_treekem_key_package") + && !d.contains("send_direct_with_gossip_ack") }) .collect(); assert!( @@ -547,6 +563,21 @@ fn all_client_methods_are_covered() { ); } +#[test] +fn direct_send_request_encodes_gossip_ack_intent() { + let req = DirectSendRequest { + agent_id: "agent-a".to_owned(), + payload: "aGVsbG8=".to_owned(), + require_gossip_ack: Some(true), + }; + + let encoded = serde_json::to_value(req).expect("direct send request should serialize"); + + assert_eq!(encoded["agent_id"], "agent-a"); + assert_eq!(encoded["payload"], "aGVsbG8="); + assert_eq!(encoded["require_gossip_ack"], true); +} + /// Verifies every public method in websocket.rs is tracked in COVERED_WS. #[test] fn all_ws_methods_are_covered() { diff --git a/communitas-x0x-client/tests/contacts_contract.rs b/communitas-x0x-client/tests/contacts_contract.rs index ee75911ac..c6dc82a6d 100644 --- a/communitas-x0x-client/tests/contacts_contract.rs +++ b/communitas-x0x-client/tests/contacts_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use communitas_x0x_client::{TrustLevel, X0xClient}; #[tokio::test] diff --git a/communitas-x0x-client/tests/groups_contract.rs b/communitas-x0x-client/tests/groups_contract.rs index a873b4b0d..d4888801a 100644 --- a/communitas-x0x-client/tests/groups_contract.rs +++ b/communitas-x0x-client/tests/groups_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use communitas_x0x_client::X0xClient; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/communitas-x0x-client/tests/health_contract.rs b/communitas-x0x-client/tests/health_contract.rs index 121ef7849..067b7b78b 100644 --- a/communitas-x0x-client/tests/health_contract.rs +++ b/communitas-x0x-client/tests/health_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use communitas_x0x_client::X0xClient; #[tokio::test] diff --git a/communitas-x0x-client/tests/live_matrix_contract.rs b/communitas-x0x-client/tests/live_matrix_contract.rs index 68a83419a..7e7e0db9a 100644 --- a/communitas-x0x-client/tests/live_matrix_contract.rs +++ b/communitas-x0x-client/tests/live_matrix_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + #[path = "support/harness.rs"] mod harness; diff --git a/communitas-x0x-client/tests/live_mutation_contract.rs b/communitas-x0x-client/tests/live_mutation_contract.rs index 8c2d722c9..c6205edc3 100644 --- a/communitas-x0x-client/tests/live_mutation_contract.rs +++ b/communitas-x0x-client/tests/live_mutation_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + #[path = "support/harness.rs"] mod harness; diff --git a/communitas-x0x-client/tests/parity_manifest.rs b/communitas-x0x-client/tests/parity_manifest.rs index bb347fb75..fef397989 100644 --- a/communitas-x0x-client/tests/parity_manifest.rs +++ b/communitas-x0x-client/tests/parity_manifest.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Parity proof: every x0x REST endpoint in the `named-groups` category //! has a corresponding client method. //! diff --git a/communitas-x0x-client/tests/stores_contract.rs b/communitas-x0x-client/tests/stores_contract.rs index 6988888a2..738ddd4db 100644 --- a/communitas-x0x-client/tests/stores_contract.rs +++ b/communitas-x0x-client/tests/stores_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use base64::Engine as _; use communitas_x0x_client::X0xClient; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/communitas-x0x-client/tests/swift_parity.rs b/communitas-x0x-client/tests/swift_parity.rs index 43f92b2e6..0f34b81f7 100644 --- a/communitas-x0x-client/tests/swift_parity.rs +++ b/communitas-x0x-client/tests/swift_parity.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + //! Verifies Rust↔Swift client method parity. //! //! Every Rust client method should have a corresponding Swift method. @@ -56,6 +58,7 @@ const RUST_TO_SWIFT: &[(&str, &str)] = &[ ("exec_cancel", "execCancel"), ("exec_sessions", "execSessions"), ("send_direct", "sendDirect"), + ("send_direct_with_gossip_ack", "sendDirect"), ("direct_connections", "directConnections"), // Contacts ("list_contacts", "listContacts"), @@ -96,6 +99,10 @@ const RUST_TO_SWIFT: &[(&str, &str)] = &[ ("update_group_policy", "updateGroupPolicy"), ("list_named_group_members", "listNamedGroupMembers"), ("add_named_group_member", "addNamedGroupMember"), + ( + "add_named_group_member_with_treekem_key_package", + "addNamedGroupMember", + ), ("remove_named_group_member", "removeNamedGroupMember"), ("set_named_group_member_role", "setNamedGroupMemberRole"), ("ban_group_member", "banGroupMember"), diff --git a/communitas-x0x-client/tests/websocket_contract.rs b/communitas-x0x-client/tests/websocket_contract.rs index 7ec5b0172..b56e193fc 100644 --- a/communitas-x0x-client/tests/websocket_contract.rs +++ b/communitas-x0x-client/tests/websocket_contract.rs @@ -1,5 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + use base64::Engine as _; use communitas_x0x_client::{WsInbound, X0xClient, X0xWebSocket}; use std::time::{SystemTime, UNIX_EPOCH}; diff --git a/docs/testing/known-issues.md b/docs/testing/known-issues.md index 02d14619a..d72fc17fc 100644 --- a/docs/testing/known-issues.md +++ b/docs/testing/known-issues.md @@ -1,6 +1,7 @@ # Known Issues - Milestone 10 **Report Date**: 2026-01-29 +**Last Updated**: 2026-06-09 **Milestone**: M10 - MCP Testnet Validation **Status**: Complete @@ -9,11 +10,12 @@ This document catalogs all known issues, limitations, and workarounds discovered during Milestone 10 testing. Issues are categorized by severity and include remediation recommendations. **Summary**: -- Total Issues: 2 +- Total Issues: 17 - Critical: 0 -- High: 0 -- Medium: 1 (Hetzner firewall) -- Low: 1 (Windows build) +- High: 6 (WRY/WKWebView blank windows mitigated locally on this Mac session, Dioxus live-WS macOS high CPU mitigated locally; soak needed, Swift Swarm, Feed, Wiki/Web, and Network action crashes fixed locally) +- Medium: 8 (Hetzner firewall, installed x0x still presents installer, Dioxus space scoping fixed locally, Dioxus no-live action state fixed locally, x0x CLI group-create parse regression, Dioxus Files refresh/path bugs fixed locally, Swift Board task mapping fixed locally, x0x embedded GUI file recipient selector fixed locally) +- Low: 3 (Windows build, duplicate Swift windows fixed locally, GUI accessibility send automation gaps) +- Fixed Locally Pending Regression Coverage: 12 - Resolved: 3 (flaky tests) ## Active Issues @@ -125,6 +127,731 @@ Use `cargo build --release` for Windows development. Fuzzing is not required for --- +### Issue 3: Installed x0x Still Presents Installer Path + +**Severity**: Medium +**Status**: Reproduced during MacBook/studio dogfood testing +**Affected Component**: Swift and Dioxus onboarding/install flow +**Discovery Date**: 2026-06-08 + +**Description**: +When x0x is already installed and the daemon API is healthy, the app can still present the x0x install action instead of treating the machine as ready. The release dogfood run intentionally clicked through install paths as part of onboarding coverage, but this should not be offered as the primary path once an existing x0x installation is detected. + +**Impact**: +- **User Experience**: Users with a working x0x installation may be prompted to install again. +- **Regression Risk**: Reinstall flow can mask daemon-discovery problems because clicking install may appear to recover the app. +- **Test Coverage**: Onboarding must cover both installed-and-healthy and missing/unhealthy x0x states. + +**Evidence**: +MacBook and studio both had x0x bearer-token files and healthy daemon APIs during the 2026-06-08 dogfood run, yet the install path remained part of the manual UI test plan. Bearer tokens were present at: +```text +~/Library/Application Support/x0x/api-token +``` + +**Expected Behavior**: +If `x0xd` is installed, the token is readable, and `/health` succeeds through the discovered API base, onboarding should skip the install CTA and show the ready/connected app state. The install action should be secondary or absent unless health probing fails. + +**Remediation**: +1. Centralize installed/healthy detection across Swift and Dioxus onboarding. +2. Treat install CTA visibility as a state-machine output, not a static fallback. +3. Add regression tests for: + - x0x missing + - x0x installed but daemon stopped + - x0x installed and daemon healthy + - x0x token present but invalid + +**Priority**: Medium +**Estimated Resolution Time**: 1-2 days including UI tests + +--- + +### Issue 4: Dioxus Space Switch Can Render Stale Channel State and Peg CPU + +**Severity**: Medium +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Dioxus desktop space/channel navigation +**Discovery Date**: 2026-06-08 + +**Description**: +During MacBook/studio dogfood testing, selecting `GUI Full 1780881800710` from a Dioxus window backed by the studio x0x daemon initially left the inner channel sidebar and message pane on the previously selected space. A later build changed the stale render to a loading skeleton, but the process then held about 100% CPU. + +**Impact**: +- **User Experience**: Space switching can show messages from the wrong space or appear stuck. +- **Safety**: Cross-space message bleed is a privacy/confusion risk even if only cached UI state is stale. +- **Performance**: Dioxus desktop can become unusable until relaunched. + +**Evidence**: +- Dioxus process `communitas-dioxus` held ~99-100% CPU after switching spaces. +- `GUI Full 1780881800710/#general` should be empty, while the stale UI showed messages from `GUI Full 1780876295790`. +- After the local patch, the second space correctly showed the empty-channel welcome state and CPU stayed idle. + +**Root Cause**: +The Dioxus route and channel sidebar kept enough component state alive across `/space/:id` transitions that the inner sidebar could hydrate all groups before filtering to the active space. + +**Remediation**: +1. Key `SpaceView` route instances by space/tab. +2. Pass `active_space_id` into `ChannelSidebar`. +3. Filter to the active space before fetching per-group details and channel metadata. +4. Add a regression test that switches between two spaces with different histories and asserts no stale messages render. + +**Priority**: Medium +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 5: Dioxus Live WebSocket Mode Can Spin on macOS After Focus/AX Interaction + +**Severity**: High +**Status**: Mitigated locally; needs longer soak/regression coverage +**Affected Component**: Dioxus desktop live WebSocket handling on macOS +**Discovery Date**: 2026-06-08 + +**Description**: +With `COMMUNITAS_DIOXUS_ENABLE_LIVE_WS=1`, the Dioxus desktop app can enter a sustained ~99% CPU spin after focus changes and accessibility-driven interaction with the Swift app. The window remains visible but Computer Use times out reading the app state. + +**Impact**: +- **User Experience**: The Dioxus window becomes effectively unresponsive. +- **Battery/CPU**: The native process consumes a full core. +- **Test Coverage**: GUI stress tests become unreliable when live WS is enabled. + +**Evidence**: +Sample captured during the spin: +```text +/tmp/communitas-dioxus-post-swift-confirm-highcpu-sample.txt +``` + +Observed process state: +```text +communitas-dioxus ... 98-100% CPU +``` + +Unified logs around the transition showed WebKit activity-state changes after the Dioxus app became inactive, followed by sustained native-process CPU usage. + +Local mitigation added deterministic cleanup for `X0xWebSocket` background send/receive tasks when a UI-owned WebSocket handle is dropped. After rebuilding and re-signing the Dioxus app, a live-WS launch against the studio daemon stayed idle through the previous stress path: +```text +tab switch Chat -> Swarm -> Feed -> Chat, foreground Swift, foreground Dioxus +CPU samples: 0.0, 0.0, 0.0, 0.0, 0.8, 0.0 +Computer Use state still readable; Dioxus showed Connected 3 peers v0.21.3 +``` + +**Workaround**: +If the spin reappears during longer soak, unset `COMMUNITAS_DIOXUS_ENABLE_LIVE_WS` on macOS dogfood runs. The HTTP/no-live path now keeps Chat, Swarm, and Feed connected and actionable. + +**Remediation**: +1. Add a macOS regression smoke that backgrounds Dioxus, manipulates Swift, and asserts Dioxus CPU returns to idle. +2. Run a longer live-WS soak with tab switching, accessibility reads, and cross-app focus changes. +3. If the spin reappears, reproduce with a debug-symbolized Dioxus build and inspect whether WebView redraw or virtual-DOM work is waking continuously. + +**Priority**: High +**Estimated Resolution Time**: Mitigated locally; 0.5-1 day for automated regression plus soak. + +--- + +### Issue 6: Dioxus No-Live Mode Disabled Chat, Swarm, and Feed Actions + +**Severity**: Medium +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Dioxus desktop chat composer +**Discovery Date**: 2026-06-08 + +**Description**: +When live streams were disabled on macOS, Dioxus showed `Connecting...` in the chat header and disabled the message composer even though the app status bar was connected to a healthy x0x daemon. Sending is HTTP-backed for the tested SignedPublic flow, so WebSocket connection state should not block composer entry. The same no-live connection-state bug also affected Swarm and Feed until the local follow-up patch. + +**Impact**: +- **User Experience**: Workaround mode for the live-WS spin could not send messages. +- **Test Coverage**: No-live macOS GUI tests could verify rendering but not message composition. +- **Feature Coverage**: Swarm/Feed live surfaces need an explicit offline/no-live state rather than an indefinite connecting state. + +**Evidence**: +After launching without `COMMUNITAS_DIOXUS_ENABLE_LIVE_WS`, status bar showed: +```text +Connected 3 peers v0.21.3 +``` +while the chat header showed: +```text +Connecting... +``` +and the input was disabled. + +After the local fix, the no-live Dioxus desktop build was launched against the studio x0x daemon through SSH forwarding and Computer Use verified: +```text +Swarm tab: Connected; Post Task visible; app status Connected 3 peers v0.21.3 +Feed tab: Connected; post input enabled; Post disabled only while empty +``` + +**Remediation**: +Initialize chat, Swarm, and Feed `ws_connected` as true when live streams are disabled, so no-live mode is treated as HTTP-connected. Add a regression test that launches Dioxus without live WS and verifies HTTP-backed composers/actions are enabled once daemon health is connected. + +**Priority**: Medium +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 7: Swift Can Open Duplicate Main Windows + +**Severity**: Low +**Status**: Fixed locally; needs launch/reopen regression coverage +**Affected Component**: Swift macOS window lifecycle +**Discovery Date**: 2026-06-08 + +**Description**: +The Swift app process exposed two standard app windows during dogfood testing: `Communitas Swift Codex` and `Dashboard`. Both rendered the app shell rather than one being a crash dialog or transient modal. + +**Impact**: +- **User Experience**: Users can end up with two competing main windows. +- **Test Coverage**: GUI automation must first identify which Swift window is authoritative. + +**Evidence**: +System Events reported: +```text +Communitas Swift Codex, Communitas +AXStandardWindow, AXStandardWindow +``` + +Raising the second window showed a full `Dashboard` app shell with the same sidebar and content layout. + +After the local fix, the relaunched signed Swift app exposed one visible standard window: +```text +PROCESS Communitas WINDOW Communitas Swift Codex POS 314,180 SIZE 1100x750 MINIMIZED false +``` + +Computer Use also verified a single key window: +```text +Window: "Communitas Swift Codex", App: Communitas Swift Codex +``` + +A later Swarm crash caused macOS to reopen the process without the Codex launch arguments, and the old lifecycle fallback produced a `Communitas`/`Dashboard`-style fallback window again. The local follow-up patch now only creates the manual fallback window for explicit reopen/automation recovery, not from normal SwiftUI root-view appearance. After reinstalling the patched signed bundle, repeated launches exposed one `Communitas Swift Codex` window with the expected launch args. + +**Remediation**: +1. Reuse an existing main window when `CommunitasForceMainWindow` or activation handling runs. +2. Add a UI smoke that launches/reopens the app repeatedly and asserts one main app window. + +**Priority**: Low +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 8: Desktop GUI Send Controls Are Hard to Drive Through Accessibility + +**Severity**: Low +**Status**: Open testability gap +**Affected Component**: Swift and Dioxus desktop accessibility/test automation +**Discovery Date**: 2026-06-08 + +**Description**: +Computer Use could read both Swift and Dioxus accessibility trees, but action calls did not reliably bind to the same app instance after `get_app_state`. Fallback macOS accessibility and coordinate input could navigate some controls, but message composers were not reliably driven: +- Swift text field AX value could be set, but SwiftUI did not enable Send because the bound state did not update. +- Dioxus WebKit AX value manipulation selected page text or left the composer empty rather than dispatching an input event. + +**Impact**: +- **Test Coverage**: Visual state can be verified, but end-to-end GUI send tests need stable app-level test hooks. +- **Accessibility**: If assistive technologies hit the same setter path, message composition may be unreliable outside direct keyboard/mouse usage. + +**Evidence**: +Swift composer showed: +```text +Value: swift-field-set-probe-1780916102 +Send message: disabled +``` +after setting the AX value. Dioxus `AXTextArea` exposed `AXPress`, `AXShowMenu`, and `AXScrollToVisible`, but AX value setting did not update the visible composer or enable send. + +After the live-WS Dioxus rebuild, a keyboard-path attempt did send from the native GUI by focusing the `AXTextArea` and pressing Enter, but the typed text was corrupted before submission: +```text +requested: dioxus-gui-ax-focus-send-1780920114-19185 +rendered: dioxsg-a-fcusd-70911195 +``` + +The equivalent Swift AX focus search returned `false`; Computer Use still showed an empty composer and disabled Send button afterward. + +During Swift People testing, the Add Contact sheet opened and submitted without crashing, but AX-filled text fields behaved unevenly: the disposable agent ID reached x0x, while the optional label displayed in the sheet but was not persisted in `/contacts`. The disposable contact lifecycle still passed through the daemon API: +```text +known -> blocked -> known -> removed +``` + +**Remediation**: +1. Add deterministic UI test hooks/test IDs for desktop composer controls. +2. For Swift, verify `TextField`/`TextEditor` accessibility value changes feed the same state as keyboard input, or document keyboard-only automation. +3. For Dioxus, dispatch input events when accessibility value changes, or add an e2e-test-mode command for composer send. +4. Add a desktop GUI smoke that sends one message through each app via the intended automation path. + +**Priority**: Low +**Estimated Resolution Time**: 1-2 days. + +--- + +### Issue 9: x0x CLI `group create` Treats Group Name as Named Instance + +**Severity**: Medium +**Status**: Reproduced during MacBook/studio dogfood testing +**Affected Component**: x0x CLI named-group commands +**Discovery Date**: 2026-06-08 + +**Description**: +The installed `x0x 0.21.3` CLI fails before contacting the daemon when creating a named group from the command line. The group name is interpreted as the hidden global named-instance target, producing a daemon-discovery error. + +**Impact**: +- **User Experience**: Users cannot reliably create named groups through `x0x group create`. +- **Test Coverage**: CLI add/remove group coverage must use direct REST until the parse regression is fixed. +- **App Impact**: REST-backed GUI and app flows still work; this appears limited to the CLI argument path. + +**Evidence**: +Both tested CLI forms failed before mutation: +```text +x0x group create --description ... --display-name ... --preset public_open --json +x0x group create --json --description ... --display-name ... --preset public_open +``` + +Both returned: +```text +error: Named instance '' is not running. Start it with: x0x --name start +``` + +Direct authenticated REST against the same daemon succeeded: +```text +POST /groups -> ok true, group_id 85bf84a6..., policy public_open +DELETE /groups/:id -> ok true +group_cleanup_verified=true +``` + +**Remediation**: +1. Add a CLI regression test for `x0x group create ` and `x0x group create --json `. +2. Check Clap parsing interaction between the hidden global `--name` option and the `GroupSub::Create { name }` positional. +3. Keep REST route tests for `POST /groups` and `DELETE /groups/:id` as the daemon contract guard. + +**Priority**: Medium +**Estimated Resolution Time**: 0.5-1 day. + +--- + +### Issue 10: WRY/WKWebView Windows Paint Blank on macOS + +**Severity**: High +**Status**: Mitigated locally; needs Apple display/session regression coverage +**Affected Component**: Dioxus desktop / WRY / macOS WKWebView +**Discovery Date**: 2026-06-08 + +**Description**: +The Dioxus desktop app can show only a blank white WebView on macOS while the process is alive and the x0x daemon/API are healthy. This was initially observed in the Communitas Dioxus app on the external monitor, but minimal reproductions show the problem is below Communitas application code: + +- Communitas Dioxus 0.7.9 / WRY 0.53.5: blank white WebView. +- Minimal Dioxus 0.7.9 desktop app: blank white WebView. +- Minimal Dioxus 0.8.0-alpha.0 desktop app with WRY 0.55.1: blank white WebView. +- Minimal WRY 0.55.1 static HTML app: blank white content area even after keeping both window and WebView handles alive. + +The local Communitas Dioxus bundle was later patched to place the native WRY window deterministically on the main display. After rebuilding and re-signing the Codex test bundle, the app rendered normally and the Dioxus GUI dogfood run continued through Chat, Board, Files, Swarm, Feed, Wiki, Web, People, Network, and Settings surfaces. + +**Impact**: +- **User Experience**: Before the local placement fix, the Dioxus app appeared stuck/broken with a throbber over an empty window. +- **Test Coverage**: Native Dioxus GUI testing was blocked until the main-display placement fix was applied. +- **Release Risk**: A Dioxus/WRY/WKWebView dependency or macOS session/display-state issue can make the Apple Dioxus build unusable even when the backend is healthy. + +**Evidence**: +Repo lock state during reproduction: +```text +dioxus = 0.7.9 +dioxus-desktop = 0.7.9 +tao = 0.34.8 +wry = 0.53.5 +``` + +Current upstream crate check during the run: +```text +dioxus = 0.8.0-alpha.0 +dioxus-desktop = 0.8.0-alpha.0 +wry = 0.55.1 +``` + +Blank-window captures: +```text +/tmp/communitas-dioxus-probe-window.png +/tmp/dioxus-min-window2.png +/tmp/dioxus-min-08-window.png +/tmp/dioxus-min-08-window2.png +/tmp/wry-min-window3.png +``` + +Unified logs for Communitas Dioxus showed WebKit loading resources and reaching first meaningful paint, then marking the WebContent process `NotVisible` and suspending it: +```text +WebPageProxy::didGeneratePageLoadTiming ... firstMeaningfulPaint=0.072 +WebProcessProxy::didChangeThrottleState(Suspended) +WebProcess::markAllLayersVolatile: Failed to mark layers as volatile +``` + +Authenticated daemon checks remained healthy while the window was blank: +```text +local healthy 0.21.3 +studio healthy 0.21.3 +``` + +Post-fix dogfood evidence: +```text +CommunitasDioxus window bounds: 220,48 1200x812 +Dioxus status: Connected 3 peers v0.21.3 +GUI send/receive and space feature tests proceeded through the native window +``` + +**Workaround**: +Use the patched Dioxus bundle that positions the WRY window on the main display before testing. If a blank/throbber window reappears, continue release dogfood through the Swift app plus direct authenticated x0x API checks and capture a minimal WRY reproduction from the same display/session state. + +**Remediation**: +1. Reproduce outside the current screen-sharing/automation session on the physical display. +2. Add a tiny WRY static HTML smoke to CI/manual Apple validation to catch blank WKWebView rendering before testing Communitas. +3. Check macOS/WebKit GPU and display-state settings, especially external-monitor and screen-sharing combinations. +4. If the WRY smoke paints outside this session, compare process environment and window placement; if it remains blank, file upstream against WRY with the minimal static HTML reproduction. +5. Keep Dioxus upgrade testing separate: 0.8 alpha/WRY 0.55.1 did not fix the blank window in this session. + +**Priority**: High +**Estimated Resolution Time**: Mitigated locally; add Apple display/session smoke before release. + +--- + +### Issue 11: Dioxus Files Send Did Not Refresh and Could Fail Relative Paths + +**Severity**: Medium +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Dioxus Files view / x0x file transfer +**Discovery Date**: 2026-06-08 + +**Description**: +In the no-poll macOS dogfood launch, the Dioxus Files form could successfully call `/files/send` but the transfer table did not repaint after the mutation. A follow-up test also showed that entering a relative file path was accepted by the Dioxus app, but x0xd later failed to stream the file because the relative `source_path` was resolved in the daemon working directory rather than the Dioxus process working directory. + +**Impact**: +- **User Experience**: Users could click Send File and see success text without the new transfer appearing in the table until a later reload. +- **File Transfer Reliability**: Relative paths could create a pending transfer that failed with `Cannot open file: No such file or directory`. +- **Test Coverage**: No-poll desktop testing missed transfer status changes unless the Files view was reloaded. + +**Evidence**: +Before the local fix, a GUI-created transfer completed through the daemon/API but did not appear in the visible table until the app was relaunched: +```text +communitas-dioxus-files-gui-1780945075.txt +local: Complete 43/43 +studio: Complete 43/43 +``` + +The relative-path failure reproduced as: +```text +filename: communitasfilesrefresh1780945744.txt +source_path: communitasfilesrefresh1780945744.txt +status: Failed +error: Cannot open file: No such file or directory (os error 2) +``` + +After the local fix, the Files table repainted immediately and x0xd received an absolute source path: +```text +filename: communitasfilescanonical1780946312.txt +source_path: /Users/davidirvine/Desktop/Devel/projects/communitas/communitasfilescanonical1780946312.txt +local: Complete 54/54 +studio: Complete 54/54 +``` + +**Remediation**: +1. Refresh the transfer list immediately after Dioxus Files send/accept/reject mutations. +2. Canonicalize the selected file path before calling `/files/send`. +3. Add a desktop GUI regression that sends a file using both absolute and relative path input and verifies the visible row plus local/studio completion. + +**Priority**: Medium +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 12: Swift Board Hid x0x Tasks with Empty State and Blank Titles + +**Severity**: Medium +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Swift Board view / x0x task-list integration +**Discovery Date**: 2026-06-08 + +**Description**: +During Swift macOS dogfood testing, adding a board card through the GUI successfully created x0x tasks, but the Board columns still showed zero visible cards. The created tasks had `state: "empty"`, while the Swift Board view only rendered strict `todo`, `in_progress`, and `done` states. After broadening the state mapping, cards appeared, but they initially rendered blank because the UI displayed task `description` while GUI-created tasks stored the entered text in `title`. + +**Impact**: +- **User Experience**: A user could add a task and see no visible result, even though the task existed in x0x. +- **Kanban Workflow**: Claim/complete flows were inaccessible for tasks created with the default x0x empty state. +- **Parity**: Swift did not match the x0x task model used by the REST API and Dioxus Board. + +**Evidence**: +Swift GUI-created tasks existed in x0x with empty state: +```text +title: swiftboard1780950418 +state: empty +title: swiftboard1780950833 +state: empty +``` + +Before the local fix, the Swift Board UI still showed: +```text +To Do 0 +In Progress 0 +Done 0 +``` + +After the local fix and signed-bundle relaunch, Computer Use verified the Board showed both task titles in To Do. The GUI claim/complete workflow was then exercised end-to-end: +```text +swiftboard1780950418 -> claimed:b3ce9af3... +swiftboard1780950418 -> done:b3ce9af3... +Swift Board UI: To Do 1, In Progress 0, Done 1 +``` + +**Remediation**: +1. Normalize Swift Board states so `empty`, nil, and blank map to To Do; `claimed:*` maps to In Progress; and `done:*` maps to Done. +2. Render task `title` first, falling back to description and then `Untitled task`. +3. Add a Swift regression test that creates a task through the same API shape as the GUI and verifies it renders, can be claimed, and can be completed. + +**Priority**: Medium +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 13: Swift Swarm Post Task Crashed in SwiftUI Button Gesture + +**Severity**: High +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Swift Swarm view / macOS SwiftUI button dispatch +**Discovery Date**: 2026-06-08 + +**Description**: +The Swift Swarm `Post Task` control crashed the process when clicked after entering a task description. The crash happened before app frames appeared in the stack and consistently pointed at SwiftUI button gesture dispatch and MainActor executor checks. + +**Impact**: +- **User Experience**: Posting a Swarm task could terminate the Swift app. +- **Test Coverage**: The crash also triggered macOS crash-reopen behavior, which re-exposed the duplicate/fallback-window lifecycle issue. +- **Swarm Workflow**: Swift could show active agents but could not safely submit tasks until the form was changed. + +**Evidence**: +The crash reproduced multiple times with fresh diagnostic reports: +```text +Communitas-2026-06-08-232805.ips +Communitas-2026-06-08-233201.ips +Communitas-2026-06-08-234634.ips +``` + +The common crash signature was: +```text +EXC_BAD_ACCESS KERN_INVALID_ADDRESS at 0x000000000000001e +faulting thread: com.apple.main-thread +SwiftUI: _ButtonGesture.internalBody.getter +Swift concurrency: swift_task_isCurrentExecutorWithFlagsImpl +``` + +After replacing the Swift Swarm submission form with an AppKit-backed `NSTextView`/`NSTextField`/`NSButton` panel, the signed Codex bundle posted a task without crashing: +```text +swiftswarmappkit1780958970 task from AppKit Swift Swarm form +Swift UI: POSTED 23:49 ... b3ce9af3 +process remained alive with no new crash report +``` + +**Remediation**: +1. Keep the Swarm submission form off the crashing SwiftUI `Button` gesture path on affected macOS builds. +2. Add a GUI regression that types a Swarm task, clicks Post Task, asserts the event feed renders the marker, and asserts no `Communitas-*.ips` report appears. +3. Track whether a future macOS/SwiftUI update fixes the underlying button gesture crash before reverting to a pure SwiftUI control. + +**Priority**: High +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 14: Swift Feed Post Crashed in SwiftUI Button Gesture + +**Severity**: High +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Swift Feed view / macOS SwiftUI button dispatch +**Discovery Date**: 2026-06-08 + +**Description**: +After the Swarm form fix, the Swift Feed composer reproduced the same crash class when a user-entered post was submitted through the SwiftUI `Button { Task { ... } }` path. Accessibility value assignment alone also left the Post button disabled until real keyboard input updated the SwiftUI binding, which made the workflow brittle for both users and GUI automation. + +**Impact**: +- **User Experience**: Posting to a space feed could terminate the Swift app. +- **Messaging Workflow**: The Feed tab could subscribe and display empty state, but could not safely publish posts before the local fix. +- **Regression Risk**: Any other SwiftUI async button in the space views may share the same macOS crash path. + +**Evidence**: +The crash reproduced from the signed Swift Codex bundle after real keyboard input enabled the Post button: +```text +swiftfeed1780959441 feed post from Swift GUI keyboard path +Communitas-2026-06-08-235746.ips +``` + +The crash behavior matched the earlier Swarm diagnostic class: +```text +EXC_BAD_ACCESS KERN_INVALID_ADDRESS +SwiftUI button gesture dispatch +``` + +After replacing the Feed composer with an AppKit-backed form, the signed Codex bundle posted the same workflow without crashing: +```text +swiftfeedappkit1780959685 feed post from AppKit Swift Feed form AX value +Swift UI: feed row rendered for b3ce9af3 +process remained alive with no new crash report +``` + +**Remediation**: +1. Replace the Swift Feed composer with an AppKit-backed `NSTextView`/`NSButton` panel on affected macOS builds. +2. Publish Feed posts from a synchronous AppKit action wrapper that schedules the x0x publish in a background task and updates state on the main actor. +3. Add a GUI regression that types a Feed post, clicks Post, asserts the post row renders, and asserts no new `Communitas-*.ips` report appears. + +**Priority**: High +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 15: Swift Wiki/Web Page Actions Crashed in SwiftUI Button Gesture + +**Severity**: High +**Status**: Fixed locally; needs release regression coverage +**Affected Component**: Swift Wiki and Web page create/edit/publish controls +**Discovery Date**: 2026-06-09 + +**Description**: +After the Feed fix, clicking the Swift Wiki `New Page` control crashed the app with the same SwiftUI button gesture/MainActor executor signature seen in Swarm and Feed. macOS then relaunched the app without the Codex launch arguments, returning it to Chat and re-exposing the crash-reopen lifecycle behavior. + +**Impact**: +- **User Experience**: Wiki page creation could terminate the Swift app before a slug field appeared. +- **Content Workflow**: Wiki and Web page create/edit/publish flows were unsafe because they used the same SwiftUI button/action pattern. +- **Regression Risk**: Space-level actions that combine SwiftUI buttons with async state transitions need targeted crash regression coverage. + +**Evidence**: +The Wiki `New Page` click produced a fresh diagnostic report: +```text +Communitas-2026-06-09-000543.ips +EXC_BAD_ACCESS KERN_INVALID_ADDRESS at 0x000000000000001e +SwiftUI: _ButtonGesture.internalBody.getter +Swift concurrency: swift_task_isCurrentExecutorWithFlagsImpl +``` + +The crash relaunched the app as: +```text +/Users/davidirvine/Applications/Communitas-Swift-Codex.app/Contents/MacOS/Communitas +``` +without `-CommunitasForceMainWindow`, while the intended Codex launch includes the force-main-window argument. + +After replacing Wiki/Web page controls with AppKit-backed forms, the signed Codex bundle completed both content workflows without a fresh crash report: +```text +Wiki: swift-wiki-1780960249 -> body saved from AppKit Wiki editor +Web: swift-web-1780960370.html ->

...html

published from AppKit Swift Web editor

+process remained alive with -CommunitasForceMainWindow and 0.0% CPU +``` + +**Remediation**: +1. Replace Wiki/Web page create, edit, save, and publish controls with AppKit-backed `NSTextField`/`NSTextView`/`NSButton` panels on affected macOS builds. +2. Add GUI regressions that create and edit a Wiki page, create and publish a Web page, assert content renders, and assert no new `Communitas-*.ips` report appears. +3. Keep crash-reopen lifecycle coverage because macOS relaunches can still arrive without the Codex launch arguments. + +**Priority**: High +**Estimated Resolution Time**: Fixed locally; add regression test before release. + +--- + +### Issue 16: Swift Network Page Crashed During View/Layout Evaluation + +**Severity**: High +**Status**: Fixed locally; needs visual regression coverage +**Affected Component**: Swift Network system page +**Discovery Date**: 2026-06-09 + +**Description**: +Opening the Swift Network page from the View menu crashed the app after the daemon returned healthy network data. Unlike the Swarm/Feed/Wiki action crashes, this report did not point at `_ButtonGesture`; it crashed during SwiftUI body/layout evaluation while constructing or sizing the Network view. + +The local fix replaces the Network view's deep layout tree with bounded, flatter sections, moves refresh work into a cancellable `.task`, caps peer diagnostics, and forces automation-launched windows back onto the active Space. Process/crash-report smoke now opens the Network menu command without producing a new `Communitas-*.ips` report. + +**Impact**: +- **User Experience**: Opening Network no longer terminated the patched Swift app in process/crash-report smoke, but visible desktop regression coverage is still required. +- **Diagnostics Workflow**: Users cannot reliably inspect peers, direct connections, addresses, and connectivity diagnostics from the Swift UI. +- **Regression Risk**: macOS crash-reopen again relaunches without the Codex launch arguments, so this also exercises the lifecycle bug path. + +**Evidence**: +The x0x daemon was healthy immediately after the crash: +```text +/status: connected, version 0.21.3, peers 3 +/network/status: connected_peers 3, direct_connections 3, avg_rtt_ms 24 +/direct/connections: 3 connections +``` + +The Swift diagnostic report: +```text +Communitas-2026-06-09-001708.ips +EXC_BAD_ACCESS KERN_INVALID_ADDRESS +faulting thread: com.apple.main-thread +SwiftUICore/SwiftUI layout and ViewBodyAccessor.updateBody stack +``` + +After the local patch and signed reinstall: +```text +swift build --package-path communitas-apple -c release +codesign --verify --deep --strict --verbose=2 /Users/davidirvine/Applications/Communitas-Swift-Codex.app +View -> Network selected; process stayed alive for the 20 second smoke window +no Communitas-*.ips report newer than Communitas-2026-06-09-001708.ips +``` + +The current macOS desktop inspection layer returned zero Accessibility windows for all apps during the final pass, so the crash fix still needs a visual regression run when the screen/session capture path is reliable again. + +**Remediation**: +1. Reduce the Network view to smaller, independently keyed subviews and remove any layout/body work that can trigger generic metadata crashes on macOS. +2. Move expensive daemon polling and peer diagnostics out of view-body construction and into isolated observable state. +3. Add a GUI regression that opens Network from the menu/sidebar, waits for peer/direct-connection values, and asserts no new crash report appears. + +**Priority**: High +**Estimated Resolution Time**: Fixed locally; add visual regression coverage before release. + +--- + +### Issue 17: x0x Embedded GUI File Send Uses First Contact Without Recipient Selection + +**Severity**: Medium +**Status**: Fixed locally; needs live transfer regression coverage +**Affected Component**: x0x embedded `/gui` Files space app +**Discovery Date**: 2026-06-09 + +**Description**: +The embedded x0x GUI Files app rendered a file input/drop zone, but it did not expose a recipient selector. The original `handleFileDrop()` path chose `contacts[0].agent_id` automatically. In the live dogfood contact book, the first contact was not a deliberate file-transfer target, so a user could send a file to the wrong agent. + +The local x0x fix adds an explicit recipient selector, excludes blocked contacts, disables file selection until a recipient is chosen, and sends the selected agent ID in the `/files/send` body. + +**Impact**: +- **User Experience**: Users can now choose the intended file recipient from the embedded GUI. +- **Safety**: The patched GUI no longer automatically targets the first contact in large/noisy contact books. +- **Test Coverage**: A browser proof verifies selector behavior on both MacBook and studio; a live cross-machine embedded-GUI file-transfer proof is still needed. + +**Evidence**: +Focused embedded GUI proof through loopback auth proxies: +```text +/tmp/x0x-live-gui-wiki-web-files-1780962446835/report.json +PASS embedded-gui.files-view-renders +WARN embedded-gui.files-send-lacks-recipient-selector +reason: handleFileDrop() sends to contacts[0], which is unsafe in large/noisy contact books +``` + +The same dogfood session verified Swift `NSOpenPanel` file send to studio and studio accept completed with matching transfer status, so this is specific to the embedded x0x GUI Files app. + +After the local x0x patch, the rebuilt `x0xd` was installed on both MacBook and studio. Browser proof against both `/gui` instances passed: +```text +/tmp/x0x-gui-file-selector-1780966403240/report.json +macbook: selector defaults blank, file input disabled until studio recipient selected, /files/send body uses studio agent_id +studio: selector defaults blank, file input disabled until MacBook recipient selected, /files/send body uses MacBook agent_id +``` + +Source and build validation: +```text +x0x: cargo fmt --all +x0x: cargo clippy --all-features --all-targets -- -D warnings +x0x: cargo check --workspace --all-targets +x0x: cargo test --test gui_smoke gui_files_requires_explicit_recipient_selection +``` + +**Expected Behavior**: +The Files app should require an explicit recipient selection before enabling file send. The chosen recipient should be visible in the UI and reflected in the `/files/send` body. + +**Remediation**: +1. Keep the recipient picker populated from non-blocked contacts. +2. Keep file send disabled until a recipient is selected. +3. Prefer stable labels plus shortened agent IDs so users can distinguish peers. +4. Add a live GUI regression that selects the studio peer, sends a file, accepts it on the studio side, and asserts the transfer reaches `Complete`. + +**Priority**: Medium +**Estimated Resolution Time**: Fixed locally; add live transfer regression before release. + +--- + ## Resolved Issues ### Resolved Issue 1: Timing-Dependent Test Flakiness (Phase 10.2) @@ -321,6 +1048,21 @@ All performance metrics significantly exceed targets: |----|-------|----------|--------|-----| | I1 | Hetzner firewall blocks port 3040 | Medium | Workaround in place | 5 min (manual fix) | | I2 | Windows build excludes fuzzing | Low | Accepted limitation | N/A | +| I3 | Installed x0x still presents installer path | Medium | Reproduced | 1-2 days | +| I4 | Dioxus space switch stale state / CPU | Medium | Fixed locally | Regression coverage needed | +| I5 | Dioxus live WebSocket macOS CPU spin | High | Mitigated locally | Soak needed | +| I6 | Dioxus no-live actions disabled | Medium | Fixed locally | Regression coverage needed | +| I7 | Swift duplicate main windows | Low | Fixed locally | Regression coverage needed | +| I8 | Desktop GUI send accessibility gaps | Low | Open | 1-2 days | +| I9 | x0x CLI group create parse regression | Medium | Reproduced | 0.5-1 day | +| I10 | WRY/WKWebView blank windows on macOS | High | Reproduced | Unknown | +| I11 | Dioxus Files refresh/path bugs | Medium | Fixed locally | Regression coverage needed | +| I12 | Swift Board task mapping/title bug | Medium | Fixed locally | Regression coverage needed | +| I13 | Swift Swarm post crash | High | Fixed locally | Regression coverage needed | +| I14 | Swift Feed post crash | High | Fixed locally | Regression coverage needed | +| I15 | Swift Wiki/Web page action crashes | High | Fixed locally | Regression coverage needed | +| I16 | Swift Network page crash | High | Fixed locally | Visual regression coverage needed | +| I17 | x0x embedded GUI file recipient selector missing | Medium | Fixed locally | Live transfer regression needed | ### Resolved Issues @@ -330,27 +1072,25 @@ All performance metrics significantly exceed targets: | R2 | Messaging race condition | 2026-01-27 | 1000 consecutive passes | | R3 | CRDT sync delays | 2026-01-27 | 100% pass rate | -### No Critical or High Severity Issues - ---- - ## Conclusion -Milestone 10 identified **2 minor issues** with documented workarounds: -1. Hetzner firewall (infrastructure issue, not code) -2. Windows fuzzing limitation (accepted constraint) +The 2026-06-08/09 MacBook and studio release dogfood pass identified **17 tracked issues**: +1. Several infrastructure/known limitations remain documented with workarounds. +2. Multiple Swift and Dioxus desktop regressions were fixed locally and need regression coverage before release. +3. The Swift Network page crash is fixed locally, but still needs visual regression coverage in a reliable desktop session. All **3 test reliability issues** were resolved during testing with 100% verification. -**No critical, high, or blocking issues remain.** +**No critical issues are currently tracked, but high-severity desktop issues remain.** -The codebase demonstrates **production-ready quality** with: -- Zero security vulnerabilities -- Zero performance issues -- Zero test failures -- Zero quality gate violations +Current release readiness depends on: +- Adding regression coverage for the locally fixed Swift/Dioxus GUI issues. +- Adding visual regression coverage for the Swift Network page crash fix. +- Adding live transfer regression coverage for the x0x embedded GUI Files recipient selector. +- Running a longer Dioxus live-WebSocket/macOS soak. +- Rechecking onboarding so installed x0x does not present the primary installer path. -**Status**: **READY FOR PRODUCTION** ✅ +**Status**: Release dogfood in progress; not yet ready to call complete. ---