From 710a3f684468b0e6adc6b4115cd30d25e48549bc Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sun, 26 Jul 2026 14:37:01 +0800 Subject: [PATCH 1/2] Revert "Merge pull request #173 from Tryanks/feat/multiplatform-groundwork" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 490aa676243eb31373140dd3f774c50574484c84, reversing changes made to 2cbb089d1b90da1d68a3f6851ff0f13d7e7dae5b. Parked rather than abandoned: the remote host, the browser and phone clients, and the multiplatform CI gates all come back out of main while the work continues on its branch. Nothing here was failing — CI was green on all seven jobs when it merged. Note for whoever brings it back: re-merging the original branch will not restore these changes, because its commits remain ancestors of main through the merge this reverts. The way back is to revert *this* commit, which is what the follow-up branch does. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 139 - .github/workflows/port-scoreboard.yml | 115 - Cargo.lock | 284 +- Cargo.toml | 40 - README.md | 31 - crates/agent/Cargo.toml | 29 +- crates/agent/examples/image_probe.rs | 1 - crates/agent/src/acp.rs | 4 - crates/agent/src/claude.rs | 6 - crates/agent/src/codex.rs | 3 - crates/agent/src/lib.rs | 391 +- crates/agent/src/opencode.rs | 4 - crates/agent/src/pi.rs | 3 - crates/app/Cargo.toml | 3 - crates/app/src/gpui_pin.rs | 131 - crates/app/src/main.rs | 55 +- crates/client-app/.gitignore | 3 - crates/client-app/Cargo.lock | 8692 ----------------- crates/client-app/Cargo.toml | 23 - crates/client-app/src/lib.rs | 1396 --- crates/client-app/src/native_transport.rs | 128 - crates/client-app/src/pairing.rs | 136 - crates/client-app/src/storage.rs | 194 - crates/core/Cargo.toml | 11 +- crates/core/src/git.rs | 3 - crates/core/src/session.rs | 35 +- crates/gpui-android/Cargo.toml | 17 - crates/gpui-android/README.md | 56 - crates/gpui-android/src/dispatcher.rs | 346 - crates/gpui-android/src/display.rs | 59 - crates/gpui-android/src/keyboard.rs | 13 - crates/gpui-android/src/lib.rs | 25 - crates/gpui-android/src/native.rs | 179 - crates/gpui-android/src/platform.rs | 735 -- crates/gpui-android/src/window.rs | 670 -- crates/gpui-ios/Cargo.toml | 18 - crates/gpui-ios/README.md | 53 - crates/gpui-ios/src/dispatcher.rs | 160 - crates/gpui-ios/src/display.rs | 59 - crates/gpui-ios/src/keyboard.rs | 13 - crates/gpui-ios/src/lib.rs | 34 - crates/gpui-ios/src/native.rs | 248 - crates/gpui-ios/src/platform.rs | 736 -- crates/gpui-ios/src/window.rs | 667 -- crates/runtime/Cargo.toml | 1 - crates/runtime/src/app.rs | 237 +- crates/runtime/src/blocking.rs | 20 +- crates/services/Cargo.toml | 44 +- crates/services/src/git.rs | 1 - crates/services/src/lib.rs | 26 - crates/services/src/process.rs | 20 +- crates/services/src/settings.rs | 49 - crates/services/src/store.rs | 324 +- crates/sync-client/Cargo.toml | 19 - crates/sync-client/src/bin/sync-probe.rs | 535 - crates/sync-client/src/lib.rs | 835 -- crates/sync-host/Cargo.toml | 28 - crates/sync-host/src/lib.rs | 1011 -- crates/sync-host/src/pairing.rs | 237 - crates/sync-host/src/server.rs | 460 - crates/sync-host/src/store_source.rs | 366 - .../sync-host/tests/websocket_end_to_end.rs | 295 - crates/sync-protocol/Cargo.toml | 12 - crates/sync-protocol/src/lib.rs | 525 - crates/tcode-android/Cargo.toml | 23 - crates/tcode-android/README.md | 64 - .../android/app/src/main/AndroidManifest.xml | 18 - .../java/com/tryanks/tcode/TcodeActivity.kt | 212 - crates/tcode-android/src/bridge.rs | 258 - crates/tcode-android/src/entry.rs | 358 - crates/tcode-android/src/lib.rs | 20 - crates/tcode-android/tools/verify-link.sh | 38 - crates/tcode-ios/Cargo.toml | 20 - crates/tcode-ios/README.md | 81 - crates/tcode-ios/src/bridge.rs | 183 - crates/tcode-ios/src/entry.rs | 276 - crates/tcode-ios/src/lib.rs | 17 - crates/tcode-ios/tools/verify-link.sh | 68 - crates/tcode-server/Cargo.toml | 26 - crates/tcode-server/src/main.rs | 208 - crates/tcode-web/.gitignore | 1 - crates/tcode-web/Cargo.toml | 33 - crates/tcode-web/README.md | 73 - crates/tcode-web/Trunk.toml | 11 - crates/tcode-web/index.html | 36 - crates/tcode-web/src/lib.rs | 2 - crates/tcode-web/src/wasm.rs | 213 - crates/ui/Cargo.toml | 34 +- crates/ui/src/assets.rs | 13 +- crates/ui/src/chat.rs | 712 +- crates/ui/src/composer.rs | 1 - crates/ui/src/diff/mod.rs | 3 - crates/ui/src/lib.rs | 30 - crates/ui/src/markdown/view.rs | 22 +- crates/ui/src/preview_panel.rs | 56 +- docs/multiplatform-plan.md | 697 -- docs/remote-and-mobile.md | 212 - docs/sync-protocol.md | 142 - 98 files changed, 295 insertions(+), 24859 deletions(-) delete mode 100644 .github/workflows/port-scoreboard.yml delete mode 100644 crates/app/src/gpui_pin.rs delete mode 100644 crates/client-app/.gitignore delete mode 100644 crates/client-app/Cargo.lock delete mode 100644 crates/client-app/Cargo.toml delete mode 100644 crates/client-app/src/lib.rs delete mode 100644 crates/client-app/src/native_transport.rs delete mode 100644 crates/client-app/src/pairing.rs delete mode 100644 crates/client-app/src/storage.rs delete mode 100644 crates/gpui-android/Cargo.toml delete mode 100644 crates/gpui-android/README.md delete mode 100644 crates/gpui-android/src/dispatcher.rs delete mode 100644 crates/gpui-android/src/display.rs delete mode 100644 crates/gpui-android/src/keyboard.rs delete mode 100644 crates/gpui-android/src/lib.rs delete mode 100644 crates/gpui-android/src/native.rs delete mode 100644 crates/gpui-android/src/platform.rs delete mode 100644 crates/gpui-android/src/window.rs delete mode 100644 crates/gpui-ios/Cargo.toml delete mode 100644 crates/gpui-ios/README.md delete mode 100644 crates/gpui-ios/src/dispatcher.rs delete mode 100644 crates/gpui-ios/src/display.rs delete mode 100644 crates/gpui-ios/src/keyboard.rs delete mode 100644 crates/gpui-ios/src/lib.rs delete mode 100644 crates/gpui-ios/src/native.rs delete mode 100644 crates/gpui-ios/src/platform.rs delete mode 100644 crates/gpui-ios/src/window.rs delete mode 100644 crates/sync-client/Cargo.toml delete mode 100644 crates/sync-client/src/bin/sync-probe.rs delete mode 100644 crates/sync-client/src/lib.rs delete mode 100644 crates/sync-host/Cargo.toml delete mode 100644 crates/sync-host/src/lib.rs delete mode 100644 crates/sync-host/src/pairing.rs delete mode 100644 crates/sync-host/src/server.rs delete mode 100644 crates/sync-host/src/store_source.rs delete mode 100644 crates/sync-host/tests/websocket_end_to_end.rs delete mode 100644 crates/sync-protocol/Cargo.toml delete mode 100644 crates/sync-protocol/src/lib.rs delete mode 100644 crates/tcode-android/Cargo.toml delete mode 100644 crates/tcode-android/README.md delete mode 100644 crates/tcode-android/android/app/src/main/AndroidManifest.xml delete mode 100644 crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt delete mode 100644 crates/tcode-android/src/bridge.rs delete mode 100644 crates/tcode-android/src/entry.rs delete mode 100644 crates/tcode-android/src/lib.rs delete mode 100755 crates/tcode-android/tools/verify-link.sh delete mode 100644 crates/tcode-ios/Cargo.toml delete mode 100644 crates/tcode-ios/README.md delete mode 100644 crates/tcode-ios/src/bridge.rs delete mode 100644 crates/tcode-ios/src/entry.rs delete mode 100644 crates/tcode-ios/src/lib.rs delete mode 100755 crates/tcode-ios/tools/verify-link.sh delete mode 100644 crates/tcode-server/Cargo.toml delete mode 100644 crates/tcode-server/src/main.rs delete mode 100644 crates/tcode-web/.gitignore delete mode 100644 crates/tcode-web/Cargo.toml delete mode 100644 crates/tcode-web/README.md delete mode 100644 crates/tcode-web/Trunk.toml delete mode 100644 crates/tcode-web/index.html delete mode 100644 crates/tcode-web/src/lib.rs delete mode 100644 crates/tcode-web/src/wasm.rs delete mode 100644 docs/multiplatform-plan.md delete mode 100644 docs/remote-and-mobile.md delete mode 100644 docs/sync-protocol.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8747e13..380f3b21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,142 +82,3 @@ jobs: - name: Run workspace tests if: runner.os != 'Linux' run: cargo test --workspace --locked - - client-app: - name: Shared client - runs-on: ubuntu-24.04 - env: - CARGO_TERM_COLOR: always - CARGO_PROFILE_DEV_DEBUG: line-tables-only - - steps: - - uses: actions/checkout@v7 - - - name: Install stable Rust - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential clang cmake pkg-config libssl-dev - - - name: Cache Cargo build data - uses: Swatinem/rust-cache@v2 - with: - # client-app is intentionally excluded from the root workspace, so - # its build data lives under its own manifest directory. - workspaces: crates/client-app -> target - shared-key: multiplatform-client-host - - - name: Run shared client tests - run: cargo test --manifest-path crates/client-app/Cargo.toml --locked - - - name: Lint the shared client - run: cargo clippy --manifest-path crates/client-app/Cargo.toml --locked -- -D warnings - - wasm: - name: Web targets - runs-on: ubuntu-24.04 - env: - CARGO_TERM_COLOR: always - CARGO_PROFILE_DEV_DEBUG: line-tables-only - # GPUI's web dispatcher assumes wasm threads and shared memory, which - # require both a rebuilt standard library and these target features. - CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: "-C target-feature=+atomics,+bulk-memory,+mutable-globals" - RUSTC_BOOTSTRAP: "1" - - steps: - - uses: actions/checkout@v7 - - - name: Install nightly Rust for wasm - uses: dtolnay/rust-toolchain@nightly - with: - components: rust-src - targets: wasm32-unknown-unknown - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential clang cmake pkg-config libssl-dev - - - name: Cache Cargo build data - uses: Swatinem/rust-cache@v2 - with: - # The excluded client manifest and root web crate use separate target - # directories even though they compile for the same platform. - workspaces: | - . -> target - crates/client-app -> target - shared-key: multiplatform-wasm32-unknown-unknown - - - name: Check the shared client for wasm - run: cargo check --manifest-path crates/client-app/Cargo.toml --target wasm32-unknown-unknown --locked - - - name: Check the browser shell - run: cargo +nightly -Zbuild-std=std,panic_abort check -p tcode-web --target wasm32-unknown-unknown --locked - - ios: - name: iOS targets - runs-on: macos-15 - env: - CARGO_TERM_COLOR: always - CARGO_PROFILE_DEV_DEBUG: line-tables-only - - steps: - - uses: actions/checkout@v7 - - - name: Install stable Rust for iOS - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-ios - - - name: Cache Cargo build data - uses: Swatinem/rust-cache@v2 - with: - shared-key: multiplatform-aarch64-apple-ios - - - name: Check the iOS backends - run: cargo check -p gpui-ios -p tcode-ios --target aarch64-apple-ios --locked - - android: - name: Android targets - runs-on: ubuntu-24.04 - env: - CARGO_TERM_COLOR: always - CARGO_PROFILE_DEV_DEBUG: line-tables-only - - steps: - - uses: actions/checkout@v7 - - - name: Install stable Rust for Android - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-linux-android - - - name: Install Linux system dependencies - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential clang cmake pkg-config libssl-dev - - - name: Cache Cargo build data - uses: Swatinem/rust-cache@v2 - with: - shared-key: multiplatform-aarch64-linux-android - - - name: Configure the Android NDK - run: | - # psm assembles AArch64 while Cargo checks GPUI, so host Clang cannot - # stand in for the Android API 26 toolchain verified by this project. - toolchain="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" - test -x "$toolchain/aarch64-linux-android26-clang" - test -x "$toolchain/llvm-ar" - echo "CC_aarch64_linux_android=$toolchain/aarch64-linux-android26-clang" >> "$GITHUB_ENV" - echo "AR_aarch64_linux_android=$toolchain/llvm-ar" >> "$GITHUB_ENV" - - - name: Check the Android backends - run: cargo check -p gpui-android -p tcode-android --target aarch64-linux-android --locked diff --git a/.github/workflows/port-scoreboard.yml b/.github/workflows/port-scoreboard.yml deleted file mode 100644 index 3bf4332d..00000000 --- a/.github/workflows/port-scoreboard.yml +++ /dev/null @@ -1,115 +0,0 @@ -# This workflow is a measurement, not a gate. It never fails the build. -name: Port scoreboard - -# Tracks how far each crate is from compiling for the mobile and web targets -# (docs/multiplatform-plan.md, phase 0). Every job is expected to fail today — -# the number we care about is *how many* errors, trending down as crates get -# split and feature-gated. -# -# Deliberately not run on pull requests: a permanently-red job on every PR -# teaches people to ignore CI. Weekly, plus on demand. -on: - workflow_dispatch: - schedule: - # Mondays, 04:00 UTC. - - cron: "0 4 * * 1" - -permissions: - contents: read - -concurrency: - group: port-scoreboard-${{ github.ref }} - cancel-in-progress: true - -jobs: - scoreboard: - name: ${{ matrix.target }} - runs-on: ${{ matrix.runner || 'ubuntu-24.04' }} - strategy: - fail-fast: false - matrix: - include: - - target: wasm32-unknown-unknown - # Matches Zed's own wasm CI: gpui needs a rebuilt std plus the - # atomics/shared-memory features its multithreaded web dispatcher - # assumes (crates/gpui_web/src/dispatcher.rs). - extra_flags: "-Zbuild-std=std,panic_abort" - - target: aarch64-linux-android - extra_flags: "" - - target: aarch64-apple-ios - extra_flags: "" - # iOS needs Apple's toolchain, so this row runs on macOS. - runner: macos-15 - env: - CARGO_TERM_COLOR: never - RUSTC_BOOTSTRAP: "1" - CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: "-C target-feature=+atomics,+bulk-memory,+mutable-globals" - - steps: - - uses: actions/checkout@v7 - - - name: Install nightly Rust with ${{ matrix.target }} - uses: dtolnay/rust-toolchain@nightly - with: - components: rust-src - targets: ${{ matrix.target }} - - - name: Install Linux system dependencies - if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - build-essential clang cmake pkg-config libssl-dev - - - name: Check each crate and count errors - run: | - # Crates the plan expects to become portable, cheapest first. `term` - # is absent on purpose: it is desktop-only forever (PTY + login shell - # + tcgetpgrp), so counting its errors would only add noise. - crates="tcode-i18n sync-protocol sync-client tcode-core agent tcode-services tcode-runtime tcode-ui" - - # The platform backends only exist for one target each, and are added - # to their own row. gpui-android additionally needs an NDK this job - # does not install — psm assembles AArch64 during dependency checking - # — so it is deliberately absent rather than counted as broken. - case "${{ matrix.target }}" in - aarch64-apple-ios) crates="$crates gpui-ios tcode-ios" ;; - esac - - { - echo "## ${{ matrix.target }}" - echo - echo "| crate | errors |" - echo "| --- | ---: |" - } >> "$GITHUB_STEP_SUMMARY" - - total=0 - for crate in $crates; do - log="/tmp/${crate}.log" - # `|| true`: every one of these is expected to fail right now. - # `-Z` flags go before the subcommand, matching the invocation - # Zed's own wasm CI uses (.github/workflows/run_tests.yml there). - cargo ${{ matrix.extra_flags }} check -p "$crate" \ - --target "${{ matrix.target }}" --locked > "$log" 2>&1 || true - - # Count distinct rustc errors, not lines: one error spans many. - errors=$(grep -cE '^error(\[E[0-9]+\])?:' "$log" || true) - total=$((total + errors)) - - if [ "$errors" -eq 0 ]; then - echo "| \`$crate\` | ✅ 0 |" >> "$GITHUB_STEP_SUMMARY" - else - echo "| \`$crate\` | $errors |" >> "$GITHUB_STEP_SUMMARY" - fi - - echo "::group::$crate ($errors errors)" - cat "$log" - echo "::endgroup::" - done - - { - echo "| **total** | **$total** |" - echo - echo "Expected to be non-zero until the crate split lands." - echo "See \`docs/multiplatform-plan.md\` §3.2." - } >> "$GITHUB_STEP_SUMMARY" diff --git a/Cargo.lock b/Cargo.lock index 1e3c84a8..da8600b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,23 +258,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android_log-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" - -[[package]] -name = "android_logger" -version = "0.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" -dependencies = [ - "android_log-sys", - "env_filter 0.1.4", - "log", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -748,7 +731,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "base64", "bytes", "form_urlencoded", "futures-util", @@ -767,10 +749,8 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -1323,21 +1303,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "client-app" -version = "0.1.0" -dependencies = [ - "futures-util", - "gpui", - "gpui-component", - "log", - "sync-client", - "sync-protocol", - "tcode-ui", - "tokio", - "tokio-tungstenite 0.28.0", -] - [[package]] name = "cocoa" version = "0.25.0" @@ -1927,12 +1892,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - [[package]] name = "data-url" version = "0.3.2" @@ -2273,16 +2232,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - [[package]] name = "env_filter" version = "2.0.0" @@ -2301,7 +2250,7 @@ checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter 2.0.0", + "env_filter", "jiff", "log", ] @@ -3241,20 +3190,6 @@ dependencies = [ "zed-scap", ] -[[package]] -name = "gpui-android" -version = "0.1.0" -dependencies = [ - "anyhow", - "futures", - "gpui", - "gpui_wgpu", - "log", - "ndk", - "raw-window-handle", - "uuid", -] - [[package]] name = "gpui-component" version = "0.5.2" @@ -3327,19 +3262,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "gpui-ios" -version = "0.1.0" -dependencies = [ - "anyhow", - "futures", - "gpui", - "gpui_wgpu", - "log", - "raw-window-handle", - "uuid", -] - [[package]] name = "gpui-wry" version = "0.5.0" @@ -5515,16 +5437,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-core-media" version = "0.3.2" @@ -5746,18 +5658,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.0", - "block2 0.6.2", "objc2 0.6.4", - "objc2-cloud-kit", - "objc2-core-data 0.3.2", "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image 0.3.2", - "objc2-core-location", - "objc2-core-text", "objc2-foundation 0.3.2", - "objc2-quartz-core 0.3.2", - "objc2-user-notifications", ] [[package]] @@ -5770,16 +5673,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2 0.6.4", - "objc2-foundation 0.3.2", -] - [[package]] name = "objc2-web-kit" version = "0.3.2" @@ -7851,17 +7744,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1_smol" version = "1.0.1" @@ -8415,48 +8297,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync-client" -version = "0.1.0" -dependencies = [ - "agent", - "futures-util", - "log", - "serde_json", - "sync-protocol", - "tcode-core", - "tcode-services", - "tokio", - "tokio-tungstenite 0.28.0", -] - -[[package]] -name = "sync-host" -version = "0.1.0" -dependencies = [ - "agent", - "async-channel", - "axum", - "futures-util", - "log", - "serde_json", - "sync-protocol", - "tcode-core", - "tcode-services", - "tokio", - "tokio-tungstenite 0.28.0", - "uuid", -] - -[[package]] -name = "sync-protocol" -version = "0.1.0" -dependencies = [ - "agent", - "serde", - "serde_json", -] - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -8633,28 +8473,10 @@ dependencies = [ "log", "orchestrate-mcp", "preview-mcp", - "sync-host", - "sync-protocol", "tcode-i18n", "tcode-runtime", "tcode-services", "tcode-ui", - "uuid", -] - -[[package]] -name = "tcode-android" -version = "0.1.0" -dependencies = [ - "android_logger", - "anyhow", - "client-app", - "gpui", - "gpui-android", - "jni", - "log", - "ndk", - "raw-window-handle", ] [[package]] @@ -8676,20 +8498,6 @@ dependencies = [ "sys-locale", ] -[[package]] -name = "tcode-ios" -version = "0.1.0" -dependencies = [ - "anyhow", - "client-app", - "gpui", - "gpui-ios", - "log", - "objc2 0.6.4", - "objc2-foundation 0.3.2", - "objc2-ui-kit", -] - [[package]] name = "tcode-runtime" version = "0.1.0" @@ -8704,28 +8512,12 @@ dependencies = [ "serde", "serde_json", "smol", - "sync-host", "tcode-core", "tcode-services", "term", "uuid", ] -[[package]] -name = "tcode-server" -version = "0.1.0" -dependencies = [ - "async-channel", - "axum", - "env_logger", - "futures-util", - "log", - "sync-host", - "sync-protocol", - "tcode-services", - "tokio", -] - [[package]] name = "tcode-services" version = "0.1.0" @@ -8788,23 +8580,6 @@ dependencies = [ "term", ] -[[package]] -name = "tcode-web" -version = "0.1.0" -dependencies = [ - "client-app", - "gpui", - "gpui-component", - "gpui-component-assets", - "gpui_platform", - "js-sys", - "sync-client", - "sync-protocol", - "tcode-ui", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -9071,30 +8846,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.28.0", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.29.0", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -9334,39 +9085,6 @@ dependencies = [ "core_maths", ] -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", - "utf-8", -] - -[[package]] -name = "tungstenite" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.4", - "sha1", - "thiserror 2.0.18", -] - [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 1d65a739..017e7eb5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,29 +9,9 @@ members = [ "crates/preview-mcp", "crates/orchestrate-mcp", "crates/computer-use-mcp", - "crates/gpui-android", - "crates/gpui-ios", - "crates/sync-client", - "crates/sync-host", - "crates/sync-protocol", - "crates/tcode-android", - "crates/tcode-ios", - "crates/tcode-server", - "crates/tcode-web", "crates/ui", "crates/app", ] -# client-app is deliberately not a member. It is the *client* half of the app — -# the UI a phone or browser mounts — and it depends on tcode-ui's `portable` -# feature, which is the mutually exclusive opposite of `desktop`. Cargo unifies -# features across a workspace build, so listing it here would build tcode-ui -# with both at once and neither constructor would be satisfiable. -# -# The three shells depend on it by path, inside target tables, so it is built -# for wasm/Android/iOS and never for the host. Check it directly: -# cargo check -p client-app --target wasm32-unknown-unknown -exclude = ["crates/client-app"] - resolver = "3" default-members = ["crates/app"] @@ -41,23 +21,3 @@ lto = "fat" codegen-units = 1 strip = "symbols" panic = "unwind" - -# On pinning gpui: the Zed dependency deliberately carries no `rev` in any -# manifest, and Cargo.lock is the pin. Two things rule out the alternatives. -# -# Putting `rev = ...` on our own `gpui` entries does NOT pin the tree: -# `gpui-component` depends on the same repository without a rev, so the graph -# ends up with two distinct source ids (`git+…#rev` and `git+…?rev=…#rev`) — -# two copies of `gpui` in one binary, and gpui-component's gpui types stop -# being our gpui types. -# -# Collapsing them with `[patch."https://github.com/zed-industries/zed"]` is -# also rejected: Cargo requires a patch to point at a *different* source, and -# the only correct revision lives at that same URL. (Patching would additionally -# need `version = "=0.2.2"`, because the tree carries a second package named -# `gpui` — the 0.0.0 lint fixture under `tooling/lints/test_fixture/gpui`.) -# -# So the pin is Cargo.lock, enforced two ways: CI runs every cargo command with -# `--locked`, and `crates/app/src/gpui_pin.rs` asserts the locked commit matches -# a constant recorded there. Bumping gpui = update the lock and that constant in -# one deliberate commit. diff --git a/README.md b/README.md index 9c17934f..224a9a7a 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,6 @@ promoted to a steer with one click. send it as context), an embedded preview browser the agent can drive over MCP, and a live plan/task panel. -**Check in from elsewhere.** A running Tcode can be followed from a browser — -same session, same timeline, and you can send turns and answer approval prompts -from it. A phone reads the six-character code off your screen once and never -asks again. The agent still runs on your machine; the other clients are remote -controls, not copies. [`docs/remote-and-mobile.md`](docs/remote-and-mobile.md) -covers setup, and is candid about what has been exercised versus what has only -been built — the Android and iOS shells link but have not yet run on hardware. -
Diff panel Queued messages above the composer @@ -177,29 +169,6 @@ details. The normal source command remains `cargo run` because `crates/app` is the workspace's sole default binary package. [`docs/DESIGN.md`](docs/DESIGN.md) is the visual contract. -Remote work adds a second group. It hangs off the same event log the desktop UI -already folds into a timeline, which is why it costs so little: - -``` -crates/sync-protocol the wire vocabulary, shared by both ends -crates/sync-host host state machine, with no I/O and no GPUI in it -crates/sync-client the client half of the same conversation -crates/tcode-server headless host: sync-host with a different main -crates/client-app the client UI the browser and both phones mount -crates/tcode-web browser shell (wasm) -crates/gpui-android GPUI Platform backend for Android -crates/gpui-ios GPUI Platform backend for iOS -crates/tcode-android Android app shell (JNI) -crates/tcode-ios iOS app shell (UIKit) -``` - -`client-app` is deliberately outside the workspace — it needs `tcode-ui`'s -`portable` feature, which is the mutually exclusive opposite of `desktop`, and -Cargo unifies features across a workspace build. The root `Cargo.toml` says so -at the exclusion. See [`docs/remote-and-mobile.md`](docs/remote-and-mobile.md) -for how to run any of it and [`docs/sync-protocol.md`](docs/sync-protocol.md) -for the protocol itself. - ## Contributing Issues and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index cc1cf80a..1bb0da2c 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -3,34 +3,21 @@ name = "agent" version = "0.1.0" edition = "2024" -[features] -default = ["local"] -local = [ - "dep:agent-client-protocol", - "dep:async-channel", - "dep:base64", - "dep:futures-lite", - "dep:log", - "dep:smol", - "dep:ureq", - "dep:uuid", -] - [dependencies] agent-client-protocol = { version = "1.2", features = [ "unstable_auth_methods", "unstable_end_turn_token_usage", -], optional = true } -async-channel = { version = "2", optional = true } -futures-lite = { version = "2", optional = true } -log = { version = "0.4", optional = true } -base64 = { version = "0.22", optional = true } +] } +async-channel = "2" +futures-lite = "2" +log = "0.4" +base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" -smol = { version = "2", optional = true } +smol = "2" thiserror = "2" -ureq = { version = "2", default-features = false, features = ["tls", "gzip"], optional = true } -uuid = { version = "1", features = ["v4"], optional = true } +ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } +uuid = { version = "1", features = ["v4"] } [dev-dependencies] env_logger = "0.11" diff --git a/crates/agent/examples/image_probe.rs b/crates/agent/examples/image_probe.rs index fccfdf2e..066da326 100644 --- a/crates/agent/examples/image_probe.rs +++ b/crates/agent/examples/image_probe.rs @@ -106,7 +106,6 @@ fn main() { media_type: mime_from_path(&image_path), data_base64: base64_encode(&bytes), source_path: None, - workspace_path: None, }; eprintln!( "image_probe: provider={provider:?} image={} ({} bytes, {}) prompt={prompt:?}", diff --git a/crates/agent/src/acp.rs b/crates/agent/src/acp.rs index 30c148ca..2bbdc709 100644 --- a/crates/agent/src/acp.rs +++ b/crates/agent/src/acp.rs @@ -2138,7 +2138,6 @@ fn file_changes(tool: &ToolState) -> Vec { acp::ToolCallContent::Diff(diff) => { let path = diff.path.to_string_lossy().into_owned(); Some(FileChange { - workspace_path: None, kind: match (tool.kind, diff.old_text.as_deref()) { (acp::ToolKind::Delete, _) => FileChangeKind::Delete, (acp::ToolKind::Move, _) => FileChangeKind::Rename, @@ -2163,7 +2162,6 @@ fn file_changes(tool: &ToolState) -> Vec { .iter() .map(|location| FileChange { path: location.path.to_string_lossy().into_owned(), - workspace_path: None, kind: match tool.kind { acp::ToolKind::Delete => FileChangeKind::Delete, _ => FileChangeKind::Rename, @@ -2283,7 +2281,6 @@ pub(crate) fn approval_request( ..Default::default() }), cwd: None, - workspace_cwd: None, reason: None, }, acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { @@ -3519,7 +3516,6 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: None, - workspace_path: None, }], ); let value = serde_json::to_value(&blocks).unwrap(); diff --git a/crates/agent/src/claude.rs b/crates/agent/src/claude.rs index 6b405477..0e3905cb 100644 --- a/crates/agent/src/claude.rs +++ b/crates/agent/src/claude.rs @@ -2213,7 +2213,6 @@ impl Mapper { .unwrap_or("") .to_string(), cwd: input.get("cwd").and_then(Value::as_str).map(str::to_string), - workspace_cwd: None, reason, }, ClaudeRequestType::FileChange => ApprovalKind::FileChange { @@ -2490,7 +2489,6 @@ fn file_changes(name: &str, input: &Value) -> Vec { let content = input.get("content").and_then(Value::as_str).unwrap_or(""); vec![FileChange { path, - workspace_path: None, kind: FileChangeKind::Create, diff: (!content.is_empty()).then(|| { content @@ -2523,14 +2521,12 @@ fn file_changes(name: &str, input: &Value) -> Vec { } vec![FileChange { path, - workspace_path: None, kind: FileChangeKind::Modify, diff: (!diff.is_empty()).then(|| diff.trim_end().to_string()), }] } _ => vec![FileChange { path, - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }], @@ -3465,13 +3461,11 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: None, - workspace_path: None, }, Attachment { media_type: "image/jpeg".into(), data_base64: "BBBB".into(), source_path: None, - workspace_path: None, }, ]; let msg = user_message("what color is this?", &attachments); diff --git a/crates/agent/src/codex.rs b/crates/agent/src/codex.rs index 89184dcd..d2c6fbc2 100644 --- a/crates/agent/src/codex.rs +++ b/crates/agent/src/codex.rs @@ -1409,7 +1409,6 @@ impl Actor { .unwrap_or_default() .into(), cwd: params.get("cwd").and_then(Value::as_str).map(str::to_owned), - workspace_cwd: None, reason: params .get("reason") .and_then(Value::as_str) @@ -2361,7 +2360,6 @@ fn map_file_change(change: &Value) -> Option { }; Some(FileChange { path: change.get("path").and_then(Value::as_str)?.into(), - workspace_path: None, kind, diff: change .get("diff") @@ -2675,7 +2673,6 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: None, - workspace_path: None, }]; let params = actor.build_turn_params("what color?", None, &attachments); let input = params["input"].as_array().unwrap(); diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 2e6ffac0..9abf7eea 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -6,21 +6,12 @@ //! types; nothing provider-shaped leaks past this crate except [`ResumeCursor`], //! which is intentionally opaque. -// Provider adapters are local-only because they spawn and supervise child processes. -#[cfg(feature = "local")] pub mod acp; -#[cfg(feature = "local")] pub mod claude; -#[cfg(feature = "local")] pub mod codex; -#[cfg(feature = "local")] pub mod opencode; -#[cfg(feature = "local")] pub mod pi; -#[cfg(feature = "local")] mod process; -// Subagent tailing is local-only because it reads provider transcript files. -#[cfg(feature = "local")] mod subagent_tail; use std::path::PathBuf; @@ -298,8 +289,6 @@ pub struct SelectOption { /// working-directory change makes PATH resolution unreliable (it fails outright /// when PATH holds unexpanded entries such as `~/.dotnet/tools`). Resolving the /// binary ourselves against the parent's PATH keeps the lookup deterministic. -// Binary resolution is local-only because it probes PATH and the filesystem before spawning. -#[cfg(feature = "local")] pub(crate) fn resolve_binary( binary_path: Option<&std::path::Path>, default_name: &str, @@ -334,8 +323,6 @@ pub(crate) fn resolve_binary( /// /// Shared with the app crate (`crate::process`), which routes every bare-name /// spawn (npm, pnpm, bun, git, …) through it on Windows. -// PATH lookup is local-only because it inspects host environment and filesystem state. -#[cfg(feature = "local")] pub fn find_on_path(name: &str) -> Option { let path_var = std::env::var_os("PATH")?; find_in_dirs( @@ -348,7 +335,6 @@ pub fn find_on_path(name: &str) -> Option { /// The executable extensions to try for a bare name: `PATHEXT` on Windows /// (falling back to its documented default), and nothing anywhere else. -#[cfg(feature = "local")] fn path_extensions() -> Vec { if !cfg!(windows) { return Vec::new(); @@ -369,7 +355,6 @@ fn path_extensions() -> Vec { /// extensionless name tried last (so a Unix-style extensionless binary dropped /// on a Windows PATH still resolves). With an empty `pathext` (every non-Windows /// target) this is just `[name]`. -#[cfg(feature = "local")] fn candidate_names(name: &str, pathext: &[String]) -> Vec { if std::path::Path::new(name).extension().is_some() { return vec![name.to_string()]; @@ -382,7 +367,6 @@ fn candidate_names(name: &str, pathext: &[String]) -> Vec { /// The PATH walk itself, parameterized on the directories, the extension list /// and the "is this executable" predicate so both platform branches are testable /// from any host (see `resolve_binary_tests`). -#[cfg(feature = "local")] fn find_in_dirs( dirs: impl IntoIterator, name: &str, @@ -406,7 +390,7 @@ fn find_in_dirs( None } -#[cfg(all(feature = "local", unix))] +#[cfg(unix)] fn is_executable(path: &std::path::Path) -> bool { use std::os::unix::fs::PermissionsExt as _; std::fs::metadata(path) @@ -416,7 +400,7 @@ fn is_executable(path: &std::path::Path) -> bool { /// Windows has no exec bit: a regular file whose name matched a `PATHEXT` /// candidate *is* the executable. -#[cfg(all(feature = "local", not(unix)))] +#[cfg(not(unix))] fn is_executable(path: &std::path::Path) -> bool { path.is_file() } @@ -464,11 +448,6 @@ pub struct Attachment { /// the timeline can render the image without re-encoding the base64. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_path: Option, - /// Workspace-relative counterpart of `source_path`, when the source lives - /// inside the session workspace. The absolute path remains authoritative - /// for local rendering; remote clients use this additive form. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_path: Option, } /// A provider-native command or skill surfaced to the composer's `/` and `$` @@ -502,7 +481,7 @@ pub enum InteractionMode { /// Per-turn overrides layered on top of the session's persisted options. /// Codex and OpenCode apply effort/variant per turn; Claude and pi use their /// session-level option selection. -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct TurnOptions { pub effort: Option, pub interaction_mode: Option, @@ -511,8 +490,6 @@ pub struct TurnOptions { /// List the provider's models (spawn, query, teardown). `launch_env` carries the /// provider's configured environment/home so the catalog reflects the same CLI /// (and account) a session would actually run against. -// Model discovery is local-only because it launches or queries provider processes. -#[cfg(feature = "local")] pub async fn list_models( provider: ProviderKind, binary_path: Option, @@ -568,17 +545,7 @@ pub enum AgentError { } /// Commands the UI sends into a live session's actor loop. -/// -/// Serializable because this is also the client-to-host half of the remote -/// protocol (`docs/multiplatform-plan.md` §3.1): a phone or browser client -/// cannot spawn a provider, so it sends these to whichever machine can. -/// -/// Adjacently tagged rather than internally tagged like [`AgentEvent`]: -/// `SetApprovalMode` and `SetInteractionMode` are newtype variants wrapping -/// enums that serialize to strings, and Serde cannot represent those inside an -/// internally tagged enum — it fails at runtime, not compile time. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] +#[derive(Debug, Clone)] pub enum SessionCommand { SendTurn { /// Runtime queue id echoed by [`AgentEvent::TurnAccepted`] after the @@ -647,8 +614,6 @@ pub enum SessionCommand { /// A live provider session: send commands in, read canonical events out. /// Dropping both channels (or sending `Shutdown`) tears the child process down. -// Session handles are local-only because their channels are owned by provider actors. -#[cfg(feature = "local")] pub struct SessionHandle { pub provider: ProviderKind, pub commands: async_channel::Sender, @@ -656,8 +621,6 @@ pub struct SessionHandle { } /// Start a new (or resumed) session with the given provider. -// Session startup is local-only because every adapter launches a child process. -#[cfg(feature = "local")] pub async fn start_session( provider: ProviderKind, opts: SessionOptions, @@ -858,51 +821,6 @@ pub enum AgentEvent { }, } -impl AgentEvent { - /// Add portable siblings to every typed workspace path in this event. - /// - /// This is intentionally enrichment rather than rewriting: local consumers - /// keep the exact host paths while persisted and synced events also carry - /// the form a remote client can resolve. - pub fn add_workspace_paths(&mut self, workspace_id: &str, workspace_root: &std::path::Path) { - match self { - Self::TurnChangesUpdated { changes, .. } => { - add_file_change_workspace_paths(changes, workspace_id, workspace_root); - } - Self::ItemStarted(item) | Self::ItemUpdated(item) | Self::ItemCompleted(item) => { - if let ItemContent::FileChange { changes, .. } = &mut item.content { - add_file_change_workspace_paths(changes, workspace_id, workspace_root); - } - } - Self::ApprovalRequested(request) => match &mut request.kind { - ApprovalKind::ExecCommand { - cwd, workspace_cwd, .. - } => { - *workspace_cwd = cwd.as_deref().and_then(|cwd| { - WorkspacePath::from_host_path(workspace_id, workspace_root, cwd) - }); - } - ApprovalKind::FileChange { changes, .. } => { - add_file_change_workspace_paths(changes, workspace_id, workspace_root); - } - ApprovalKind::FileRead { .. } | ApprovalKind::ToolUse { .. } => {} - }, - _ => {} - } - } -} - -fn add_file_change_workspace_paths( - changes: &mut [FileChange], - workspace_id: &str, - workspace_root: &std::path::Path, -) { - for change in changes { - change.workspace_path = - WorkspacePath::from_host_path(workspace_id, workspace_root, &change.path); - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanStep { pub step: String, @@ -1047,80 +965,11 @@ pub enum ItemContent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileChange { pub path: String, - /// Portable counterpart of `path`. Kept separate so old logs and the local - /// UI continue to use the provider's exact path unchanged. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub workspace_path: Option, pub kind: FileChangeKind, /// Unified diff for this file when the provider supplies one. pub diff: Option, } -/// A path a remote client can interpret without knowing the host filesystem. -/// -/// `workspace_id` is the persisted project id, not its display name: basenames -/// are neither unique nor stable enough to correlate paths across sessions. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspacePath { - pub workspace_id: String, - pub relative_path: String, -} - -impl WorkspacePath { - /// Build a portable path only when `path` is relative already or is inside - /// `workspace_root`. Outside paths deliberately stay host-only rather than - /// acquiring misleading `..` segments. - pub fn from_host_path( - workspace_id: &str, - workspace_root: &std::path::Path, - path: &str, - ) -> Option { - let path = std::path::Path::new(path); - // Not `is_absolute()`: on Windows that is false for a path beginning - // `/`, which has a root but no drive letter. Such a path would then take - // the relative branch and be emitted verbatim — claiming an absolute - // path is workspace-relative, which is exactly the misleading output - // this function refuses to produce for `..`. A drive prefix without a - // root (`C:file`) is not workspace-relative either, so both count. - let rooted = path.components().next().is_some_and(|first| { - matches!( - first, - std::path::Component::Prefix(_) | std::path::Component::RootDir - ) - }); - let relative = if rooted { - path.strip_prefix(workspace_root).ok()? - } else { - path - }; - if relative - .components() - .any(|component| component == std::path::Component::ParentDir) - { - return None; - } - let relative_path = if relative.as_os_str().is_empty() { - ".".to_owned() - } else { - relative.to_string_lossy().into_owned() - }; - Some(Self { - workspace_id: workspace_id.to_owned(), - relative_path, - }) - } -} - -impl Attachment { - /// Add a portable sibling when this local source is a workspace file. - pub fn add_workspace_path(&mut self, workspace_id: &str, workspace_root: &std::path::Path) { - self.workspace_path = self - .source_path - .as_deref() - .and_then(|path| WorkspacePath::from_host_path(workspace_id, workspace_root, path)); - } -} - #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FileChangeKind { @@ -1208,7 +1057,6 @@ pub fn file_changes_from_unified_diff(diff: &str) -> Result, Str Ok(FileChange { path, - workspace_path: None, kind, diff: Some(section), }) @@ -1254,10 +1102,6 @@ pub enum ApprovalKind { ExecCommand { command: String, cwd: Option, - /// Portable counterpart of `cwd`; absent for legacy events and for - /// commands whose working directory is outside the workspace. - #[serde(default, skip_serializing_if = "Option::is_none")] - workspace_cwd: Option, reason: Option, }, /// A read-only file/search operation (Claude's `file_read_approval` family: @@ -1388,7 +1232,7 @@ mod launch_env_tests { } } -#[cfg(all(test, feature = "local"))] +#[cfg(test)] mod resolve_binary_tests { use super::*; @@ -1566,7 +1410,7 @@ mod resolve_binary_tests { } } -#[cfg(all(test, feature = "local"))] +#[cfg(test)] mod pathext_logic_tests { use super::*; use std::path::PathBuf; @@ -1702,7 +1546,6 @@ mod turn_diff_tests { turn_id: "turn-7".into(), changes: vec![FileChange { path: "src/main.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("@@ -1 +1 @@\n-old\n+new\n".into()), }], @@ -1719,98 +1562,6 @@ mod turn_diff_tests { } if turn_id == "turn-7" && changes[0].path == "src/main.rs" )); } - - #[test] - fn typed_paths_gain_portable_siblings_and_legacy_json_still_parses() { - let legacy = r#"{"type":"approval_requested","id":"approval-1","turn_id":null,"kind":{"kind":"exec_command","command":"cargo test","cwd":"/work/tcode","reason":null},"options":[]}"#; - let mut event: AgentEvent = serde_json::from_str(legacy).unwrap(); - - assert!(matches!( - &event, - AgentEvent::ApprovalRequested(ApprovalRequest { - kind: ApprovalKind::ExecCommand { - workspace_cwd: None, - .. - }, - .. - }) - )); - - event.add_workspace_paths("project-stable-id", std::path::Path::new("/work/tcode")); - let json = serde_json::to_value(&event).unwrap(); - assert_eq!(json["kind"]["cwd"], "/work/tcode"); - assert_eq!( - json["kind"]["workspace_cwd"], - serde_json::json!({ - "workspace_id": "project-stable-id", - "relative_path": "." - }) - ); - - let mut change = AgentEvent::TurnChangesUpdated { - turn_id: "turn-1".into(), - changes: vec![FileChange { - path: "/work/tcode/src/main.rs".into(), - workspace_path: None, - kind: FileChangeKind::Modify, - diff: None, - }], - completeness: ChangeCompleteness::Exact, - }; - change.add_workspace_paths("project-stable-id", std::path::Path::new("/work/tcode")); - let AgentEvent::TurnChangesUpdated { changes, .. } = change else { - unreachable!() - }; - assert_eq!( - changes[0].workspace_path, - Some(WorkspacePath { - workspace_id: "project-stable-id".into(), - relative_path: "src/main.rs".into(), - }) - ); - } - - #[test] - fn attachment_source_path_gets_a_workspace_relative_sibling() { - let mut attachment = Attachment { - media_type: "image/png".into(), - data_base64: "AAAA".into(), - source_path: Some("/work/tcode/assets/diagram.png".into()), - workspace_path: None, - }; - attachment.add_workspace_path("project-stable-id", std::path::Path::new("/work/tcode")); - assert_eq!( - attachment.workspace_path, - Some(WorkspacePath { - workspace_id: "project-stable-id".into(), - relative_path: "assets/diagram.png".into(), - }) - ); - - let legacy = r#"{"media_type":"image/png","data_base64":"AAAA","source_path":"/work/tcode/assets/diagram.png"}"#; - let decoded: Attachment = serde_json::from_str(legacy).unwrap(); - assert_eq!(decoded.workspace_path, None); - } - - /// A path outside the workspace must produce nothing rather than a portable - /// path that lies about where it points. - /// - /// This caught a real Windows-only defect: the branch was chosen with - /// `is_absolute()`, which is false there for a rooted path carrying no drive - /// letter, so `/elsewhere/secret.txt` took the relative branch and came back - /// as a "workspace-relative" path to a file in neither the workspace nor any - /// relative location. - #[test] - fn a_path_outside_the_workspace_has_no_portable_form() { - assert_eq!( - WorkspacePath::from_host_path( - "project-stable-id", - std::path::Path::new("/work/tcode"), - "/elsewhere/secret.txt", - ), - None - ); - } } #[cfg(test)] @@ -1871,133 +1622,3 @@ mod thread_item_serde_tests { )); } } - -#[cfg(test)] -mod session_command_wire_tests { - use super::*; - - /// One fixture per [`SessionCommand`] variant. - /// - /// Kept honest by `discriminant`, whose match is exhaustive: adding a - /// variant stops this module compiling until the new command is covered - /// here as well. A command that cannot cross the wire would strand a - /// remote client on an action the desktop UI still offers, and that is - /// exactly the kind of gap a type system can close for us. - fn every_variant() -> Vec { - vec![ - SessionCommand::SendTurn { - delivery_id: 7, - text: "hello".into(), - options: Some(TurnOptions { - effort: Some("high".into()), - interaction_mode: Some(InteractionMode::Plan), - }), - attachments: vec![Attachment { - media_type: "image/png".into(), - data_base64: "iVBORw0=".into(), - source_path: None, - workspace_path: None, - }], - }, - SessionCommand::Interrupt, - SessionCommand::RespondApproval { - request_id: "req-1".into(), - decision: ApprovalDecision::Approve, - }, - SessionCommand::RespondUserInput { - request_id: "req-2".into(), - answers: serde_json::json!({ "q1": "yes", "q2": ["a", "b"] }) - .as_object() - .cloned() - .expect("fixture is an object"), - }, - SessionCommand::SetApprovalMode(ApprovalMode::default()), - SessionCommand::Steer { - request_id: "req-3".into(), - text: "actually, use ripgrep".into(), - attachments: Vec::new(), - }, - SessionCommand::SetInteractionMode(InteractionMode::Plan), - SessionCommand::SetOption { - id: "reasoning".into(), - value: serde_json::json!({ "depth": 3 }), - }, - SessionCommand::Rewind { - checkpoint_id: "ckpt-9".into(), - mode: RewindMode::Conversation, - }, - SessionCommand::Shutdown, - ] - } - - fn discriminant(command: &SessionCommand) -> &'static str { - match command { - SessionCommand::SendTurn { .. } => "send_turn", - SessionCommand::Interrupt => "interrupt", - SessionCommand::RespondApproval { .. } => "respond_approval", - SessionCommand::RespondUserInput { .. } => "respond_user_input", - SessionCommand::SetApprovalMode(_) => "set_approval_mode", - SessionCommand::Steer { .. } => "steer", - SessionCommand::SetInteractionMode(_) => "set_interaction_mode", - SessionCommand::SetOption { .. } => "set_option", - SessionCommand::Rewind { .. } => "rewind", - SessionCommand::Shutdown => "shutdown", - } - } - - #[test] - fn every_variant_round_trips() { - for command in every_variant() { - let json = serde_json::to_string(&command) - .unwrap_or_else(|err| panic!("{command:?} must serialize: {err}")); - let back: SessionCommand = serde_json::from_str(&json) - .unwrap_or_else(|err| panic!("{command:?} must deserialize from {json}: {err}")); - assert_eq!(back, command, "round trip changed the command via {json}"); - } - } - - #[test] - fn fixtures_cover_every_variant() { - let mut seen: Vec<&str> = every_variant().iter().map(discriminant).collect(); - seen.sort_unstable(); - let mut deduped = seen.clone(); - deduped.dedup(); - assert_eq!(seen, deduped, "a variant is fixtured twice: {seen:?}"); - assert_eq!( - seen.len(), - 10, - "fixtures no longer cover every SessionCommand variant: {seen:?}" - ); - } - - /// The two newtype variants are the reason this enum is adjacently tagged - /// rather than internally tagged like `AgentEvent`. Serde only fails on - /// them at runtime, so pin the shape here: an internally tagged enum could - /// not produce a string `data` field at all. - #[test] - fn newtype_variants_survive_the_tagging_scheme() { - let json = serde_json::to_value(SessionCommand::SetInteractionMode(InteractionMode::Plan)) - .expect("newtype variant must serialize"); - assert_eq!(json["type"], "set_interaction_mode"); - assert!( - json["data"].is_string(), - "expected an adjacently tagged string payload, got {json}" - ); - } - - /// Struct variants share that shape, so a decoder never has to branch on - /// variant kind. - #[test] - fn struct_variants_nest_their_fields_under_data() { - let json = serde_json::to_value(SessionCommand::Interrupt).expect("unit variant"); - assert_eq!(json["type"], "interrupt"); - - let json = serde_json::to_value(SessionCommand::RespondApproval { - request_id: "req-1".into(), - decision: ApprovalDecision::Approve, - }) - .expect("struct variant"); - assert_eq!(json["type"], "respond_approval"); - assert_eq!(json["data"]["request_id"], "req-1"); - } -} diff --git a/crates/agent/src/opencode.rs b/crates/agent/src/opencode.rs index 675a9283..b145e715 100644 --- a/crates/agent/src/opencode.rs +++ b/crates/agent/src/opencode.rs @@ -749,7 +749,6 @@ impl OpenCodeMapper { .filter_map(Value::as_str) .map(|path| FileChange { path: path.to_owned(), - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }) @@ -944,7 +943,6 @@ fn map_permission(properties: &Value) -> Option { .get("cwd") .and_then(Value::as_str) .map(str::to_owned), - workspace_cwd: None, reason: metadata .get("reason") .and_then(Value::as_str) @@ -955,7 +953,6 @@ fn map_permission(properties: &Value) -> Option { .iter() .map(|path| FileChange { path: path.clone(), - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }) @@ -1029,7 +1026,6 @@ fn map_snapshot_diffs(value: &Value) -> Vec { }; Some(FileChange { path, - workspace_path: None, kind, diff: diff.get("patch").and_then(Value::as_str).map(str::to_owned), }) diff --git a/crates/agent/src/pi.rs b/crates/agent/src/pi.rs index 1270f274..c425ad66 100644 --- a/crates/agent/src/pi.rs +++ b/crates/agent/src/pi.rs @@ -1110,7 +1110,6 @@ fn tool_item(id: &str, name: &str, input: Value, output: String, status: ItemSta .map(|path| { vec![FileChange { path: path.to_owned(), - workspace_path: None, kind: if name == "write" { FileChangeKind::Create } else { @@ -1153,7 +1152,6 @@ fn approval_kind(tool_name: &str, payload: &Value) -> ApprovalKind { .get("cwd") .and_then(Value::as_str) .map(str::to_owned), - workspace_cwd: None, reason, }, "edit" | "write" => ApprovalKind::FileChange { @@ -1163,7 +1161,6 @@ fn approval_kind(tool_name: &str, payload: &Value) -> ApprovalKind { .map(|path| { vec![FileChange { path: path.to_owned(), - workspace_path: None, kind: if tool_name == "write" { FileChangeKind::Create } else { diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 36612b52..f20eb4bd 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -21,13 +21,10 @@ tcode-ui = { path = "../ui" } preview-mcp = { path = "../preview-mcp" } orchestrate-mcp = { path = "../orchestrate-mcp" } computer-use-mcp = { path = "../computer-use-mcp" } -sync-host = { path = "../sync-host" } -sync-protocol = { path = "../sync-protocol" } gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] } gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } env_logger = "0.11" -uuid = { version = "1", features = ["v4"] } log = "0.4" [features] diff --git a/crates/app/src/gpui_pin.rs b/crates/app/src/gpui_pin.rs deleted file mode 100644 index 9206a078..00000000 --- a/crates/app/src/gpui_pin.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Guards the pinned Zed/gpui revision. -//! -//! gpui comes from a git dependency with no `rev` in any manifest (the root -//! `Cargo.toml` explains why neither a manifest `rev` nor a `[patch]` can work -//! here), so Cargo.lock is the only thing holding the revision still. A bare -//! `cargo update` would move it to whatever Zed's default branch points at that -//! day, and the resulting lockfile diff is large enough that the one line that -//! matters is easy to miss in review. -//! -//! These tests make that move loud: bumping gpui means editing [`EXPECTED_ZED_REV`] -//! in the same commit as the lockfile, which is exactly the deliberate, -//! separately-reviewed bump the multiplatform plan asks for -//! (`docs/multiplatform-plan.md`, phase 0). - -/// The Zed commit every `zed-industries/zed` package must resolve to. -/// -/// zed v1.13.0 · gpui 0.2.2 · 2026-07-16. -const EXPECTED_ZED_REV: &str = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba"; - -const ZED_REPO: &str = "git+https://github.com/zed-industries/zed"; - -/// Every distinct commit the lockfile resolves `zed-industries/zed` packages to. -/// -/// A `source` line looks like -/// `source = "git+https://github.com/zed-industries/zed#<40-hex>"`, and gains a -/// `?rev=…` query when a manifest pins one. -fn locked_zed_revs(lockfile: &str) -> Vec { - let mut revs: Vec = locked_zed_sources(lockfile) - .iter() - .filter_map(|source| Some(source.rsplit_once('#')?.1.to_owned())) - .collect(); - revs.sort(); - revs.dedup(); - revs -} - -/// Every distinct `zed-industries/zed` *source id* the lockfile mentions. -/// -/// Distinct from [`locked_zed_revs`]: one commit can still appear under two -/// source ids, which is the duplicate-crate failure described on -/// `zed_packages_share_one_source_id`. -fn locked_zed_sources(lockfile: &str) -> Vec { - let mut sources: Vec = lockfile - .lines() - .filter_map(|line| { - let value = line.trim().strip_prefix("source = \"")?.strip_suffix('"')?; - value.starts_with(ZED_REPO).then(|| value.to_owned()) - }) - .collect(); - sources.sort(); - sources.dedup(); - sources -} - -#[cfg(test)] -mod tests { - use super::*; - - fn lockfile() -> String { - // crates/app -> crates -> workspace root - let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .and_then(std::path::Path::parent) - .expect("app manifest directory must be nested under workspace/crates"); - let path = workspace_root.join("Cargo.lock"); - std::fs::read_to_string(&path) - .unwrap_or_else(|err| panic!("workspace lockfile must be readable at {path:?}: {err}")) - } - - #[test] - fn locked_gpui_matches_the_recorded_revision() { - let revs = locked_zed_revs(&lockfile()); - assert_eq!( - revs, - vec![EXPECTED_ZED_REV.to_owned()], - "Cargo.lock resolves zed-industries/zed to a different commit than \ - EXPECTED_ZED_REV in crates/app/src/gpui_pin.rs. If this is a deliberate \ - gpui bump, update that constant in the same commit as the lockfile; \ - otherwise restore the lockfile (`git checkout Cargo.lock`)." - ); - } - - /// A single commit is not enough — the packages must also share one *source - /// id*. Adding `rev = ...` to our own gpui entries while `gpui-component` - /// keeps depending on the unpinned URL yields `git+…#rev` and - /// `git+…?rev=…#rev` side by side: two `gpui` crates at the same commit, - /// whose types are nonetheless incompatible. That failure surfaces as a wall - /// of unrelated-looking type errors, so name it here instead. - #[test] - fn zed_packages_share_one_source_id() { - let sources = locked_zed_sources(&lockfile()); - assert_eq!( - sources.len(), - 1, - "zed-industries/zed resolved to {} distinct source ids, so the build \ - carries duplicate gpui crates whose types will not unify: {sources:#?}", - sources.len() - ); - } - - /// The lockfile shape both predicates parse. The first two lines are the - /// duplicate-source state: one commit, two source ids. - const SPLIT_SOURCE_LOCKFILE: &str = concat!( - "source = \"git+https://github.com/zed-industries/zed#aaa\"\n", - "source = \"git+https://github.com/zed-industries/zed?rev=aaa#aaa\"\n", - "source = \"git+https://github.com/longbridge/gpui-component?rev=bbb#bbb\"\n", - "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", - ); - - #[test] - fn rev_extraction_handles_both_pinned_and_unpinned_sources() { - assert_eq!( - locked_zed_revs(SPLIT_SOURCE_LOCKFILE), - vec!["aaa".to_owned()] - ); - } - - /// The duplicate-source guard must actually fire on the state it names — - /// one commit reached through two source ids — rather than only ever seeing - /// the healthy lockfile and passing vacuously. - #[test] - fn source_id_extraction_separates_pinned_from_unpinned() { - let sources = locked_zed_sources(SPLIT_SOURCE_LOCKFILE); - assert_eq!( - sources.len(), - 2, - "one commit under two source ids must count as two: {sources:#?}" - ); - assert_eq!(locked_zed_revs(SPLIT_SOURCE_LOCKFILE).len(), 1); - } -} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index c5a1b81e..5859c96c 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,9 +2,6 @@ // Debug builds keep the console so `RUST_LOG` output stays visible. #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] -// Lockfile guard only; nothing here is reachable from the binary. -#[cfg(test)] -mod gpui_pin; mod smoke; use std::{borrow::Cow, time::Duration}; @@ -13,9 +10,8 @@ use gpui::{ App, AppContext as _, Entity, KeyBinding, ParentElement as _, Styled as _, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowOptions, point, px, size, }; -use sync_protocol::HostInfo; use tcode_runtime::app::AppState; -use tcode_services::{settings::SettingsStore, shell_env, store::SessionStore}; +use tcode_services::{shell_env, store::SessionStore}; use tcode_ui::{AppShell, Quit, TogglePalette}; use tcode_ui::{assets, settings}; @@ -331,7 +327,7 @@ fn main() { Theme::global_mut(cx).apply_config(&light); Theme::global_mut(cx).apply_config(&dark); - let app_state = cx.new(|_| AppState::new(store.clone())); + let app_state = cx.new(|_| AppState::new(store)); cx.on_action::({ let app_state = app_state.clone(); move |action, cx| handle_quit(action, &app_state, cx) @@ -352,53 +348,6 @@ fn main() { } Err(err) => log::warn!("orchestrate MCP server failed to start: {err}"), } - let display_name = std::env::var("HOSTNAME") - .or_else(|_| std::env::var("COMPUTERNAME")) - .unwrap_or_else(|_| "tcode".into()); - let host_info = HostInfo { - host_id: format!("{display_name}:{}", store.root().display()), - display_name, - platform: std::env::consts::OS.into(), - app_version: env!("CARGO_PKG_VERSION").into(), - }; - let sync_server = SettingsStore::new(store.root().clone()) - .load_or_create_sync_token() - .and_then(|token| { - sync_host::start_on( - store.clone(), - host_info, - token, - std::net::SocketAddr::from(([127, 0, 0, 1], 0)), - sync_host::WakeSource::Broadcast, - ) - }); - match sync_server { - Ok(server) => { - log::info!("sync host listening at {}", server.url); - // A pairing code, not the token: a phone cannot be handed a - // credential the way a query string hands one to a browser, - // and the code is what a person can read off this screen and - // type into another. Single-use and short-lived, so logging - // it is not the exposure logging the token would be. - let code = server.pairing.issue(std::time::Instant::now(), || { - // A v4 UUID's bytes, because they come from the system - // CSPRNG. The clock is not an acceptable substitute: an - // earlier attempt read it once per character and every - // code came out `AAAAAA`. - let bytes = *uuid::Uuid::new_v4().as_bytes(); - std::array::from_fn(|index| bytes[index]) - }); - log::info!( - "pair a device within {}s with code {code}", - sync_host::pairing::CODE_LIFETIME.as_secs() - ); - app_state.update(cx, |state, cx| { - state.attach_sync_host(server); - state.pump_sync_commands(cx); - }); - } - Err(err) => log::warn!("sync host failed to start: {err}"), - } match computer_use_mcp::start() { Ok(server) => { log::info!("computer-use MCP server listening at {}", server.url); diff --git a/crates/client-app/.gitignore b/crates/client-app/.gitignore deleted file mode 100644 index d120ce0a..00000000 --- a/crates/client-app/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Not a workspace member, so it builds into its own target/ rather than -# inheriting the root one that the top-level .gitignore covers. -/target/ diff --git a/crates/client-app/Cargo.lock b/crates/client-app/Cargo.lock deleted file mode 100644 index 9febe4a2..00000000 --- a/crates/client-app/Cargo.lock +++ /dev/null @@ -1,8692 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "accesskit" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" -dependencies = [ - "enumn", - "uuid", -] - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "agent" -version = "0.1.0" -dependencies = [ - "agent-client-protocol", - "async-channel", - "base64", - "futures-lite", - "log", - "serde", - "serde_json", - "smol", - "thiserror 2.0.19", - "ureq", - "uuid", -] - -[[package]] -name = "agent-client-protocol" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d386d6a58f4dbe0ebfa98e915caa54005dabdefd419de3d4678b28cd5752444" -dependencies = [ - "agent-client-protocol-derive", - "agent-client-protocol-schema", - "async-io", - "async-process", - "blocking", - "futures", - "futures-concurrency", - "rustc-hash", - "rustix 1.1.4", - "schemars 1.2.1", - "serde", - "serde_json", - "shell-words", - "tracing", - "uuid", - "windows-sys 0.61.2", -] - -[[package]] -name = "agent-client-protocol-derive" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b94934d118a69e921d14e94d4af5b3076563318c52662c89a0ecb3f139e1da" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "agent-client-protocol-schema" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06679e1542356341f4550ccfb16338b64f37f6af70de2105446ef6fbb078234c" -dependencies = [ - "anyhow", - "derive_more 2.1.1", - "schemars 1.2.1", - "serde", - "serde_json", - "serde_with", - "strum 0.28.0", - "tracing", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "getrandom 0.3.4", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -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 = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "annotate-snippets" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" -dependencies = [ - "anstyle", - "memchr", - "unicode-width", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "ar_archive_writer" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" -dependencies = [ - "object", -] - -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - -[[package]] -name = "arc-swap" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "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.119", -] - -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "as-slice" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" -dependencies = [ - "stable_deref_trait", -] - -[[package]] -name = "async-channel" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-compression" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" -dependencies = [ - "compression-codecs", - "compression-core", - "futures-io", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", -] - -[[package]] -name = "async-fs" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" -dependencies = [ - "async-lock", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 1.1.4", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-net" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" -dependencies = [ - "async-io", - "blocking", - "futures-lite", -] - -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener", - "futures-lite", - "rustix 1.1.4", -] - -[[package]] -name = "async-signal" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.4", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "atk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" -dependencies = [ - "atk-sys", - "glib", - "libc", -] - -[[package]] -name = "atk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[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.19", - "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 = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link 0.2.1", -] - -[[package]] -name = "base62" -version = "2.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bindgen" -version = "0.71.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" -dependencies = [ - "bitflags 2.13.1", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash", - "shlex 1.3.0", - "syn 2.0.119", -] - -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -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 = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - -[[package]] -name = "bitflags" -version = "2.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" - -[[package]] -name = "bitstream-io" -version = "4.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" -dependencies = [ - "no_std_io2", -] - -[[package]] -name = "block" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - -[[package]] -name = "blocking" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" -dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - -[[package]] -name = "borsh" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" -dependencies = [ - "bytes", - "cfg_aliases", -] - -[[package]] -name = "bs58" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "bstr" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" -dependencies = [ - "memchr", - "serde_core", -] - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytemuck" -version = "1.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" - -[[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.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "cairo-rs" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" -dependencies = [ - "bitflags 2.13.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", - "thiserror 1.0.69", -] - -[[package]] -name = "cairo-sys-rs" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "cbindgen" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" -dependencies = [ - "heck 0.4.1", - "indexmap 2.14.0", - "log", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn 2.0.119", - "tempfile", - "toml 0.8.23", -] - -[[package]] -name = "cc" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex 2.0.1", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", -] - -[[package]] -name = "cfg-expr" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" -dependencies = [ - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link 0.2.1", -] - -[[package]] -name = "clang-sys" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" -dependencies = [ - "glob", - "libc", - "libloading", -] - -[[package]] -name = "client-app" -version = "0.1.0" -dependencies = [ - "futures-util", - "gpui", - "gpui-component", - "log", - "sync-client", - "sync-protocol", - "tcode-ui", - "tokio", - "tokio-tungstenite", -] - -[[package]] -name = "cocoa" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" -dependencies = [ - "bitflags 1.3.2", - "block", - "cocoa-foundation 0.1.2", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa" -version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" -dependencies = [ - "bitflags 2.13.1", - "block", - "cocoa-foundation 0.2.0", - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "foreign-types", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" -dependencies = [ - "bitflags 1.3.2", - "block", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "libc", - "objc", -] - -[[package]] -name = "cocoa-foundation" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" -dependencies = [ - "bitflags 2.13.1", - "block", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", - "libc", - "objc", -] - -[[package]] -name = "collections" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "gpui_util", - "indexmap 2.14.0", - "rustc-hash", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "compression-codecs" -version = "0.4.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" -dependencies = [ - "bzip2 0.6.1", - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "convert_case" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "cookie" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" -dependencies = [ - "time", - "version_check", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "core-graphics-types 0.2.0", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-helmer-fork" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - -[[package]] -name = "core-graphics-types" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "libc", -] - -[[package]] -name = "core-graphics2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" -dependencies = [ - "bitflags 2.13.1", - "block", - "cfg-if", - "core-foundation 0.10.1", - "libc", -] - -[[package]] -name = "core-text" -version = "21.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" -dependencies = [ - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "foreign-types", - "libc", -] - -[[package]] -name = "core-video" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" -dependencies = [ - "block", - "core-foundation 0.10.1", - "core-graphics2", - "io-surface", - "libc", - "metal", -] - -[[package]] -name = "core_maths" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" -dependencies = [ - "libm", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-queue" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "cssparser" -version = "0.29.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "matches", - "phf 0.10.1", - "proc-macro2", - "quote", - "smallvec", - "syn 1.0.109", -] - -[[package]] -name = "cssparser-macros" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" -dependencies = [ - "quote", - "syn 2.0.119", -] - -[[package]] -name = "ctor" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e30e509674ef0ec91e21a7735766db37d163d46151b6a361d8b83dd79116bd" -dependencies = [ - "link-section", - "linktime-proc-macro", -] - -[[package]] -name = "darling" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" -dependencies = [ - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.119", -] - -[[package]] -name = "darling_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" -dependencies = [ - "darling_core", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "data-encoding" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" - -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] - -[[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 2.0.119", -] - -[[package]] -name = "derive_more" -version = "0.99.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" -dependencies = [ - "convert_case 0.4.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", -] - -[[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 0.10.0", - "proc-macro2", - "quote", - "rustc_version", - "syn 2.0.119", - "unicode-xid", -] - -[[package]] -name = "derive_refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer 0.12.1", - "const-oid", - "crypto-common 0.2.2", -] - -[[package]] -name = "dirs" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] - -[[package]] -name = "dispatch" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" - -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.1", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dlib" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" -dependencies = [ - "libloading", -] - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" - -[[package]] -name = "dtoa" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" - -[[package]] -name = "dtoa-short" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" -dependencies = [ - "dtoa", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dwrote" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" -dependencies = [ - "lazy_static", - "libc", - "winapi", - "wio", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "either" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" - -[[package]] -name = "embed-resource" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" -dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 1.1.3+spec-1.1.0", - "vswhom", - "winreg", -] - -[[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 = "encoding_rs_io" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "enum-iterator" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" -dependencies = [ - "enum-iterator-derive", -] - -[[package]] -name = "enum-iterator-derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "enumn" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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.119", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - -[[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 = "etagere" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" -dependencies = [ - "euclid", - "svg_fmt", -] - -[[package]] -name = "euclid" -version = "0.22.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" -dependencies = [ - "num-traits", -] - -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - -[[package]] -name = "exr" -version = "1.74.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "num-complex", - "pulp", - "rayon-core", - "smallvec", - "zune-inflate", -] - -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -dependencies = [ - "getrandom 0.4.3", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "field-offset" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] - -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[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 = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - -[[package]] -name = "float-ord" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" - -[[package]] -name = "float_next_after" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" - -[[package]] -name = "fluent-uri" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "flume" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" -dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "spin 0.9.9", -] - -[[package]] -name = "flume" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "fastrand", - "futures-core", - "futures-sink", - "spin 0.9.9", -] - -[[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 = "fontconfig-parser" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" -dependencies = [ - "roxmltree 0.20.0", -] - -[[package]] -name = "fontdb" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" -dependencies = [ - "fontconfig-parser", - "log", - "memmap2", - "slotmap", - "tinyvec", - "ttf-parser", -] - -[[package]] -name = "foreign-types" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" -dependencies = [ - "foreign-types-macros", - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-macros" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "foreign-types-shared" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" - -[[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 = "freetype-sys" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - -[[package]] -name = "futures" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-concurrency" -version = "7.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" -dependencies = [ - "fixedbitset", - "futures-core", - "futures-lite", - "pin-project", - "smallvec", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-executor" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - -[[package]] -name = "futures-lite" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - -[[package]] -name = "gdk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" -dependencies = [ - "cairo-rs", - "gdk-pixbuf", - "gdk-sys", - "gio", - "glib", - "libc", - "pango", -] - -[[package]] -name = "gdk-pixbuf" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" -dependencies = [ - "gdk-pixbuf-sys", - "gio", - "glib", - "libc", - "once_cell", -] - -[[package]] -name = "gdk-pixbuf-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gdk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" -dependencies = [ - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "pkg-config", - "system-deps", -] - -[[package]] -name = "gdkx11" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" -dependencies = [ - "gdk", - "gdkx11-sys", - "gio", - "glib", - "libc", - "x11", -] - -[[package]] -name = "gdkx11-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" -dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - -[[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 0.11.1+wasi-snapshot-preview1", - "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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - -[[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 = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "gio" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "gio-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", -] - -[[package]] -name = "glib" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" -dependencies = [ - "bitflags 2.13.1", - "futures-channel", - "futures-core", - "futures-executor", - "futures-task", - "futures-util", - "gio-sys", - "glib-macros", - "glib-sys", - "gobject-sys", - "libc", - "memchr", - "once_cell", - "smallvec", - "thiserror 1.0.69", -] - -[[package]] -name = "glib-macros" -version = "0.18.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" -dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.0", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "glib-sys" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" -dependencies = [ - "libc", - "system-deps", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "globset" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" -dependencies = [ - "aho-corasick", - "bstr", - "log", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "globwalk" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" -dependencies = [ - "bitflags 1.3.2", - "ignore", - "walkdir", -] - -[[package]] -name = "gobject-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" -dependencies = [ - "glib-sys", - "libc", - "system-deps", -] - -[[package]] -name = "gpui" -version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "accesskit", - "anyhow", - "async-channel", - "async-task", - "bindgen", - "bitflags 2.13.1", - "block", - "cbindgen", - "chrono", - "cocoa 0.26.0", - "cocoa-foundation 0.2.0", - "collections", - "core-foundation 0.10.1", - "core-foundation-sys", - "core-graphics 0.24.0", - "core-text", - "core-video", - "ctor", - "derive_more 2.1.1", - "embed-resource", - "etagere", - "foreign-types", - "futures", - "futures-concurrency", - "getrandom 0.3.4", - "gpui_macros", - "gpui_shared_string", - "gpui_util", - "heapless", - "http_client", - "image", - "inventory", - "itertools 0.14.0", - "log", - "lyon", - "mach2", - "media", - "metal", - "num_cpus", - "objc", - "parking", - "parking_lot", - "pathfinder_geometry", - "pin-project", - "pollster 0.4.0", - "postage", - "profiling", - "rand 0.9.5", - "raw-window-handle", - "refineable", - "regex", - "resvg 0.46.0", - "scheduler", - "schemars 1.2.1", - "seahash", - "serde", - "serde_json", - "slotmap", - "smallvec", - "spin 0.10.1", - "stacksafe", - "strum 0.27.2", - "sum_tree", - "taffy", - "thiserror 2.0.19", - "ttf-parser", - "url", - "usvg 0.46.0", - "util_macros", - "uuid", - "waker-fn", - "web-time", - "windows 0.61.3", - "zed-font-kit", - "zed-scap", -] - -[[package]] -name = "gpui-component" -version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" -dependencies = [ - "aho-corasick", - "anyhow", - "async-channel", - "chrono", - "core-text", - "enum-iterator", - "futures", - "gpui", - "gpui-component-assets", - "gpui-component-macros", - "gpui_macros", - "html5ever 0.27.0", - "instant", - "itertools 0.13.0", - "log", - "lsp-types", - "markdown", - "markup5ever_rcdom", - "notify", - "num-traits", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "once_cell", - "paste", - "raw-window-handle", - "regex", - "resvg 0.45.1", - "ropey", - "rust-i18n", - "schemars 1.2.1", - "serde", - "serde_json", - "serde_repr", - "smallvec", - "smol", - "tracing", - "unicode-segmentation", - "uuid", - "windows 0.58.0", - "zed-sum-tree", -] - -[[package]] -name = "gpui-component-assets" -version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" -dependencies = [ - "anyhow", - "gpui", - "log", - "rust-embed", - "wasm-bindgen", - "wasm-bindgen-futures", - "zed-reqwest", -] - -[[package]] -name = "gpui-component-macros" -version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "gpui-wry" -version = "0.5.0" -source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" -dependencies = [ - "anyhow", - "gpui", - "lb-wry", -] - -[[package]] -name = "gpui_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "gpui_shared_string" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "schemars 1.2.1", - "serde", - "smol_str", -] - -[[package]] -name = "gpui_util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "anyhow", - "log", - "which", -] - -[[package]] -name = "granit-parser" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" -dependencies = [ - "arraydeque", - "smallvec", -] - -[[package]] -name = "grid" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" - -[[package]] -name = "gtk" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" -dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", -] - -[[package]] -name = "gtk-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" -dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", -] - -[[package]] -name = "gtk3-macros" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" -dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap 2.14.0", - "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.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" -dependencies = [ - "byteorder", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heapless" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" -dependencies = [ - "hash32", - "stable_deref_trait", -] - -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "home" -version = "0.5.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "html5ever" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" -dependencies = [ - "log", - "mac", - "markup5ever 0.12.1", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "html5ever" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" -dependencies = [ - "log", - "mac", - "markup5ever 0.14.1", - "match_token", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "http_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "anyhow", - "async-compression", - "bytes", - "derive_more 2.1.1", - "futures", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "serde_urlencoded", - "url", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hybrid-array" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" -dependencies = [ - "typenum", -] - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "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", - "hyper", - "hyper-util", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[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 = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[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 = "ignore" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" -dependencies = [ - "crossbeam-deque", - "globset", - "log", - "memchr", - "regex-automata", - "same-file", - "walkdir", - "winapi-util", -] - -[[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 0.14.2", - "image-webp", - "moxcms", - "num-traits", - "png 0.18.1", - "qoi", - "ravif", - "rayon", - "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "image-webp" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" -dependencies = [ - "byteorder-lite", - "quick-error", -] - -[[package]] -name = "imagesize" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" - -[[package]] -name = "imagesize" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" - -[[package]] -name = "imara-diff" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" -dependencies = [ - "hashbrown 0.15.5", - "memchr", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "inotify" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" -dependencies = [ - "bitflags 1.3.2", - "inotify-sys", - "libc", -] - -[[package]] -name = "inotify-sys" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" -dependencies = [ - "libc", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[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.119", -] - -[[package]] -name = "inventory" -version = "0.3.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" -dependencies = [ - "rustversion", -] - -[[package]] -name = "io-surface" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" -dependencies = [ - "cgl", - "core-foundation 0.10.1", - "core-foundation-sys", - "leaky-cow", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "itertools" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "javascriptcore-rs" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" -dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", -] - -[[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[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 2.0.119", -] - -[[package]] -name = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "kqueue" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" -dependencies = [ - "kqueue-sys", - "libc", -] - -[[package]] -name = "kqueue-sys" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" -dependencies = [ - "bitflags 2.13.1", - "libc", -] - -[[package]] -name = "kuchikiki" -version = "0.8.8-speedreader" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" -dependencies = [ - "cssparser", - "html5ever 0.29.1", - "indexmap 2.14.0", - "selectors", -] - -[[package]] -name = "kurbo" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" -dependencies = [ - "arrayvec", - "euclid", - "smallvec", -] - -[[package]] -name = "kurbo" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" -dependencies = [ - "arrayvec", - "euclid", - "polycool", - "smallvec", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lb-wry" -version = "0.53.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9cfe72bff8acf9af0d6d276569be5b9cb3f313f9882761ada5a50d3044214d4" -dependencies = [ - "base64", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dpi", - "dunce", - "flume 0.11.1", - "gdkx11", - "gtk", - "html5ever 0.29.1", - "http", - "javascriptcore-rs", - "jni", - "kuchikiki", - "libc", - "ndk", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "sha2 0.10.9", - "soup3", - "tao-macros", - "thiserror 2.0.19", - "url", - "webkit2gtk", - "webkit2gtk-sys", - "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "leak" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" - -[[package]] -name = "leaky-cow" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" -dependencies = [ - "leak", -] - -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - -[[package]] -name = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[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.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" -dependencies = [ - "cfg-if", - "windows-link 0.2.1", -] - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[package]] -name = "libredox" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" -dependencies = [ - "libc", -] - -[[package]] -name = "link-section" -version = "0.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc98458dfe90986c5e2f6ddcf68360c7e5c4252600153e06aa4ee8176c0f8d1" - -[[package]] -name = "linktime-proc-macro" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" - -[[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 = "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.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" -dependencies = [ - "serde_core", - "value-bag", -] - -[[package]] -name = "loop9" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" -dependencies = [ - "imgref", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lsp-types" -version = "0.97.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" -dependencies = [ - "bitflags 1.3.2", - "fluent-uri", - "serde", - "serde_json", - "serde_repr", -] - -[[package]] -name = "lyon" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" -dependencies = [ - "lyon_algorithms", - "lyon_tessellation", -] - -[[package]] -name = "lyon_algorithms" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" -dependencies = [ - "lyon_path", - "num-traits", -] - -[[package]] -name = "lyon_geom" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" -dependencies = [ - "arrayvec", - "euclid", - "num-traits", -] - -[[package]] -name = "lyon_path" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" -dependencies = [ - "lyon_geom", - "num-traits", -] - -[[package]] -name = "lyon_tessellation" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" -dependencies = [ - "float_next_after", - "lyon_path", - "num-traits", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "mach2" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" -dependencies = [ - "libc", -] - -[[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" -dependencies = [ - "libc", -] - -[[package]] -name = "markdown" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" -dependencies = [ - "serde", - "unicode-id", -] - -[[package]] -name = "markup5ever" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" -dependencies = [ - "log", - "phf 0.11.3", - "phf_codegen 0.11.3", - "string_cache", - "string_cache_codegen", - "tendril", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" -dependencies = [ - "html5ever 0.27.0", - "markup5ever 0.12.1", - "tendril", - "xml5ever", -] - -[[package]] -name = "match_token" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "matches" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" - -[[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 = "media" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "anyhow", - "bindgen", - "core-foundation 0.10.1", - "core-video", - "ctor", - "foreign-types", - "metal", - "objc", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memmap2" -version = "0.9.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" -dependencies = [ - "libc", -] - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "metal" -version = "0.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" -dependencies = [ - "bitflags 2.13.1", - "block", - "core-graphics-types 0.2.0", - "foreign-types", - "log", - "objc", - "paste", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "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 = "nanorand" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "ndk" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" -dependencies = [ - "bitflags 2.13.1", - "jni-sys 0.3.1", - "log", - "ndk-sys", - "num_enum", - "raw-window-handle", - "thiserror 1.0.69", -] - -[[package]] -name = "ndk-sys" -version = "0.6.0+11769913" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" -dependencies = [ - "jni-sys 0.3.1", -] - -[[package]] -name = "new_debug_unreachable" -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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" - -[[package]] -name = "nohash-hasher" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - -[[package]] -name = "normpath" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "notify" -version = "7.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" -dependencies = [ - "bitflags 2.13.1", - "filetime", - "fsevent-sys", - "inotify", - "kqueue", - "libc", - "log", - "mio", - "notify-types", - "walkdir", - "windows-sys 0.52.0", -] - -[[package]] -name = "notify-types" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" -dependencies = [ - "instant", -] - -[[package]] -name = "ntapi" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" -dependencies = [ - "winapi", -] - -[[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.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "bytemuck", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", - "libm", -] - -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi", - "libc", -] - -[[package]] -name = "num_enum" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" -dependencies = [ - "num_enum_derive", - "rustversion", -] - -[[package]] -name = "num_enum_derive" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "objc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" -dependencies = [ - "malloc_buf", - "objc_exception", -] - -[[package]] -name = "objc-foundation" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" -dependencies = [ - "block", - "objc", - "objc_id", -] - -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", - "objc2-exception-helper", -] - -[[package]] -name = "objc2-app-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" -dependencies = [ - "bitflags 2.13.1", - "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.13.1", - "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.13.1", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags 2.13.1", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-graphics" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" -dependencies = [ - "bitflags 2.13.1", - "block2", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", - "objc2-metal", -] - -[[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.13.1", - "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.13.1", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-io-surface", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-exception-helper" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" -dependencies = [ - "cc", -] - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags 2.13.1", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-image-io" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" -dependencies = [ - "block2", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", -] - -[[package]] -name = "objc2-io-surface" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-javascript-core" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" -dependencies = [ - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-metal" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-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.13.1", - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-ui-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[package]] -name = "objc2-web-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" -dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "objc2-javascript-core", - "objc2-security", -] - -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", -] - -[[package]] -name = "objc_id" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" -dependencies = [ - "objc", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "option-ext" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" - -[[package]] -name = "pango" -version = "0.18.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" -dependencies = [ - "gio", - "glib", - "libc", - "once_cell", - "pango-sys", -] - -[[package]] -name = "pango-sys" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -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", - "smallvec", - "windows-link 0.2.1", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pastey" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" - -[[package]] -name = "pathfinder_geometry" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" -dependencies = [ - "log", - "pathfinder_simd", -] - -[[package]] -name = "pathfinder_simd" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" -dependencies = [ - "rustc_version", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "perf" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "collections", - "serde", - "serde_json", -] - -[[package]] -name = "phf" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" -dependencies = [ - "phf_shared 0.8.0", -] - -[[package]] -name = "phf" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" -dependencies = [ - "phf_macros", - "phf_shared 0.10.0", - "proc-macro-hack", -] - -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_shared 0.11.3", -] - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_codegen" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" -dependencies = [ - "phf_generator 0.8.0", - "phf_shared 0.8.0", -] - -[[package]] -name = "phf_codegen" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" -dependencies = [ - "phf_shared 0.8.0", - "rand 0.7.3", -] - -[[package]] -name = "phf_generator" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" -dependencies = [ - "phf_shared 0.10.0", - "rand 0.8.7", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.7", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" -dependencies = [ - "phf_generator 0.10.0", - "phf_shared 0.10.0", - "proc-macro-hack", - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "phf_shared" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" -dependencies = [ - "siphasher 0.3.11", -] - -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher 1.0.3", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher 1.0.3", -] - -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "piper" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "png" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" -dependencies = [ - "bitflags 2.13.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" - -[[package]] -name = "pollster" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" - -[[package]] -name = "polycool" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "postage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" -dependencies = [ - "atomic", - "crossbeam-queue", - "futures", - "log", - "parking_lot", - "pin-project", - "pollster 0.2.5", - "static_assertions", - "thiserror 1.0.69", -] - -[[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 = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro-crate" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] - -[[package]] -name = "proc-macro-crate" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" -dependencies = [ - "toml_edit 0.20.7", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro-hack" -version = "0.5.20+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[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.119", -] - -[[package]] -name = "psm" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "pulp" -version = "0.22.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" -dependencies = [ - "bytemuck", - "cfg-if", - "libm", - "num-complex", - "paste", - "pulp-wasm-simd-flag", - "raw-cpuid", - "reborrow", - "version_check", -] - -[[package]] -name = "pulp-wasm-simd-flag" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" - -[[package]] -name = "pxfm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" - -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "quick-error" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" - -[[package]] -name = "quick-xml" -version = "0.30.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.19", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg 0.10.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.19", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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 = "rand" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" -dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", - "rand_pcg 0.2.1", -] - -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" -dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -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", -] - -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -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_hc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" -dependencies = [ - "rand_core 0.5.1", -] - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "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.5", - "rand_chacha 0.9.0", - "simd_helpers", - "thiserror 2.0.19", - "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", - "rav1e", - "rayon", - "rgb", -] - -[[package]] -name = "raw-cpuid" -version = "11.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[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 = "reborrow" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags 2.13.1", -] - -[[package]] -name = "redox_users" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.19", -] - -[[package]] -name = "ref-cast" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "derive_refineable", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "resvg" -version = "0.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" -dependencies = [ - "gif 0.13.3", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes 0.15.3", - "tiny-skia", - "usvg 0.45.1", - "zune-jpeg 0.4.21", -] - -[[package]] -name = "resvg" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" -dependencies = [ - "gif 0.14.2", - "image-webp", - "log", - "pico-args", - "rgb", - "svgtypes 0.16.1", - "tiny-skia", - "usvg 0.46.0", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "ring" -version = "0.17.14" -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 = "ropey" -version = "2.0.0-beta.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" -dependencies = [ - "str_indices", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - -[[package]] -name = "roxmltree" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" -dependencies = [ - "memchr", -] - -[[package]] -name = "rushdown" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57358ca9b61ea373ec6bf1c9e916f521f19a919587d9179624d47c40da2ffc5d" -dependencies = [ - "bitflags 2.13.1", - "memchr", - "phf 0.13.1", - "phf_codegen 0.13.1", -] - -[[package]] -name = "rust-embed" -version = "8.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" -dependencies = [ - "mime_guess", - "proc-macro2", - "quote", - "rust-embed-utils", - "shellexpand", - "syn 2.0.119", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" -dependencies = [ - "globset", - "sha2 0.11.0", - "walkdir", -] - -[[package]] -name = "rust-i18n" -version = "4.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4" -dependencies = [ - "globwalk", - "regex", - "rust-i18n-macro", - "rust-i18n-support", - "smallvec", -] - -[[package]] -name = "rust-i18n-macro" -version = "4.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e" -dependencies = [ - "glob", - "proc-macro2", - "quote", - "rust-i18n-support", - "serde", - "serde_json", - "syn 2.0.119", -] - -[[package]] -name = "rust-i18n-support" -version = "4.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9" -dependencies = [ - "arc-swap", - "base62", - "globwalk", - "itertools 0.11.0", - "normpath", - "serde", - "serde-saphyr", - "serde_json", - "siphasher 1.0.3", - "toml 0.8.23", - "triomphe", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.13.1", - "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 2.13.1", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -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.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "rustybuzz" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "core_maths", - "log", - "smallvec", - "ttf-parser", - "unicode-bidi-mirroring", - "unicode-ccc", - "unicode-properties", - "unicode-script", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[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 = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scheduler" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "async-task", - "backtrace", - "chrono", - "flume 0.12.0", - "futures", - "parking_lot", - "rand 0.9.5", - "web-time", -] - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" -dependencies = [ - "dyn-clone", - "indexmap 2.14.0", - "ref-cast", - "schemars_derive", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.119", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "screencapturekit" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" -dependencies = [ - "screencapturekit-sys", -] - -[[package]] -name = "screencapturekit-sys" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" -dependencies = [ - "block", - "dispatch", - "objc", - "objc-foundation", - "objc_id", - "once_cell", -] - -[[package]] -name = "seahash" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "selectors" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" -dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more 0.99.20", - "fxhash", - "log", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde-saphyr" -version = "0.0.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" -dependencies = [ - "ahash", - "annotate-snippets", - "base64", - "encoding_rs_io", - "getrandom 0.3.4", - "granit-parser", - "nohash-hasher", - "num-traits", - "serde_core", - "smallvec", - "zmij", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "serde_fmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_repr" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[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_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" -dependencies = [ - "base64", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "schemars 0.9.0", - "schemars 1.2.1", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "servo_arc" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" -dependencies = [ - "nodrop", - "stable_deref_trait", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - -[[package]] -name = "shellexpand" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" -dependencies = [ - "dirs", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "simd_helpers" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" -dependencies = [ - "quote", -] - -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - -[[package]] -name = "siphasher" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "slotmap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" -dependencies = [ - "version_check", -] - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "smol" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" -dependencies = [ - "async-channel", - "async-executor", - "async-fs", - "async-io", - "async-lock", - "async-net", - "async-process", - "blocking", - "futures-lite", -] - -[[package]] -name = "smol_str" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" -dependencies = [ - "borsh", - "serde_core", -] - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "soup3" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" -dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", -] - -[[package]] -name = "soup3-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" -dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] - -[[package]] -name = "spin" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] - -[[package]] -name = "spin" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" -dependencies = [ - "lock_api", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stacker" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.61.2", -] - -[[package]] -name = "stacksafe" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" -dependencies = [ - "stacker", - "stacksafe-macro", -] - -[[package]] -name = "stacksafe-macro" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" -dependencies = [ - "proc-macro-error2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "str_indices" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" - -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp", -] - -[[package]] -name = "string_cache" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared 0.11.3", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", -] - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros 0.28.0", -] - -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "sum_tree" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "heapless", - "log", - "rayon", - "tracing", - "ztracing", -] - -[[package]] -name = "sval" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fb9efbae90f97301f4d25f3be63dfd99d6b7af9d088228a52ec960d649b2e7d" - -[[package]] -name = "sval_buffer" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff5c0280ea0af40b3a1fd0b532680b5067482ffb81412ee66ec04b1d9952b49a" -dependencies = [ - "sval", - "sval_ref", -] - -[[package]] -name = "sval_dynamic" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59b9067f2e68f58e110cf8019a268057e3791cf74b0fdb1b3c7c6e49104f44e6" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_fmt" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96ebdbf0e4b175884aa587fcf551c16dabe245ea04235abdd8cbbd160b27ed5a" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_json" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e448d9fa216a6c16670b28d624fbbcf5c04e41eb187bd7c52e01222ffa72a12" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_nested" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021de5b5c26efd544c694cef9b8a9abe8633481bf3be1ea145a18a740818b291" -dependencies = [ - "sval", - "sval_buffer", - "sval_ref", -] - -[[package]] -name = "sval_ref" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ef5ffec8bc52ded04ee424ab8d959e25e64fd40a48a16d21f8991ce824c1bb" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_serde" -version = "2.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b983833a8a2390f89ebcf9b9acd06883017014b4ffd72ee28e0c9de6852039f5" -dependencies = [ - "serde_core", - "sval", - "sval_nested", -] - -[[package]] -name = "svg_fmt" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" - -[[package]] -name = "svgtypes" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" -dependencies = [ - "kurbo 0.11.3", - "siphasher 1.0.3", -] - -[[package]] -name = "svgtypes" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" -dependencies = [ - "kurbo 0.13.1", - "siphasher 1.0.3", -] - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync-client" -version = "0.1.0" -dependencies = [ - "agent", - "futures-util", - "log", - "serde_json", - "sync-protocol", - "tcode-core", - "tcode-services", - "tokio", - "tokio-tungstenite", -] - -[[package]] -name = "sync-protocol" -version = "0.1.0" -dependencies = [ - "agent", - "serde", - "serde_json", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "syntect" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" -dependencies = [ - "bincode", - "fancy-regex", - "flate2", - "fnv", - "once_cell", - "regex-syntax", - "serde", - "serde_derive", - "thiserror 2.0.19", - "walkdir", -] - -[[package]] -name = "sys-locale" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" -dependencies = [ - "libc", -] - -[[package]] -name = "sysinfo" -version = "0.31.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" -dependencies = [ - "core-foundation-sys", - "libc", - "memchr", - "ntapi", - "rayon", - "windows 0.57.0", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "system-deps" -version = "6.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" -dependencies = [ - "cfg-expr", - "heck 0.5.0", - "pkg-config", - "toml 0.8.23", - "version-compare", -] - -[[package]] -name = "taffy" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" -dependencies = [ - "arrayvec", - "grid", - "serde", - "slotmap", -] - -[[package]] -name = "tao-core-video-sys" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "objc", -] - -[[package]] -name = "tao-macros" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - -[[package]] -name = "target-lexicon" -version = "0.12.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" - -[[package]] -name = "tcode-core" -version = "0.1.0" -dependencies = [ - "agent", - "log", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "tcode-i18n" -version = "0.1.0" -dependencies = [ - "rust-i18n", - "sys-locale", -] - -[[package]] -name = "tcode-services" -version = "0.1.0" -dependencies = [ - "agent", - "base64", - "bzip2 0.6.1", - "chrono", - "dirs", - "flate2", - "log", - "serde", - "serde_json", - "sha2 0.11.0", - "smol", - "tar", - "tcode-core", - "thiserror 2.0.19", - "ureq", - "uuid", - "zip", -] - -[[package]] -name = "tcode-ui" -version = "0.1.0" -dependencies = [ - "agent", - "base64", - "block2", - "chrono", - "gpui", - "gpui-component", - "gpui-component-assets", - "gpui-wry", - "image", - "imara-diff", - "lb-wry", - "log", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-image-io", - "objc2-web-kit", - "raw-window-handle", - "rushdown", - "serde", - "serde_json", - "syntect", - "tcode-core", - "tcode-i18n", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - -[[package]] -name = "tendril" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" -dependencies = [ - "futf", - "mac", - "utf-8", -] - -[[package]] -name = "thiserror" -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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" -dependencies = [ - "thiserror-impl 2.0.19", -] - -[[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 2.0.119", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -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", - "weezl", - "zune-jpeg 0.5.15", -] - -[[package]] -name = "time" -version = "0.3.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tiny-skia" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" -dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", - "log", - "png 0.17.16", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-socks" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" -dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", -] - -[[package]] -name = "tokio-tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.4", -] - -[[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_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.19.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.13+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.4", -] - -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - -[[package]] -name = "toml_writer" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -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-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "triomphe" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" -dependencies = [ - "arc-swap", - "serde", - "stable_deref_trait", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" -dependencies = [ - "core_maths", -] - -[[package]] -name = "tungstenite" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" -dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "sha1", - "thiserror 2.0.19", - "utf-8", -] - -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] -name = "unicode-bidi-mirroring" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" - -[[package]] -name = "unicode-ccc" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" - -[[package]] -name = "unicode-id" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-properties" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" - -[[package]] -name = "unicode-script" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-vo" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "usvg" -version = "0.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" -dependencies = [ - "base64", - "data-url", - "flate2", - "fontdb", - "imagesize 0.13.0", - "kurbo 0.11.3", - "log", - "pico-args", - "roxmltree 0.20.0", - "rustybuzz", - "simplecss", - "siphasher 1.0.3", - "strict-num", - "svgtypes 0.15.3", - "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "usvg" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" -dependencies = [ - "base64", - "data-url", - "flate2", - "fontdb", - "imagesize 0.14.0", - "kurbo 0.13.1", - "log", - "pico-args", - "roxmltree 0.21.1", - "rustybuzz", - "simplecss", - "siphasher 1.0.3", - "strict-num", - "svgtypes 0.16.1", - "tiny-skia-path", - "unicode-bidi", - "unicode-script", - "unicode-vo", - "xmlwriter", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "util_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "perf", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "uuid" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "serde_core", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "v_frame" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" -dependencies = [ - "aligned-vec", - "num-traits", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "value-bag" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef73bfbaf3216cb59c205d7176bee1194e0d84348979da31f4a71fefe3c2054e" -dependencies = [ - "value-bag-serde1", - "value-bag-sval2", -] - -[[package]] -name = "value-bag-serde1" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b92170db3db8a6354f12a5b7f13a5453928433e08fc46aee51eedfa8f7a28a1" -dependencies = [ - "erased-serde", - "serde_core", - "serde_fmt", -] - -[[package]] -name = "value-bag-sval2" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf9832097ca044466ae3f1aa43a943d4e450b7c3b8cdf65363875df07da79a0" -dependencies = [ - "sval", - "sval_buffer", - "sval_dynamic", - "sval_fmt", - "sval_json", - "sval_ref", - "sval_serde", -] - -[[package]] -name = "version-compare" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vswhom" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" -dependencies = [ - "libc", - "vswhom-sys", -] - -[[package]] -name = "vswhom-sys" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] - -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.119", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.126" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "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 = "webkit2gtk" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" -dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", - "once_cell", - "soup3", - "webkit2gtk-sys", -] - -[[package]] -name = "webkit2gtk-sys" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" -dependencies = [ - "bitflags 1.3.2", - "cairo-sys-rs", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "gtk-sys", - "javascriptcore-rs-sys", - "libc", - "pkg-config", - "soup3-sys", - "system-deps", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "webview2-com" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" -dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", -] - -[[package]] -name = "webview2-com-macros" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "webview2-com-sys" -version = "0.38.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" -dependencies = [ - "thiserror 2.0.19", - "windows 0.61.3", - "windows-core 0.61.2", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - -[[package]] -name = "which" -version = "6.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" -dependencies = [ - "either", - "home", - "rustix 0.38.44", - "winsafe", -] - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -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.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" -dependencies = [ - "windows-core 0.57.0", - "windows-targets 0.52.6", -] - -[[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" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-capture" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" -dependencies = [ - "parking_lot", - "rayon", - "thiserror 2.0.19", - "windows 0.61.3", - "windows-future", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" -dependencies = [ - "windows-implement 0.57.0", - "windows-interface 0.57.0", - "windows-result 0.1.2", - "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.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - -[[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 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", -] - -[[package]] -name = "windows-implement" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 2.0.119", -] - -[[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 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.57.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 2.0.119", -] - -[[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 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-registry" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" -dependencies = [ - "windows-result 0.3.4", - "windows-strings 0.3.1", - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - -[[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.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link 0.2.1", -] - -[[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.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -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 0.2.1", - "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", -] - -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - -[[package]] -name = "windows-version" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" -dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -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.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.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.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.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.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.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.5.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "winreg" -version = "0.55.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" -dependencies = [ - "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "winsafe" -version = "0.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" - -[[package]] -name = "wio" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" -dependencies = [ - "winapi", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "workspace-hack" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "x11" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" -dependencies = [ - "libc", - "pkg-config", -] - -[[package]] -name = "x11-dl" -version = "2.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" -dependencies = [ - "libc", - "once_cell", - "pkg-config", -] - -[[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 = "xcb" -version = "1.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" -dependencies = [ - "bitflags 1.3.2", - "libc", - "quick-xml", - "x11", -] - -[[package]] -name = "xml5ever" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" -dependencies = [ - "log", - "mac", - "markup5ever 0.12.1", -] - -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - -[[package]] -name = "y4m" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" - -[[package]] -name = "yeslogic-fontconfig-sys" -version = "6.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" -dependencies = [ - "dlib", - "once_cell", - "pkg-config", -] - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zed-font-kit" -version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" -dependencies = [ - "bitflags 2.13.1", - "byteorder", - "core-foundation 0.10.1", - "core-graphics 0.24.0", - "core-text", - "dirs", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] - -[[package]] -name = "zed-reqwest" -version = "0.12.15-zed" -source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "ipnet", - "js-sys", - "log", - "mime", - "mime_guess", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-native-certs", - "rustls-pemfile", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "system-configuration", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-util", - "tower", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "windows-registry", -] - -[[package]] -name = "zed-scap" -version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" -dependencies = [ - "anyhow", - "cocoa 0.25.0", - "core-graphics-helmer-fork", - "log", - "objc", - "rand 0.8.7", - "screencapturekit", - "screencapturekit-sys", - "sysinfo", - "tao-core-video-sys", - "windows 0.61.3", - "windows-capture", - "x11", - "xcb", -] - -[[package]] -name = "zed-sum-tree" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" -dependencies = [ - "arrayvec", - "log", - "rayon", - "workspace-hack", -] - -[[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "bzip2 0.5.2", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap 2.14.0", - "memchr", - "thiserror 2.0.19", -] - -[[package]] -name = "zlog" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "anyhow", - "chrono", - "collections", - "log", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "ztracing" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" -dependencies = [ - "tracing", - "tracing-subscriber", - "zlog", - "ztracing_macro", -] - -[[package]] -name = "ztracing_macro" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" - -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - -[[package]] -name = "zune-core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - -[[package]] -name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" -dependencies = [ - "zune-core 0.5.1", -] diff --git a/crates/client-app/Cargo.toml b/crates/client-app/Cargo.toml deleted file mode 100644 index 4ef11d97..00000000 --- a/crates/client-app/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "client-app" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -# Everything here must build for wasm, Android and iOS: this is the client UI -# all three shells mount. The only platform-specific piece is the transport, -# which each shell supplies through the `Transport` trait. -gpui = { git = "https://github.com/zed-industries/zed" } -gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } -sync-client = { path = "../sync-client" } -sync-protocol = { path = "../sync-protocol" } -tcode-ui = { path = "../ui", default-features = false, features = ["portable"] } -log = "0.4" - -# Native shells only. The browser brings its own WebSocket, and pulling tokio -# into a wasm build would not compile. -[target.'cfg(not(target_family = "wasm"))'.dependencies] -futures-util = "0.3" -tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] } -tokio-tungstenite = "0.28" diff --git a/crates/client-app/src/lib.rs b/crates/client-app/src/lib.rs deleted file mode 100644 index e0866edb..00000000 --- a/crates/client-app/src/lib.rs +++ /dev/null @@ -1,1396 +0,0 @@ -//! The tcode remote client, shared by every shell that hosts it. -//! -//! Web, Android and iOS mount this same view. They differ only in how bytes -//! reach a host — a browser `WebSocket`, or a native one — which is what -//! [`TransportFactory`] abstracts, and in where a token is kept between loads, -//! which is what [`ConnectionStore`] abstracts. Everything above those two -//! seams is identical, so a fix to pairing, the session list, or the approval -//! panel lands on all three at once. -//! -//! A device joins a host once, by typing a short pairing code the host shows on -//! startup; the token that buys is stored and the code is never needed again. -//! Portable by construction: `sync-client` folds the protocol, `tcode-ui`'s -//! `portable` feature supplies the timeline rendering, and nothing here touches -//! a filesystem or a process — which is the whole reason a phone can run it. - -use std::{ - collections::VecDeque, - path::PathBuf, - sync::{Arc, Mutex}, - time::Duration, -}; - -use gpui::{ - AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, - Render, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, - prelude::FluentBuilder as _, px, -}; -use gpui_component::{ - ActiveTheme as _, Disableable as _, StyledExt as _, - button::{Button, ButtonVariants as _}, - input::{Input, InputEvent, InputState}, -}; -use sync_client::{Client, ClientConfig}; -use sync_protocol::{ - AgentEvent, ApprovalDecision, ClientFrame, ClientInfo, HostFrame, RejectedCommand, - SessionCommand, SessionSummary, -}; -use tcode_ui::{ChatReadModel, ChatSessionReadModel, ChatView}; - -pub mod pairing; -pub mod storage; - -#[cfg(not(target_family = "wasm"))] -pub use storage::FileStore; -pub use storage::{ConnectionStore, MemoryStore, StoredConnection}; - -// Native shells (Android, iOS) share one WebSocket implementation; the browser -// supplies its own, since it has no sockets of this kind. -#[cfg(not(target_family = "wasm"))] -pub mod native_transport; - -/// Monotonic within this process. `SendTurn` carries the id and `TurnAccepted` -/// echoes it back, so two turns sharing one would be indistinguishable to the -/// acknowledgement tracking — which is what makes a reconnect able to tell what -/// actually landed. -fn next_delivery_id() -> u64 { - use std::sync::atomic::{AtomicU64, Ordering}; - static NEXT: AtomicU64 = AtomicU64::new(1); - NEXT.fetch_add(1, Ordering::Relaxed) -} - -fn turn_command(delivery_id: u64, text: String) -> SessionCommand { - SessionCommand::SendTurn { - delivery_id, - text, - options: None, - attachments: Vec::new(), - } -} - -fn approval_command(request_id: String, decision: ApprovalDecision) -> SessionCommand { - SessionCommand::RespondApproval { - request_id, - decision, - } -} - -fn provider_approval_decision(option_id: String) -> ApprovalDecision { - ApprovalDecision::Option(option_id) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum LocalTurnStatus { - AwaitingAcceptance, - Rejected(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LocalTurn { - session_id: String, - delivery_id: u64, - text: String, - status: LocalTurnStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum CommandAttemptKind { - Turn(u64), - Approval(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CommandAttempt { - session_id: String, - kind: CommandAttemptKind, -} - -#[derive(Default)] -struct CommandUiState { - attempts: VecDeque, - turns: Vec, -} - -impl CommandUiState { - fn sent_turn(&mut self, session_id: String, delivery_id: u64, text: String) { - self.attempts.push_back(CommandAttempt { - session_id: session_id.clone(), - kind: CommandAttemptKind::Turn(delivery_id), - }); - self.turns.push(LocalTurn { - session_id, - delivery_id, - text, - status: LocalTurnStatus::AwaitingAcceptance, - }); - } - - fn sent_approval(&mut self, session_id: String, request_id: String) { - self.attempts.push_back(CommandAttempt { - session_id, - kind: CommandAttemptKind::Approval(request_id), - }); - } - - fn accepted_turn(&mut self, delivery_id: u64) { - self.attempts.retain( - |attempt| !matches!(attempt.kind, CommandAttemptKind::Turn(id) if id == delivery_id), - ); - // The host records the canonical user-message event before acknowledging - // delivery, so keeping the optimistic copy would render the turn twice. - self.turns.retain(|turn| turn.delivery_id != delivery_id); - } - - fn resolved_approvals<'a>( - &mut self, - session_id: &str, - pending_request_ids: impl Iterator, - ) { - let pending: Vec<&str> = pending_request_ids.collect(); - self.attempts.retain(|attempt| { - attempt.session_id != session_id - || !matches!( - &attempt.kind, - CommandAttemptKind::Approval(request_id) - if !pending.contains(&request_id.as_str()) - ) - }); - } - - fn rejected(&mut self, session_id: &str, command: Option<&RejectedCommand>, reason: String) { - let index = match command { - Some(RejectedCommand::Turn { delivery_id }) => { - self.attempts.iter().position(|attempt| { - attempt.session_id == session_id - && matches!( - attempt.kind, - CommandAttemptKind::Turn(id) if id == *delivery_id - ) - }) - } - Some(RejectedCommand::Approval { request_id }) => { - self.attempts.iter().position(|attempt| { - attempt.session_id == session_id - && matches!( - &attempt.kind, - CommandAttemptKind::Approval(id) if id == request_id - ) - }) - } - // Hosts predating correlation leave no safer choice than send order; - // consuming one attempt prevents optimistic state from hanging forever. - None => self - .attempts - .iter() - .position(|attempt| attempt.session_id == session_id), - }; - let Some(index) = index else { - return; - }; - let Some(attempt) = self.attempts.remove(index) else { - return; - }; - if let CommandAttemptKind::Turn(delivery_id) = attempt.kind - && let Some(turn) = self - .turns - .iter_mut() - .find(|turn| turn.delivery_id == delivery_id) - { - turn.status = LocalTurnStatus::Rejected(reason); - } - } -} - -/// A live connection to a host, owned by the shell. -pub trait Transport { - /// Send one encoded frame. The error is surfaced to the user, so it should - /// say what went wrong rather than just that something did. - fn send(&self, text: &str) -> Result<(), String>; -} - -/// How a shell opens connections. -/// -/// Taken as a factory rather than a single connection because the client -/// reconnects on its own: a dropped socket is normal on a phone, and the -/// reconnect logic belongs here — with the cursors — rather than in three -/// separate shells. -pub trait TransportFactory: Send + Sync { - /// Open a connection, send `hello` once it is ready, and push every - /// incoming frame onto `inbox`. - fn connect(&self, url: &str, hello: &str, inbox: Inbox) -> Result, String>; -} - -/// Where a transport delivers what it receives. -/// -/// A queue rather than a callback: transports deliver on whatever thread or -/// event loop they live on, while GPUI state may only be touched from its own -/// context, so the client drains this on a timer instead. -/// -/// `Arc>` rather than `Rc>` because a native transport -/// pushes from its socket thread. The browser pays nothing for that — it is -/// single-threaded, so the lock is never contended — and it is the difference -/// between one shared client and two. -pub type Inbox = Arc>>; - -/// What a shell reports about itself. -pub struct ClientIdentity { - pub client_id: String, - pub display_name: String, - pub platform: String, - pub app_version: String, -} - -impl ClientIdentity { - fn info(&self) -> ClientInfo { - ClientInfo { - client_id: self.client_id.clone(), - display_name: self.display_name.clone(), - platform: self.platform.clone(), - app_version: self.app_version.clone(), - } - } -} - -/// True when nothing is waiting. Split out so the poison handling lives in one -/// place: a panicked producer must not stop the client draining what it already -/// received. -fn inbox_is_empty(inbox: &Inbox) -> bool { - lock_inbox(inbox).is_empty() -} - -fn lock_inbox(inbox: &Inbox) -> std::sync::MutexGuard<'_, VecDeque> { - inbox - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} - -pub enum TransportEvent { - Open, - Text(String), - Error, - Closed(String), -} - -/// What a shell supplies to start the client. -pub struct ClientConnection { - pub identity: ClientIdentity, - /// Optional prefill for the host-address field. A URL is not a secret, so a - /// shell may pass one taken from its page URL; the token is never taken - /// that way. - pub url: Option, - /// Where the token earned by pairing is kept between loads. - pub store: Arc, -} - -/// Which screen the client is on. -/// -/// A device with no stored token starts on `Connect`; one that has paired goes -/// straight to `Session`. There is no third state — a running session is the -/// only thing worth showing once a token exists. -#[derive(Clone, Copy, PartialEq, Eq)] -enum Phase { - /// Collecting a host address and a pairing code. `pairing` is true from the - /// moment Connect is pressed until a `Paired` or a refusal comes back, so - /// the button can disable itself while a code is in flight. - Connect { - pairing: bool, - }, - Session, -} - -pub struct ClientApp { - identity: ClientIdentity, - store: Arc, - client: Client, - transport: Arc, - socket: Option>, - incoming: Inbox, - phase: Phase, - // Connect screen. - url_input: Entity, - code_input: Entity, - /// In-progress or refusal text on the connect screen. Held apart from - /// `status` so returning to pairing does not inherit a session's last line. - connect_status: SharedString, - // Session screen. - sessions: Vec, - selected: Option, - chat: Entity, - composer: Entity, - status: SharedString, - /// Retained so a dropped socket can be re-established. Mobile and laptop - /// networks drop connections constantly; a client that cannot reconnect is - /// a client that works once. - url: Option, - hello: String, - /// Consecutive failed attempts, for backoff. Reset on a successful open. - reconnect_attempts: u32, - /// How many rejections had been reported the last time we looked, so a new - /// one can be surfaced. The host only sends these when a command could not - /// be applied, and staying silent would leave a turn rendered that never ran. - rejections_seen: usize, - command_ui: CommandUiState, - _subscriptions: Vec, - _poll: gpui::Task<()>, - _reconnect: Option>, -} - -impl ClientApp { - pub fn new( - config: ClientConnection, - transport: Arc, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let ClientConnection { - identity, - url: url_prefill, - store, - } = config; - let incoming: Inbox = Arc::new(Mutex::new(VecDeque::new())); - let chat = cx.new(|cx| ChatView::from_read_model(ChatReadModel::default(), window, cx)); - let composer = - cx.new(|cx| InputState::new(window, cx).placeholder("Send a message to this session…")); - let url_input = cx.new(|cx| InputState::new(window, cx).placeholder("ws://host:port/sync")); - let code_input = - cx.new(|cx| InputState::new(window, cx).placeholder("6-character code from the host")); - - let subscriptions = vec![ - // Upper-case the code as it is typed. A code is read off one screen - // and typed into another; failing that on caps reads as "pairing is - // broken" rather than as a typo. `set_value` suppresses its own - // Change event, so this does not recurse. - cx.subscribe_in( - &code_input, - window, - |this, input, event, window, cx| match event { - InputEvent::Change => { - let raw = input.read(cx).value().to_string(); - let upper = raw.to_ascii_uppercase(); - if upper != raw { - input.update(cx, |state, cx| state.set_value(upper, window, cx)); - } - } - InputEvent::PressEnter { .. } => this.start_pairing(cx), - _ => {} - }, - ), - cx.subscribe_in(&url_input, window, |this, _input, event, _window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.start_pairing(cx); - } - }), - ]; - - let stored = storage::usable_connection(store.load()); - if let Some(prefill) = stored - .as_ref() - .map(|connection| connection.url.clone()) - .or(url_prefill) - { - url_input.update(cx, |state, cx| state.set_value(prefill, window, cx)); - } - - // A stored token skips the connect screen entirely and opens the - // session; its absence leaves the client on Connect awaiting a code. - let mut client = Client::new(ClientConfig { - client: identity.info(), - token: String::new(), - }); - let mut phase = Phase::Connect { pairing: false }; - let mut retained_hello = String::new(); - let mut socket = None; - let mut retained_url = None; - let mut status = SharedString::default(); - if let Some(connection) = stored { - client = Client::new(ClientConfig { - client: identity.info(), - token: connection.token, - }); - retained_hello = client - .connect() - .encode() - .expect("the fixed hello frame must encode"); - retained_url = Some(connection.url.clone()); - phase = Phase::Session; - status = "Connecting…".into(); - match transport.connect(&connection.url, &retained_hello, incoming.clone()) { - Ok(open) => socket = Some(open), - Err(error) => { - status = format!("Could not reach {}: {error}", connection.url).into() - } - } - } - - let poll = cx.spawn({ - let incoming = incoming.clone(); - async move |this, cx| loop { - cx.background_executor() - .timer(Duration::from_millis(16)) - .await; - if inbox_is_empty(&incoming) { - continue; - } - if this - .update(cx, |this, cx| this.drain_transport(cx)) - .is_err() - { - break; - } - } - }); - - Self { - identity, - store, - client, - transport, - socket, - incoming, - phase, - url_input, - code_input, - connect_status: SharedString::default(), - sessions: Vec::new(), - selected: None, - chat, - composer, - status, - url: retained_url, - hello: retained_hello, - reconnect_attempts: 0, - rejections_seen: 0, - command_ui: CommandUiState::default(), - _subscriptions: subscriptions, - _poll: poll, - _reconnect: None, - } - } - - /// Trade the typed code for a token: open a connection whose first frame is - /// `Pair` rather than `Hello`. - fn start_pairing(&mut self, cx: &mut Context) { - if matches!(self.phase, Phase::Connect { pairing: true }) { - return; - } - let url = self.url_input.read(cx).value().trim().to_string(); - let code = pairing::normalize_code(&self.code_input.read(cx).value()); - if url.is_empty() { - self.connect_status = "Enter the host address.".into(); - cx.notify(); - return; - } - if !pairing::is_complete_code(&code) { - self.connect_status = - format!("A pairing code is {} characters.", pairing::CODE_LEN).into(); - cx.notify(); - return; - } - - // A pairing client carries no token — the code stands in for one — so it - // must not fall back to any stored secret. The `Pair` frame is the - // connection's first, before any `Hello`. - self.client = Client::new(ClientConfig { - client: self.identity.info(), - token: String::new(), - }); - let pair = self - .client - .pair(code) - .encode() - .expect("the pair frame must encode"); - self.url = Some(url.clone()); - self.socket = None; - lock_inbox(&self.incoming).clear(); - self.phase = Phase::Connect { pairing: true }; - self.connect_status = "Pairing…".into(); - match self.transport.connect(&url, &pair, self.incoming.clone()) { - Ok(socket) => self.socket = Some(socket), - Err(error) => { - self.connect_status = format!("Could not reach {url}: {error}").into(); - self.phase = Phase::Connect { pairing: false }; - } - } - cx.notify(); - } - - /// A code was accepted: persist the token so the code is never needed again, - /// then open the session with an ordinary `Hello`. - fn complete_pairing(&mut self, token: String, cx: &mut Context) { - let url = self.url.clone().unwrap_or_default(); - self.store.save(&StoredConnection { - url: url.clone(), - token: token.clone(), - }); - self.open_session(url, token, cx); - } - - /// Open (or reopen) the session connection with a token in hand. - /// - /// Shared by first pairing and by a stored-token start: both recreate the - /// client with the token, cache the `Hello` for reconnects, and connect. - fn open_session(&mut self, url: String, token: String, cx: &mut Context) { - self.client = Client::new(ClientConfig { - client: self.identity.info(), - token, - }); - self.hello = self - .client - .connect() - .encode() - .expect("the fixed hello frame must encode"); - self.url = Some(url.clone()); - self.phase = Phase::Session; - self.reconnect_attempts = 0; - self.rejections_seen = 0; - self.sessions.clear(); - self.selected = None; - self.command_ui = CommandUiState::default(); - // Drop the pairing socket and discard anything it left queued before the - // session socket opens, so a pairing-socket close cannot be mistaken for - // a session drop. - self.socket = None; - lock_inbox(&self.incoming).clear(); - self.status = "Connecting…".into(); - match self - .transport - .connect(&url, &self.hello, self.incoming.clone()) - { - Ok(socket) => self.socket = Some(socket), - Err(error) => self.status = format!("Could not reach {url}: {error}").into(), - } - cx.notify(); - } - - /// Forget the stored token and return to the connect screen. - fn sign_out(&mut self, cx: &mut Context) { - self.store.clear(); - self.socket = None; - self._reconnect = None; - lock_inbox(&self.incoming).clear(); - self.client = Client::new(ClientConfig { - client: self.identity.info(), - token: String::new(), - }); - self.sessions.clear(); - self.selected = None; - self.command_ui = CommandUiState::default(); - self.phase = Phase::Connect { pairing: false }; - self.status = SharedString::default(); - // The address field keeps its value: signing out is not a reason to - // make the user retype where the host is. - self.connect_status = "Signed out.".into(); - cx.notify(); - } - - fn drain_transport(&mut self, cx: &mut Context) { - let events: Vec<_> = lock_inbox(&self.incoming).drain(..).collect(); - for event in events { - match event { - TransportEvent::Open => match self.phase { - Phase::Session => { - self.status = "Authenticating…".into(); - self.reconnect_attempts = 0; - } - Phase::Connect { .. } => self.connect_status = "Pairing…".into(), - }, - TransportEvent::Text(text) => self.handle_text(&text, cx), - TransportEvent::Error => match self.phase { - Phase::Session => self.status = "WebSocket transport error".into(), - Phase::Connect { pairing } if pairing => { - self.connect_status = - "Could not reach the host — check the address.".into(); - self.phase = Phase::Connect { pairing: false }; - self.socket = None; - } - Phase::Connect { .. } => {} - }, - TransportEvent::Closed(reason) => match self.phase { - Phase::Session => { - self.status = if reason.is_empty() { - "Disconnected — reconnecting…".into() - } else { - format!("Disconnected: {reason} — reconnecting…").into() - }; - self.schedule_reconnect(cx); - } - Phase::Connect { pairing } if pairing => { - self.connect_status = - "The host closed the connection before pairing — check the address." - .into(); - self.phase = Phase::Connect { pairing: false }; - self.socket = None; - } - Phase::Connect { .. } => {} - }, - } - } - cx.notify(); - } - - fn handle_text(&mut self, text: &str, cx: &mut Context) { - let frame = match HostFrame::decode(text) { - Ok(frame) => frame, - Err(error) => { - let message: SharedString = format!("Protocol decode error: {error}").into(); - match self.phase { - Phase::Session => self.status = message, - Phase::Connect { .. } => self.connect_status = message, - } - return; - } - }; - - // Before a token exists the shared `Client` cannot fold these frames — - // its connection is not yet in a handshake — so pairing is handled here, - // exactly as `sync-probe`'s `pair` command does. - if matches!(self.phase, Phase::Connect { .. }) { - match frame { - HostFrame::Paired { token, .. } => self.complete_pairing(token, cx), - HostFrame::Refused { reason } => { - self.connect_status = pairing::refusal_message(&reason).into(); - self.phase = Phase::Connect { pairing: false }; - self.socket = None; - } - _ => {} - } - return; - } - - let was_ready = self.client.is_ready(); - let is_session_list = matches!(frame, HostFrame::SessionList { .. }); - let affects_selected = matches!( - &frame, - HostFrame::Events { session_id, .. } | HostFrame::SessionEnded { session_id, .. } - if self.selected.as_deref() == Some(session_id) - ); - - let accepted_delivery_ids: Vec = match &frame { - HostFrame::Events { events, .. } => events - .iter() - .filter_map(|event| match event.event { - AgentEvent::TurnAccepted { delivery_id } => Some(delivery_id), - _ => None, - }) - .collect(), - _ => Vec::new(), - }; - let rejected = match &frame { - HostFrame::CommandRejected { - session_id, - command, - reason, - } => Some((session_id.clone(), command.clone(), format!("{reason:?}"))), - _ => None, - }; - - let outgoing = self.client.handle(frame); - self.send_all(outgoing); - for delivery_id in accepted_delivery_ids { - self.command_ui.accepted_turn(delivery_id); - self.status = "Turn accepted by host".into(); - } - if let Some((session_id, command, reason)) = rejected { - self.command_ui - .rejected(&session_id, command.as_ref(), reason); - } - - if !was_ready && self.client.is_ready() { - let host = self - .client - .host() - .map(|host| host.display_name.clone()) - .unwrap_or_else(|| "host".into()); - self.status = format!("Connected to {host}").into(); - self.send(self.client.request_sessions()); - } else if let Some(reason) = self.client.refusal_reason().cloned() { - // A stored token the host no longer honours cannot be salvaged by - // reconnecting, so clear it and send the user back to pairing rather - // than into a loop. Other refusals land there too — the connect - // screen is where a fresh code is entered. - self.store.clear(); - self.socket = None; - self._reconnect = None; - self.phase = Phase::Connect { pairing: false }; - self.connect_status = pairing::refusal_message(&reason).into(); - return; - } - - if is_session_list { - self.sessions = self.client.sessions().to_vec(); - if self.selected.is_none() - && let Some(session_id) = self - .sessions - .first() - .map(|session| session.session_id.clone()) - { - self.select(session_id, cx); - } - } - if affects_selected { - self.refresh_chat(cx); - if let Some(session_id) = self.selected.as_deref() - && let Some(state) = self.client.session(session_id) - { - self.command_ui.resolved_approvals( - session_id, - state - .timeline() - .pending_approvals - .iter() - .map(|request| request.id.as_str()), - ); - } - } - - // A rejected command is the one failure the timeline cannot show: the - // host applied nothing, so no event will ever arrive to explain it. - let rejections = self.client.command_rejections(); - if rejections.len() > self.rejections_seen { - if let Some(latest) = rejections.last() { - self.status = format!("Command rejected: {:?}", latest.reason).into(); - } - self.rejections_seen = rejections.len(); - } - } - - fn send_all(&mut self, frames: Vec) { - for frame in frames { - self.send(frame); - } - } - - fn send(&mut self, frame: ClientFrame) -> bool { - let Some(socket) = &self.socket else { - return false; - }; - let result = frame - .encode() - .map_err(|error| error.to_string()) - .and_then(|text| socket.send(&text)); - if let Err(error) = result { - self.status = format!("WebSocket send error: {error}").into(); - false - } else { - true - } - } - - fn select(&mut self, session_id: String, cx: &mut Context) { - if self.selected.as_deref() == Some(&session_id) { - return; - } - if let Some(previous) = self.selected.replace(session_id.clone()) { - let unsubscribe = self.client.unsubscribe(previous); - self.send(unsubscribe); - } - let subscribe = self.client.subscribe(session_id); - self.send(subscribe); - self.refresh_chat(cx); - } - - /// Reconnect after a backoff, then resume every subscription from the - /// cursor this client already holds — so a reconnect costs the events that - /// were missed, not the whole log. - fn schedule_reconnect(&mut self, cx: &mut Context) { - let Some(url) = self.url.clone() else { - return; - }; - self.socket = None; - self.reconnect_attempts = self.reconnect_attempts.saturating_add(1); - // Capped exponential backoff: a host that is down should not be - // hammered, but a transient drop should recover in well under a second. - let delay = Duration::from_millis(250u64 << self.reconnect_attempts.min(6)); - let hello = self.hello.clone(); - let incoming = self.incoming.clone(); - self._reconnect = Some(cx.spawn(async move |this, cx| { - cx.background_executor().timer(delay).await; - let _ = this.update(cx, |this, cx| { - match this.transport.connect(&url, &hello, incoming) { - Ok(socket) => { - this.socket = Some(socket); - // The client keeps its cursors across the drop, so the - // resubscribe below asks only for the gap. - let resume: Vec = this - .selected - .clone() - .map(|session_id| vec![this.client.subscribe(session_id)]) - .unwrap_or_default(); - this.send_all(resume); - } - Err(error) => { - this.status = format!("Reconnect failed: {error:?}").into(); - this.schedule_reconnect(cx); - } - } - cx.notify(); - }); - })); - } - - /// Send whatever is in the composer as a turn. - /// - /// `Client::command` retains the allocated `delivery_id`; the turn is not - /// accepted until `AgentEvent::TurnAccepted` echoes it back, which is what - /// the status line reports. - fn send_turn(&mut self, window: &mut Window, cx: &mut Context) { - let Some(session_id) = self.selected.clone() else { - return; - }; - let text = self.composer.read(cx).value().to_string(); - if text.trim().is_empty() { - return; - } - // Monotonic within the page: `Date::now` alone can repeat inside one - // millisecond, and a repeated delivery id would make two turns - // indistinguishable to the acknowledgement tracking. - let delivery_id = next_delivery_id(); - let command = turn_command(delivery_id, text.clone()); - let frame = self.client.command(session_id.clone(), command); - self.command_ui - .sent_turn(session_id.clone(), delivery_id, text); - let sent = self.send(frame); - self.composer - .update(cx, |state, cx| state.set_value("", window, cx)); - if sent { - self.status = "Turn sent — awaiting acceptance…".into(); - } else { - self.command_ui.rejected( - &session_id, - Some(&RejectedCommand::Turn { delivery_id }), - "transport could not send the command".into(), - ); - } - cx.notify(); - } - - fn respond_approval( - &mut self, - request_id: String, - decision: ApprovalDecision, - cx: &mut Context, - ) { - let Some(session_id) = self.selected.clone() else { - return; - }; - let command = approval_command(request_id.clone(), decision); - let frame = self.client.command(session_id.clone(), command); - self.command_ui - .sent_approval(session_id.clone(), request_id.clone()); - if !self.send(frame) { - self.command_ui.rejected( - &session_id, - Some(&RejectedCommand::Approval { request_id }), - "transport could not send the command".into(), - ); - } - cx.notify(); - } - - /// Return to the session list on a narrow layout. - /// - /// The subscription is left in place: the host keeps streaming, so coming - /// back is instant and no backfill is re-fetched. Only the selection — - /// which is presentation state — is cleared. - fn deselect(&mut self, cx: &mut Context) { - self.selected = None; - cx.notify(); - } - - fn refresh_chat(&mut self, cx: &mut Context) { - let Some(session_id) = self.selected.as_deref() else { - return; - }; - let Some(summary) = self - .sessions - .iter() - .find(|summary| summary.session_id == session_id) - else { - return; - }; - let timeline = self - .client - .session(session_id) - .map(|state| state.timeline().clone()) - .unwrap_or_default(); - // SessionSummary intentionally carries a display name rather than a - // meaningless host-absolute path. Relative file labels still render. - let cwd = PathBuf::from(summary.project.as_deref().unwrap_or(".")); - let model = ChatReadModel::new(ChatSessionReadModel::new( - &summary.session_id, - &summary.title, - cwd, - summary.provider, - timeline, - )); - self.chat - .update(cx, |chat, cx| chat.set_read_model(model, cx)); - } -} - -impl Render for ClientApp { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - match self.phase { - Phase::Connect { pairing } => self.render_connect(pairing, cx).into_any_element(), - Phase::Session => self.render_session(window, cx).into_any_element(), - } - } -} - -impl ClientApp { - /// The connect screen: a host address, a pairing code, and one action. - fn render_connect(&self, pairing: bool, cx: &mut Context) -> gpui::AnyElement { - let muted = cx.theme().muted_foreground; - let field = |label: &str, input: &Entity| { - div() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_xs() - .text_color(muted) - .child(SharedString::from(label.to_string())), - ) - .child(Input::new(input)) - }; - - div() - .flex() - .flex_col() - .size_full() - .items_center() - .justify_center() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) - .child( - div() - .flex() - .flex_col() - .gap_4() - .w(px(320.)) - .child(div().text_xl().font_semibold().child("Connect to a host")) - .child(div().text_sm().text_color(muted).child( - "On the host computer, tcode shows a short pairing code when it starts. \ - Enter the host's address and that code to pair this device once.", - )) - .child(field("Host address", &self.url_input)) - .child(field("Pairing code", &self.code_input)) - .child( - Button::new("connect") - .primary() - .label(if pairing { "Pairing…" } else { "Connect" }) - .disabled(pairing) - .on_click(cx.listener(|this, _, _, cx| this.start_pairing(cx))), - ) - .when(!self.connect_status.is_empty(), |column| { - column.child( - div() - .text_sm() - .text_color(muted) - .child(self.connect_status.clone()), - ) - }), - ) - .into_any_element() - } - - fn render_session(&self, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { - // One breakpoint, not a scale factor. Below it a phone cannot show two - // panes at once — a 280px sidebar eats 72% of a 390pt screen — so the - // shell switches from side-by-side to one-at-a-time rather than - // shrinking both into uselessness. - // - // 640 logical pixels is where a sidebar plus a readable chat column - // stops fitting, not a device class: a narrow desktop window gets the - // same treatment, which is the honest generalisation and also makes the - // layout testable without a phone. - let narrow = window.viewport_size().width < px(640.0); - let showing_chat = self.selected.is_some(); - let sidebar = - self.sessions - .iter() - .fold(div().flex().flex_col().gap_1(), |column, session| { - let session_id = session.session_id.clone(); - let selected = self.selected.as_deref() == Some(session_id.as_str()); - let meta: SharedString = format!( - "{:?}{}{}", - session.provider, - if session.working { " · working" } else { "" }, - if session.awaiting_approval { - " · awaiting approval" - } else { - "" - } - ) - .into(); - column.child( - div() - .id(session_id.clone()) - .flex() - .flex_col() - .gap_1() - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .when(selected, |item| item.bg(cx.theme().accent.opacity(0.18))) - .on_click(cx.listener(move |this, _, _, cx| { - this.select(session_id.clone(), cx); - })) - .child( - div() - .text_sm() - .font_semibold() - .text_color(cx.theme().foreground) - .child(session.title.clone()), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(meta), - ), - ) - }); - - let session_list = div() - .flex() - .flex_col() - .h_full() - .when(!narrow, |column| { - column - .w(px(280.)) - .flex_shrink_0() - .border_r_1() - .border_color(cx.theme().border) - }) - .when(narrow, |column| column.size_full()) - .p_3() - .gap_3() - .child( - // The host name and the one control that leaves it: sign-out - // clears the stored token and returns to pairing. - div() - .flex() - .items_center() - .justify_between() - .child(div().text_lg().font_semibold().child("tcode")) - .child( - Button::new("sign-out") - .ghost() - .label("Sign out") - .on_click(cx.listener(|this, _, _, cx| this.sign_out(cx))), - ), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.status.clone()), - ) - .child(sidebar); - - let conversation = div() - .flex() - .flex_col() - .flex_1() - .min_w_0() - .h_full() - // On a phone the only way back to the list is an explicit control: - // there is no second pane to click, and hiding it behind a gesture - // would make the app's one navigation action undiscoverable. - .when(narrow, |column| { - column.child( - div() - .flex() - .p_2() - .border_b_1() - .border_color(cx.theme().border) - .child( - Button::new("back-to-sessions") - .ghost() - .label("‹ Sessions") - .on_click(cx.listener(|this, _, _, cx| this.deselect(cx))), - ), - ) - }) - .child(div().flex_1().min_h_0().child(self.chat.clone())) - .children(self.render_local_turns(cx)) - .children(self.render_approvals(cx)) - .child(self.render_composer(cx)); - - div() - .flex() - .size_full() - .bg(cx.theme().background) - .text_color(cx.theme().foreground) - // Narrow shows exactly one pane, chosen by whether a session is - // open; wide shows both. Collected first because each pane can only - // be placed once. - .children(if narrow { - if showing_chat { - vec![conversation.into_any_element()] - } else { - vec![session_list.into_any_element()] - } - } else { - vec![ - session_list.into_any_element(), - conversation.into_any_element(), - ] - }) - .into_any_element() - } - - fn render_local_turns(&self, cx: &mut Context) -> Vec { - let Some(session_id) = self.selected.as_deref() else { - return Vec::new(); - }; - self.command_ui - .turns - .iter() - .filter(|turn| turn.session_id == session_id) - .map(|turn| { - let (label, color) = match &turn.status { - LocalTurnStatus::AwaitingAcceptance => ( - "Sending — awaiting host acceptance".to_owned(), - cx.theme().warning, - ), - LocalTurnStatus::Rejected(reason) => ( - format!("Not sent — host rejected the command: {reason}"), - cx.theme().danger, - ), - }; - div() - .flex() - .flex_col() - .items_end() - .gap_1() - .px_3() - .py_2() - .child( - div() - .max_w(px(680.)) - .rounded_lg() - .px_3() - .py_2() - .bg(cx.theme().accent.opacity(0.16)) - .child(turn.text.clone()), - ) - .child(div().text_xs().text_color(color).child(label)) - .into_any_element() - }) - .collect() - } - - /// Pending approvals, rendered above the composer rather than inline in the - /// timeline. - /// - /// Approving a command while away from the desk is the reason a remote - /// client exists, so this must be impossible to scroll past. The requests - /// come from the synced `Timeline`, which the shared reducer already - /// maintains — the browser does not track them itself. - fn render_approvals(&self, cx: &mut Context) -> Vec { - let Some(session_id) = self.selected.as_deref() else { - return Vec::new(); - }; - let Some(state) = self.client.session(session_id) else { - return Vec::new(); - }; - state - .timeline() - .pending_approvals - .iter() - .map(|request| { - let id = request.id.clone(); - let request_id = request.id.clone(); - let options = request.options.clone(); - div() - .flex() - .flex_col() - .gap_2() - .m_3() - .p_3() - .rounded_lg() - .border_1() - .border_color(cx.theme().warning) - .bg(cx.theme().warning.opacity(0.08)) - .child(div().text_sm().font_semibold().child("Approval needed")) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!("{:?}", request.kind))), - ) - .child(if options.is_empty() { - self.render_fixed_approval_choices(id, cx) - } else { - options - .into_iter() - .fold(div().flex().gap_2(), |row, option| { - let request_id = request_id.clone(); - let option_id = option.id.clone(); - row.child( - Button::new(SharedString::from(format!( - "approval-option-{}", - option.id - ))) - .label(option.label) - .on_click(cx.listener(move |this, _, _, cx| { - this.respond_approval( - request_id.clone(), - provider_approval_decision(option_id.clone()), - cx, - ); - })), - ) - }) - }) - .into_any_element() - }) - .collect() - } - - fn render_fixed_approval_choices( - &self, - request_id: String, - cx: &mut Context, - ) -> gpui::Div { - [ - ("Cancel turn", ApprovalDecision::Cancel), - ("Deny", ApprovalDecision::Deny), - ("Always allow", ApprovalDecision::ApproveForSession), - ("Approve", ApprovalDecision::Approve), - ] - .into_iter() - .fold(div().flex().gap_2(), |row, (label, decision)| { - let request_id = request_id.clone(); - row.child( - Button::new(SharedString::from(format!("approval-{label}-{request_id}"))) - .label(label) - .on_click(cx.listener(move |this, _, _, cx| { - this.respond_approval(request_id.clone(), decision.clone(), cx); - })), - ) - }) - } - - fn render_composer(&self, cx: &mut Context) -> gpui::AnyElement { - let live = self.selected.is_some() && self.socket.is_some(); - div() - .flex() - .gap_2() - .p_3() - .border_t_1() - .border_color(cx.theme().border) - .child(div().flex_1().min_w_0().child(Input::new(&self.composer))) - .child( - Button::new("send") - .primary() - .label("Send") - .disabled(!live) - .on_click(cx.listener(|this, _, window, cx| this.send_turn(window, cx))), - ) - .into_any_element() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn constructs_text_turn_command() { - assert!(matches!( - turn_command(42, "ship it".into()), - SessionCommand::SendTurn { - delivery_id: 42, - text, - options: None, - attachments, - } if text == "ship it" && attachments.is_empty() - )); - } - - #[test] - fn maps_provider_choice_to_opaque_option_id() { - assert_eq!( - provider_approval_decision("allow-worktree".into()), - ApprovalDecision::Option("allow-worktree".into()) - ); - } - - #[test] - fn constructs_approval_response_command() { - assert_eq!( - approval_command("request-7".into(), ApprovalDecision::ApproveForSession), - SessionCommand::RespondApproval { - request_id: "request-7".into(), - decision: ApprovalDecision::ApproveForSession, - } - ); - } - - #[test] - fn rejection_marks_the_matching_optimistic_turn() { - let mut state = CommandUiState::default(); - state.sent_turn("s1".into(), 7, "run tests".into()); - state.rejected("s1", None, "session ended".into()); - - assert_eq!( - state.turns[0].status, - LocalTurnStatus::Rejected("session ended".into()) - ); - } - - #[test] - fn acceptance_replaces_the_optimistic_turn_with_canonical_timeline() { - let mut state = CommandUiState::default(); - state.sent_turn("s1".into(), 7, "run tests".into()); - state.accepted_turn(7); - - assert!(state.turns.is_empty()); - assert!(state.attempts.is_empty()); - } - - #[test] - fn approval_rejection_does_not_mark_a_turn_rejected() { - let mut state = CommandUiState::default(); - state.sent_approval("s1".into(), "approval-1".into()); - state.sent_turn("s1".into(), 8, "continue".into()); - state.rejected("s1", None, "approval expired".into()); - - assert_eq!(state.turns[0].status, LocalTurnStatus::AwaitingAcceptance); - } - - #[test] - fn correlated_approval_rejection_does_not_reject_an_earlier_turn() { - let mut state = CommandUiState::default(); - state.sent_turn("s1".into(), 8, "continue".into()); - state.sent_approval("s1".into(), "approval-1".into()); - state.rejected( - "s1", - Some(&RejectedCommand::Approval { - request_id: "approval-1".into(), - }), - "approval expired".into(), - ); - - assert_eq!(state.turns[0].status, LocalTurnStatus::AwaitingAcceptance); - } - - #[test] - fn uncorrelated_rejection_still_consumes_the_oldest_attempt() { - let mut state = CommandUiState::default(); - state.sent_turn("s1".into(), 8, "continue".into()); - state.sent_approval("s1".into(), "approval-1".into()); - state.rejected("s1", None, "older host refused command".into()); - - assert_eq!( - state.turns[0].status, - LocalTurnStatus::Rejected("older host refused command".into()) - ); - assert_eq!(state.attempts.len(), 1); - } -} diff --git a/crates/client-app/src/native_transport.rs b/crates/client-app/src/native_transport.rs deleted file mode 100644 index bac17ad3..00000000 --- a/crates/client-app/src/native_transport.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! A native WebSocket transport, shared by the Android and iOS shells. -//! -//! Both are ordinary native targets with threads and sockets, so unlike the -//! browser they can use the same tokio-tungstenite client the desktop probe -//! uses. Keeping it here rather than in two shells means the framing and -//! disconnect behaviour cannot drift between the two phones. - -use std::sync::Arc; - -use futures_util::{SinkExt as _, StreamExt as _}; -use tokio::runtime::Runtime; -use tokio::sync::mpsc; -use tokio_tungstenite::tungstenite::Message; - -use crate::{Inbox, Transport, TransportEvent, TransportFactory}; - -/// Owns the tokio runtime every socket runs on. -/// -/// One runtime for the process, built once. A phone has few cores, and giving -/// each reconnect its own runtime would spawn a fresh thread pool every time a -/// train enters a tunnel. -pub struct NativeTransportFactory { - runtime: Arc, -} - -impl NativeTransportFactory { - pub fn new() -> std::io::Result { - Ok(Self { - runtime: Arc::new( - tokio::runtime::Builder::new_multi_thread() - .worker_threads(2) - .enable_all() - .build()?, - ), - }) - } -} - -/// A connected socket. -/// -/// Sends are queued rather than awaited: the caller is GPUI's render thread and -/// must never block on the network, however briefly. -pub struct NativeTransport { - outgoing: mpsc::UnboundedSender, -} - -impl Transport for NativeTransport { - fn send(&self, text: &str) -> Result<(), String> { - self.outgoing - .send(text.to_owned()) - .map_err(|_| "the connection is closed".to_owned()) - } -} - -fn deliver(inbox: &Inbox, event: TransportEvent) { - inbox - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push_back(event); -} - -impl TransportFactory for NativeTransportFactory { - fn connect(&self, url: &str, hello: &str, inbox: Inbox) -> Result, String> { - let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); - let url = url.to_owned(); - let hello = hello.to_owned(); - - self.runtime.spawn(async move { - let (socket, _) = match tokio_tungstenite::connect_async(&url).await { - Ok(connected) => connected, - Err(error) => { - // Reported as a close rather than an error so the client's - // reconnect path handles it: a host that is not up yet is - // the same situation as one that went away. - deliver(&inbox, TransportEvent::Closed(error.to_string())); - return; - } - }; - let (mut sink, mut stream) = socket.split(); - - if sink.send(Message::Text(hello.into())).await.is_err() { - deliver(&inbox, TransportEvent::Error); - return; - } - deliver(&inbox, TransportEvent::Open); - - loop { - tokio::select! { - outgoing = outgoing_rx.recv() => { - let Some(text) = outgoing else { - // The client dropped its handle: close cleanly so - // the host frees the subscription instead of - // waiting for a timeout. - let _ = sink.close().await; - break; - }; - if sink.send(Message::Text(text.into())).await.is_err() { - deliver(&inbox, TransportEvent::Error); - break; - } - } - incoming = stream.next() => { - match incoming { - Some(Ok(Message::Text(text))) => { - deliver(&inbox, TransportEvent::Text(text.to_string())); - } - Some(Ok(Message::Close(_))) | None => { - deliver(&inbox, TransportEvent::Closed(String::new())); - break; - } - // Ping/pong are handled by tungstenite; binary - // frames are not part of this protocol. - Some(Ok(_)) => {} - Some(Err(error)) => { - deliver(&inbox, TransportEvent::Closed(error.to_string())); - break; - } - } - } - } - } - }); - - Ok(Box::new(NativeTransport { - outgoing: outgoing_tx, - })) - } -} diff --git a/crates/client-app/src/pairing.rs b/crates/client-app/src/pairing.rs deleted file mode 100644 index 451db0c4..00000000 --- a/crates/client-app/src/pairing.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Pure helpers for the connect screen: turning what a user types into a code -//! the host will recognise, and turning a host's refusal into a sentence that -//! tells them what to do about it. -//! -//! Kept free of any UI or platform type so it is testable without a browser — -//! the mistakes this guards against (a code that fails only because it was typed -//! in lower case; a refusal shown as "network error") are exactly the ones a -//! headless test can pin down. - -use sync_protocol::RefuseReason; - -/// How many characters the host mints. Mirrors `sync-host`'s `CODE_LEN`; a code -/// of any other length cannot be one this host issued. -pub const CODE_LEN: usize = 6; - -/// Canonicalise a code as typed into the form the host issued. -/// -/// A code is read off one screen and typed into another, so people add spaces -/// or dashes ("K7 M2 QX") and type in whatever case is convenient. Dropping the -/// separators and upper-casing makes those the same code rather than three -/// different failures — the host's alphabet is upper-case only, so a lower-case -/// letter is never a real character, just a typed one. -pub fn normalize_code(raw: &str) -> String { - raw.chars() - .filter(char::is_ascii_alphanumeric) - .map(|c| c.to_ascii_uppercase()) - .collect() -} - -/// Whether a normalised code is worth sending. -/// -/// Length only, deliberately not the alphabet: the exact set of characters is a -/// host detail (`sync-host/src/pairing.rs`), and mirroring it here would make -/// this crate reject valid codes the day the host widens it. A wrong-but-well- -/// formed code is the host's to refuse, and it does so with one reason on -/// purpose. -pub fn is_complete_code(code: &str) -> bool { - code.chars().count() == CODE_LEN -} - -/// Phrase a handshake refusal as an instruction, not a stack trace. -/// -/// The one thing the user must learn from a refusal is whether to retype the -/// code or check the address — so none of these read as a transport error, and -/// the pairing case names the code explicitly. -pub fn refusal_message(reason: &RefuseReason) -> String { - match reason { - RefuseReason::PairingRejected => { - "That code was wrong or has expired. Read the current code off the host and try again." - .to_string() - } - RefuseReason::Unauthorized => { - "The host no longer accepts this device's saved token. Pair again to get a new one." - .to_string() - } - RefuseReason::UnsupportedVersion { host_min, host_max } => format!( - "This app and that host do not share a protocol version (host speaks {host_min}–{host_max}). Update whichever is older." - ), - RefuseReason::Unavailable { detail } => { - format!("The host is not accepting connections right now: {detail}") - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn case_and_separators_do_not_change_the_code() { - // The bug this exists to prevent: the same code typed three ways - // resolving to three different strings, one of which the host rejects. - assert_eq!(normalize_code("k7m2qx"), "K7M2QX"); - assert_eq!(normalize_code("K7 M2 QX"), "K7M2QX"); - assert_eq!(normalize_code(" k7-m2-qx "), "K7M2QX"); - } - - #[test] - fn completeness_is_measured_after_normalising() { - assert!(is_complete_code(&normalize_code("k7-m2-qx"))); - assert!(!is_complete_code(&normalize_code("k7m2"))); - assert!(!is_complete_code(&normalize_code("k7m2qxx"))); - } - - #[test] - fn a_wrong_code_reads_as_a_typo_not_a_network_fault() { - let message = refusal_message(&RefuseReason::PairingRejected); - assert!(message.to_lowercase().contains("code")); - assert!(!mentions_transport(&message)); - } - - #[test] - fn every_refusal_is_actionable_and_never_a_transport_error() { - for reason in [ - RefuseReason::PairingRejected, - RefuseReason::Unauthorized, - RefuseReason::UnsupportedVersion { - host_min: 2, - host_max: 3, - }, - RefuseReason::Unavailable { - detail: "at capacity".into(), - }, - ] { - let message = refusal_message(&reason); - assert!(!message.is_empty()); - assert!( - !mentions_transport(&message), - "a refusal must not read as a connection failure: {message}" - ); - } - } - - /// The version mismatch has to say which side is behind, not just "refused". - #[test] - fn a_version_mismatch_names_the_hosts_range() { - let message = refusal_message(&RefuseReason::UnsupportedVersion { - host_min: 4, - host_max: 7, - }); - assert!(message.contains('4') && message.contains('7')); - } - - fn mentions_transport(message: &str) -> bool { - let lower = message.to_lowercase(); - [ - "network", - "socket", - "websocket", - "connection error", - "transport", - ] - .iter() - .any(|term| lower.contains(term)) - } -} diff --git a/crates/client-app/src/storage.rs b/crates/client-app/src/storage.rs deleted file mode 100644 index 05326712..00000000 --- a/crates/client-app/src/storage.rs +++ /dev/null @@ -1,194 +0,0 @@ -//! Where a shell persists the result of pairing, so a device pairs once rather -//! than every load. -//! -//! The concrete backend is injected — never chosen here — so this crate does -//! not learn what platform it runs on: the browser backs [`ConnectionStore`] -//! with `localStorage`, a phone will back it with a file, and neither has to -//! fork the client to do it. - -use std::sync::{Arc, Mutex}; - -/// The durable outcome of pairing: which host, and the token that authenticates -/// to it. -/// -/// The token is the secret. The URL is kept beside it only so a reload need not -/// re-type the address — a URL in an address bar was never the problem pairing -/// set out to solve. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct StoredConnection { - pub url: String, - pub token: String, -} - -/// A place to keep one [`StoredConnection`] across loads. -pub trait ConnectionStore: Send + Sync { - /// The saved connection, or `None` if this device has not paired. - fn load(&self) -> Option; - /// Persist `connection`, replacing any previous one. - fn save(&self, connection: &StoredConnection); - /// Forget the saved connection. Backs the sign-out action. - fn clear(&self); -} - -/// Drop a stored connection whose token cannot authenticate. -/// -/// A blank token is no token: some backends return `Some("")` for a missing key -/// rather than `None`, and an empty token can only produce a doomed handshake — -/// so it must land the user on the connect screen, not in a reconnect loop. -pub fn usable_connection(stored: Option) -> Option { - stored.filter(|connection| !connection.token.trim().is_empty()) -} - -/// A store that keeps its value in memory only. -/// -/// For tests, and for any shell that deliberately does not persist. Cloneable -/// so a test can hold a handle to inspect what the client wrote. -#[derive(Clone, Default)] -pub struct MemoryStore { - slot: Arc>>, -} - -impl MemoryStore { - pub fn new() -> Self { - Self::default() - } - - /// Seed a store as if the device had already paired. - pub fn with(connection: StoredConnection) -> Self { - Self { - slot: Arc::new(Mutex::new(Some(connection))), - } - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Option> { - self.slot - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } -} - -/// A [`ConnectionStore`] backed by a single file. -/// -/// The native shells' equivalent of the browser's `localStorage`: Android hands -/// it the app-private files dir, iOS the app container's Documents dir, both of -/// which the OS already isolates per app. Not built for wasm, which has no -/// filesystem — the browser uses `localStorage` instead. -/// -/// Format is deliberately trivial: the URL on the first line, the token on the -/// second. Neither contains a newline (a URL cannot, and the host mints tokens -/// from a newline-free alphabet), so no escaping or serialization dependency is -/// needed to round-trip them. -#[cfg(not(target_family = "wasm"))] -pub struct FileStore { - path: std::path::PathBuf, -} - -#[cfg(not(target_family = "wasm"))] -impl FileStore { - /// Keep the connection under `dir`, which the shell supplies (its app-private - /// storage). The directory is created on save if it does not yet exist. - pub fn new(dir: impl Into) -> Self { - Self { - path: dir.into().join("sync-connection"), - } - } -} - -#[cfg(not(target_family = "wasm"))] -impl ConnectionStore for FileStore { - fn load(&self) -> Option { - let contents = std::fs::read_to_string(&self.path).ok()?; - // A file without the second line is corrupt, not a valid empty - // connection, so it is treated as absent rather than half-read. - let (url, token) = contents.split_once('\n')?; - Some(StoredConnection { - url: url.to_string(), - token: token.trim_end_matches('\n').to_string(), - }) - } - - fn save(&self, connection: &StoredConnection) { - if let Some(parent) = self.path.parent() { - let _ = std::fs::create_dir_all(parent); - } - // Best-effort, like the browser: failing to persist is not worth - // interrupting a working session — the user simply pairs again next - // launch. - let _ = std::fs::write( - &self.path, - format!("{}\n{}", connection.url, connection.token), - ); - } - - fn clear(&self) { - let _ = std::fs::remove_file(&self.path); - } -} - -impl ConnectionStore for MemoryStore { - fn load(&self) -> Option { - self.lock().clone() - } - - fn save(&self, connection: &StoredConnection) { - *self.lock() = Some(connection.clone()); - } - - fn clear(&self) { - *self.lock() = None; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn connection(token: &str) -> StoredConnection { - StoredConnection { - url: "ws://host:1234/sync".into(), - token: token.into(), - } - } - - #[test] - fn a_saved_connection_survives_until_cleared() { - let store = MemoryStore::new(); - assert_eq!(store.load(), None); - - store.save(&connection("durable")); - assert_eq!(store.load(), Some(connection("durable"))); - - store.clear(); - assert_eq!(store.load(), None, "sign-out must leave nothing behind"); - } - - #[test] - fn an_empty_token_is_treated_as_no_connection() { - // A backend that returns "" for a missing key must not send the client - // into a handshake it cannot pass. - assert_eq!(usable_connection(Some(connection(""))), None); - assert_eq!(usable_connection(Some(connection(" "))), None); - assert_eq!( - usable_connection(Some(connection("real"))), - Some(connection("real")) - ); - } - - #[test] - fn a_file_store_round_trips_and_clears() { - // This test owns this directory name, so the process id is enough to - // keep concurrent test binaries apart without a temp-file dependency. - let dir = - std::env::temp_dir().join(format!("tcode-filestore-roundtrip-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&dir); - let store = FileStore::new(&dir); - - assert_eq!(store.load(), None, "nothing is stored before a save"); - store.save(&connection("durable")); - assert_eq!(store.load(), Some(connection("durable"))); - - store.clear(); - assert_eq!(store.load(), None, "clearing removes the file"); - let _ = std::fs::remove_dir_all(&dir); - } -} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 42dc9867..1b309116 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,17 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] -# Types only: core holds domain data and pure reducers, none of which needs a -# provider process. Keeping `local` off is what lets it — and the sync client -# that folds timelines from it — build for wasm. -agent = { path = "../agent", default-features = false } +agent = { path = "../agent" } log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } - -# `uuid::new_v4` needs a randomness source, and on wasm that is the browser's -# crypto API rather than the OS. Scoped to wasm so native builds keep their -# current dependency set unchanged. -[target.'cfg(target_family = "wasm")'.dependencies] -uuid = { version = "1", features = ["v4", "serde", "js"] } diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index 8843ebe1..58d792dc 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -599,19 +599,16 @@ mod tests { let changes = [ FileChange { path: "/repo/src/lib.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("--- a/src/lib.rs\n+++ b/src/lib.rs\n-old\n+middle\n".into()), }, FileChange { path: "/repo/other.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-gone\n+new\n".into()), }, FileChange { path: "/repo/src/lib.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-middle\n-final\n+replacement\n".into()), }, diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index b6d99494..da881e65 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -136,29 +136,12 @@ pub fn append_review_comments_to_prompt(prompt: &str, comments: &[ReviewComment] #[derive(Debug, Clone)] pub struct StoredEvent { pub ts: Option, - /// Position in the session's log: 1-based, contiguous, and strictly - /// increasing. This — not `ts` — is the session's total order. - /// - /// `ts` cannot order events: it is a wall clock the writer explicitly - /// tolerates repeating or going backwards, so two events can share one - /// millisecond and a clock adjustment can invert a pair. Remote clients - /// resume from a `seq` cursor, which requires an order that actually holds. - /// - /// `None` means the event did not come from a log — a synthetic or - /// in-memory event. Everything `SessionStore::read_events` returns carries - /// `Some`, including lines written before the field existed (they are - /// numbered by position). - pub seq: Option, pub event: AgentEvent, } impl From for StoredEvent { fn from(event: AgentEvent) -> Self { - StoredEvent { - ts: None, - seq: None, - event, - } + StoredEvent { ts: None, event } } } @@ -1583,7 +1566,6 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/lib.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-old\n+new\n".into()), }], @@ -1596,7 +1578,6 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/child.rs".into(), - workspace_path: None, kind: FileChangeKind::Create, diff: Some("+child\n".into()), }], @@ -1625,7 +1606,6 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/lib.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-intermediate\n+value\n".into()), }], @@ -1640,7 +1620,6 @@ mod tests { turn_id: "turn-1".into(), changes: vec![FileChange { path: "src/lib.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-before\n+after\n".into()), }], @@ -1655,7 +1634,6 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "late.txt".into(), - workspace_path: None, kind: FileChangeKind::Create, diff: Some("+late\n".into()), }], @@ -1697,7 +1675,6 @@ mod tests { turn_id: "turn-1".into(), changes: vec![FileChange { path: "first.txt".into(), - workspace_path: None, kind: FileChangeKind::Create, diff: Some("+first\n".into()), }], @@ -1722,7 +1699,6 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/lib.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }], @@ -2047,7 +2023,6 @@ mod tests { fn fold_codex_style_trace_with_approval() { let changes = vec![FileChange { path: "/tmp/probe-codex/hello.txt".into(), - workspace_path: None, kind: FileChangeKind::Create, diff: Some("hi\n".into()), }]; @@ -2220,19 +2195,16 @@ mod tests { let stored = vec![ StoredEvent { ts: Some(1_000_000), - seq: None, event: user_msg("u1", "first"), }, StoredEvent { ts: Some(1_000_500), - seq: None, event: AgentEvent::TurnStarted { turn_id: "t1".into(), }, }, StoredEvent { ts: Some(1_002_000), - seq: None, event: AgentEvent::ItemCompleted(ThreadItem { id: "a1".into(), parent_item_id: None, @@ -2241,7 +2213,6 @@ mod tests { }, StoredEvent { ts: Some(1_005_500), - seq: None, event: AgentEvent::TurnCompleted { turn_id: "t1".into(), status: TurnStatus::Completed, @@ -2250,12 +2221,10 @@ mod tests { }, StoredEvent { ts: Some(2_000_000), - seq: None, event: user_msg("u2", "second"), }, StoredEvent { ts: Some(2_000_400), - seq: None, event: AgentEvent::TurnStarted { turn_id: "t2".into(), }, @@ -2595,7 +2564,6 @@ mod tests { kind: ApprovalKind::ExecCommand { command: "rm -rf /".into(), cwd: None, - workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -2801,7 +2769,6 @@ mod tests { fn at(ts: u64, event: AgentEvent) -> StoredEvent { StoredEvent { ts: Some(ts), - seq: None, event, } } diff --git a/crates/gpui-android/Cargo.toml b/crates/gpui-android/Cargo.toml deleted file mode 100644 index a55c0bcc..00000000 --- a/crates/gpui-android/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "gpui-android" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["lib"] - -[target.'cfg(target_os = "android")'.dependencies] -anyhow = "1" -futures = "0.3" -gpui = { git = "https://github.com/zed-industries/zed", default-features = false } -gpui_wgpu = { git = "https://github.com/zed-industries/zed" } -log = "0.4" -ndk = "0.9" -raw-window-handle = { version = "0.6", features = ["std"] } -uuid = "1" diff --git a/crates/gpui-android/README.md b/crates/gpui-android/README.md deleted file mode 100644 index 2b75414f..00000000 --- a/crates/gpui-android/README.md +++ /dev/null @@ -1,56 +0,0 @@ -# gpui-android - -This crate is the Android `gpui::Platform` backend. Its Rust implementation is -compiled only for `target_os = "android"` so ordinary workspace builds remain -host-only. - -## Android check prerequisites - -An Android C toolchain is required even for `cargo check`. GPUI depends on -`stacksafe`, which depends on `psm`; `psm`'s build script assembles an AArch64 -source file while dependencies are being checked. Installing only the Rust -`aarch64-linux-android` standard library is therefore insufficient. - -The supported floor is Android API 26. This was verified with Homebrew's Android -NDK revision 29.0.14206865. A verification shell can be configured without -committing toolchain paths: - -```sh -export CC_aarch64_linux_android=/opt/homebrew/share/android-ndk/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android26-clang -export AR_aarch64_linux_android=/opt/homebrew/share/android-ndk/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar -cargo check -p gpui-android --target aarch64-linux-android -``` - -The prebuilt directory may be named `darwin-arm64` in other NDK distributions. -Do not point these variables at host Clang for a real Android build: host Clang -is useful only as an explicitly reported type-check fallback and is not an -Android linker/toolchain. - -## JNI/NDK boundary - -`AndroidHost` is the outbound, thread-safe half of the boundary. A bridge should -hold a `JavaVM` and global references rather than a thread-local `JNIEnv`, because -`wake_main_thread` can be called by Rust worker threads. It must post that wake -to the designated Android Looper, coalesce `request_frame` calls into one pending -Choreographer callback, and implement the Activity, intent, clipboard, credential, -back, and IME operations declared by the trait. - -`AndroidEventSink` is the inbound, UI-thread-only half. Start in this order: - -1. Convert the Java `Surface` to an owned `ndk::NativeWindow`, wrap it in - `AndroidNativeWindow`, and pair it with validated physical dimensions and - density in `AndroidSurface`. -2. Construct `AndroidPlatform` on the same Looper thread and retain its - `event_sink`; launch GPUI while that initial surface remains valid. -3. After `wake_main_thread`, call `drain_main_thread`. After Choreographer fires, - call `frame`; the sink invokes GPUI's frame callback and rearms while active. -4. Translate lifecycle, input, insets, back, memory, appearance, thermal, and - InputConnection callbacks into the matching sink methods. Coordinates passed - to `touch` are physical; insets and IME ranges must already use GPUI units. - -Before Android releases a surface generation, call `surface_destroyed`. This -unconfigures rendering and retains the old `ANativeWindow` lease so wgpu cannot -outlive it. On recreation, acquire a new owned `NativeWindow` and call -`surface_created`; the renderer replaces its surface using the existing wgpu -instance, then releases the retired lease. Serialize these calls on the original -Looper thread and do not deliver a frame between destroy and recreate. diff --git a/crates/gpui-android/src/dispatcher.rs b/crates/gpui-android/src/dispatcher.rs deleted file mode 100644 index 20f65658..00000000 --- a/crates/gpui-android/src/dispatcher.rs +++ /dev/null @@ -1,346 +0,0 @@ -use std::{ - cmp::Ordering, - collections::{BinaryHeap, VecDeque}, - mem, - sync::{ - Arc, Condvar, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}, - mpsc, - }, - thread::{self, JoinHandle, ThreadId}, - time::{Duration, Instant}, -}; - -use anyhow::{Context as _, Result}; -use gpui::{PlatformDispatcher, Priority, RunnableVariant}; - -use crate::AndroidHost; - -const MIN_BACKGROUND_THREADS: usize = 2; -const MAX_BACKGROUND_THREADS: usize = 4; -const MAX_TIMER_DELAY: Duration = Duration::from_millis(i32::MAX as u64); - -struct ScheduledRunnable { - deadline: Instant, - sequence: u64, - runnable: RunnableVariant, -} - -impl PartialEq for ScheduledRunnable { - fn eq(&self, other: &Self) -> bool { - self.deadline == other.deadline && self.sequence == other.sequence - } -} - -impl Eq for ScheduledRunnable {} - -impl PartialOrd for ScheduledRunnable { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for ScheduledRunnable { - fn cmp(&self, other: &Self) -> Ordering { - other - .deadline - .cmp(&self.deadline) - .then_with(|| other.sequence.cmp(&self.sequence)) - } -} - -struct PriorityWorkQueue { - state: Mutex>, - available: Condvar, -} - -struct PriorityWorkQueueState { - high: VecDeque, - medium: VecDeque, - low: VecDeque, - closed: bool, -} - -impl PriorityWorkQueue { - fn new() -> Self { - Self { - state: Mutex::new(PriorityWorkQueueState { - high: VecDeque::new(), - medium: VecDeque::new(), - low: VecDeque::new(), - closed: false, - }), - available: Condvar::new(), - } - } - - fn push(&self, priority: Priority, item: T) -> Result<(), T> { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if state.closed { - return Err(item); - } - match priority { - Priority::RealtimeAudio => { - panic!("RealtimeAudio priority must use spawn_realtime") - } - Priority::High => state.high.push_back(item), - Priority::Medium => state.medium.push_back(item), - Priority::Low => state.low.push_back(item), - } - drop(state); - self.available.notify_one(); - Ok(()) - } - - fn try_pop(&self) -> Option { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - pop_highest_priority(&mut state) - } - - fn pop(&self) -> Option { - let mut state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - loop { - if let Some(item) = pop_highest_priority(&mut state) { - return Some(item); - } - if state.closed { - return None; - } - state = self - .available - .wait(state) - .unwrap_or_else(|poisoned| poisoned.into_inner()); - } - } - - fn is_empty(&self) -> bool { - let state = self - .state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - state.high.is_empty() && state.medium.is_empty() && state.low.is_empty() - } - - fn close(&self) { - self.state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .closed = true; - self.available.notify_all(); - } -} - -fn pop_highest_priority(state: &mut PriorityWorkQueueState) -> Option { - state - .high - .pop_front() - .or_else(|| state.medium.pop_front()) - .or_else(|| state.low.pop_front()) -} - -/// GPUI dispatcher backed by Android's one UI Looper plus Rust worker threads. -pub struct AndroidDispatcher { - host: Arc, - main_thread_id: ThreadId, - main_queue: Arc>, - main_wake_pending: AtomicBool, - background_queue: Arc>, - timer_sender: mpsc::Sender, - timer_sequence: AtomicU64, - _background_threads: Vec>, - _timer_thread: JoinHandle<()>, -} - -impl AndroidDispatcher { - /// Creates worker queues while recording the current thread as Android's UI thread. - pub fn new(host: Arc) -> Result { - let main_queue = Arc::new(PriorityWorkQueue::new()); - let background_queue = Arc::new(PriorityWorkQueue::new()); - - let thread_count = thread::available_parallelism() - .map(|count| count.get().saturating_sub(1)) - .unwrap_or(MIN_BACKGROUND_THREADS) - .clamp(MIN_BACKGROUND_THREADS, MAX_BACKGROUND_THREADS); - - let mut background_threads = Vec::with_capacity(thread_count); - for index in 0..thread_count { - let queue = background_queue.clone(); - let worker = thread::Builder::new() - .name(format!("gpui-worker-{index}")) - .spawn(move || { - while let Some(runnable) = queue.pop() { - execute_runnable(runnable); - } - }) - .with_context(|| format!("failed to spawn GPUI worker {index}"))?; - background_threads.push(worker); - } - - let (timer_sender, timer_receiver) = mpsc::channel(); - let timer_thread = thread::Builder::new() - .name("gpui-timer".into()) - .spawn(move || run_timer_thread(timer_receiver)) - .context("failed to spawn GPUI timer thread")?; - - Ok(Self { - host, - main_thread_id: thread::current().id(), - main_queue, - main_wake_pending: AtomicBool::new(false), - background_queue, - timer_sender, - timer_sequence: AtomicU64::new(0), - _background_threads: background_threads, - _timer_thread: timer_thread, - }) - } - - /// Runs all queued foreground work. The Android main Looper calls this after a wake. - pub fn drain_main_thread(&self) { - assert!( - self.is_main_thread(), - "Android main-thread queue drained from a non-main thread" - ); - - loop { - loop { - let runnable = self.main_queue.try_pop(); - let Some(runnable) = runnable else { - break; - }; - execute_runnable(runnable); - } - - self.main_wake_pending.store(false, AtomicOrdering::Release); - if self.main_queue.is_empty() { - break; - } - - // A producer raced with the flag reset. Drain its item here; its posted - // Looper wake may later find an empty queue, which is harmless. - self.main_wake_pending.store(true, AtomicOrdering::Release); - } - } - - fn enqueue_main(&self, runnable: RunnableVariant, priority: Priority) { - if let Err(runnable) = self.main_queue.push(priority, runnable) { - log::error!("Android main-thread queue disconnected during shutdown"); - // A foreground runnable may own !Send state and cannot be dropped here. - mem::forget(runnable); - return; - } - - if !self.main_wake_pending.swap(true, AtomicOrdering::AcqRel) { - self.host.wake_main_thread(); - } - } -} - -impl PlatformDispatcher for AndroidDispatcher { - fn is_main_thread(&self) -> bool { - thread::current().id() == self.main_thread_id - } - - fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { - if priority == Priority::RealtimeAudio { - panic!("RealtimeAudio priority must use spawn_realtime"); - } - if let Err(runnable) = self.background_queue.push(priority, runnable) { - log::error!("Android background queue disconnected during shutdown"); - mem::forget(runnable); - } - } - - fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { - self.enqueue_main(runnable, priority); - } - - fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - let duration = duration.min(MAX_TIMER_DELAY); - let scheduled = ScheduledRunnable { - deadline: Instant::now() + duration, - sequence: self.timer_sequence.fetch_add(1, AtomicOrdering::Relaxed), - runnable, - }; - if let Err(error) = self.timer_sender.send(scheduled) { - log::error!("Android timer queue disconnected during shutdown"); - mem::forget(error.0.runnable); - } - } - - fn spawn_realtime(&self, function: Box) { - // Android realtime scheduling needs audio-specific Java/AAudio policy; a - // dedicated thread preserves isolation until that policy is supplied. - if let Err(error) = thread::Builder::new() - .name("gpui-realtime".into()) - .spawn(function) - { - log::error!("failed to spawn Android realtime thread: {error}"); - } - } -} - -impl Drop for AndroidDispatcher { - fn drop(&mut self) { - self.main_queue.close(); - // The final dispatcher Arc can be released by a worker; leak queued - // foreground futures rather than dropping their !Send state there. - while let Some(runnable) = self.main_queue.try_pop() { - mem::forget(runnable); - } - self.background_queue.close(); - } -} - -fn execute_runnable(runnable: RunnableVariant) { - let location = runnable.metadata().location; - let spawned = runnable.metadata().spawned; - gpui::profiler::update_running_task(spawned, location); - runnable.run(); - gpui::profiler::save_task_timing(); -} - -fn run_timer_thread(receiver: mpsc::Receiver) { - let mut pending = BinaryHeap::new(); - loop { - let next = pending.peek().map(|entry: &ScheduledRunnable| { - entry.deadline.saturating_duration_since(Instant::now()) - }); - - let received = match next { - Some(timeout) => receiver.recv_timeout(timeout), - None => match receiver.recv() { - Ok(item) => { - pending.push(item); - continue; - } - Err(_) => break, - }, - }; - - match received { - Ok(item) => pending.push(item), - Err(mpsc::RecvTimeoutError::Timeout) => { - if let Some(item) = pending.pop() { - execute_runnable(item.runnable); - } - } - Err(mpsc::RecvTimeoutError::Disconnected) => break, - } - } - - // Dropping scheduled runnables cancels awaiters; during shutdown they must - // remain pending instead. - for item in pending { - mem::forget(item.runnable); - } -} diff --git a/crates/gpui-android/src/display.rs b/crates/gpui-android/src/display.rs deleted file mode 100644 index f3395e4c..00000000 --- a/crates/gpui-android/src/display.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::{cell::Cell, fmt}; - -use anyhow::Result; -use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay}; - -use crate::AndroidDisplayMetrics; - -pub(crate) struct AndroidDisplay { - metrics: Cell, -} - -impl AndroidDisplay { - pub(crate) fn new(metrics: AndroidDisplayMetrics) -> Self { - Self { - metrics: Cell::new(metrics), - } - } - - pub(crate) fn update(&self, metrics: AndroidDisplayMetrics) { - self.metrics.set(metrics); - } -} - -impl fmt::Debug for AndroidDisplay { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("AndroidDisplay") - .field("id", &DisplayId::new(1)) - .field("metrics", &self.metrics.get()) - .finish() - } -} - -impl PlatformDisplay for AndroidDisplay { - fn id(&self) -> DisplayId { - DisplayId::new(1) - } - - fn uuid(&self) -> Result { - // Android's activity window is modeled as one stable logical display. - Ok(uuid::Uuid::from_u128( - 0x69d9_8b2a_37c3_4dca_a37d_726f_6964_0001, - )) - } - - fn bounds(&self) -> Bounds { - self.metrics.get().logical_bounds() - } - - fn visible_bounds(&self) -> Bounds { - // System bars and cutouts are overlays represented by WindowInsets. - self.bounds() - } - - fn default_bounds(&self) -> Bounds { - // Android activities own a single full-surface window. - self.bounds() - } -} diff --git a/crates/gpui-android/src/keyboard.rs b/crates/gpui-android/src/keyboard.rs deleted file mode 100644 index 76cdc504..00000000 --- a/crates/gpui-android/src/keyboard.rs +++ /dev/null @@ -1,13 +0,0 @@ -use gpui::PlatformKeyboardLayout; - -pub(crate) struct AndroidKeyboardLayout; - -impl PlatformKeyboardLayout for AndroidKeyboardLayout { - fn id(&self) -> &str { - "android" - } - - fn name(&self) -> &str { - "Android" - } -} diff --git a/crates/gpui-android/src/lib.rs b/crates/gpui-android/src/lib.rs deleted file mode 100644 index 96ad13fc..00000000 --- a/crates/gpui-android/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! GPUI platform backend for Android. -//! -//! The host build intentionally exports no backend. Android-only dependencies and -//! implementation details stay behind `target_os = "android"` so this workspace can -//! continue to build on desktop hosts. - -#[cfg(target_os = "android")] -mod dispatcher; -#[cfg(target_os = "android")] -mod display; -#[cfg(target_os = "android")] -mod keyboard; -#[cfg(target_os = "android")] -mod native; -#[cfg(target_os = "android")] -mod platform; -#[cfg(target_os = "android")] -mod window; - -#[cfg(target_os = "android")] -pub use dispatcher::AndroidDispatcher; -#[cfg(target_os = "android")] -pub use native::{AndroidDisplayMetrics, AndroidHost, AndroidNativeWindow, AndroidSurface}; -#[cfg(target_os = "android")] -pub use platform::{AndroidEventSink, AndroidPlatform}; diff --git a/crates/gpui-android/src/native.rs b/crates/gpui-android/src/native.rs deleted file mode 100644 index 03eb1cdb..00000000 --- a/crates/gpui-android/src/native.rs +++ /dev/null @@ -1,179 +0,0 @@ -use std::path::Path; - -use anyhow::{Result, bail}; -use gpui::{ - Bounds, Pixels, Size, TextInputStateChange, ThermalState, WindowAppearance, point, px, size, -}; -use ndk::native_window::NativeWindow; -use raw_window_handle::{ - DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, -}; - -/// Physical display information supplied by the Android activity. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct AndroidDisplayMetrics { - /// Width of the current surface in physical pixels. - pub width_px: u32, - /// Height of the current surface in physical pixels. - pub height_px: u32, - /// Number of physical pixels per GPUI logical pixel. - pub scale_factor: f32, -} - -impl AndroidDisplayMetrics { - /// Creates validated display metrics. - pub fn new(width_px: u32, height_px: u32, scale_factor: f32) -> Result { - let metrics = Self { - width_px, - height_px, - scale_factor, - }; - metrics.validate()?; - Ok(metrics) - } - - pub(crate) fn validate(self) -> Result<()> { - if self.width_px == 0 || self.height_px == 0 { - bail!("Android surface dimensions must be non-zero"); - } - if self.width_px > i32::MAX as u32 || self.height_px > i32::MAX as u32 { - bail!("Android surface dimensions exceed GPUI's device-pixel range"); - } - if !self.scale_factor.is_finite() || self.scale_factor <= 0.0 { - bail!("Android display scale factor must be finite and positive"); - } - Ok(()) - } - - pub(crate) fn logical_size(self) -> Size { - size( - px(self.width_px as f32 / self.scale_factor), - px(self.height_px as f32 / self.scale_factor), - ) - } - - pub(crate) fn logical_bounds(self) -> Bounds { - Bounds::new(point(px(0.0), px(0.0)), self.logical_size()) - } -} - -/// A retained `ANativeWindow` suitable for raw-window-handle and wgpu. -/// -/// `ndk::NativeWindow` owns an `ANativeWindow_acquire` reference, so cloning -/// this value keeps the pointer alive until every renderer/surface lease drops. -#[derive(Clone, Debug)] -pub struct AndroidNativeWindow { - inner: NativeWindow, -} - -impl AndroidNativeWindow { - /// Wraps a retained native window acquired by the JNI/NDK integration. - pub fn new(inner: NativeWindow) -> Self { - Self { inner } - } - - /// Returns the retained NDK window for integrations that need NDK APIs. - pub fn as_ndk(&self) -> &NativeWindow { - &self.inner - } -} - -impl From for AndroidNativeWindow { - fn from(inner: NativeWindow) -> Self { - Self::new(inner) - } -} - -impl HasWindowHandle for AndroidNativeWindow { - fn window_handle(&self) -> Result, HandleError> { - self.inner.window_handle() - } -} - -impl HasDisplayHandle for AndroidNativeWindow { - fn display_handle(&self) -> Result, HandleError> { - Ok(DisplayHandle::android()) - } -} - -/// One Android surface generation and its matching display metrics. -#[derive(Clone, Debug)] -pub struct AndroidSurface { - pub(crate) window: AndroidNativeWindow, - pub(crate) metrics: AndroidDisplayMetrics, -} - -impl AndroidSurface { - /// Creates a surface generation from a retained native window. - pub fn new(window: AndroidNativeWindow, metrics: AndroidDisplayMetrics) -> Result { - metrics.validate()?; - Ok(Self { window, metrics }) - } - - /// Returns the physical display metrics associated with this surface. - pub fn metrics(&self) -> AndroidDisplayMetrics { - self.metrics - } -} - -/// Outbound half of the Android JNI/NDK boundary. -/// -/// The Java/Kotlin activity (or `android-activity` glue) implements this trait. -/// Its matching inbound half is [`crate::AndroidEventSink`]. Keeping OS calls -/// here lets surface, lifecycle, Choreographer, clipboard, back, and IME glue be -/// added without changing the GPUI trait implementations. -pub trait AndroidHost: Send + Sync { - /// Wake the activity's main Looper so it calls `drain_main_thread`. - fn wake_main_thread(&self); - - /// Schedule one Choreographer callback that calls `frame`. - /// - /// The implementation must coalesce calls while a callback is pending. - /// `AndroidEventSink::frame` rearms the next callback while the window is active. - fn request_frame(&self); - - /// Finish the owning activity. - fn finish_activity(&self); - - /// Launch an Android intent for the URI. - fn open_uri(&self, uri: &str) -> Result<()>; - - /// Launch an Android intent for a filesystem path, if the app can expose it. - fn open_path(&self, path: &Path) -> Result<()>; - - /// Read plain text through Android's ClipboardManager. - fn read_clipboard_text(&self) -> Result>; - - /// Write plain text through Android's ClipboardManager. - fn write_clipboard_text(&self, text: &str) -> Result<()>; - - /// Store a credential through the Android Keystore-backed host service. - fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Result<()>; - - /// Read a credential through the Android Keystore-backed host service. - fn read_credentials(&self, url: &str) -> Result)>>; - - /// Delete a credential through the Android Keystore-backed host service. - fn delete_credentials(&self, url: &str) -> Result<()>; - - /// Show the activity's input method. - fn show_soft_keyboard(&self); - - /// Hide the activity's input method. - fn hide_soft_keyboard(&self); - - /// Update the logical caret bounds used to position the IME. - fn update_ime_position(&self, bounds: Bounds); - - /// Notify the host that GPUI's focused text-input state changed. - fn text_input_state_changed(&self, change: TextInputStateChange); - - /// Enable or disable the activity's registered back callback. - fn set_back_enabled(&self, enabled: bool); - - /// Query the current Android UI mode. - fn window_appearance(&self) -> WindowAppearance; - - /// Query Android's current thermal status. - fn thermal_state(&self) -> ThermalState; -} diff --git a/crates/gpui-android/src/platform.rs b/crates/gpui-android/src/platform.rs deleted file mode 100644 index b3f944e0..00000000 --- a/crates/gpui-android/src/platform.rs +++ /dev/null @@ -1,735 +0,0 @@ -use std::{ - borrow::Cow, - cell::RefCell, - ops::Range, - path::{Path, PathBuf}, - rc::{Rc, Weak}, - sync::Arc, -}; - -use anyhow::{Context as _, Result, anyhow, bail}; -use futures::channel::oneshot; -use gpui::{ - Action, AnyWindowHandle, AppLifecyclePhase, BackgroundExecutor, ClipboardItem, CursorStyle, - DispatchEventResult, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, - OwnedMenu, PathPromptOptions, Platform, PlatformDispatcher, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, - ScreenCaptureSource, Task, ThermalState, TouchEvent, TouchId, TouchPhase, WindowAppearance, - WindowInsets, WindowKind, WindowParams, point, popup::PopupNotSupportedError, px, -}; -use gpui_wgpu::{CosmicTextSystem, GpuContext}; - -use crate::{ - AndroidDisplayMetrics, AndroidHost, AndroidSurface, - dispatcher::AndroidDispatcher, - display::AndroidDisplay, - keyboard::AndroidKeyboardLayout, - window::{AndroidWindow, AndroidWindowInner}, -}; - -static BUNDLED_FONTS: &[&[u8]] = &[ - include_bytes!("../../../assets/fonts/DMSans[wght].ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), -]; - -#[derive(Default)] -struct AndroidPlatformCallbacks { - open_urls: Option)>>, - quit: Option>, - reopen: Option>, - system_wake: Option>, - lifecycle: Option>, - memory_warning: Option>, - thermal_state_change: Option>, - keyboard_layout_change: Option>, -} - -struct AndroidPlatformState { - surface: Option, - active_window: Option, - window: Option>, - appearance: WindowAppearance, - thermal_state: ThermalState, - callbacks: AndroidPlatformCallbacks, -} - -/// GPUI's Android platform implementation. -pub struct AndroidPlatform { - host: Arc, - dispatcher: Arc, - background_executor: BackgroundExecutor, - foreground_executor: ForegroundExecutor, - text_system: Arc, - display: Rc, - state: Rc>, - gpu_context: GpuContext, -} - -impl AndroidPlatform { - /// Creates the backend on Android's UI thread with the current surface generation. - pub fn new(host: Arc, surface: AndroidSurface) -> Result { - surface.metrics.validate()?; - - let dispatcher = Arc::new(AndroidDispatcher::new(host.clone())?); - let background_executor = BackgroundExecutor::new(dispatcher.clone()); - let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - - let text_system = Arc::new(CosmicTextSystem::new_without_system_fonts("DM Sans")); - text_system.add_fonts( - BUNDLED_FONTS - .iter() - .map(|bytes| Cow::Borrowed(*bytes)) - .collect(), - )?; - let text_system: Arc = text_system; - - let display = Rc::new(AndroidDisplay::new(surface.metrics)); - let appearance = host.window_appearance(); - let thermal_state = host.thermal_state(); - - Ok(Self { - host, - dispatcher, - background_executor, - foreground_executor, - text_system, - display, - state: Rc::new(RefCell::new(AndroidPlatformState { - surface: Some(surface), - active_window: None, - window: None, - appearance, - thermal_state, - callbacks: AndroidPlatformCallbacks::default(), - })), - gpu_context: Rc::new(RefCell::new(None)), - }) - } - - /// Returns the inbound JNI/NDK event boundary for the owning activity. - pub fn event_sink(&self) -> AndroidEventSink { - AndroidEventSink { - host: self.host.clone(), - dispatcher: self.dispatcher.clone(), - display: self.display.clone(), - state: self.state.clone(), - } - } -} - -/// Main-thread entry points called by the Android activity and Choreographer. -/// -/// This type is deliberately `!Send`: Android UI, lifecycle, input, and surface -/// callbacks must enter GPUI on the same thread that constructed the platform. -#[derive(Clone)] -pub struct AndroidEventSink { - host: Arc, - dispatcher: Arc, - display: Rc, - state: Rc>, -} - -impl AndroidEventSink { - /// Drains foreground tasks after `AndroidHost::wake_main_thread`. - pub fn drain_main_thread(&self) { - self.dispatcher.drain_main_thread(); - } - - /// Replaces a surface after Android creates a new `ANativeWindow`. - pub fn surface_created(&self, surface: AndroidSurface) -> Result<()> { - self.assert_main_thread(); - surface.metrics.validate()?; - - if let Some(window) = self.window() { - window.replace_surface(surface.clone())?; - } - self.display.update(surface.metrics); - self.state.borrow_mut().surface = Some(surface); - - if self - .window() - .is_some_and(|window| window.should_schedule_frames()) - { - self.host.request_frame(); - } - Ok(()) - } - - /// Unconfigures wgpu before Android releases the current native window. - pub fn surface_destroyed(&self) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.detach_surface(); - } - self.state.borrow_mut().surface = None; - } - - /// Applies a native surface resize or density change. - pub fn resized(&self, metrics: AndroidDisplayMetrics) -> Result<()> { - self.assert_main_thread(); - metrics.validate()?; - if let Some(surface) = self.state.borrow_mut().surface.as_mut() { - surface.metrics = metrics; - } - self.display.update(metrics); - if let Some(window) = self.window() { - window.resize_from_native(metrics); - } - Ok(()) - } - - /// Delivers one Choreographer frame and schedules the next while visible. - pub fn frame(&self) { - self.assert_main_thread(); - let Some(window) = self.window() else { - return; - }; - window.frame(); - if window.should_schedule_frames() { - self.host.request_frame(); - } - } - - /// Delivers an already translated GPUI input event. - pub fn dispatch_input(&self, input: gpui::PlatformInput) -> Option { - self.assert_main_thread(); - self.window() - .and_then(|window| window.dispatch_input(input)) - } - - /// Converts a physical Android touch into logical coordinates and mouse input. - /// - /// GPUI core does not yet dispatch `TouchEvent` to elements, so the primary - /// contact is synthesized as left-button mouse input for the mobile MVP. - pub fn touch( - &self, - id: u64, - phase: TouchPhase, - x_px: f32, - y_px: f32, - force: Option, - ) -> Option { - self.assert_main_thread(); - let window = self.window()?; - let scale = window.scale_factor(); - window.dispatch_touch(TouchEvent { - id: TouchId(id), - phase, - position: point(px(x_px / scale), px(y_px / scale)), - force, - }) - } - - /// Updates focus from Android lifecycle or pointer-device callbacks. - pub fn active_changed(&self, active: bool) { - self.assert_main_thread(); - if let Some(window) = self.window() { - let changed = window.set_active(active); - if changed && active && window.should_schedule_frames() { - // A surface can survive pause/resume, so restart the frame chain explicitly. - self.host.request_frame(); - } - } - } - - /// Updates hover state for attached mouse or stylus devices. - pub fn hovered_changed(&self, hovered: bool) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.set_hovered(hovered); - } - } - - /// Notifies GPUI that Android moved the activity to another display. - pub fn moved(&self) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.moved(); - } - } - - /// Runs GPUI's registered window-control hit test, if any. - pub fn hit_test_window_control(&self) -> Option { - self.assert_main_thread(); - self.window() - .and_then(|window| window.hit_test_window_control()) - } - - /// Delivers Android's application lifecycle vocabulary. - pub fn lifecycle_changed(&self, phase: AppLifecyclePhase) { - self.assert_main_thread(); - if let Some(window) = self.window() { - let active = matches!(phase, AppLifecyclePhase::Active); - let changed = window.set_active(active); - if changed && active && window.should_schedule_frames() { - // Android may resume without recreating the surface. - self.host.request_frame(); - } - } - if let Some(callback) = self.state.borrow_mut().callbacks.lifecycle.as_mut() { - callback(phase); - } - } - - /// Delivers `onTrimMemory`/memory-pressure notification. - pub fn memory_warning(&self) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.memory_warning.as_mut() { - callback(); - } - } - - /// Delivers animated safe-area and IME inset updates in logical pixels. - pub fn insets_changed(&self, insets: WindowInsets) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.set_insets(insets); - } - } - - /// Runs GPUI's back callback. Returns false when Android should use its default. - pub fn back_pressed(&self) -> bool { - self.assert_main_thread(); - self.window().is_some_and(|window| window.back_pressed()) - } - - /// Consults GPUI's close veto, delivers close, and finishes the activity. - pub fn close_requested(&self) -> bool { - self.assert_main_thread(); - if let Some(window) = self.window() - && !window.close_requested() - { - return false; - } - - { - let mut state = self.state.borrow_mut(); - state.active_window = None; - state.window = None; - } - self.host.finish_activity(); - true - } - - /// Delivers Android UI-mode changes. - pub fn appearance_changed(&self, appearance: WindowAppearance) { - self.assert_main_thread(); - self.state.borrow_mut().appearance = appearance; - if let Some(window) = self.window() { - window.set_appearance(appearance); - } - } - - /// Delivers Android thermal-status changes. - pub fn thermal_state_changed(&self, thermal_state: ThermalState) { - self.assert_main_thread(); - let changed = self.state.borrow().thermal_state != thermal_state; - self.state.borrow_mut().thermal_state = thermal_state; - if changed - && let Some(callback) = self - .state - .borrow_mut() - .callbacks - .thermal_state_change - .as_mut() - { - callback(); - } - } - - /// Delivers Android keyboard-layout changes. - pub fn keyboard_layout_changed(&self) { - self.assert_main_thread(); - if let Some(callback) = self - .state - .borrow_mut() - .callbacks - .keyboard_layout_change - .as_mut() - { - callback(); - } - } - - /// Delivers VIEW intents registered by the Android manifest. - pub fn open_urls(&self, urls: Vec) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.open_urls.as_mut() { - callback(urls); - } - } - - /// Delivers a host-driven reopen request. - pub fn reopen(&self) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.reopen.as_mut() { - callback(); - } - } - - /// Delivers a system-wake notification. - pub fn system_wake(&self) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.system_wake.as_mut() { - callback(); - } - } - - /// Commits text from Android's InputConnection. - pub fn ime_replace_text(&self, range: Option>, text: &str) -> bool { - self.assert_main_thread(); - self.window() - .is_some_and(|window| window.ime_replace_text(range, text)) - } - - /// Updates composing text from Android's InputConnection. - pub fn ime_set_composing_text( - &self, - range: Option>, - text: &str, - selected_range: Option>, - ) -> bool { - self.assert_main_thread(); - self.window() - .is_some_and(|window| window.ime_set_composing_text(range, text, selected_range)) - } - - /// Finishes the current IME composition. - pub fn ime_finish_composing(&self) -> bool { - self.assert_main_thread(); - self.window() - .is_some_and(|window| window.ime_finish_composing()) - } - - fn window(&self) -> Option> { - self.state.borrow().window.as_ref().and_then(Weak::upgrade) - } - - fn assert_main_thread(&self) { - assert!( - self.dispatcher.is_main_thread(), - "Android event delivered outside the main thread" - ); - } -} - -impl Platform for AndroidPlatform { - fn background_executor(&self) -> BackgroundExecutor { - self.background_executor.clone() - } - - fn foreground_executor(&self) -> ForegroundExecutor { - self.foreground_executor.clone() - } - - fn text_system(&self) -> Arc { - self.text_system.clone() - } - - fn run(&self, on_finish_launching: Box) { - assert!( - self.dispatcher.is_main_thread(), - "AndroidPlatform::run must be called on the activity UI thread" - ); - // Android's Looper already owns the event loop; GPUI launches inside it. - on_finish_launching(); - } - - fn quit(&self) { - if let Some(callback) = self.state.borrow_mut().callbacks.quit.as_mut() { - callback(); - } - self.host.finish_activity(); - } - - fn restart(&self, _binary_path: Option) { - // Android packages cannot restart by executing a replacement binary path. - log::warn!("Android does not support Platform::restart"); - } - - fn activate(&self, _ignoring_other_apps: bool) { - // Android owns activity activation through its lifecycle. - } - - fn hide(&self) { - // Android activities are backgrounded by the OS, not hidden as windows. - } - - fn hide_other_apps(&self) { - // Android apps cannot hide other applications. - } - - fn unhide_other_apps(&self) { - // Android apps cannot change other applications' visibility. - } - - fn displays(&self) -> Vec> { - vec![self.display.clone()] - } - - fn primary_display(&self) -> Option> { - Some(self.display.clone()) - } - - fn active_window(&self) -> Option { - self.state.borrow().active_window - } - - fn window_stack(&self) -> Option> { - self.state.borrow().active_window.map(|window| vec![window]) - } - - fn is_screen_capture_supported(&self) -> bool { - false - } - - fn screen_capture_sources( - &self, - ) -> oneshot::Receiver>>> { - // Android MediaProjection is consent/activity-result based and not this synchronous API. - unsupported_receiver("screen capture is not supported by gpui-android") - } - - fn open_window( - &self, - handle: AnyWindowHandle, - params: WindowParams, - ) -> Result> { - if matches!(¶ms.kind, WindowKind::AnchoredPopup(_)) { - return Err(PopupNotSupportedError.into()); - } - if !matches!(¶ms.kind, WindowKind::Normal) { - bail!("Android currently supports only its single normal activity window"); - } - if self - .state - .borrow() - .window - .as_ref() - .and_then(Weak::upgrade) - .is_some() - { - bail!("Android currently supports only one GPUI platform window"); - } - - let surface = self - .state - .borrow() - .surface - .clone() - .context("Android native surface is not currently available")?; - let appearance = self.state.borrow().appearance; - let window = AndroidWindow::new( - self.host.clone(), - self.gpu_context.clone(), - self.display.clone(), - surface, - appearance, - )?; - - { - let mut state = self.state.borrow_mut(); - state.active_window = Some(handle); - state.window = Some(Rc::downgrade(&window.inner)); - } - Ok(Box::new(window)) - } - - fn window_appearance(&self) -> WindowAppearance { - self.state.borrow().appearance - } - - fn open_url(&self, url: &str) { - if let Err(error) = self.host.open_uri(url) { - log::warn!("failed to open Android URI {url:?}: {error:#}"); - } - } - - fn on_open_urls(&self, callback: Box)>) { - self.state.borrow_mut().callbacks.open_urls = Some(callback); - } - - fn register_url_scheme(&self, _url: &str) -> Task> { - // Android URL schemes are declared statically as manifest intent filters. - Task::ready(Err(anyhow!( - "Android URL schemes must be registered in AndroidManifest.xml" - ))) - } - - fn prompt_for_paths( - &self, - _options: PathPromptOptions, - ) -> oneshot::Receiver>>> { - // Android's Storage Access Framework requires an Activity result contract. - unsupported_receiver("file selection is not supported by gpui-android") - } - - fn prompt_for_new_path( - &self, - _directory: &Path, - _suggested_name: Option<&str>, - ) -> oneshot::Receiver>> { - // Android's Storage Access Framework returns content URIs, not writable Paths. - unsupported_receiver("new-path prompts are not supported by gpui-android") - } - - fn can_select_mixed_files_and_dirs(&self) -> bool { - false - } - - fn reveal_path(&self, _path: &Path) { - // Android has no system file-manager reveal operation for arbitrary Paths. - } - - fn open_with_system(&self, path: &Path) { - if let Err(error) = self.host.open_path(path) { - log::warn!("failed to open Android path {path:?}: {error:#}"); - } - } - - fn on_quit(&self, callback: Box) { - self.state.borrow_mut().callbacks.quit = Some(callback); - } - - fn on_reopen(&self, callback: Box) { - self.state.borrow_mut().callbacks.reopen = Some(callback); - } - - fn on_system_wake(&self, callback: Box) { - self.state.borrow_mut().callbacks.system_wake = Some(callback); - } - - fn on_app_lifecycle(&self, callback: Box) { - self.state.borrow_mut().callbacks.lifecycle = Some(callback); - } - - fn on_memory_warning(&self, callback: Box) { - self.state.borrow_mut().callbacks.memory_warning = Some(callback); - } - - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) { - // Android has no process-wide desktop menu bar. - } - - fn get_menus(&self) -> Option> { - // Android has no process-wide desktop menu bar to query. - None - } - - fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) { - // Android has no desktop dock menu. - } - - fn perform_dock_menu_action(&self, _action: usize) { - // Android has no desktop dock menu action source. - } - - fn add_recent_document(&self, _path: &Path) { - // Android's recents screen tracks activities, not application documents. - } - - fn on_app_menu_action(&self, _callback: Box) { - // Android has no GPUI app-menu action source. - } - - fn on_will_open_app_menu(&self, _callback: Box) { - // Android has no process-wide application menu. - } - - fn on_validate_app_menu_command(&self, _callback: Box bool>) { - // Android has no process-wide application-menu commands to validate. - } - - fn thermal_state(&self) -> ThermalState { - self.state.borrow().thermal_state - } - - fn on_thermal_state_change(&self, callback: Box) { - self.state.borrow_mut().callbacks.thermal_state_change = Some(callback); - } - - fn compositor_name(&self) -> &'static str { - "Android SurfaceFlinger" - } - - fn app_path(&self) -> Result { - // Android applications are APKs, not executable filesystem bundles. - bail!("application paths are not available on Android") - } - - fn path_for_auxiliary_executable(&self, _name: &str) -> Result { - // Android packages cannot ship independently executed auxiliary binaries. - bail!("auxiliary executable paths are not available on Android") - } - - fn set_cursor_style(&self, _style: CursorStyle) { - // The mobile MVP has touch input and no application-controlled cursor. - } - - fn hide_cursor_until_mouse_moves(&self) { - // The mobile MVP has no visible mouse cursor to hide. - } - - fn is_cursor_visible(&self) -> bool { - false - } - - fn should_auto_hide_scrollbars(&self) -> bool { - true - } - - fn read_from_clipboard(&self) -> Option { - match self.host.read_clipboard_text() { - Ok(Some(text)) => Some(ClipboardItem::new_string(text)), - Ok(None) => None, - Err(error) => { - log::warn!("failed to read Android clipboard: {error:#}"); - None - } - } - } - - fn write_to_clipboard(&self, item: ClipboardItem) { - let Some(text) = item.text() else { - // Android host seam currently supports GPUI's plain-text clipboard entries only. - log::warn!("gpui-android cannot write a non-text clipboard item"); - return; - }; - if let Err(error) = self.host.write_clipboard_text(&text) { - log::warn!("failed to write Android clipboard: {error:#}"); - } - } - - fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { - Task::ready(self.host.write_credentials(url, username, password)) - } - - fn read_credentials(&self, url: &str) -> Task)>>> { - Task::ready(self.host.read_credentials(url)) - } - - fn delete_credentials(&self, url: &str) -> Task> { - Task::ready(self.host.delete_credentials(url)) - } - - fn keyboard_layout(&self) -> Box { - Box::new(AndroidKeyboardLayout) - } - - fn keyboard_mapper(&self) -> Rc { - Rc::new(DummyKeyboardMapper) - } - - fn on_keyboard_layout_change(&self, callback: Box) { - self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); - } -} - -fn unsupported_receiver(message: &'static str) -> oneshot::Receiver> { - let (sender, receiver) = oneshot::channel(); - sender.send(Err(anyhow!(message))).ok(); - receiver -} diff --git a/crates/gpui-android/src/window.rs b/crates/gpui-android/src/window.rs deleted file mode 100644 index fed5e4b8..00000000 --- a/crates/gpui-android/src/window.rs +++ /dev/null @@ -1,670 +0,0 @@ -use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; - -use anyhow::{Context as _, Result}; -use futures::channel::oneshot; -use gpui::{ - Bounds, Capslock, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, - RequestFrameOptions, Scene, Size, TextInputStateChange, TouchEvent, TouchId, TouchPhase, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowInsets, -}; -use gpui_wgpu::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; -use raw_window_handle::{ - DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, -}; - -use crate::{ - AndroidDisplayMetrics, AndroidHost, AndroidNativeWindow, AndroidSurface, - display::AndroidDisplay, -}; - -type InputCallback = Box DispatchEventResult>; -type ResizeCallback = Box, f32)>; -type HitTestCallback = Box Option>; - -#[derive(Default)] -struct AndroidWindowCallbacks { - request_frame: Option>, - input: Option, - active_status_change: Option>, - hover_status_change: Option>, - resize: Option, - moved: Option>, - should_close: Option bool>>, - hit_test_window_control: Option, - close: Option>, - appearance_changed: Option>, - insets_changed: Option>, - back: Option>, -} - -struct AndroidWindowState { - // Drop the renderer before either retained ANativeWindow lease. - renderer: WgpuRenderer, - native_window: Option, - retired_native_window: Option, - bounds: Bounds, - scale_factor: f32, - appearance: WindowAppearance, - background_appearance: WindowBackgroundAppearance, - title: String, - input_handler: Option, - active: bool, - hovered: bool, - mouse_position: Point, - modifiers: Modifiers, - capslock: Capslock, - pressed_button: Option, - primary_touch: Option, - insets: WindowInsets, - back_enabled: bool, -} - -pub(crate) struct AndroidWindowInner { - host: Arc, - gpu_context: GpuContext, - state: RefCell, - callbacks: RefCell, -} - -pub(crate) struct AndroidWindow { - pub(crate) inner: Rc, - display: Rc, -} - -impl AndroidWindow { - pub(crate) fn new( - host: Arc, - gpu_context: GpuContext, - display: Rc, - surface: AndroidSurface, - appearance: WindowAppearance, - ) -> Result { - let renderer = WgpuRenderer::new( - gpu_context.clone(), - &surface.window, - surface_config(surface.metrics, false), - None, - ) - .context("failed to create Android wgpu renderer")?; - - let state = AndroidWindowState { - renderer, - native_window: Some(surface.window), - retired_native_window: None, - bounds: surface.metrics.logical_bounds(), - scale_factor: surface.metrics.scale_factor, - appearance, - background_appearance: WindowBackgroundAppearance::Opaque, - title: String::new(), - input_handler: None, - active: true, - hovered: false, - mouse_position: Point::default(), - modifiers: Modifiers::default(), - capslock: Capslock::default(), - pressed_button: None, - primary_touch: None, - insets: WindowInsets::default(), - back_enabled: false, - }; - - Ok(Self { - inner: Rc::new(AndroidWindowInner { - host, - gpu_context, - state: RefCell::new(state), - callbacks: RefCell::new(AndroidWindowCallbacks::default()), - }), - display, - }) - } -} - -impl AndroidWindowInner { - pub(crate) fn replace_surface(&self, surface: AndroidSurface) -> Result<()> { - let instance = self - .gpu_context - .borrow() - .as_ref() - .context("wgpu context missing while replacing Android surface")? - .instance - .clone(); - - { - let mut state = self.state.borrow_mut(); - let transparent = state.background_appearance != WindowBackgroundAppearance::Opaque; - state.renderer.replace_surface( - &surface.window, - surface_config(surface.metrics, transparent), - &instance, - )?; - state.native_window = Some(surface.window); - state.retired_native_window = None; - state.bounds = surface.metrics.logical_bounds(); - state.scale_factor = surface.metrics.scale_factor; - } - - self.notify_resize(surface.metrics); - Ok(()) - } - - pub(crate) fn detach_surface(&self) { - let mut state = self.state.borrow_mut(); - if state.native_window.is_some() { - state.renderer.unconfigure_surface(); - state.retired_native_window = state.native_window.take(); - } - } - - pub(crate) fn resize_from_native(&self, metrics: AndroidDisplayMetrics) { - { - let mut state = self.state.borrow_mut(); - state.bounds = metrics.logical_bounds(); - state.scale_factor = metrics.scale_factor; - if state.native_window.is_some() { - state.renderer.update_drawable_size(Size { - width: DevicePixels(metrics.width_px as i32), - height: DevicePixels(metrics.height_px as i32), - }); - } - } - self.notify_resize(metrics); - } - - fn notify_resize(&self, metrics: AndroidDisplayMetrics) { - if let Some(callback) = self.callbacks.borrow_mut().resize.as_mut() { - callback(metrics.logical_size(), metrics.scale_factor); - } - } - - pub(crate) fn frame(&self) { - if let Some(callback) = self.callbacks.borrow_mut().request_frame.as_mut() { - callback(RequestFrameOptions { - require_presentation: true, - force_render: false, - }); - } - } - - pub(crate) fn should_schedule_frames(&self) -> bool { - let state = self.state.borrow(); - state.active && state.native_window.is_some() - } - - pub(crate) fn dispatch_input(&self, input: PlatformInput) -> Option { - { - let mut state = self.state.borrow_mut(); - match &input { - PlatformInput::MouseDown(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - state.pressed_button = Some(event.button); - } - PlatformInput::MouseUp(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - state.pressed_button = None; - } - PlatformInput::MouseMove(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - state.pressed_button = event.pressed_button; - } - PlatformInput::ModifiersChanged(event) => { - state.modifiers = event.modifiers; - state.capslock = event.capslock; - } - PlatformInput::ScrollWheel(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - } - PlatformInput::Pinch(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - } - PlatformInput::KeyDown(_) - | PlatformInput::KeyUp(_) - | PlatformInput::MousePressure(_) - | PlatformInput::MouseExited(_) - | PlatformInput::FileDrop(_) - | PlatformInput::Touch(_) => {} - } - } - - self.callbacks - .borrow_mut() - .input - .as_mut() - .map(|callback| callback(input)) - } - - pub(crate) fn dispatch_touch(&self, touch: TouchEvent) -> Option { - let input = { - let mut state = self.state.borrow_mut(); - match touch.phase { - TouchPhase::Started if state.primary_touch.is_none() => { - state.primary_touch = Some(touch.id); - Some(PlatformInput::MouseDown(MouseDownEvent { - button: MouseButton::Left, - position: touch.position, - modifiers: Modifiers::default(), - click_count: 1, - first_mouse: false, - })) - } - TouchPhase::Moved if state.primary_touch == Some(touch.id) => { - Some(PlatformInput::MouseMove(MouseMoveEvent { - position: touch.position, - pressed_button: Some(MouseButton::Left), - modifiers: Modifiers::default(), - })) - } - TouchPhase::Ended | TouchPhase::Cancelled - if state.primary_touch == Some(touch.id) => - { - state.primary_touch = None; - // GPUI has no pointer-cancel mouse event yet; MouseUp is the - // only portable way to release captures for a cancelled touch. - Some(PlatformInput::MouseUp(MouseUpEvent { - button: MouseButton::Left, - position: touch.position, - modifiers: Modifiers::default(), - click_count: 1, - })) - } - TouchPhase::Started - | TouchPhase::Moved - | TouchPhase::Ended - | TouchPhase::Cancelled => None, - } - }; - input.and_then(|input| self.dispatch_input(input)) - } - - pub(crate) fn scale_factor(&self) -> f32 { - self.state.borrow().scale_factor - } - - pub(crate) fn set_active(&self, active: bool) -> bool { - let changed = { - let mut state = self.state.borrow_mut(); - if state.active == active { - false - } else { - state.active = active; - true - } - }; - if changed && let Some(callback) = self.callbacks.borrow_mut().active_status_change.as_mut() - { - callback(active); - } - changed - } - - pub(crate) fn set_hovered(&self, hovered: bool) { - let changed = { - let mut state = self.state.borrow_mut(); - if state.hovered == hovered { - false - } else { - state.hovered = hovered; - true - } - }; - if changed && let Some(callback) = self.callbacks.borrow_mut().hover_status_change.as_mut() - { - callback(hovered); - } - } - - pub(crate) fn moved(&self) { - if let Some(callback) = self.callbacks.borrow_mut().moved.as_mut() { - callback(); - } - } - - pub(crate) fn hit_test_window_control(&self) -> Option { - self.callbacks - .borrow_mut() - .hit_test_window_control - .as_mut() - .and_then(|callback| callback()) - } - - pub(crate) fn close_requested(&self) -> bool { - let should_close = self - .callbacks - .borrow_mut() - .should_close - .as_mut() - .is_none_or(|callback| callback()); - if !should_close { - return false; - } - - if let Some(callback) = self.callbacks.borrow_mut().close.take() { - callback(); - } - true - } - - pub(crate) fn set_appearance(&self, appearance: WindowAppearance) { - let changed = self.state.borrow().appearance != appearance; - self.state.borrow_mut().appearance = appearance; - if changed && let Some(callback) = self.callbacks.borrow_mut().appearance_changed.as_mut() { - callback(); - } - } - - pub(crate) fn set_insets(&self, insets: WindowInsets) { - let changed = self.state.borrow().insets != insets; - self.state.borrow_mut().insets = insets.clone(); - if changed && let Some(callback) = self.callbacks.borrow_mut().insets_changed.as_mut() { - callback(insets); - } - } - - pub(crate) fn back_pressed(&self) -> bool { - if !self.state.borrow().back_enabled { - return false; - } - if let Some(callback) = self.callbacks.borrow_mut().back.as_mut() { - callback(); - true - } else { - false - } - } - - pub(crate) fn ime_replace_text(&self, range: Option>, text: &str) -> bool { - self.with_input_handler(|handler| handler.replace_text_in_range(range, text)) - .is_some() - } - - pub(crate) fn ime_set_composing_text( - &self, - range: Option>, - text: &str, - selected_range: Option>, - ) -> bool { - self.with_input_handler(|handler| { - handler.replace_and_mark_text_in_range(range, text, selected_range); - }) - .is_some() - } - - pub(crate) fn ime_finish_composing(&self) -> bool { - self.with_input_handler(PlatformInputHandler::unmark_text) - .is_some() - } - - fn with_input_handler( - &self, - callback: impl FnOnce(&mut PlatformInputHandler) -> T, - ) -> Option { - let mut handler = self.state.borrow_mut().input_handler.take()?; - let result = callback(&mut handler); - self.state.borrow_mut().input_handler = Some(handler); - Some(result) - } -} - -impl HasWindowHandle for AndroidWindow { - fn window_handle(&self) -> Result, HandleError> { - let raw = { - let state = self.inner.state.borrow(); - let native_window = state - .native_window - .as_ref() - .ok_or(HandleError::Unavailable)?; - native_window.window_handle()?.as_raw() - }; - - // SAFETY: AndroidWindowInner retains the corresponding NativeWindow - // lease, and all lifecycle mutation is confined to the UI thread. - Ok(unsafe { WindowHandle::borrow_raw(raw) }) - } -} - -impl HasDisplayHandle for AndroidWindow { - fn display_handle(&self) -> Result, HandleError> { - Ok(DisplayHandle::android()) - } -} - -impl PlatformWindow for AndroidWindow { - fn bounds(&self) -> Bounds { - self.inner.state.borrow().bounds - } - - fn is_maximized(&self) -> bool { - false - } - - fn window_bounds(&self) -> WindowBounds { - WindowBounds::Fullscreen(self.bounds()) - } - - fn content_size(&self) -> Size { - self.bounds().size - } - - fn resize(&mut self, _size: Size) { - // Android owns activity surface dimensions; native resize callbacks update GPUI. - } - - fn scale_factor(&self) -> f32 { - self.inner.state.borrow().scale_factor - } - - fn appearance(&self) -> WindowAppearance { - self.inner.state.borrow().appearance - } - - fn display(&self) -> Option> { - Some(self.display.clone()) - } - - fn mouse_position(&self) -> Point { - self.inner.state.borrow().mouse_position - } - - fn modifiers(&self) -> Modifiers { - self.inner.state.borrow().modifiers - } - - fn capslock(&self) -> Capslock { - self.inner.state.borrow().capslock - } - - fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { - self.inner.state.borrow_mut().input_handler = Some(input_handler); - } - - fn take_input_handler(&mut self) -> Option { - self.inner.state.borrow_mut().input_handler.take() - } - - fn prompt( - &self, - _level: PromptLevel, - _msg: &str, - _detail: Option<&str>, - _answers: &[PromptButton], - ) -> Option> { - // Android native prompts need an Activity result contract, absent from GPUI's sync API. - None - } - - fn activate(&self) { - // Android, not an application window, owns foreground activation. - } - - fn is_active(&self) -> bool { - self.inner.state.borrow().active - } - - fn is_hovered(&self) -> bool { - self.inner.state.borrow().hovered - } - - fn background_appearance(&self) -> WindowBackgroundAppearance { - self.inner.state.borrow().background_appearance - } - - fn set_title(&mut self, title: &str) { - // Android task labels come from the manifest; retain the title for GPUI queries. - self.inner.state.borrow_mut().title = title.to_owned(); - } - - fn set_background_appearance(&self, appearance: WindowBackgroundAppearance) { - let mut state = self.inner.state.borrow_mut(); - state.background_appearance = appearance; - if state.native_window.is_some() { - // Reconfiguring the retired surface after surfaceDestroyed can hang Android drivers. - state - .renderer - .update_transparency(appearance != WindowBackgroundAppearance::Opaque); - } - } - - fn minimize(&self) { - // Android activity visibility is lifecycle-owned, not window-minimized. - } - - fn zoom(&self) { - // Android has no desktop maximize/zoom window operation. - } - - fn toggle_fullscreen(&self) { - // The activity surface is the backend's single full-screen GPUI window. - } - - fn is_fullscreen(&self) -> bool { - true - } - - fn on_request_frame(&self, callback: Box) { - self.inner.callbacks.borrow_mut().request_frame = Some(callback); - self.inner.host.request_frame(); - } - - fn on_input(&self, callback: Box DispatchEventResult>) { - self.inner.callbacks.borrow_mut().input = Some(callback); - } - - fn on_active_status_change(&self, callback: Box) { - self.inner.callbacks.borrow_mut().active_status_change = Some(callback); - } - - fn on_hover_status_change(&self, callback: Box) { - self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); - } - - fn on_resize(&self, callback: Box, f32)>) { - self.inner.callbacks.borrow_mut().resize = Some(callback); - } - - fn on_moved(&self, callback: Box) { - self.inner.callbacks.borrow_mut().moved = Some(callback); - } - - fn on_should_close(&self, callback: Box bool>) { - self.inner.callbacks.borrow_mut().should_close = Some(callback); - } - - fn on_hit_test_window_control(&self, callback: Box Option>) { - self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); - } - - fn on_close(&self, callback: Box) { - self.inner.callbacks.borrow_mut().close = Some(callback); - } - - fn on_appearance_changed(&self, callback: Box) { - self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); - } - - fn draw(&self, scene: &Scene) { - self.inner.state.borrow_mut().renderer.draw(scene); - } - - fn completed_frame(&self) { - // WgpuRenderer presents the Android surface as part of draw. - } - - fn sprite_atlas(&self) -> Arc { - self.inner.state.borrow().renderer.sprite_atlas().clone() - } - - fn is_subpixel_rendering_supported(&self) -> bool { - // Android display rotation makes RGB stripe order unstable; use grayscale glyphs. - false - } - - fn get_title(&self) -> String { - self.inner.state.borrow().title.clone() - } - - fn window_controls(&self) -> WindowControls { - WindowControls { - fullscreen: false, - maximize: false, - minimize: false, - window_menu: false, - } - } - - fn gpu_specs(&self) -> Option { - Some(self.inner.state.borrow().renderer.gpu_specs()) - } - - fn update_ime_position(&self, bounds: Bounds) { - self.inner.host.update_ime_position(bounds); - } - - fn insets(&self) -> WindowInsets { - self.inner.state.borrow().insets.clone() - } - - fn on_insets_changed(&self, callback: Box) { - self.inner.callbacks.borrow_mut().insets_changed = Some(callback); - } - - fn set_back_handler(&self, callback: Box) { - self.inner.callbacks.borrow_mut().back = Some(callback); - } - - fn set_back_enabled(&self, enabled: bool) { - self.inner.state.borrow_mut().back_enabled = enabled; - self.inner.host.set_back_enabled(enabled); - } - - fn show_soft_keyboard(&self) { - self.inner.host.show_soft_keyboard(); - } - - fn hide_soft_keyboard(&self) { - self.inner.host.hide_soft_keyboard(); - } - - fn text_input_state_changed(&self, change: TextInputStateChange) { - self.inner.host.text_input_state_changed(change); - } -} - -fn surface_config(metrics: AndroidDisplayMetrics, transparent: bool) -> WgpuSurfaceConfig { - WgpuSurfaceConfig { - size: Size { - width: DevicePixels(metrics.width_px as i32), - height: DevicePixels(metrics.height_px as i32), - }, - transparent, - // FIFO is universally available and remains valid across native-window replacement. - preferred_present_mode: None, - } -} diff --git a/crates/gpui-ios/Cargo.toml b/crates/gpui-ios/Cargo.toml deleted file mode 100644 index 869ea6db..00000000 --- a/crates/gpui-ios/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "gpui-ios" -version = "0.1.0" -edition = "2024" -publish = false - -[dependencies] -log = "0.4" - -# iOS-only. Like gpui-android, this crate exports nothing on a host build so -# ordinary desktop workspace builds are unaffected by an in-progress backend. -[target.'cfg(target_os = "ios")'.dependencies] -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_wgpu = { git = "https://github.com/zed-industries/zed" } -anyhow = "1" -futures = "0.3" -raw-window-handle = "0.6" -uuid = { version = "1", features = ["v4"] } diff --git a/crates/gpui-ios/README.md b/crates/gpui-ios/README.md deleted file mode 100644 index 2803ffdc..00000000 --- a/crates/gpui-ios/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# gpui-ios - -The iOS `gpui::Platform` backend. Compiled only for `target_os = "ios"`, so host -workspace builds are unaffected. - -Same division of labour as `gpui-android`: this crate owns the platform -implementation and the wgpu/Metal plumbing and links no Objective-C. The UIKit -shell implements `IosHost`, which keeps the backend a plain Rust crate that could -be offered upstream on its own. - -## iOS needs no upstream change - -An earlier plan recorded that it did, because `gpui_wgpu::WgpuContext::instance` -hardcodes `Backends::VULKAN | GL`. That was wrong, and the correction is the most -useful thing in this file. - -That helper is a convenience, not the only entry point: - -- `WgpuContext::new(instance, surface, compositor_gpu)` accepts an instance the - caller builds. -- `WgpuRenderer::new` takes `GpuContext = Rc>>` — a - shared slot it populates *only when empty*. - -So `metal_context()` builds a `Backends::METAL` instance, selects an adapter, and -hands back a pre-filled slot. The renderer then uses it and never reaches the -VULKAN|GL default. Backend selection happens once, in code we own. - -Measured against `aarch64-apple-ios`: `gpui` compiles, `gpui_wgpu` compiles, and -this crate compiles, all with zero errors. - -## What is verified - -Type-checking and clippy for `aarch64-apple-ios`. Nothing more. - -## What is not - -No device, no simulator, no linked binary. Unlike the Android side — where -`libtcode_android.so` links and so proves symbol resolution — nothing here has -been through a linker. The first unexercised step is `metal_context` itself: -adapter selection and surface creation have never met a real Metal device. - -Touch is also parked. GPUI's core touch dispatch is still marked -"implementation pending" upstream, so `IosEventSink::touch` converts -coordinates but does not deliver; a usable app must synthesize mouse events the -way `gpui_web` does. - -## Still needed for a running app - -- `tcode-ios`: a staticlib plus an Xcode project, `UIViewController` and - `CAMetalLayer`, and an `IosHost` implementation. -- A `CADisplayLink` driving `frame`, coalesced per the trait's contract. -- Mouse-event synthesis from touches. -- Keychain-backed credentials and a `UITextInput` conformance for IME. diff --git a/crates/gpui-ios/src/dispatcher.rs b/crates/gpui-ios/src/dispatcher.rs deleted file mode 100644 index ca09e675..00000000 --- a/crates/gpui-ios/src/dispatcher.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! `PlatformDispatcher` over Grand Central Dispatch. -//! -//! The Android backend hand-rolls a priority queue and thread pool, because -//! GPUI's reusable one is cfg-gated away from that target and Android has no -//! equivalent system facility. iOS does: libdispatch is part of libSystem, it is -//! already the scheduler UIKit itself runs on, and its global queues map -//! directly onto GPUI's priorities. Reusing it is both less code and better -//! behaved than a second thread pool competing with the system's. - -use std::ffi::c_void; -use std::time::{Duration, Instant}; - -use gpui::{PlatformDispatcher, Priority, RunnableVariant}; - -// libdispatch, from libSystem. Declared rather than pulled in as a crate: these -// five symbols are the entire surface used, and a dependency would add a -// version to track for no benefit. -#[allow(non_camel_case_types)] -type dispatch_queue_t = *mut c_void; -#[allow(non_camel_case_types)] -type dispatch_function_t = extern "C" fn(*mut c_void); - -unsafe extern "C" { - static _dispatch_main_q: c_void; - - fn dispatch_get_global_queue(identifier: isize, flags: usize) -> dispatch_queue_t; - fn dispatch_async_f(queue: dispatch_queue_t, context: *mut c_void, work: dispatch_function_t); - fn dispatch_after_f( - when: u64, - queue: dispatch_queue_t, - context: *mut c_void, - work: dispatch_function_t, - ); - fn dispatch_time(base: u64, delta: i64) -> u64; -} - -/// libdispatch's quality-of-service classes, from `dispatch/queue.h`. -const QOS_CLASS_USER_INTERACTIVE: isize = 0x21; -const QOS_CLASS_USER_INITIATED: isize = 0x19; -const QOS_CLASS_UTILITY: isize = 0x11; - -/// `DISPATCH_TIME_NOW`. -const DISPATCH_TIME_NOW: u64 = 0; - -fn main_queue() -> dispatch_queue_t { - // Taking the address of an extern static is how libdispatch's own C macro - // resolves the main queue, and `&raw const` needs no unsafe block: nothing - // is read, only the pointer formed. - &raw const _dispatch_main_q as dispatch_queue_t -} - -fn global_queue(priority: Priority) -> dispatch_queue_t { - // Mapped onto QoS rather than collapsed to one queue: the OS uses these to - // decide thread priority and, on battery, whether to run the work at all. - // Telling it everything is user-interactive would be a lie that costs - // battery. - let qos = match priority { - // Audio work asks for the strongest guarantee an app can request. iOS - // reserves true realtime threads for AVAudioSession clients, so - // user-interactive is the honest ceiling rather than a promise the - // platform will not keep. - Priority::RealtimeAudio | Priority::High => QOS_CLASS_USER_INTERACTIVE, - Priority::Medium => QOS_CLASS_USER_INITIATED, - Priority::Low => QOS_CLASS_UTILITY, - }; - // SAFETY: a well-known QoS constant with no flags. - unsafe { dispatch_get_global_queue(qos, 0) } -} - -/// Trampoline: libdispatch calls a C function with an opaque pointer, so the -/// runnable crosses as a leaked box and is reclaimed exactly once here. -extern "C" fn run_boxed(context: *mut c_void) { - // SAFETY: `context` came from `Box::into_raw` in the paired dispatch call, - // and libdispatch invokes this exactly once per submission. - let runnable = unsafe { Box::from_raw(context.cast::()) }; - runnable.run(); -} - -fn submit(queue: dispatch_queue_t, runnable: RunnableVariant) { - let context = Box::into_raw(Box::new(runnable)).cast::(); - // SAFETY: the queue is a libSystem global, and `run_boxed` reclaims the box. - unsafe { dispatch_async_f(queue, context, run_boxed) }; -} - -pub struct IosDispatcher; - -impl IosDispatcher { - pub fn new() -> Self { - Self - } -} - -impl Default for IosDispatcher { - fn default() -> Self { - Self::new() - } -} - -impl PlatformDispatcher for IosDispatcher { - fn is_main_thread(&self) -> bool { - // `pthread_main_np` rather than `NSThread.isMainThread`: it needs no - // Objective-C runtime call and is valid from any thread, including ones - // GPUI spawned that never touched UIKit. - unsafe extern "C" { - fn pthread_main_np() -> i32; - } - // SAFETY: a libSystem call with no arguments and no preconditions. - unsafe { pthread_main_np() != 0 } - } - - fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { - submit(global_queue(priority), runnable); - } - - fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - // The main queue is serial and has no QoS to choose; priority is - // meaningful only in ordering against other main-queue work, which - // libdispatch already handles FIFO. - submit(main_queue(), runnable); - } - - fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - let context = Box::into_raw(Box::new(runnable)).cast::(); - // Saturating: a delay beyond i64 nanoseconds is ~292 years, and - // wrapping would fire the timer immediately — the opposite of intent. - let delta = i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX); - // SAFETY: `run_boxed` reclaims the box exactly once when the timer fires. - unsafe { - let when = dispatch_time(DISPATCH_TIME_NOW, delta); - dispatch_after_f(when, global_queue(Priority::Medium), context, run_boxed); - } - } - - fn spawn_realtime(&self, f: Box) { - // iOS has no public realtime thread API for apps — that is reserved for - // audio units through `AVAudioSession`. User-interactive QoS is the - // highest an app can honestly ask for, so it is what this returns rather - // than pretending to a guarantee the platform does not offer. - extern "C" fn run_closure(context: *mut c_void) { - // SAFETY: paired with the `Box::into_raw` below. - let closure = unsafe { Box::from_raw(context.cast::>()) }; - closure(); - } - let context = Box::into_raw(Box::new(f)).cast::(); - // SAFETY: `run_closure` reclaims the box exactly once. - unsafe { dispatch_async_f(global_queue(Priority::High), context, run_closure) }; - } - - fn now(&self) -> Instant { - Instant::now() - } -} - -impl IosDispatcher { - /// Symmetry with the Android dispatcher, which owns a hand-rolled main-queue - /// and must be pumped. libdispatch delivers to the main queue itself, so - /// there is nothing to drain — the entry point exists so the shell's wake - /// callback has the same shape on both platforms. - pub fn drain_main_thread(&self) {} -} diff --git a/crates/gpui-ios/src/display.rs b/crates/gpui-ios/src/display.rs deleted file mode 100644 index 196bb714..00000000 --- a/crates/gpui-ios/src/display.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::{cell::Cell, fmt}; - -use anyhow::Result; -use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay}; - -use crate::IosDisplayMetrics; - -pub(crate) struct IosDisplay { - metrics: Cell, -} - -impl IosDisplay { - pub(crate) fn new(metrics: IosDisplayMetrics) -> Self { - Self { - metrics: Cell::new(metrics), - } - } - - pub(crate) fn update(&self, metrics: IosDisplayMetrics) { - self.metrics.set(metrics); - } -} - -impl fmt::Debug for IosDisplay { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("IosDisplay") - .field("id", &DisplayId::new(1)) - .field("metrics", &self.metrics.get()) - .finish() - } -} - -impl PlatformDisplay for IosDisplay { - fn id(&self) -> DisplayId { - DisplayId::new(1) - } - - fn uuid(&self) -> Result { - // iOS exposes one scene per app, modeled as a single stable logical display. - Ok(uuid::Uuid::from_u128( - 0x69d9_8b2a_37c3_4dca_a37d_726f_6964_0001, - )) - } - - fn bounds(&self) -> Bounds { - self.metrics.get().logical_bounds() - } - - fn visible_bounds(&self) -> Bounds { - // System bars and cutouts are overlays represented by WindowInsets. - self.bounds() - } - - fn default_bounds(&self) -> Bounds { - // An iOS scene owns a single full-screen window. - self.bounds() - } -} diff --git a/crates/gpui-ios/src/keyboard.rs b/crates/gpui-ios/src/keyboard.rs deleted file mode 100644 index 3c155155..00000000 --- a/crates/gpui-ios/src/keyboard.rs +++ /dev/null @@ -1,13 +0,0 @@ -use gpui::PlatformKeyboardLayout; - -pub(crate) struct IosKeyboardLayout; - -impl PlatformKeyboardLayout for IosKeyboardLayout { - fn id(&self) -> &str { - "android" - } - - fn name(&self) -> &str { - "Ios" - } -} diff --git a/crates/gpui-ios/src/lib.rs b/crates/gpui-ios/src/lib.rs deleted file mode 100644 index 31d5b1bb..00000000 --- a/crates/gpui-ios/src/lib.rs +++ /dev/null @@ -1,34 +0,0 @@ -//! GPUI platform backend for iOS. -//! -//! Deliberately the same module layout as `gpui-android`: `platform`, `window`, -//! `display`, `keyboard`, `dispatcher`, `native`. The two platforms differ in -//! their system APIs, not in what a GPUI backend must provide, so keeping the -//! shapes identical means a reader who knows one knows the other — and a fix to -//! one is trivially checkable against the other. -//! -//! **iOS needs no upstream change.** An earlier plan recorded that it did, -//! because `gpui_wgpu::WgpuContext::instance` hardcodes `VULKAN | GL`. That -//! helper is not the only entry point: `WgpuContext::new` accepts an instance -//! the caller builds, and `WgpuRenderer::new` takes a `GpuContext` slot it fills -//! only when empty. Pre-filling that slot with a Metal context bypasses the -//! default — which is what [`metal_context`] does. - -#[cfg(target_os = "ios")] -mod dispatcher; -#[cfg(target_os = "ios")] -mod display; -#[cfg(target_os = "ios")] -mod keyboard; -#[cfg(target_os = "ios")] -mod native; -#[cfg(target_os = "ios")] -mod platform; -#[cfg(target_os = "ios")] -mod window; - -#[cfg(target_os = "ios")] -pub use dispatcher::IosDispatcher; -#[cfg(target_os = "ios")] -pub use native::{IosDisplayMetrics, IosHost, IosLayer, IosSurface, metal_context}; -#[cfg(target_os = "ios")] -pub use platform::{IosEventSink, IosPlatform}; diff --git a/crates/gpui-ios/src/native.rs b/crates/gpui-ios/src/native.rs deleted file mode 100644 index 576a7026..00000000 --- a/crates/gpui-ios/src/native.rs +++ /dev/null @@ -1,248 +0,0 @@ -//! The UIKit seam, and the Metal context that makes iOS work without an -//! upstream change. -//! -//! Everything UIKit-shaped is behind [`IosHost`]. This crate never links -//! Objective-C itself: the shell that owns a `UIViewController` implements the -//! trait, so the backend stays a plain Rust crate that could be offered -//! upstream. - -use std::cell::RefCell; -use std::rc::Rc; - -use anyhow::{Context as _, Result, anyhow, bail}; -use gpui::{Bounds, Pixels, Size, WindowAppearance, point, px, size}; -use gpui_wgpu::wgpu; -use gpui_wgpu::{GpuContext, WgpuContext}; -use raw_window_handle::{ - DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawWindowHandle, - UiKitWindowHandle, WindowHandle, -}; - -/// Physical size and scale of the `CAMetalLayer` GPUI draws into. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct IosDisplayMetrics { - /// Width of the current surface in physical pixels. - pub width_px: u32, - /// Height of the current surface in physical pixels. - pub height_px: u32, - /// Number of physical pixels per GPUI logical pixel. - pub scale_factor: f32, -} - -impl IosDisplayMetrics { - /// Creates validated display metrics. - pub fn new(width_px: u32, height_px: u32, scale_factor: f32) -> Result { - let metrics = Self { - width_px, - height_px, - scale_factor, - }; - metrics.validate()?; - Ok(metrics) - } - - pub(crate) fn validate(self) -> Result<()> { - if self.width_px == 0 || self.height_px == 0 { - bail!("iOS drawable dimensions must be non-zero"); - } - if self.width_px > i32::MAX as u32 || self.height_px > i32::MAX as u32 { - bail!("iOS drawable dimensions exceed GPUI's device-pixel range"); - } - if !self.scale_factor.is_finite() || self.scale_factor <= 0.0 { - bail!("iOS contentScaleFactor must be finite and positive"); - } - Ok(()) - } - - pub(crate) fn logical_size(self) -> Size { - size( - px(self.width_px as f32 / self.scale_factor), - px(self.height_px as f32 / self.scale_factor), - ) - } - - pub(crate) fn logical_bounds(self) -> Bounds { - Bounds::new(point(px(0.0), px(0.0)), self.logical_size()) - } -} - -/// A retained `CAMetalLayer` pointer, owned by the UIKit shell. -/// -/// wgpu needs a `RawWindowHandle::UiKit` carrying the *view*, and iOS keeps the -/// layer alive as long as its view lives. This type therefore borrows rather -/// than owns: the shell must guarantee the pointer outlives the surface, which -/// is why [`IosSurface`] is created and destroyed around UIKit's own -/// view-lifecycle callbacks. -#[derive(Debug, Clone, Copy)] -pub struct IosLayer { - /// `UIView*` whose layer is a `CAMetalLayer`. - view: *mut std::ffi::c_void, -} - -// SAFETY: the pointer is only dereferenced by wgpu on the thread that created -// the surface, and the shell keeps the view alive across that window. Marking it -// Send/Sync is what lets the surface be held in GPUI's window state, which is -// not itself thread-local. -unsafe impl Send for IosLayer {} -unsafe impl Sync for IosLayer {} - -impl IosLayer { - /// # Safety - /// `view` must be a live `UIView*` whose `layer` is a `CAMetalLayer`, and it - /// must outlive every surface created from it. - pub unsafe fn from_raw(view: *mut std::ffi::c_void) -> Result { - if view.is_null() { - return Err(anyhow!("a null UIView cannot back a Metal surface")); - } - Ok(Self { view }) - } -} - -impl HasWindowHandle for IosLayer { - fn window_handle(&self) -> std::result::Result, HandleError> { - let pointer = std::ptr::NonNull::new(self.view).ok_or(HandleError::Unavailable)?; - let handle = UiKitWindowHandle::new(pointer); - // SAFETY: the handle borrows `self`, and `from_raw` established that the - // pointer is a live view the shell keeps alive. - Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::UiKit(handle)) }) - } -} - -impl HasDisplayHandle for IosLayer { - fn display_handle(&self) -> std::result::Result, HandleError> { - // UIKit has no display object to hand out; the window handle carries - // everything Metal needs. - Ok(DisplayHandle::uikit()) - } -} - -/// One drawable generation: a layer plus the metrics it was measured at. -#[derive(Debug, Clone, Copy)] -pub struct IosSurface { - /// The view whose layer is a `CAMetalLayer`. - pub window: IosLayer, - /// Size and scale this generation was measured at. - pub metrics: IosDisplayMetrics, -} - -impl IosSurface { - pub fn new(layer: IosLayer, metrics: IosDisplayMetrics) -> Result { - metrics.validate()?; - Ok(Self { - window: layer, - metrics, - }) - } - - pub fn layer(&self) -> IosLayer { - self.window - } -} - -/// A `GpuContext` pre-filled with a Metal-backed `WgpuContext`. -/// -/// This is the whole reason iOS needs no upstream patch. `WgpuContext::instance` -/// hardcodes `VULKAN | GL`, but it is only a convenience: `WgpuContext::new` -/// accepts any instance, and `WgpuRenderer::new` uses the context already in the -/// slot rather than building its own. So the backend selection happens here, -/// once, in code we own. -pub fn metal_context(surface_target: &IosSurface) -> Result { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - // Metal only. Listing anything else would let wgpu silently pick a - // software or GL path on a platform where Metal is the only real answer, - // and a slow success is harder to diagnose than a clean failure. - backends: wgpu::Backends::METAL, - flags: wgpu::InstanceFlags::default(), - backend_options: wgpu::BackendOptions::default(), - memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), - display: None, - }); - - let target = wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: None, - raw_window_handle: surface_target - .layer() - .window_handle() - .map_err(|error| anyhow!("the UIKit view has no usable window handle: {error}"))? - .as_raw(), - }; - // SAFETY: the handle came from a live view the shell keeps alive for at - // least as long as this surface. - let probe = unsafe { instance.create_surface_unsafe(target) } - .context("creating a Metal surface for adapter selection")?; - - let context = - WgpuContext::new(instance, &probe, None).context("selecting a Metal adapter and device")?; - Ok(Rc::new(RefCell::new(Some(context)))) -} - -/// Everything the UIKit shell must provide. -/// -/// Mirrors `gpui-android`'s `AndroidHost` on purpose — the two platforms differ -/// in their callback names, not in what a GPUI backend needs from them, and a -/// shared shape means a reader who knows one knows the other. -pub trait IosHost: Send + Sync { - /// Wake the main run loop so it calls `drain_main_thread`. - /// - /// Called from GPUI's worker threads, so an implementation must post to the - /// main queue rather than assume it is already there. - fn wake_main_thread(&self); - - /// Schedule one `CADisplayLink` tick that calls `frame`. - /// - /// Must coalesce while a tick is pending: GPUI may ask several times within - /// one vsync, and each extra tick would queue a redundant frame. - fn request_frame(&self); - - /// Ask the shell to suspend the app. - /// - /// iOS apps do not exit on demand — `exit(0)` is grounds for App Store - /// rejection and looks like a crash to the user — so this is advisory and a - /// shell may reasonably do nothing. - fn finish_activity(&self); - - /// Open a URL through `UIApplication`. - fn open_url(&self, url: &str) -> Result<()>; - - /// Read plain text from `UIPasteboard`. - fn read_clipboard_text(&self) -> Result>; - - /// Write plain text to `UIPasteboard`. - fn write_clipboard_text(&self, text: &str) -> Result<()>; - - /// Store a secret in the iOS keychain. - fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Result<()>; - - /// Read a secret from the iOS keychain. - fn read_credentials(&self, url: &str) -> Result)>>; - - /// Delete a secret from the iOS keychain. - fn delete_credentials(&self, url: &str) -> Result<()>; - - /// Become first responder so the software keyboard appears. - fn show_soft_keyboard(&self); - - /// Resign first responder. - fn hide_soft_keyboard(&self); - - /// Report the caret rectangle so the IME can position its candidate bar. - fn update_ime_position(&self, bounds: Bounds); - - /// Current `UITraitCollection.userInterfaceStyle`. - fn window_appearance(&self) -> WindowAppearance; - - /// Current thermal pressure. - /// - /// iOS reports this through `ProcessInfo.thermalState`, which is a - /// notification rather than a poll, so a shell caches the last value. - fn thermal_state(&self) -> gpui::ThermalState; - - /// GPUI's focused text-input state changed. - fn text_input_state_changed(&self, change: gpui::TextInputStateChange); - - /// Enable or disable the shell's back gesture, if it has one. - /// - /// iOS has no system back button; this exists so the shared window code can - /// be identical on both backends, and a shell may ignore it. - fn set_back_enabled(&self, enabled: bool); -} diff --git a/crates/gpui-ios/src/platform.rs b/crates/gpui-ios/src/platform.rs deleted file mode 100644 index 89cac7f5..00000000 --- a/crates/gpui-ios/src/platform.rs +++ /dev/null @@ -1,736 +0,0 @@ -use std::{ - borrow::Cow, - cell::RefCell, - ops::Range, - path::{Path, PathBuf}, - rc::{Rc, Weak}, - sync::Arc, -}; - -use anyhow::{Context as _, Result, anyhow, bail}; -use futures::channel::oneshot; -use gpui::{ - Action, AnyWindowHandle, AppLifecyclePhase, BackgroundExecutor, ClipboardItem, CursorStyle, - DispatchEventResult, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, - OwnedMenu, PathPromptOptions, Platform, PlatformDispatcher, PlatformDisplay, - PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, - ScreenCaptureSource, Task, ThermalState, TouchEvent, TouchId, TouchPhase, WindowAppearance, - WindowInsets, WindowKind, WindowParams, point, popup::PopupNotSupportedError, px, -}; -use gpui_wgpu::{CosmicTextSystem, GpuContext}; - -use crate::{ - IosDisplayMetrics, IosHost, IosSurface, - dispatcher::IosDispatcher, - display::IosDisplay, - keyboard::IosKeyboardLayout, - window::{IosWindow, IosWindowInner}, -}; - -static BUNDLED_FONTS: &[&[u8]] = &[ - include_bytes!("../../../assets/fonts/DMSans[wght].ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), - include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), -]; - -#[derive(Default)] -struct IosPlatformCallbacks { - open_urls: Option)>>, - quit: Option>, - reopen: Option>, - system_wake: Option>, - lifecycle: Option>, - memory_warning: Option>, - thermal_state_change: Option>, - keyboard_layout_change: Option>, -} - -struct IosPlatformState { - surface: Option, - active_window: Option, - window: Option>, - appearance: WindowAppearance, - thermal_state: ThermalState, - callbacks: IosPlatformCallbacks, -} - -/// GPUI's Android platform implementation. -pub struct IosPlatform { - host: Arc, - dispatcher: Arc, - background_executor: BackgroundExecutor, - foreground_executor: ForegroundExecutor, - text_system: Arc, - display: Rc, - state: Rc>, - gpu_context: GpuContext, -} - -impl IosPlatform { - /// Creates the backend on Android's UI thread with the current surface generation. - pub fn new(host: Arc, surface: IosSurface) -> Result { - surface.metrics.validate()?; - - let dispatcher = Arc::new(IosDispatcher::new()); - let background_executor = BackgroundExecutor::new(dispatcher.clone()); - let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); - - let text_system = Arc::new(CosmicTextSystem::new_without_system_fonts("DM Sans")); - text_system.add_fonts( - BUNDLED_FONTS - .iter() - .map(|bytes| Cow::Borrowed(*bytes)) - .collect(), - )?; - let text_system: Arc = text_system; - - let display = Rc::new(IosDisplay::new(surface.metrics)); - let appearance = host.window_appearance(); - let thermal_state = host.thermal_state(); - - Ok(Self { - host, - dispatcher, - background_executor, - foreground_executor, - text_system, - display, - state: Rc::new(RefCell::new(IosPlatformState { - surface: Some(surface), - active_window: None, - window: None, - appearance, - thermal_state, - callbacks: IosPlatformCallbacks::default(), - })), - gpu_context: Rc::new(RefCell::new(None)), - }) - } - - /// Returns the inbound JNI/NDK event boundary for the owning activity. - pub fn event_sink(&self) -> IosEventSink { - IosEventSink { - host: self.host.clone(), - dispatcher: self.dispatcher.clone(), - display: self.display.clone(), - state: self.state.clone(), - } - } -} - -/// Main-thread entry points called by the Android activity and Choreographer. -/// -/// This type is deliberately `!Send`: Android UI, lifecycle, input, and surface -/// callbacks must enter GPUI on the same thread that constructed the platform. -#[derive(Clone)] -pub struct IosEventSink { - host: Arc, - dispatcher: Arc, - display: Rc, - state: Rc>, -} - -impl IosEventSink { - /// Drains foreground tasks after `IosHost::wake_main_thread`. - pub fn drain_main_thread(&self) { - self.dispatcher.drain_main_thread(); - } - - /// Replaces a surface after Android creates a new `ANativeWindow`. - pub fn surface_created(&self, surface: IosSurface) -> Result<()> { - self.assert_main_thread(); - surface.metrics.validate()?; - - if let Some(window) = self.window() { - window.replace_surface(surface)?; - } - self.display.update(surface.metrics); - self.state.borrow_mut().surface = Some(surface); - - if self - .window() - .is_some_and(|window| window.should_schedule_frames()) - { - self.host.request_frame(); - } - Ok(()) - } - - /// Unconfigures wgpu before Android releases the current native window. - pub fn surface_destroyed(&self) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.detach_surface(); - } - self.state.borrow_mut().surface = None; - } - - /// Applies a native surface resize or density change. - pub fn resized(&self, metrics: IosDisplayMetrics) -> Result<()> { - self.assert_main_thread(); - metrics.validate()?; - if let Some(surface) = self.state.borrow_mut().surface.as_mut() { - surface.metrics = metrics; - } - self.display.update(metrics); - if let Some(window) = self.window() { - window.resize_from_native(metrics); - } - Ok(()) - } - - /// Delivers one Choreographer frame and schedules the next while visible. - pub fn frame(&self) { - self.assert_main_thread(); - let Some(window) = self.window() else { - return; - }; - window.frame(); - if window.should_schedule_frames() { - self.host.request_frame(); - } - } - - /// Delivers an already translated GPUI input event. - pub fn dispatch_input(&self, input: gpui::PlatformInput) -> Option { - self.assert_main_thread(); - self.window() - .and_then(|window| window.dispatch_input(input)) - } - - /// Converts a physical Android touch into logical coordinates and mouse input. - /// - /// GPUI core does not yet dispatch `TouchEvent` to elements, so the primary - /// contact is synthesized as left-button mouse input for the mobile MVP. - pub fn touch( - &self, - id: u64, - phase: TouchPhase, - x_px: f32, - y_px: f32, - force: Option, - ) -> Option { - self.assert_main_thread(); - let window = self.window()?; - let scale = window.scale_factor(); - window.dispatch_touch(TouchEvent { - id: TouchId(id), - phase, - position: point(px(x_px / scale), px(y_px / scale)), - force, - }) - } - - /// Updates focus from Android lifecycle or pointer-device callbacks. - pub fn active_changed(&self, active: bool) { - self.assert_main_thread(); - if let Some(window) = self.window() { - let changed = window.set_active(active); - if changed && active && window.should_schedule_frames() { - // A surface can survive pause/resume, so restart the frame chain explicitly. - self.host.request_frame(); - } - } - } - - /// Updates hover state for attached mouse or stylus devices. - pub fn hovered_changed(&self, hovered: bool) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.set_hovered(hovered); - } - } - - /// Notifies GPUI that Android moved the activity to another display. - pub fn moved(&self) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.moved(); - } - } - - /// Runs GPUI's registered window-control hit test, if any. - pub fn hit_test_window_control(&self) -> Option { - self.assert_main_thread(); - self.window() - .and_then(|window| window.hit_test_window_control()) - } - - /// Delivers Android's application lifecycle vocabulary. - pub fn lifecycle_changed(&self, phase: AppLifecyclePhase) { - self.assert_main_thread(); - if let Some(window) = self.window() { - let active = matches!(phase, AppLifecyclePhase::Active); - let changed = window.set_active(active); - if changed && active && window.should_schedule_frames() { - // Android may resume without recreating the surface. - self.host.request_frame(); - } - } - if let Some(callback) = self.state.borrow_mut().callbacks.lifecycle.as_mut() { - callback(phase); - } - } - - /// Delivers `onTrimMemory`/memory-pressure notification. - pub fn memory_warning(&self) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.memory_warning.as_mut() { - callback(); - } - } - - /// Delivers animated safe-area and IME inset updates in logical pixels. - pub fn insets_changed(&self, insets: WindowInsets) { - self.assert_main_thread(); - if let Some(window) = self.window() { - window.set_insets(insets); - } - } - - /// Runs GPUI's back callback. Returns false when Android should use its default. - pub fn back_pressed(&self) -> bool { - self.assert_main_thread(); - self.window().is_some_and(|window| window.back_pressed()) - } - - /// Consults GPUI's close veto, delivers close, and finishes the activity. - pub fn close_requested(&self) -> bool { - self.assert_main_thread(); - if let Some(window) = self.window() - && !window.close_requested() - { - return false; - } - - { - let mut state = self.state.borrow_mut(); - state.active_window = None; - state.window = None; - } - self.host.finish_activity(); - true - } - - /// Delivers Android UI-mode changes. - pub fn appearance_changed(&self, appearance: WindowAppearance) { - self.assert_main_thread(); - self.state.borrow_mut().appearance = appearance; - if let Some(window) = self.window() { - window.set_appearance(appearance); - } - } - - /// Delivers Android thermal-status changes. - pub fn thermal_state_changed(&self, thermal_state: ThermalState) { - self.assert_main_thread(); - let changed = self.state.borrow().thermal_state != thermal_state; - self.state.borrow_mut().thermal_state = thermal_state; - if changed - && let Some(callback) = self - .state - .borrow_mut() - .callbacks - .thermal_state_change - .as_mut() - { - callback(); - } - } - - /// Delivers Android keyboard-layout changes. - pub fn keyboard_layout_changed(&self) { - self.assert_main_thread(); - if let Some(callback) = self - .state - .borrow_mut() - .callbacks - .keyboard_layout_change - .as_mut() - { - callback(); - } - } - - /// Delivers VIEW intents registered by the Android manifest. - pub fn open_urls(&self, urls: Vec) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.open_urls.as_mut() { - callback(urls); - } - } - - /// Delivers a host-driven reopen request. - pub fn reopen(&self) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.reopen.as_mut() { - callback(); - } - } - - /// Delivers a system-wake notification. - pub fn system_wake(&self) { - self.assert_main_thread(); - if let Some(callback) = self.state.borrow_mut().callbacks.system_wake.as_mut() { - callback(); - } - } - - /// Commits text from Android's InputConnection. - pub fn ime_replace_text(&self, range: Option>, text: &str) -> bool { - self.assert_main_thread(); - self.window() - .is_some_and(|window| window.ime_replace_text(range, text)) - } - - /// Updates composing text from Android's InputConnection. - pub fn ime_set_composing_text( - &self, - range: Option>, - text: &str, - selected_range: Option>, - ) -> bool { - self.assert_main_thread(); - self.window() - .is_some_and(|window| window.ime_set_composing_text(range, text, selected_range)) - } - - /// Finishes the current IME composition. - pub fn ime_finish_composing(&self) -> bool { - self.assert_main_thread(); - self.window() - .is_some_and(|window| window.ime_finish_composing()) - } - - fn window(&self) -> Option> { - self.state.borrow().window.as_ref().and_then(Weak::upgrade) - } - - fn assert_main_thread(&self) { - assert!( - self.dispatcher.is_main_thread(), - "Android event delivered outside the main thread" - ); - } -} - -impl Platform for IosPlatform { - fn background_executor(&self) -> BackgroundExecutor { - self.background_executor.clone() - } - - fn foreground_executor(&self) -> ForegroundExecutor { - self.foreground_executor.clone() - } - - fn text_system(&self) -> Arc { - self.text_system.clone() - } - - fn run(&self, on_finish_launching: Box) { - assert!( - self.dispatcher.is_main_thread(), - "IosPlatform::run must be called on the activity UI thread" - ); - // Android's Looper already owns the event loop; GPUI launches inside it. - on_finish_launching(); - } - - fn quit(&self) { - if let Some(callback) = self.state.borrow_mut().callbacks.quit.as_mut() { - callback(); - } - self.host.finish_activity(); - } - - fn restart(&self, _binary_path: Option) { - // Android packages cannot restart by executing a replacement binary path. - log::warn!("Android does not support Platform::restart"); - } - - fn activate(&self, _ignoring_other_apps: bool) { - // Android owns activity activation through its lifecycle. - } - - fn hide(&self) { - // Android activities are backgrounded by the OS, not hidden as windows. - } - - fn hide_other_apps(&self) { - // Android apps cannot hide other applications. - } - - fn unhide_other_apps(&self) { - // Android apps cannot change other applications' visibility. - } - - fn displays(&self) -> Vec> { - vec![self.display.clone()] - } - - fn primary_display(&self) -> Option> { - Some(self.display.clone()) - } - - fn active_window(&self) -> Option { - self.state.borrow().active_window - } - - fn window_stack(&self) -> Option> { - self.state.borrow().active_window.map(|window| vec![window]) - } - - fn is_screen_capture_supported(&self) -> bool { - false - } - - fn screen_capture_sources( - &self, - ) -> oneshot::Receiver>>> { - // Android MediaProjection is consent/activity-result based and not this synchronous API. - unsupported_receiver("screen capture is not supported by gpui-android") - } - - fn open_window( - &self, - handle: AnyWindowHandle, - params: WindowParams, - ) -> Result> { - if matches!(¶ms.kind, WindowKind::AnchoredPopup(_)) { - return Err(PopupNotSupportedError.into()); - } - if !matches!(¶ms.kind, WindowKind::Normal) { - bail!("iOS supports only its single scene window"); - } - if self - .state - .borrow() - .window - .as_ref() - .and_then(Weak::upgrade) - .is_some() - { - bail!("Android currently supports only one GPUI platform window"); - } - - // Copy, not clone: IosSurface is a view pointer plus metrics, unlike - // Android's ref-counted ANativeWindow wrapper. - let surface = self - .state - .borrow() - .surface - .context("no CAMetalLayer is currently available")?; - let appearance = self.state.borrow().appearance; - let window = IosWindow::new( - self.host.clone(), - self.gpu_context.clone(), - self.display.clone(), - surface, - appearance, - )?; - - { - let mut state = self.state.borrow_mut(); - state.active_window = Some(handle); - state.window = Some(Rc::downgrade(&window.inner)); - } - Ok(Box::new(window)) - } - - fn window_appearance(&self) -> WindowAppearance { - self.state.borrow().appearance - } - - fn open_url(&self, url: &str) { - if let Err(error) = self.host.open_url(url) { - log::warn!("failed to open Android URI {url:?}: {error:#}"); - } - } - - fn on_open_urls(&self, callback: Box)>) { - self.state.borrow_mut().callbacks.open_urls = Some(callback); - } - - fn register_url_scheme(&self, _url: &str) -> Task> { - // Android URL schemes are declared statically as manifest intent filters. - Task::ready(Err(anyhow!( - "Android URL schemes must be registered in AndroidManifest.xml" - ))) - } - - fn prompt_for_paths( - &self, - _options: PathPromptOptions, - ) -> oneshot::Receiver>>> { - // Android's Storage Access Framework requires an Activity result contract. - unsupported_receiver("file selection is not supported by gpui-android") - } - - fn prompt_for_new_path( - &self, - _directory: &Path, - _suggested_name: Option<&str>, - ) -> oneshot::Receiver>> { - // Android's Storage Access Framework returns content URIs, not writable Paths. - unsupported_receiver("new-path prompts are not supported by gpui-android") - } - - fn can_select_mixed_files_and_dirs(&self) -> bool { - false - } - - fn reveal_path(&self, _path: &Path) { - // Android has no system file-manager reveal operation for arbitrary Paths. - } - - fn open_with_system(&self, path: &Path) { - if let Err(error) = self.host.open_url(&format!("file://{}", path.display())) { - log::warn!("failed to open Android path {path:?}: {error:#}"); - } - } - - fn on_quit(&self, callback: Box) { - self.state.borrow_mut().callbacks.quit = Some(callback); - } - - fn on_reopen(&self, callback: Box) { - self.state.borrow_mut().callbacks.reopen = Some(callback); - } - - fn on_system_wake(&self, callback: Box) { - self.state.borrow_mut().callbacks.system_wake = Some(callback); - } - - fn on_app_lifecycle(&self, callback: Box) { - self.state.borrow_mut().callbacks.lifecycle = Some(callback); - } - - fn on_memory_warning(&self, callback: Box) { - self.state.borrow_mut().callbacks.memory_warning = Some(callback); - } - - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) { - // Android has no process-wide desktop menu bar. - } - - fn get_menus(&self) -> Option> { - // Android has no process-wide desktop menu bar to query. - None - } - - fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) { - // Android has no desktop dock menu. - } - - fn perform_dock_menu_action(&self, _action: usize) { - // Android has no desktop dock menu action source. - } - - fn add_recent_document(&self, _path: &Path) { - // Android's recents screen tracks activities, not application documents. - } - - fn on_app_menu_action(&self, _callback: Box) { - // Android has no GPUI app-menu action source. - } - - fn on_will_open_app_menu(&self, _callback: Box) { - // Android has no process-wide application menu. - } - - fn on_validate_app_menu_command(&self, _callback: Box bool>) { - // Android has no process-wide application-menu commands to validate. - } - - fn thermal_state(&self) -> ThermalState { - self.state.borrow().thermal_state - } - - fn on_thermal_state_change(&self, callback: Box) { - self.state.borrow_mut().callbacks.thermal_state_change = Some(callback); - } - - fn compositor_name(&self) -> &'static str { - "Android SurfaceFlinger" - } - - fn app_path(&self) -> Result { - // Android applications are APKs, not executable filesystem bundles. - bail!("application paths are not available on Android") - } - - fn path_for_auxiliary_executable(&self, _name: &str) -> Result { - // Android packages cannot ship independently executed auxiliary binaries. - bail!("auxiliary executable paths are not available on Android") - } - - fn set_cursor_style(&self, _style: CursorStyle) { - // The mobile MVP has touch input and no application-controlled cursor. - } - - fn hide_cursor_until_mouse_moves(&self) { - // The mobile MVP has no visible mouse cursor to hide. - } - - fn is_cursor_visible(&self) -> bool { - false - } - - fn should_auto_hide_scrollbars(&self) -> bool { - true - } - - fn read_from_clipboard(&self) -> Option { - match self.host.read_clipboard_text() { - Ok(Some(text)) => Some(ClipboardItem::new_string(text)), - Ok(None) => None, - Err(error) => { - log::warn!("failed to read Android clipboard: {error:#}"); - None - } - } - } - - fn write_to_clipboard(&self, item: ClipboardItem) { - let Some(text) = item.text() else { - // Android host seam currently supports GPUI's plain-text clipboard entries only. - log::warn!("gpui-android cannot write a non-text clipboard item"); - return; - }; - if let Err(error) = self.host.write_clipboard_text(&text) { - log::warn!("failed to write Android clipboard: {error:#}"); - } - } - - fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { - Task::ready(self.host.write_credentials(url, username, password)) - } - - fn read_credentials(&self, url: &str) -> Task)>>> { - Task::ready(self.host.read_credentials(url)) - } - - fn delete_credentials(&self, url: &str) -> Task> { - Task::ready(self.host.delete_credentials(url)) - } - - fn keyboard_layout(&self) -> Box { - Box::new(IosKeyboardLayout) - } - - fn keyboard_mapper(&self) -> Rc { - Rc::new(DummyKeyboardMapper) - } - - fn on_keyboard_layout_change(&self, callback: Box) { - self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); - } -} - -fn unsupported_receiver(message: &'static str) -> oneshot::Receiver> { - let (sender, receiver) = oneshot::channel(); - sender.send(Err(anyhow!(message))).ok(); - receiver -} diff --git a/crates/gpui-ios/src/window.rs b/crates/gpui-ios/src/window.rs deleted file mode 100644 index 3a59b30b..00000000 --- a/crates/gpui-ios/src/window.rs +++ /dev/null @@ -1,667 +0,0 @@ -use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; - -use anyhow::{Context as _, Result}; -use futures::channel::oneshot; -use gpui::{ - Bounds, Capslock, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, - RequestFrameOptions, Scene, Size, TextInputStateChange, TouchEvent, TouchId, TouchPhase, - WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, - WindowInsets, -}; -use gpui_wgpu::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; -use raw_window_handle::{ - DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, -}; - -use crate::{IosDisplayMetrics, IosHost, IosLayer, IosSurface, display::IosDisplay}; - -type InputCallback = Box DispatchEventResult>; -type ResizeCallback = Box, f32)>; -type HitTestCallback = Box Option>; - -#[derive(Default)] -struct IosWindowCallbacks { - request_frame: Option>, - input: Option, - active_status_change: Option>, - hover_status_change: Option>, - resize: Option, - moved: Option>, - should_close: Option bool>>, - hit_test_window_control: Option, - close: Option>, - appearance_changed: Option>, - insets_changed: Option>, - back: Option>, -} - -struct IosWindowState { - // Drop the renderer before either retained ANativeWindow lease. - renderer: WgpuRenderer, - native_window: Option, - retired_native_window: Option, - bounds: Bounds, - scale_factor: f32, - appearance: WindowAppearance, - background_appearance: WindowBackgroundAppearance, - title: String, - input_handler: Option, - active: bool, - hovered: bool, - mouse_position: Point, - modifiers: Modifiers, - capslock: Capslock, - pressed_button: Option, - primary_touch: Option, - insets: WindowInsets, - back_enabled: bool, -} - -pub(crate) struct IosWindowInner { - host: Arc, - gpu_context: GpuContext, - state: RefCell, - callbacks: RefCell, -} - -pub(crate) struct IosWindow { - pub(crate) inner: Rc, - display: Rc, -} - -impl IosWindow { - pub(crate) fn new( - host: Arc, - gpu_context: GpuContext, - display: Rc, - surface: IosSurface, - appearance: WindowAppearance, - ) -> Result { - let renderer = WgpuRenderer::new( - gpu_context.clone(), - &surface.window, - surface_config(surface.metrics, false), - None, - ) - .context("failed to create the iOS Metal renderer")?; - - let state = IosWindowState { - renderer, - native_window: Some(surface.window), - retired_native_window: None, - bounds: surface.metrics.logical_bounds(), - scale_factor: surface.metrics.scale_factor, - appearance, - background_appearance: WindowBackgroundAppearance::Opaque, - title: String::new(), - input_handler: None, - active: true, - hovered: false, - mouse_position: Point::default(), - modifiers: Modifiers::default(), - capslock: Capslock::default(), - pressed_button: None, - primary_touch: None, - insets: WindowInsets::default(), - back_enabled: false, - }; - - Ok(Self { - inner: Rc::new(IosWindowInner { - host, - gpu_context, - state: RefCell::new(state), - callbacks: RefCell::new(IosWindowCallbacks::default()), - }), - display, - }) - } -} - -impl IosWindowInner { - pub(crate) fn replace_surface(&self, surface: IosSurface) -> Result<()> { - let instance = self - .gpu_context - .borrow() - .as_ref() - .context("wgpu context missing while replacing iOS drawable")? - .instance - .clone(); - - { - let mut state = self.state.borrow_mut(); - let transparent = state.background_appearance != WindowBackgroundAppearance::Opaque; - state.renderer.replace_surface( - &surface.window, - surface_config(surface.metrics, transparent), - &instance, - )?; - state.native_window = Some(surface.window); - state.retired_native_window = None; - state.bounds = surface.metrics.logical_bounds(); - state.scale_factor = surface.metrics.scale_factor; - } - - self.notify_resize(surface.metrics); - Ok(()) - } - - pub(crate) fn detach_surface(&self) { - let mut state = self.state.borrow_mut(); - if state.native_window.is_some() { - state.renderer.unconfigure_surface(); - state.retired_native_window = state.native_window.take(); - } - } - - pub(crate) fn resize_from_native(&self, metrics: IosDisplayMetrics) { - { - let mut state = self.state.borrow_mut(); - state.bounds = metrics.logical_bounds(); - state.scale_factor = metrics.scale_factor; - if state.native_window.is_some() { - state.renderer.update_drawable_size(Size { - width: DevicePixels(metrics.width_px as i32), - height: DevicePixels(metrics.height_px as i32), - }); - } - } - self.notify_resize(metrics); - } - - fn notify_resize(&self, metrics: IosDisplayMetrics) { - if let Some(callback) = self.callbacks.borrow_mut().resize.as_mut() { - callback(metrics.logical_size(), metrics.scale_factor); - } - } - - pub(crate) fn frame(&self) { - if let Some(callback) = self.callbacks.borrow_mut().request_frame.as_mut() { - callback(RequestFrameOptions { - require_presentation: true, - force_render: false, - }); - } - } - - pub(crate) fn should_schedule_frames(&self) -> bool { - let state = self.state.borrow(); - state.active && state.native_window.is_some() - } - - pub(crate) fn dispatch_input(&self, input: PlatformInput) -> Option { - { - let mut state = self.state.borrow_mut(); - match &input { - PlatformInput::MouseDown(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - state.pressed_button = Some(event.button); - } - PlatformInput::MouseUp(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - state.pressed_button = None; - } - PlatformInput::MouseMove(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - state.pressed_button = event.pressed_button; - } - PlatformInput::ModifiersChanged(event) => { - state.modifiers = event.modifiers; - state.capslock = event.capslock; - } - PlatformInput::ScrollWheel(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - } - PlatformInput::Pinch(event) => { - state.mouse_position = event.position; - state.modifiers = event.modifiers; - } - PlatformInput::KeyDown(_) - | PlatformInput::KeyUp(_) - | PlatformInput::MousePressure(_) - | PlatformInput::MouseExited(_) - | PlatformInput::FileDrop(_) - | PlatformInput::Touch(_) => {} - } - } - - self.callbacks - .borrow_mut() - .input - .as_mut() - .map(|callback| callback(input)) - } - - pub(crate) fn dispatch_touch(&self, touch: TouchEvent) -> Option { - let input = { - let mut state = self.state.borrow_mut(); - match touch.phase { - TouchPhase::Started if state.primary_touch.is_none() => { - state.primary_touch = Some(touch.id); - Some(PlatformInput::MouseDown(MouseDownEvent { - button: MouseButton::Left, - position: touch.position, - modifiers: Modifiers::default(), - click_count: 1, - first_mouse: false, - })) - } - TouchPhase::Moved if state.primary_touch == Some(touch.id) => { - Some(PlatformInput::MouseMove(MouseMoveEvent { - position: touch.position, - pressed_button: Some(MouseButton::Left), - modifiers: Modifiers::default(), - })) - } - TouchPhase::Ended | TouchPhase::Cancelled - if state.primary_touch == Some(touch.id) => - { - state.primary_touch = None; - // GPUI has no pointer-cancel mouse event yet; MouseUp is the - // only portable way to release captures for a cancelled touch. - Some(PlatformInput::MouseUp(MouseUpEvent { - button: MouseButton::Left, - position: touch.position, - modifiers: Modifiers::default(), - click_count: 1, - })) - } - TouchPhase::Started - | TouchPhase::Moved - | TouchPhase::Ended - | TouchPhase::Cancelled => None, - } - }; - input.and_then(|input| self.dispatch_input(input)) - } - - pub(crate) fn scale_factor(&self) -> f32 { - self.state.borrow().scale_factor - } - - pub(crate) fn set_active(&self, active: bool) -> bool { - let changed = { - let mut state = self.state.borrow_mut(); - if state.active == active { - false - } else { - state.active = active; - true - } - }; - if changed && let Some(callback) = self.callbacks.borrow_mut().active_status_change.as_mut() - { - callback(active); - } - changed - } - - pub(crate) fn set_hovered(&self, hovered: bool) { - let changed = { - let mut state = self.state.borrow_mut(); - if state.hovered == hovered { - false - } else { - state.hovered = hovered; - true - } - }; - if changed && let Some(callback) = self.callbacks.borrow_mut().hover_status_change.as_mut() - { - callback(hovered); - } - } - - pub(crate) fn moved(&self) { - if let Some(callback) = self.callbacks.borrow_mut().moved.as_mut() { - callback(); - } - } - - pub(crate) fn hit_test_window_control(&self) -> Option { - self.callbacks - .borrow_mut() - .hit_test_window_control - .as_mut() - .and_then(|callback| callback()) - } - - pub(crate) fn close_requested(&self) -> bool { - let should_close = self - .callbacks - .borrow_mut() - .should_close - .as_mut() - .is_none_or(|callback| callback()); - if !should_close { - return false; - } - - if let Some(callback) = self.callbacks.borrow_mut().close.take() { - callback(); - } - true - } - - pub(crate) fn set_appearance(&self, appearance: WindowAppearance) { - let changed = self.state.borrow().appearance != appearance; - self.state.borrow_mut().appearance = appearance; - if changed && let Some(callback) = self.callbacks.borrow_mut().appearance_changed.as_mut() { - callback(); - } - } - - pub(crate) fn set_insets(&self, insets: WindowInsets) { - let changed = self.state.borrow().insets != insets; - self.state.borrow_mut().insets = insets.clone(); - if changed && let Some(callback) = self.callbacks.borrow_mut().insets_changed.as_mut() { - callback(insets); - } - } - - pub(crate) fn back_pressed(&self) -> bool { - if !self.state.borrow().back_enabled { - return false; - } - if let Some(callback) = self.callbacks.borrow_mut().back.as_mut() { - callback(); - true - } else { - false - } - } - - pub(crate) fn ime_replace_text(&self, range: Option>, text: &str) -> bool { - self.with_input_handler(|handler| handler.replace_text_in_range(range, text)) - .is_some() - } - - pub(crate) fn ime_set_composing_text( - &self, - range: Option>, - text: &str, - selected_range: Option>, - ) -> bool { - self.with_input_handler(|handler| { - handler.replace_and_mark_text_in_range(range, text, selected_range); - }) - .is_some() - } - - pub(crate) fn ime_finish_composing(&self) -> bool { - self.with_input_handler(PlatformInputHandler::unmark_text) - .is_some() - } - - fn with_input_handler( - &self, - callback: impl FnOnce(&mut PlatformInputHandler) -> T, - ) -> Option { - let mut handler = self.state.borrow_mut().input_handler.take()?; - let result = callback(&mut handler); - self.state.borrow_mut().input_handler = Some(handler); - Some(result) - } -} - -impl HasWindowHandle for IosWindow { - fn window_handle(&self) -> Result, HandleError> { - let raw = { - let state = self.inner.state.borrow(); - let native_window = state - .native_window - .as_ref() - .ok_or(HandleError::Unavailable)?; - native_window.window_handle()?.as_raw() - }; - - // SAFETY: IosWindowInner retains the corresponding NativeWindow - // lease, and all lifecycle mutation is confined to the UI thread. - Ok(unsafe { WindowHandle::borrow_raw(raw) }) - } -} - -impl HasDisplayHandle for IosWindow { - fn display_handle(&self) -> Result, HandleError> { - Ok(DisplayHandle::android()) - } -} - -impl PlatformWindow for IosWindow { - fn bounds(&self) -> Bounds { - self.inner.state.borrow().bounds - } - - fn is_maximized(&self) -> bool { - false - } - - fn window_bounds(&self) -> WindowBounds { - WindowBounds::Fullscreen(self.bounds()) - } - - fn content_size(&self) -> Size { - self.bounds().size - } - - fn resize(&mut self, _size: Size) { - // UIKit owns the drawable's dimensions; layout callbacks update GPUI. - } - - fn scale_factor(&self) -> f32 { - self.inner.state.borrow().scale_factor - } - - fn appearance(&self) -> WindowAppearance { - self.inner.state.borrow().appearance - } - - fn display(&self) -> Option> { - Some(self.display.clone()) - } - - fn mouse_position(&self) -> Point { - self.inner.state.borrow().mouse_position - } - - fn modifiers(&self) -> Modifiers { - self.inner.state.borrow().modifiers - } - - fn capslock(&self) -> Capslock { - self.inner.state.borrow().capslock - } - - fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { - self.inner.state.borrow_mut().input_handler = Some(input_handler); - } - - fn take_input_handler(&mut self) -> Option { - self.inner.state.borrow_mut().input_handler.take() - } - - fn prompt( - &self, - _level: PromptLevel, - _msg: &str, - _detail: Option<&str>, - _answers: &[PromptButton], - ) -> Option> { - // UIAlertController is presented asynchronously, which GPUI's synchronous prompt API cannot express. - None - } - - fn activate(&self) { - // iOS, not an application window, owns foreground activation. - } - - fn is_active(&self) -> bool { - self.inner.state.borrow().active - } - - fn is_hovered(&self) -> bool { - self.inner.state.borrow().hovered - } - - fn background_appearance(&self) -> WindowBackgroundAppearance { - self.inner.state.borrow().background_appearance - } - - fn set_title(&mut self, title: &str) { - // iOS takes its task label from Info.plist; the title is retained for GPUI's own queries. - self.inner.state.borrow_mut().title = title.to_owned(); - } - - fn set_background_appearance(&self, appearance: WindowBackgroundAppearance) { - let mut state = self.inner.state.borrow_mut(); - state.background_appearance = appearance; - if state.native_window.is_some() { - // Reconfiguring a retired CAMetalLayer after it is released can wedge the Metal driver. - state - .renderer - .update_transparency(appearance != WindowBackgroundAppearance::Opaque); - } - } - - fn minimize(&self) { - // iOS scene visibility is lifecycle-owned; there is no minimize. - } - - fn zoom(&self) { - // iOS has no desktop maximize or zoom operation. - } - - fn toggle_fullscreen(&self) { - // The activity surface is the backend's single full-screen GPUI window. - } - - fn is_fullscreen(&self) -> bool { - true - } - - fn on_request_frame(&self, callback: Box) { - self.inner.callbacks.borrow_mut().request_frame = Some(callback); - self.inner.host.request_frame(); - } - - fn on_input(&self, callback: Box DispatchEventResult>) { - self.inner.callbacks.borrow_mut().input = Some(callback); - } - - fn on_active_status_change(&self, callback: Box) { - self.inner.callbacks.borrow_mut().active_status_change = Some(callback); - } - - fn on_hover_status_change(&self, callback: Box) { - self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); - } - - fn on_resize(&self, callback: Box, f32)>) { - self.inner.callbacks.borrow_mut().resize = Some(callback); - } - - fn on_moved(&self, callback: Box) { - self.inner.callbacks.borrow_mut().moved = Some(callback); - } - - fn on_should_close(&self, callback: Box bool>) { - self.inner.callbacks.borrow_mut().should_close = Some(callback); - } - - fn on_hit_test_window_control(&self, callback: Box Option>) { - self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); - } - - fn on_close(&self, callback: Box) { - self.inner.callbacks.borrow_mut().close = Some(callback); - } - - fn on_appearance_changed(&self, callback: Box) { - self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); - } - - fn draw(&self, scene: &Scene) { - self.inner.state.borrow_mut().renderer.draw(scene); - } - - fn completed_frame(&self) { - // WgpuRenderer presents the iOS drawable as part of draw. - } - - fn sprite_atlas(&self) -> Arc { - self.inner.state.borrow().renderer.sprite_atlas().clone() - } - - fn is_subpixel_rendering_supported(&self) -> bool { - // iOS display rotation makes RGB stripe order unstable; use grayscale glyphs. - false - } - - fn get_title(&self) -> String { - self.inner.state.borrow().title.clone() - } - - fn window_controls(&self) -> WindowControls { - WindowControls { - fullscreen: false, - maximize: false, - minimize: false, - window_menu: false, - } - } - - fn gpu_specs(&self) -> Option { - Some(self.inner.state.borrow().renderer.gpu_specs()) - } - - fn update_ime_position(&self, bounds: Bounds) { - self.inner.host.update_ime_position(bounds); - } - - fn insets(&self) -> WindowInsets { - self.inner.state.borrow().insets.clone() - } - - fn on_insets_changed(&self, callback: Box) { - self.inner.callbacks.borrow_mut().insets_changed = Some(callback); - } - - fn set_back_handler(&self, callback: Box) { - self.inner.callbacks.borrow_mut().back = Some(callback); - } - - fn set_back_enabled(&self, enabled: bool) { - self.inner.state.borrow_mut().back_enabled = enabled; - self.inner.host.set_back_enabled(enabled); - } - - fn show_soft_keyboard(&self) { - self.inner.host.show_soft_keyboard(); - } - - fn hide_soft_keyboard(&self) { - self.inner.host.hide_soft_keyboard(); - } - - fn text_input_state_changed(&self, change: TextInputStateChange) { - self.inner.host.text_input_state_changed(change); - } -} - -fn surface_config(metrics: IosDisplayMetrics, transparent: bool) -> WgpuSurfaceConfig { - WgpuSurfaceConfig { - size: Size { - width: DevicePixels(metrics.width_px as i32), - height: DevicePixels(metrics.height_px as i32), - }, - transparent, - // FIFO is universally available and remains valid across native-window replacement. - preferred_present_mode: None, - } -} diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index b4faeba3..e3f31e9f 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -14,7 +14,6 @@ preview-mcp = { path = "../preview-mcp" } serde = { version = "1", features = ["derive"] } serde_json = "1" smol = "2" -sync-host = { path = "../sync-host" } tcode-core = { path = "../core" } tcode-services = { path = "../services" } term = { path = "../term" } diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 8814518c..2fe544b6 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -3,7 +3,6 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::{Duration, Instant}; use agent::{ @@ -179,19 +178,13 @@ fn run_store_write( store: &SessionStore, settings_store: &SettingsStore, terminal_preferences_path: &Path, - sync_server: Option<&sync_host::SyncServer>, write: StoreWrite, ) -> Option { match write { - StoreWrite::AppendEvent { id, ts, event } => match store.append_event(&id, ts, &event) { - Ok(_) => { - if let Some(server) = sync_server { - server.notify_advanced(&id); - } - None - } - Err(err) => Some(StoreWriteFailure::PersistEvent(err.to_string())), - }, + StoreWrite::AppendEvent { id, ts, event } => store + .append_event(&id, ts, &event) + .err() + .map(|err| StoreWriteFailure::PersistEvent(err.to_string())), StoreWrite::UpsertMeta { meta, initial } => store.upsert_meta(&meta).err().map(|err| { if initial { StoreWriteFailure::PersistSession(err.to_string()) @@ -736,16 +729,11 @@ impl ActiveSession { &mut self, request_id: String, text: String, - mut attachments: Vec, + attachments: Vec, ) -> Result<(), ()> { let Runtime::Live(commands) = &self.runtime else { return Err(()); }; - if let Some(workspace_id) = self.meta.project_id.as_deref() { - for attachment in &mut attachments { - attachment.add_workspace_path(workspace_id, &self.meta.cwd); - } - } commands .try_send(SessionCommand::Steer { request_id, @@ -757,13 +745,8 @@ impl ActiveSession { /// Append a message to the queue, consuming the armed Ultrathink flag (it is /// per-send, so it belongs to this message, not to whatever is sent later). - fn push_queued(&mut self, text: String, mut attachments: Vec) -> u64 { + fn push_queued(&mut self, text: String, attachments: Vec) -> u64 { self.idle_since = None; - if let Some(workspace_id) = self.meta.project_id.as_deref() { - for attachment in &mut attachments { - attachment.add_workspace_path(workspace_id, &self.meta.cwd); - } - } let id = self.next_queue_id; self.next_queue_id += 1; let options = self.turn_options(); @@ -1020,9 +1003,6 @@ pub struct AppState { orchestrate_registrations: HashMap, /// Requests from the orchestrate MCP runtime, pumped on the gpui thread. pub orchestrate_requests: Option>, - sync_server: Option>, - sync_commands: Option>, - sync_live: Option, /// Process-wide computer-use MCP registration, supplied only to sessions /// while the global computer-use setting is enabled. computer_use_registration: Option, @@ -1178,9 +1158,6 @@ impl AppState { orchestrate_tokens: None, orchestrate_registrations: HashMap::new(), orchestrate_requests: None, - sync_server: None, - sync_commands: None, - sync_live: None, computer_use_registration: None, callback_last_turn: HashMap::new(), callback_approval_requests: HashSet::new(), @@ -1213,7 +1190,6 @@ impl AppState { let settings_store = self.settings_store.clone(); let terminal_preferences_path = self.terminal_preferences_path.clone(); let failures = self.store_write_failures.clone(); - let sync_server = self.sync_server.clone(); cx.background_executor() .spawn(async move { while let Ok(write) = writes.recv().await { @@ -1221,7 +1197,6 @@ impl AppState { &store, &settings_store, &terminal_preferences_path, - sync_server.as_deref(), write, ) { let _ = failures.send(failure).await; @@ -1342,12 +1317,6 @@ impl AppState { self.orchestrate_requests = Some(server.requests); } - pub fn attach_sync_host(&mut self, server: sync_host::SyncServer) { - self.sync_commands = Some(server.commands.clone()); - self.sync_live = Some(server.live.clone()); - self.sync_server = Some(Arc::new(server)); - } - pub fn attach_computer_use_mcp(&mut self, url: String, token: String) { self.computer_use_registration = Some(agent::McpRegistration { name: agent::McpRegistration::SERVER_NAME_COMPUTER_USE.into(), @@ -1377,72 +1346,6 @@ impl AppState { .detach(); } - /// Pump remote commands through the same provider channels as local UI actions. - pub fn pump_sync_commands(&mut self, cx: &mut Context) { - let Some(commands) = self.sync_commands.take() else { - return; - }; - cx.spawn(async move |this, cx| { - while let Ok(request) = commands.recv().await { - let Ok(()) = this.update(cx, |state, _| state.apply_sync_command(request)) else { - break; - }; - } - }) - .detach(); - } - - fn apply_sync_command(&mut self, request: sync_host::CommandRequest) { - let sync_host::CommandRequest { - session_id, - command, - } = request; - let commands = self - .active - .as_ref() - .filter(|session| session.meta.id == session_id) - .or_else(|| self.background.get(&session_id)) - .and_then(|session| match &session.runtime { - Runtime::Live(commands) => Some(commands.clone()), - Runtime::Idle | Runtime::Starting { .. } => None, - }); - let Some(commands) = commands else { - log::warn!("dropping sync command for session {session_id}: no live provider"); - return; - }; - if let Err(err) = commands.try_send(command) { - log::warn!("dropping sync command for session {session_id}: {err}"); - } - } - - fn publish_sync_liveness(&self, session_id: &str) { - let Some(live) = &self.sync_live else { - return; - }; - let working = self - .active - .as_ref() - .filter(|session| session.meta.id == session_id) - .or_else(|| self.background.get(session_id)) - .is_some_and(|session| session.turn_in_flight); - live.set( - session_id, - sync_host::LiveFlags { - working, - awaiting_approval: self - .sessions_awaiting_approval - .get(session_id) - .is_some_and(|requests| !requests.is_empty()), - }, - ); - } - - fn clear_sync_liveness(&self, session_id: &str) { - if let Some(live) = &self.sync_live { - live.clear(session_id); - } - } - /// Persistently opt a session into native orchestration. Callers restart a /// currently-live provider so its next spawn receives the MCP registration. pub fn enable_orchestrate( @@ -6971,7 +6874,6 @@ impl AppState { parked.background_task_count = 0; } state.pending_native_rewinds.remove(&session_id); - state.clear_sync_liveness(&session_id); let error_event = AgentEvent::ProviderStartFailed { error: err.to_string(), }; @@ -7022,9 +6924,6 @@ impl AppState { } if let AgentEvent::TurnAccepted { delivery_id } = &event { - // This control-plane acknowledgement must also be durable: sync - // clients use it after reconnecting to decide which turns to resend. - self.record_event(session_id, &event, cx); self.on_turn_accepted(session_id, *delivery_id, cx); return; } @@ -7056,7 +6955,6 @@ impl AppState { if let AgentEvent::SessionClosed { reason } = &event { self.pending_native_rewinds.remove(session_id); self.sessions_awaiting_approval.remove(session_id); - self.clear_sync_liveness(session_id); self.close_orchestrator_children(session_id); let is_active = self.active_session_id() == Some(session_id); if !is_active { @@ -7381,17 +7279,6 @@ impl AppState { self.on_background_turn_completed(session_id, cx); } - if matches!( - &event, - AgentEvent::SessionStarted { .. } - | AgentEvent::TurnStarted { .. } - | AgentEvent::TurnCompleted { .. } - | AgentEvent::ApprovalRequested(_) - | AgentEvent::ApprovalResolved { .. } - ) { - self.publish_sync_liveness(session_id); - } - // Smoke-mode automation. if let Some(smoke) = self.smoke { match &event { @@ -7475,27 +7362,6 @@ impl AppState { /// The same wall-clock timestamp is persisted and folded exactly once so /// the on-disk log and the loaded timeline agree. fn record_event(&mut self, session_id: &str, event: &AgentEvent, cx: &mut Context) { - let workspace = self - .active - .as_ref() - .filter(|active| active.meta.id == session_id) - .map(|active| &active.meta) - .or_else(|| { - self.background - .get(session_id) - .map(|background| &background.meta) - }) - .or_else(|| self.sessions.iter().find(|meta| meta.id == session_id)) - .and_then(|meta| { - meta.project_id - .as_ref() - .map(|project_id| (project_id.clone(), meta.cwd.clone())) - }); - let mut event = event.clone(); - if let Some((workspace_id, workspace_root)) = workspace { - event.add_workspace_paths(&workspace_id, &workspace_root); - } - self.store_append_generation += 1; let ts = now_millis(); self.enqueue_store_write( @@ -7509,9 +7375,9 @@ impl AppState { if let Some(active) = self.active.as_mut() && active.meta.id == session_id { - active.timeline.apply_at(Some(ts), &event); + active.timeline.apply_at(Some(ts), event); } else if let Some(background) = self.background.get_mut(session_id) { - background.timeline.apply_at(Some(ts), &event); + background.timeline.apply_at(Some(ts), event); } } @@ -7640,7 +7506,6 @@ impl AppState { self.sessions_awaiting_approval.remove(&session_id); self.pending_native_rewinds.remove(&session_id); self.native_rewind_prefills.remove(&session_id); - self.clear_sync_liveness(&session_id); } if let Some(active) = self.active.take() && let Runtime::Live(commands) = active.runtime @@ -7766,7 +7631,6 @@ impl AppState { self.sessions_awaiting_approval.remove(session_id); self.pending_native_rewinds.remove(session_id); self.native_rewind_prefills.remove(session_id); - self.clear_sync_liveness(session_id); if let Some(parked) = self.background.remove(session_id) && let Runtime::Live(commands) = parked.runtime { @@ -10253,7 +10117,6 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "touch blocked".into(), cwd: Some("/tmp/project".into()), - workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -10316,7 +10179,6 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "touch allowed".into(), cwd: None, - workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -10375,7 +10237,6 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "touch blocked".into(), cwd: None, - workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -10420,7 +10281,6 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "cargo test".into(), cwd: None, - workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -11324,7 +11184,6 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: Some("/tmp/a.png".into()), - workspace_path: None, }; active.push_queued(String::new(), vec![attachment.clone()]); @@ -11419,45 +11278,6 @@ mod tests { assert!(!active.is_starting_generation(2)); } - #[gpui::test] - fn turn_acceptance_is_persisted_and_flushes_the_queued_message(cx: &mut gpui::TestAppContext) { - let root = - std::env::temp_dir().join(format!("tcode-turn-accepted-test-{}", uuid::Uuid::new_v4())); - let store = SessionStore::open_at(root.clone()).unwrap(); - let state = cx.new(|_| AppState::new(store.clone())); - let (commands, receiver) = async_channel::unbounded(); - let mut session = live_session(ProviderKind::Codex, commands); - session.meta.id = "accepted".into(); - - let mut delivery_id = 0; - state.update(cx, |state, cx| { - state.active = Some(session); - state.send_turn("persist this acceptance".into(), Vec::new(), cx); - delivery_id = match receiver.try_recv() { - Ok(SessionCommand::SendTurn { delivery_id, .. }) => delivery_id, - other => panic!("expected SendTurn, got {other:?}"), - }; - - state.on_event("accepted", AgentEvent::TurnAccepted { delivery_id }, cx); - - let active = state.active.as_ref().unwrap(); - assert!(active.queue.is_empty()); - assert_eq!(active.delivery_in_flight, None); - assert!(active.turn_in_flight); - }); - cx.run_until_parked(); - - assert!(store.read_events("accepted").iter().any(|stored| { - matches!( - stored.event, - AgentEvent::TurnAccepted { - delivery_id: stored_id - } if stored_id == delivery_id - ) - })); - let _ = std::fs::remove_dir_all(root); - } - #[gpui::test] fn unaccepted_send_survives_eof_and_is_delivered_once_after_resume( cx: &mut gpui::TestAppContext, @@ -12088,47 +11908,6 @@ mod tests { let _ = std::fs::remove_dir_all(root); } - #[gpui::test] - fn sync_command_reaches_the_intended_live_session(cx: &mut gpui::TestAppContext) { - let root = - std::env::temp_dir().join(format!("tcode-sync-command-{}", uuid::Uuid::new_v4())); - let store = SessionStore::open_at(root.clone()).unwrap(); - let state = cx.new(|_| AppState::new(store)); - let (remote_commands, requests) = async_channel::unbounded(); - let (active_commands, active_receiver) = async_channel::unbounded(); - let (target_commands, target_receiver) = async_channel::unbounded(); - - state.update(cx, |state, cx| { - let mut active = live_session(ProviderKind::Codex, active_commands); - active.meta.id = "active".into(); - state.active = Some(active); - - let mut target = live_session(ProviderKind::ClaudeCode, target_commands); - target.meta.id = "target".into(); - state.background.insert(target.meta.id.clone(), target); - - state.sync_commands = Some(requests); - state.pump_sync_commands(cx); - }); - remote_commands - .try_send(sync_host::CommandRequest { - session_id: "target".into(), - command: SessionCommand::Interrupt, - }) - .unwrap(); - cx.run_until_parked(); - - assert!(matches!( - target_receiver.try_recv(), - Ok(SessionCommand::Interrupt) - )); - assert!( - active_receiver.try_recv().is_err(), - "the command reached the wrong session" - ); - let _ = std::fs::remove_dir_all(root); - } - #[gpui::test] fn store_writer_upsert_is_visible_to_fresh_store(cx: &mut gpui::TestAppContext) { let root = diff --git a/crates/runtime/src/blocking.rs b/crates/runtime/src/blocking.rs index db0c57bc..78eea3e9 100644 --- a/crates/runtime/src/blocking.rs +++ b/crates/runtime/src/blocking.rs @@ -47,11 +47,6 @@ mod tests { let mut roots = Vec::new(); let mut discovered = Vec::new(); - // client-app is excluded from the workspace on purpose (see the root - // Cargo.toml): it builds only for the client targets, because it needs - // tcode-ui's `portable` feature and the desktop build needs `desktop`. - const NON_MEMBERS: &[&str] = &["client-app"]; - for entry in std::fs::read_dir(&crates_dir) .expect("workspace crates directory must be readable") .flatten() @@ -61,11 +56,7 @@ mod tests { if !crate_dir.is_dir() || !source_root.is_dir() { continue; } - let name = entry.file_name().to_string_lossy().into_owned(); - if NON_MEMBERS.contains(&name.as_str()) { - continue; - } - discovered.push(name); + discovered.push(entry.file_name().to_string_lossy().into_owned()); roots.push(source_root); } roots.sort(); @@ -75,20 +66,11 @@ mod tests { "app", "computer-use-mcp", "core", - "gpui-android", - "gpui-ios", "i18n", "orchestrate-mcp", "preview-mcp", "runtime", "services", - "sync-client", - "sync-host", - "sync-protocol", - "tcode-android", - "tcode-ios", - "tcode-server", - "tcode-web", "term", "ui", ] diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 62632bd4..90431bca 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -3,39 +3,21 @@ name = "tcode-services" version = "0.1.0" edition = "2024" -[features] -default = ["desktop"] -desktop = [ - "agent/local", - "dep:bzip2", - "dep:chrono", - "dep:dirs", - "dep:flate2", - "dep:log", - "dep:sha2", - "dep:smol", - "dep:tar", - "dep:thiserror", - "dep:ureq", - "dep:uuid", - "dep:zip", -] - [dependencies] -agent = { path = "../agent", default-features = false, optional = true } +agent = { path = "../agent" } tcode-core = { path = "../core" } base64 = "0.22" -bzip2 = { version = "0.6", optional = true } -chrono = { version = "0.4", optional = true } -dirs = { version = "6", optional = true } -flate2 = { version = "1", optional = true } -log = { version = "0.4", optional = true } +bzip2 = "0.6" +chrono = "0.4" +dirs = "6" +flate2 = "1" +log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = { version = "0.11", optional = true } -smol = { version = "2", optional = true } -tar = { version = "0.4", optional = true } -thiserror = { version = "2", optional = true } -ureq = { version = "2", default-features = false, features = ["tls", "gzip"], optional = true } -uuid = { version = "1", features = ["v4"], optional = true } -zip = { version = "2", default-features = false, features = ["deflate-flate2", "flate2", "bzip2"], optional = true } +sha2 = "0.11" +smol = "2" +tar = "0.4" +thiserror = "2" +ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } +uuid = { version = "1", features = ["v4"] } +zip = { version = "2", default-features = false, features = ["deflate-flate2", "flate2", "bzip2"] } diff --git a/crates/services/src/git.rs b/crates/services/src/git.rs index a770a36e..4c3bf0b8 100644 --- a/crates/services/src/git.rs +++ b/crates/services/src/git.rs @@ -138,7 +138,6 @@ fn split_git_patch(raw: &str, cwd: &Path, repo_prefix: &str) -> Vec std::io::Result { - let mut all = self.load_secrets(); - if let Some(token) = all - .get(APP_SECRETS) - .and_then(|secrets| secrets.get(SYNC_TOKEN_SECRET)) - .filter(|token| !token.is_empty()) - { - return Ok(token.clone()); - } - - let token = uuid::Uuid::new_v4().to_string(); - all.entry(APP_SECRETS.into()) - .or_default() - .insert(SYNC_TOKEN_SECRET.into(), token.clone()); - self.write_secrets(&all)?; - Ok(token) - } - /// The sensitive env values for one provider (used only when spawning). pub fn provider_secrets(&self, provider: ProviderKind) -> BTreeMap { self.profile_secrets(provider_key(provider)) @@ -347,30 +322,6 @@ mod tests { let _ = fs::remove_dir_all(root); } - #[test] - fn sync_token_survives_reopening_the_same_data_directory() { - let root = - std::env::temp_dir().join(format!("tcode-sync-token-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&root).unwrap(); - - let token = SettingsStore::new(root.clone()) - .load_or_create_sync_token() - .unwrap(); - let reopened = SettingsStore::new(root.clone()); - - assert_eq!(reopened.load_or_create_sync_token().unwrap(), token); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - let mode = fs::metadata(root.join("secrets.json")) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o600); - } - let _ = fs::remove_dir_all(root); - } - #[test] fn profile_secrets_are_keyed_by_profile_id() { let root = diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index 8e032974..6cf3d0b3 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -8,11 +8,9 @@ //! [`AgentEvent`], and fold them with [`tcode_core::session::fold_events`] into a //! [`tcode_core::session::Timeline`]. -use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::PathBuf; -use std::sync::{Arc, Mutex}; #[cfg(test)] use agent::ApprovalMode; @@ -24,35 +22,18 @@ use tcode_core::project::WorktreeInfo; use tcode_core::project::{IndexFile, Project, SessionMeta, migrate_index}; use tcode_core::session::StoredEvent; -/// On-disk envelope wrapping each event with its record time and log position. -/// Kept private: callers deal in [`StoredEvent`] (which tolerates the legacy -/// bare form). +/// On-disk envelope wrapping each event with its record time. Kept private: +/// callers deal in [`StoredEvent`] (which tolerates the legacy bare form). #[derive(Serialize, Deserialize)] struct EventEnvelope { ts: u64, - /// 1-based position in this session's log. Absent on lines written before - /// the field existed; those are numbered by position on read, so a log may - /// mix the two forms and still present one contiguous sequence. - #[serde(default, skip_serializing_if = "Option::is_none")] - seq: Option, event: AgentEvent, } /// Cheap, cloneable handle to the on-disk data directory. -/// -/// Clones share the sequence counters: they are per *store*, not per handle, -/// so two clones appending to one session cannot hand out the same `seq`. #[derive(Debug, Clone)] pub struct SessionStore { root: PathBuf, - /// Next `seq` to assign, per session id. - /// - /// Normally primed by `read_events`, which has already parsed the log and - /// so knows the answer for free. `scan_next_seq` is the fallback for a - /// session appended to without being read first. Either way it is computed - /// once per session, never per append — that would make writing a log - /// quadratic in its length. - next_seq: Arc>>, } impl SessionStore { @@ -71,10 +52,7 @@ impl SessionStore { pub fn open_at(root: PathBuf) -> std::io::Result { fs::create_dir_all(&root)?; - Ok(Self { - root, - next_seq: Arc::new(Mutex::new(HashMap::new())), - }) + Ok(Self { root }) } pub fn root(&self) -> &PathBuf { @@ -274,44 +252,11 @@ impl SessionStore { fs::rename(&tmp, self.index_path()) } - /// Next unused `seq` for a session, scanning its log once and caching the - /// result. The scan takes the larger of the line count and the highest - /// explicit `seq`, so a log that mixes legacy (position-numbered) lines - /// with newer explicit ones never reissues a number. - fn reserve_seq(&self, id: &str) -> u64 { - let mut counters = self - .next_seq - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let next = counters - .entry(id.to_owned()) - .or_insert_with(|| scan_next_seq(&self.events_path(id))); - let seq = *next; - *next += 1; - seq - } - - /// Forget a session's cached counter, so the next append rescans the log. - /// Needed wherever the file changes behind the cache's back. - fn forget_seq(&self, id: &str) { - self.next_seq - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(id); - } - - /// Append one event to the session's JSONL log, wrapped in an envelope - /// (`{"ts": , "seq": , "event": {…}}`), returning the `seq` it - /// was assigned. - /// - /// `seq` is assigned here rather than by the caller because this is the - /// point every write funnels through, so the numbers it hands out are - /// dense and gap-free by construction. - pub fn append_event(&self, id: &str, ts: u64, event: &AgentEvent) -> std::io::Result { - let seq = self.reserve_seq(id); + /// Append one event to the session's JSONL log, wrapped in a timestamped + /// envelope (`{"ts": , "event": {…}}`). + pub fn append_event(&self, id: &str, ts: u64, event: &AgentEvent) -> std::io::Result<()> { let envelope = EventEnvelope { ts, - seq: Some(seq), event: event.clone(), }; let line = serde_json::to_string(&envelope) @@ -331,8 +276,7 @@ impl SessionStore { } } file.write_all(line.as_bytes())?; - file.write_all(b"\n")?; - Ok(seq) + file.write_all(b"\n") } /// Read and parse every persisted event for a session (skipping bad lines). @@ -340,8 +284,6 @@ impl SessionStore { /// Each line is tolerantly parsed as either a timestamped envelope /// (`{"ts":…,"event":…}`) or a legacy bare event (`{"type":…}`), so logs /// written before the envelope format still replay (with `ts == None`). - /// Every returned event carries `Some(seq)`: explicit where the line has - /// one, positional otherwise. pub fn read_events(&self, id: &str) -> Vec { let path = self.events_path(id); let Ok(file) = File::open(&path) else { @@ -355,32 +297,10 @@ impl SessionStore { continue; } match parse_stored_line(trimmed) { - // Legacy lines carry no `seq`; number them by position so the - // sequence a caller sees is contiguous from 1 regardless of - // when each line was written. `events.len()` counts only lines - // that parsed, which is what keeps it contiguous — a corrupt - // line is skipped rather than consuming a number. - Ok(mut stored) => { - stored.seq = stored.seq.or(Some(events.len() as u64 + 1)); - events.push(stored); - } + Ok(stored) => events.push(stored), Err(err) => log::warn!("skipping unparseable event in {id}.jsonl: {err}"), } } - // Replaying already parsed the whole log, so the highest seq is in - // hand. Priming the counter here means the common path — open a - // session, then append to it — never pays for `scan_next_seq`, which - // would re-read a log that can run to tens of megabytes. - // - // `or_insert` rather than overwrite: a counter that already exists has - // seen appends this read may predate, and must not be walked back. - if let Some(highest) = events.last().and_then(|stored| stored.seq) { - self.next_seq - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .entry(id.to_owned()) - .or_insert(highest + 1); - } events } @@ -396,11 +316,7 @@ impl SessionStore { let dst = self.events_path(dst_id); let tmp = dst.with_extension("jsonl.tmp"); fs::write(&tmp, data)?; - fs::rename(tmp, dst)?; - // The destination's log just changed behind any cached counter (a fork - // reuses an id whose counter may still say 1). Rescan on next append. - self.forget_seq(dst_id); - Ok(()) + fs::rename(tmp, dst) } /// Remove a session from the index and delete its event log. @@ -408,7 +324,6 @@ impl SessionStore { let mut file = self.read_file(); file.sessions.retain(|meta| meta.id != id); self.write_file(&file)?; - self.forget_seq(id); match fs::remove_file(self.events_path(id)) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -425,15 +340,10 @@ fn parse_stored_line(line: &str) -> Result { match serde_json::from_str::(line) { Ok(envelope) => Ok(StoredEvent { ts: Some(envelope.ts), - seq: envelope.seq, event: envelope.event, }), Err(_envelope_err) => match serde_json::from_str::(line) { - Ok(event) => Ok(StoredEvent { - ts: None, - seq: None, - event, - }), + Ok(event) => Ok(StoredEvent { ts: None, event }), // Both forms failed: the line is genuinely corrupt. The bare-event // error is the more informative one (the envelope attempt always // fails on a bare event merely because `ts` is missing). @@ -442,51 +352,6 @@ fn parse_stored_line(line: &str) -> Result { } } -/// The first `seq` that is safe to assign in an existing log. -/// -/// The cold path. Opening a session calls `read_events`, which primes the -/// counter from a parse it was doing anyway; this runs only when something -/// appends to a session that was never read in this process. It reads each -/// line's `seq` rather than parsing events, so it costs a file read rather -/// than a full deserialization of a log that can reach tens of megabytes. -/// -/// Takes the larger of the parsed-line count and the highest explicit `seq` -/// because a log may hold both forms — legacy lines get positional numbers on -/// read, and reusing one of those for a new line would break the order the -/// numbers exist to provide. A missing file is an empty log, so `seq` starts -/// at 1. -fn scan_next_seq(path: &PathBuf) -> u64 { - let Ok(file) = File::open(path) else { - return 1; - }; - let mut lines = 0_u64; - let mut highest = 0_u64; - for line in BufReader::new(file).lines() { - let Ok(line) = line else { break }; - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - // A corrupt line still occupies a position: `read_events` skips it and - // therefore never issues its number, so counting it here only leaves a - // gap. Overshooting is safe; colliding is not. - lines += 1; - if let Ok(seq) = serde_json::from_str::(trimmed) - && let Some(seq) = seq.seq - { - highest = highest.max(seq); - } - } - lines.max(highest) + 1 -} - -/// Just enough of an envelope to read its `seq` (see [`scan_next_seq`]). -#[derive(Deserialize)] -struct SeqOnly { - #[serde(default)] - seq: Option, -} - pub fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -942,173 +807,4 @@ mod tests { assert!(!store.events_path("empty-fork").exists()); let _ = fs::remove_dir_all(store.root()); } - - // -- event sequence numbers --------------------------------------------- - - fn assistant(text: &str) -> AgentEvent { - AgentEvent::Delta { - item_id: "item-1".into(), - kind: agent::DeltaKind::AssistantText, - text: text.into(), - } - } - - fn seqs(store: &SessionStore, id: &str) -> Vec { - store - .read_events(id) - .into_iter() - .map(|stored| stored.seq.expect("read_events always assigns a seq")) - .collect() - } - - #[test] - fn appended_events_are_numbered_from_one_and_the_number_is_returned() { - let store = SessionStore::open_at(temp_root()).unwrap(); - let assigned: Vec = ["a", "b", "c"] - .iter() - .map(|text| store.append_event("s1", 1, &assistant(text)).unwrap()) - .collect(); - - assert_eq!(assigned, vec![1, 2, 3], "append must hand back its own seq"); - assert_eq!(seqs(&store, "s1"), vec![1, 2, 3]); - let _ = fs::remove_dir_all(store.root()); - } - - #[test] - fn sequences_are_per_session_not_global() { - let store = SessionStore::open_at(temp_root()).unwrap(); - store.append_event("s1", 1, &assistant("a")).unwrap(); - store.append_event("s2", 1, &assistant("b")).unwrap(); - store.append_event("s1", 1, &assistant("c")).unwrap(); - - assert_eq!(seqs(&store, "s1"), vec![1, 2]); - assert_eq!(seqs(&store, "s2"), vec![1]); - let _ = fs::remove_dir_all(store.root()); - } - - /// Logs written before `seq` existed carry only `ts`. They must still - /// present a contiguous sequence, or a client resuming from a cursor would - /// silently skip the whole legacy prefix. - #[test] - fn legacy_lines_without_seq_are_numbered_by_position() { - let store = SessionStore::open_at(temp_root()).unwrap(); - let path = store.events_path("s1"); - fs::write( - &path, - "{\"ts\":10,\"event\":{\"type\":\"session_closed\"}}\n\ - {\"type\":\"session_closed\"}\n\ - {\"ts\":30,\"event\":{\"type\":\"session_closed\"}}\n", - ) - .unwrap(); - - assert_eq!(seqs(&store, "s1"), vec![1, 2, 3]); - let _ = fs::remove_dir_all(store.root()); - } - - /// The case the counter exists to survive: a log that already has legacy - /// lines, reopened by a fresh process, then appended to. The new lines must - /// continue past the positional numbers rather than restart and collide. - #[test] - fn appending_to_a_legacy_log_continues_past_the_positional_numbers() { - let root = temp_root(); - let store = SessionStore::open_at(root.clone()).unwrap(); - fs::write( - store.events_path("s1"), - "{\"ts\":10,\"event\":{\"type\":\"session_closed\"}}\n\ - {\"ts\":20,\"event\":{\"type\":\"session_closed\"}}\n", - ) - .unwrap(); - - // A separate handle: nothing is cached, exactly like a restart. - let reopened = SessionStore::open_at(root.clone()).unwrap(); - assert_eq!(reopened.append_event("s1", 30, &assistant("x")).unwrap(), 3); - - let observed = seqs(&reopened, "s1"); - assert_eq!(observed, vec![1, 2, 3]); - assert!( - observed.windows(2).all(|w| w[1] == w[0] + 1), - "sequence must stay contiguous across the format change: {observed:?}" - ); - let _ = fs::remove_dir_all(root); - } - - /// A fork copies the log verbatim, so the destination inherits its - /// sequence. Appending to the fork must not restart at 1 and overwrite the - /// inherited numbering. - #[test] - fn cloning_a_log_carries_its_sequence_into_the_fork() { - let store = SessionStore::open_at(temp_root()).unwrap(); - store.append_event("src", 1, &assistant("a")).unwrap(); - store.append_event("src", 2, &assistant("b")).unwrap(); - - // Touch the destination first so a stale counter would exist to catch. - store.append_event("dst", 1, &assistant("scratch")).unwrap(); - store.clone_events("src", "dst").unwrap(); - - assert_eq!(store.append_event("dst", 3, &assistant("c")).unwrap(), 3); - assert_eq!(seqs(&store, "dst"), vec![1, 2, 3]); - let _ = fs::remove_dir_all(store.root()); - } - - /// Deleting a session frees its id. If the counter outlived the file, the - /// next session reusing that id would start numbering mid-sequence. - #[test] - fn removing_a_session_resets_its_sequence() { - let store = SessionStore::open_at(temp_root()).unwrap(); - store.append_event("s1", 1, &assistant("a")).unwrap(); - store.append_event("s1", 2, &assistant("b")).unwrap(); - store.remove_session("s1").unwrap(); - - assert_eq!(store.append_event("s1", 3, &assistant("c")).unwrap(), 1); - let _ = fs::remove_dir_all(store.root()); - } - - /// Opening a session must leave the counter primed, so the append that - /// follows does not re-read a log the replay just parsed. Checked directly - /// rather than through behaviour, because a working-but-rescanning - /// implementation passes every black-box assertion here. - #[test] - fn reading_a_log_primes_the_counter_so_appends_skip_the_rescan() { - let root = temp_root(); - let store = SessionStore::open_at(root.clone()).unwrap(); - store.append_event("s1", 1, &assistant("a")).unwrap(); - store.append_event("s1", 2, &assistant("b")).unwrap(); - - // A fresh handle has no cached counters, exactly like a restart. - let reopened = SessionStore::open_at(root.clone()).unwrap(); - let primed = |store: &SessionStore| -> Option { - store.next_seq.lock().unwrap().get("s1").copied() - }; - assert_eq!(primed(&reopened), None, "counter must start cold"); - - assert_eq!(reopened.read_events("s1").len(), 2); - assert_eq!( - primed(&reopened), - Some(3), - "replay must hand the counter its answer" - ); - assert_eq!(reopened.append_event("s1", 3, &assistant("c")).unwrap(), 3); - let _ = fs::remove_dir_all(root); - } - - /// `seq` must not appear on the wire for events that never had one, so a - /// reader can tell "no seq recorded" from "seq zero". - #[test] - fn envelopes_omit_seq_when_absent_and_carry_it_when_present() { - let with = serde_json::to_string(&EventEnvelope { - ts: 5, - seq: Some(9), - event: assistant("a"), - }) - .unwrap(); - assert!(with.contains("\"seq\":9"), "{with}"); - - let without = serde_json::to_string(&EventEnvelope { - ts: 5, - seq: None, - event: assistant("a"), - }) - .unwrap(); - assert!(!without.contains("seq"), "{without}"); - } } diff --git a/crates/sync-client/Cargo.toml b/crates/sync-client/Cargo.toml deleted file mode 100644 index 9a2ec359..00000000 --- a/crates/sync-client/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "sync-client" -version = "0.1.0" -edition = "2024" - -[dependencies] -sync-protocol = { path = "../sync-protocol" } -# Types only. This crate runs on phones and in browsers, where there are no -# provider processes to spawn — that is the entire reason it exists. -agent = { path = "../agent", default-features = false } -tcode-core = { path = "../core" } -log = "0.4" - -[target.'cfg(not(target_arch = "wasm32"))'.dependencies] -futures-util = "0.3" -serde_json = "1" -tcode-services = { path = "../services" } -tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } -tokio-tungstenite = "0.28" diff --git a/crates/sync-client/src/bin/sync-probe.rs b/crates/sync-client/src/bin/sync-probe.rs deleted file mode 100644 index 0b15ee1a..00000000 --- a/crates/sync-client/src/bin/sync-probe.rs +++ /dev/null @@ -1,535 +0,0 @@ -#[cfg(target_arch = "wasm32")] -fn main() { - eprintln!("sync-probe is only available on native targets"); -} - -#[cfg(not(target_arch = "wasm32"))] -mod native { - use std::error::Error; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - use agent::{ApprovalDecision, ApprovalMode, SessionCommand}; - use futures_util::{SinkExt as _, StreamExt as _}; - use sync_client::{Client, ClientConfig}; - use sync_protocol::{ClientFrame, ClientInfo, HostFrame}; - use tcode_services::settings::SettingsStore; - use tcode_services::store::SessionStore; - use tokio_tungstenite::tungstenite::Message; - - const REPLY_TIMEOUT: Duration = Duration::from_secs(30); - - type Socket = tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >; - - enum Command { - /// Trade a pairing code for the durable token. - Pair { - code: String, - }, - List, - Watch { - session_id: String, - }, - Send { - session_id: String, - text: String, - }, - Mode { - session_id: String, - mode: ApprovalMode, - }, - Approve { - session_id: String, - request_id: String, - decision: ApprovalDecision, - }, - } - - struct Args { - url: String, - token: Option, - command: Command, - } - - pub async fn run() -> Result<(), Box> { - let args = parse_args()?; - // Pairing is the one command that runs without a token — that is what it - // exists to obtain — so it must not fall back to reading the host's - // secrets file, which a phone could never do either. - let pairing_code = match &args.command { - Command::Pair { code } => Some(code.clone()), - _ => None, - }; - let token = match (args.token, &pairing_code) { - (Some(token), _) => token, - (None, Some(_)) => String::new(), - (None, None) => default_token()?, - }; - let (mut socket, _) = tokio_tungstenite::connect_async(&args.url).await?; - let mut client = Client::new(ClientConfig { - client: ClientInfo { - client_id: format!("sync-probe-{}", std::process::id()), - display_name: "tcode sync probe".into(), - platform: std::env::consts::OS.into(), - app_version: env!("CARGO_PKG_VERSION").into(), - }, - token, - }); - if let Some(code) = pairing_code { - send_frame(&mut socket, client.pair(code)).await?; - let token = loop { - let frame = recv_frame(&mut socket).await?; - match &frame { - HostFrame::Paired { token, .. } => break token.clone(), - HostFrame::Refused { reason } => { - return Err(format!("pairing refused: {reason:?}").into()); - } - _ => { - client.handle(frame); - } - } - }; - println!("paired; token: {token}"); - return Ok(()); - } - - send_frame(&mut socket, client.connect()).await?; - handshake(&mut socket, &mut client).await?; - - match args.command { - // Handled before the handshake; unreachable here. - Command::Pair { .. } => Ok(()), - Command::List => list(&mut socket, &mut client).await, - Command::Watch { session_id } => watch(&mut socket, &mut client, session_id).await, - Command::Send { session_id, text } => { - send_turn(&mut socket, &mut client, session_id, text).await - } - Command::Mode { session_id, mode } => { - set_approval_mode(&mut socket, &mut client, session_id, mode).await - } - Command::Approve { - session_id, - request_id, - decision, - } => approve(&mut socket, &mut client, session_id, request_id, decision).await, - } - } - - fn parse_args() -> Result> { - let mut positional = Vec::new(); - let mut url = None; - let mut token = None; - let mut args = std::env::args().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "--url" => url = Some(args.next().ok_or("--url requires a value")?), - "--token" => token = Some(args.next().ok_or("--token requires a value")?), - "-h" | "--help" => { - print_usage(); - std::process::exit(0); - } - _ if arg.starts_with('-') => return Err(format!("unknown flag: {arg}").into()), - _ => positional.push(arg), - } - } - - let url = url.ok_or("missing required --url")?; - let command = match positional.as_slice() { - [command, code] if command == "pair" => Command::Pair { code: code.clone() }, - [command] if command == "list" => Command::List, - [command, session_id] if command == "watch" => Command::Watch { - session_id: session_id.clone(), - }, - [command, session_id, text] if command == "send" => Command::Send { - session_id: session_id.clone(), - text: text.clone(), - }, - [command, session_id, mode] if command == "mode" => Command::Mode { - session_id: session_id.clone(), - mode: parse_approval_mode(mode)?, - }, - [command, session_id, request_id, decision] if command == "approve" => { - Command::Approve { - session_id: session_id.clone(), - request_id: request_id.clone(), - decision: parse_decision(decision)?, - } - } - _ => { - print_usage(); - return Err("invalid command arguments".into()); - } - }; - Ok(Args { - url, - token, - command, - }) - } - - fn parse_approval_mode(value: &str) -> Result> { - match value { - "supervised" => Ok(ApprovalMode::Supervised), - "read-only" => Ok(ApprovalMode::ReadOnly), - "auto-accept-edits" => Ok(ApprovalMode::AutoAcceptEdits), - "full-access" => Ok(ApprovalMode::FullAccess), - _ => Err(format!( - "invalid mode {value:?}; use supervised, read-only, auto-accept-edits, or full-access" - ) - .into()), - } - } - - fn parse_decision(value: &str) -> Result> { - match value { - "approve" => Ok(ApprovalDecision::Approve), - "approve-for-session" => Ok(ApprovalDecision::ApproveForSession), - "deny" => Ok(ApprovalDecision::Deny), - "cancel" => Ok(ApprovalDecision::Cancel), - value if value.starts_with("option:") => Ok(ApprovalDecision::Option( - value.trim_start_matches("option:").into(), - )), - _ => Err(format!( - "invalid decision {value:?}; use approve, approve-for-session, deny, cancel, or option:" - ) - .into()), - } - } - - fn print_usage() { - eprintln!( - "usage: sync-probe [arguments] --url [--token ]\n\ - commands:\n\ - \x20 list\n\ - \x20 watch \n\ - \x20 send \n\ - \x20 mode \n\ - \x20 approve " - ); - } - - fn default_token() -> Result> { - let root = SessionStore::open_default()?.root().clone(); - Ok(SettingsStore::new(root).load_or_create_sync_token()?) - } - - async fn send_frame(socket: &mut Socket, frame: ClientFrame) -> Result<(), Box> { - socket.send(Message::Text(frame.encode()?.into())).await?; - Ok(()) - } - - async fn recv_frame(socket: &mut Socket) -> Result> { - loop { - let message = tokio::time::timeout(REPLY_TIMEOUT, socket.next()) - .await - .map_err(|_| "timed out waiting for the host")? - .ok_or("host closed the connection")??; - match message { - Message::Text(text) => return Ok(HostFrame::decode(&text)?), - Message::Close(_) => return Err("host closed the connection".into()), - _ => {} - } - } - } - - async fn handle( - socket: &mut Socket, - client: &mut Client, - frame: HostFrame, - ) -> Result<(), Box> { - for outgoing in client.handle(frame) { - send_frame(socket, outgoing).await?; - } - Ok(()) - } - - async fn handshake(socket: &mut Socket, client: &mut Client) -> Result<(), Box> { - let frame = recv_frame(socket).await?; - handle(socket, client, frame).await?; - if let Some(reason) = client.refusal_reason() { - return Err(format!("host refused the connection: {reason:?}").into()); - } - if !client.is_ready() { - return Err("host did not complete the sync handshake".into()); - } - Ok(()) - } - - async fn list(socket: &mut Socket, client: &mut Client) -> Result<(), Box> { - send_frame(socket, client.request_sessions()).await?; - loop { - let frame = recv_frame(socket).await?; - let is_list = matches!(frame, HostFrame::SessionList { .. }); - handle(socket, client, frame).await?; - if is_list { - break; - } - } - - println!("sessions: {}", client.sessions().len()); - for session in client.sessions() { - println!( - "{}\t{}\t{}\tworking={}\tawaiting-approval={}", - session.session_id, - session.title, - session.provider.display_name(), - session.working, - session.awaiting_approval - ); - } - Ok(()) - } - - async fn subscribe_until_caught_up( - socket: &mut Socket, - client: &mut Client, - session_id: &str, - print_events: bool, - ) -> Result<(), Box> { - let frame = client.subscribe(session_id); - send_frame(socket, frame).await?; - loop { - let frame = recv_frame(socket).await?; - if print_events { - print_events_for(&frame, session_id); - } - handle(socket, client, frame).await?; - if client - .session(session_id) - .is_some_and(|session| session.is_caught_up()) - { - return Ok(()); - } - if let Some(rejection) = client.command_rejections().last() { - return Err(format!("host rejected command: {:?}", rejection.reason).into()); - } - } - } - - async fn watch( - socket: &mut Socket, - client: &mut Client, - session_id: String, - ) -> Result<(), Box> { - subscribe_until_caught_up(socket, client, &session_id, true).await?; - println!("watching {session_id}"); - loop { - let frame = recv_frame(socket).await?; - print_events_for(&frame, &session_id); - let ended = matches!( - &frame, - HostFrame::SessionEnded { - session_id: ended, - .. - } if ended == &session_id - ); - handle(socket, client, frame).await?; - if ended { - return Ok(()); - } - } - } - - async fn send_turn( - socket: &mut Socket, - client: &mut Client, - session_id: String, - text: String, - ) -> Result<(), Box> { - subscribe_until_caught_up(socket, client, &session_id, false).await?; - let delivery_id = SystemTime::now() - .duration_since(UNIX_EPOCH)? - .as_nanos() - .try_into() - .unwrap_or(u64::MAX); - let frame = client.command( - &session_id, - SessionCommand::SendTurn { - delivery_id, - text, - options: None, - attachments: Vec::new(), - }, - ); - send_frame(socket, frame).await?; - - loop { - let frame = match recv_frame(socket).await { - Ok(frame) => frame, - Err(err) if err.to_string().contains("timed out waiting for the host") => { - println!("delivery_id {delivery_id} echoed by TurnAccepted: no (timed out)"); - return Ok(()); - } - Err(err) => return Err(err), - }; - print_events_for(&frame, &session_id); - handle(socket, client, frame).await?; - let echoed = client.unacknowledged_turns(&session_id).next().is_none(); - if echoed { - println!("delivery_id {delivery_id} echoed by TurnAccepted: yes"); - return Ok(()); - } - if let Some(rejection) = client.command_rejections().last() { - println!( - "delivery_id {delivery_id} echoed by TurnAccepted: no ({:?})", - rejection.reason - ); - return Ok(()); - } - } - } - - async fn set_approval_mode( - socket: &mut Socket, - client: &mut Client, - session_id: String, - mode: ApprovalMode, - ) -> Result<(), Box> { - subscribe_until_caught_up(socket, client, &session_id, false).await?; - let frame = client.command(&session_id, SessionCommand::SetApprovalMode(mode)); - send_frame(socket, frame).await?; - println!("approval mode set to {mode:?} for session {session_id}"); - Ok(()) - } - - async fn approve( - socket: &mut Socket, - client: &mut Client, - session_id: String, - request_id: String, - decision: ApprovalDecision, - ) -> Result<(), Box> { - subscribe_until_caught_up(socket, client, &session_id, false).await?; - let pending = client.session(&session_id).is_some_and(|session| { - session - .timeline() - .pending_approvals - .iter() - .any(|request| request.id == request_id) - }); - if !pending { - return Err(format!("approval request {request_id:?} is not pending").into()); - } - let frame = client.command( - &session_id, - SessionCommand::RespondApproval { - request_id: request_id.clone(), - decision, - }, - ); - send_frame(socket, frame).await?; - println!("approval response sent for request {request_id}"); - Ok(()) - } - - fn print_events_for(frame: &HostFrame, session_id: &str) { - let HostFrame::Events { - session_id: received, - events, - .. - } = frame - else { - return; - }; - if received != session_id { - return; - } - for event in events { - println!("{}\t{}", event.seq, event_summary(&event.event)); - } - } - - fn event_summary(event: &agent::AgentEvent) -> String { - if let agent::AgentEvent::ApprovalRequested(request) = event { - let detail = - serde_json::to_string(request).unwrap_or_else(|_| "unserializable request".into()); - return format!( - "approval_requested request_id={} request={detail}", - request.id - ); - } - - let Ok(mut value) = serde_json::to_value(event) else { - return "unserializable event".into(); - }; - let event_type = value - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or("event") - .to_string(); - if let Some(object) = value.as_object_mut() { - object.remove("type"); - } - let detail = serde_json::to_string(&value).unwrap_or_default(); - let mut summary = if detail == "{}" { - event_type - } else { - format!("{event_type} {detail}") - }; - const LIMIT: usize = 180; - if summary.chars().count() > LIMIT { - summary = summary.chars().take(LIMIT - 1).collect::() + "…"; - } - summary - } - - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn parses_every_approval_mode() { - assert_eq!( - parse_approval_mode("supervised").unwrap(), - ApprovalMode::Supervised - ); - assert_eq!( - parse_approval_mode("read-only").unwrap(), - ApprovalMode::ReadOnly - ); - assert_eq!( - parse_approval_mode("auto-accept-edits").unwrap(), - ApprovalMode::AutoAcceptEdits - ); - assert_eq!( - parse_approval_mode("full-access").unwrap(), - ApprovalMode::FullAccess - ); - assert!(parse_approval_mode("default").is_err()); - } - - #[test] - fn approval_summary_keeps_request_id_and_full_request() { - let command = "echo ".to_owned() + &"x".repeat(200); - let event = agent::AgentEvent::ApprovalRequested(agent::ApprovalRequest { - id: "request-42".into(), - turn_id: Some("turn-7".into()), - kind: agent::ApprovalKind::ExecCommand { - command: command.clone(), - cwd: Some("/tmp/example".into()), - workspace_cwd: None, - reason: Some("exercise the remote approval path".into()), - }, - options: Vec::new(), - }); - - let summary = event_summary(&event); - - assert!(summary.starts_with( - "approval_requested request_id=request-42 request={\"id\":\"request-42\"" - )); - assert!(summary.contains(&command)); - assert!(summary.contains("\"cwd\":\"/tmp/example\"")); - } - } -} - -#[cfg(not(target_arch = "wasm32"))] -#[tokio::main] -async fn main() { - if let Err(err) = native::run().await { - eprintln!("sync-probe: {err}"); - std::process::exit(1); - } -} diff --git a/crates/sync-client/src/lib.rs b/crates/sync-client/src/lib.rs deleted file mode 100644 index 5f4bfce9..00000000 --- a/crates/sync-client/src/lib.rs +++ /dev/null @@ -1,835 +0,0 @@ -//! Client side of the tcode sync protocol: the connection and session state -//! machines, with no I/O in them. -//! -//! [`Client`] turns host frames into client frames and observable replicated -//! state. A transport opens a socket, sends [`Client::connect`], passes each -//! received frame to [`Client::handle`], and writes the returned frames. The -//! client itself never touches a socket, a clock, or a task executor, so the -//! same logic runs behind a native WebSocket and the browser's WebSocket API. - -use std::collections::{BTreeMap, HashMap}; - -use sync_protocol::{ - AgentEvent, ClientFrame, ClientInfo, CommandRejection, HostFrame, HostInfo, RefuseReason, - SessionCommand, SessionEndReason, SessionSummary, -}; -use tcode_core::session::Timeline; - -/// Identity and credential sent at the start of every connection. -pub struct ClientConfig { - pub client: ClientInfo, - pub token: String, -} - -enum ConnectionState { - Disconnected, - AwaitingWelcome, - Ready { version: u32, host: HostInfo }, - Refused(RefuseReason), -} - -/// A host frame that could not be reconciled with known-good client state. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ProtocolViolation { - /// The host selected a version outside the range this build advertised. - UnsupportedWelcomeVersion { version: u32 }, - /// No part of an invalid batch is applied. The returned resubscribe starts - /// after the cursor named here, so recovery cannot bless partial data. - EventGap { - session_id: String, - expected_seq: u64, - received_seq: u64, - cursor: Option, - }, -} - -/// One command rejection retained until the application has presented it. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RejectedCommand { - pub session_id: String, - pub command: Option, - pub reason: CommandRejection, -} - -/// One terminal session notification retained for the application. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EndedSession { - pub session_id: String, - pub reason: SessionEndReason, -} - -/// The replicated state of one session. -#[derive(Debug, Clone, Default)] -pub struct SessionState { - cursor: Option, - timeline: Timeline, - subscribed: bool, - caught_up: bool, -} - -impl SessionState { - /// Highest contiguous sequence folded into this replica. - pub fn cursor(&self) -> Option { - self.cursor - } - - pub fn timeline(&self) -> &Timeline { - &self.timeline - } - - /// False during backlog replay and after requesting a resubscribe. - pub fn is_caught_up(&self) -> bool { - self.caught_up - } - - pub fn is_subscribed(&self) -> bool { - self.subscribed - } -} - -/// Client state that survives transport connections. -/// -/// In particular, session cursors and unacknowledged turns are intentionally -/// not connection state. A mobile transport can discard and recreate its -/// socket without forcing either a full replay or a blind resend. -pub struct Client { - config: ClientConfig, - connection: ConnectionState, - sessions: Vec, - replicas: HashMap, - pending_turns: HashMap>, - violations: Vec, - rejections: Vec, - ended_sessions: Vec, - pongs: Vec, - /// Token obtained by pairing, for the shell to persist. - paired_token: Option, -} - -impl Client { - pub fn new(config: ClientConfig) -> Self { - Self { - config, - connection: ConnectionState::Disconnected, - sessions: Vec::new(), - replicas: HashMap::new(), - pending_turns: HashMap::new(), - violations: Vec::new(), - rejections: Vec::new(), - ended_sessions: Vec::new(), - pongs: Vec::new(), - paired_token: None, - } - } - - /// Start a transport connection and produce its mandatory first frame. - /// - /// Replicas and delivery bookkeeping are left intact. After `Welcome`, - /// [`Self::handle`] automatically restores every requested subscription - /// from its known-good cursor. - pub fn connect(&mut self) -> ClientFrame { - self.connection = ConnectionState::AwaitingWelcome; - ClientFrame::Hello { - min_version: sync_protocol::PROTOCOL_MIN_VERSION, - max_version: sync_protocol::PROTOCOL_MAX_VERSION, - client: self.config.client.clone(), - token: self.config.token.clone(), - } - } - - pub fn is_ready(&self) -> bool { - matches!(self.connection, ConnectionState::Ready { .. }) - } - - pub fn negotiated_version(&self) -> Option { - match self.connection { - ConnectionState::Ready { version, .. } => Some(version), - _ => None, - } - } - - pub fn host(&self) -> Option<&HostInfo> { - match &self.connection { - ConnectionState::Ready { host, .. } => Some(host), - _ => None, - } - } - - pub fn refusal_reason(&self) -> Option<&RefuseReason> { - match &self.connection { - ConnectionState::Refused(reason) => Some(reason), - _ => None, - } - } - - pub fn sessions(&self) -> &[SessionSummary] { - &self.sessions - } - - pub fn session(&self, session_id: &str) -> Option<&SessionState> { - self.replicas.get(session_id) - } - - pub fn violations(&self) -> &[ProtocolViolation] { - &self.violations - } - - pub fn command_rejections(&self) -> &[RejectedCommand] { - &self.rejections - } - - pub fn ended_sessions(&self) -> &[EndedSession] { - &self.ended_sessions - } - - pub fn pongs(&self) -> &[u64] { - &self.pongs - } - - /// Offer a pairing code in exchange for the durable token. - /// - /// Used instead of `connect` when the client has no token yet. The reply is - /// a `Paired` frame carrying one; the client then handshakes normally, - /// which is why this does not change the connection's state. - pub fn pair(&self, code: impl Into) -> ClientFrame { - ClientFrame::Pair { - min_version: sync_protocol::PROTOCOL_MIN_VERSION, - max_version: sync_protocol::PROTOCOL_MAX_VERSION, - code: code.into(), - client: self.config.client.clone(), - } - } - - /// The token a completed pairing produced, if one has arrived. - /// - /// A shell reads this to persist the token so the code is never needed - /// again — which is the entire point of pairing rather than passing a code - /// on every connection. - pub fn paired_token(&self) -> Option<&str> { - self.paired_token.as_deref() - } - - pub fn request_sessions(&self) -> ClientFrame { - ClientFrame::ListSessions - } - - /// Subscribe from the replica's highest contiguous event, if any. - pub fn subscribe(&mut self, session_id: impl Into) -> ClientFrame { - let session_id = session_id.into(); - let state = self.replicas.entry(session_id.clone()).or_default(); - state.subscribed = true; - state.caught_up = false; - ClientFrame::Subscribe { - session_id, - from_seq: state.cursor, - } - } - - /// Stop live delivery without discarding the replica or its resume cursor. - pub fn unsubscribe(&mut self, session_id: impl Into) -> ClientFrame { - let session_id = session_id.into(); - if let Some(state) = self.replicas.get_mut(&session_id) { - state.subscribed = false; - state.caught_up = false; - } - ClientFrame::Unsubscribe { session_id } - } - - /// Produce a command frame, retaining `SendTurn` until its delivery id is - /// observed in a contiguous event batch. - pub fn command( - &mut self, - session_id: impl Into, - command: SessionCommand, - ) -> ClientFrame { - let session_id = session_id.into(); - if let SessionCommand::SendTurn { delivery_id, .. } = &command { - self.pending_turns - .entry(session_id.clone()) - .or_default() - .insert(*delivery_id, command.clone()); - } - ClientFrame::Command { - session_id, - command, - } - } - - /// Turns that have been sent but have no observed `TurnAccepted`. - pub fn unacknowledged_turns(&self, session_id: &str) -> impl Iterator { - self.pending_turns - .get(session_id) - .into_iter() - .flat_map(|turns| turns.values()) - } - - /// Resend only turns for which no acceptance event has been observed. - /// - /// A caller should wait until the resumed subscription is caught up before - /// using this: its backfill may contain the acknowledgement that was in - /// flight when the old socket disappeared. - pub fn resend_unacknowledged_turns(&self, session_id: &str) -> Vec { - self.unacknowledged_turns(session_id) - .cloned() - .map(|command| ClientFrame::Command { - session_id: session_id.to_owned(), - command, - }) - .collect() - } - - pub fn ping(&self, nonce: u64) -> ClientFrame { - ClientFrame::Ping { nonce } - } - - /// Handle one host frame, returning any frames the transport should send. - pub fn handle(&mut self, frame: HostFrame) -> Vec { - match &self.connection { - ConnectionState::AwaitingWelcome => self.handle_handshake(frame), - ConnectionState::Ready { .. } => self.handle_ready(frame), - ConnectionState::Disconnected | ConnectionState::Refused(_) => Vec::new(), - } - } - - fn handle_handshake(&mut self, frame: HostFrame) -> Vec { - match frame { - HostFrame::Welcome { version, host } - if (sync_protocol::PROTOCOL_MIN_VERSION..=sync_protocol::PROTOCOL_MAX_VERSION) - .contains(&version) => - { - self.connection = ConnectionState::Ready { version, host }; - self.replicas - .iter() - .filter(|(_, state)| state.subscribed) - .map(|(session_id, state)| ClientFrame::Subscribe { - session_id: session_id.clone(), - from_seq: state.cursor, - }) - .collect() - } - HostFrame::Welcome { version, .. } => { - self.violations - .push(ProtocolViolation::UnsupportedWelcomeVersion { version }); - Vec::new() - } - HostFrame::Refused { reason } => { - self.connection = ConnectionState::Refused(reason); - Vec::new() - } - _ => Vec::new(), - } - } - - fn handle_ready(&mut self, frame: HostFrame) -> Vec { - match frame { - HostFrame::Welcome { .. } | HostFrame::Refused { .. } => Vec::new(), - HostFrame::SessionList { sessions } => { - self.sessions = sessions; - Vec::new() - } - HostFrame::Events { - session_id, - events, - caught_up, - } => self.apply_events(session_id, events, caught_up), - HostFrame::CommandRejected { - session_id, - command, - reason, - } => { - if let Some(sync_protocol::RejectedCommand::Turn { delivery_id }) = &command - && let Some(turns) = self.pending_turns.get_mut(&session_id) - { - turns.remove(delivery_id); - } - self.rejections.push(RejectedCommand { - session_id, - command, - reason, - }); - Vec::new() - } - HostFrame::SessionEnded { session_id, reason } => { - if let Some(state) = self.replicas.get_mut(&session_id) { - state.caught_up = true; - } - self.ended_sessions - .push(EndedSession { session_id, reason }); - Vec::new() - } - HostFrame::Paired { token, .. } => { - // Retained, not applied: the connection is still - // unauthenticated, and the shell decides whether to persist the - // token before handshaking with it. - self.paired_token = Some(token); - Vec::new() - } - HostFrame::Pong { nonce } => { - self.pongs.push(nonce); - Vec::new() - } - } - } - - fn apply_events( - &mut self, - session_id: String, - events: Vec, - caught_up: bool, - ) -> Vec { - let state = self.replicas.entry(session_id.clone()).or_default(); - let mut expected = state.cursor.unwrap_or(0).saturating_add(1); - for event in &events { - if event.seq != expected { - let violation = ProtocolViolation::EventGap { - session_id: session_id.clone(), - expected_seq: expected, - received_seq: event.seq, - cursor: state.cursor, - }; - log::warn!("sync event sequence violation: {violation:?}"); - self.violations.push(violation); - state.caught_up = false; - return vec![ClientFrame::Subscribe { - session_id, - from_seq: state.cursor, - }]; - } - expected = expected.saturating_add(1); - } - - for event in &events { - if let AgentEvent::TurnAccepted { delivery_id } = &event.event - && let Some(turns) = self.pending_turns.get_mut(&session_id) - { - turns.remove(delivery_id); - } - } - - // The complete contiguity check above happens before any mutation, so - // applying only this batch is equivalent to replaying the whole log: - // no duplicate or out-of-order event can reach the reducer. - for event in &events { - state.timeline.apply_at(Some(event.ts), &event.event); - } - state.cursor = events.last().map(|event| event.seq).or(state.cursor); - state.caught_up = caught_up; - Vec::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use agent::TurnStatus; - use sync_protocol::{PROTOCOL_MAX_VERSION, PROTOCOL_MIN_VERSION, SeqEvent}; - use tcode_core::session::StoredEvent; - - fn config() -> ClientConfig { - ClientConfig { - client: ClientInfo { - client_id: "client-1".into(), - display_name: "Pixel".into(), - platform: "android".into(), - app_version: "0.1.0".into(), - }, - token: "correct-horse".into(), - } - } - - fn host() -> HostInfo { - HostInfo { - host_id: "host-1".into(), - display_name: "workstation".into(), - platform: "macos".into(), - app_version: "0.1.0".into(), - } - } - - fn event(seq: u64, event: AgentEvent) -> SeqEvent { - SeqEvent { - seq, - ts: 1_000 + seq, - event, - } - } - - fn connect(client: &mut Client) { - assert!(matches!( - client.connect(), - ClientFrame::Hello { - min_version: PROTOCOL_MIN_VERSION, - max_version: PROTOCOL_MAX_VERSION, - .. - } - )); - assert!( - client - .handle(HostFrame::Welcome { - version: PROTOCOL_MAX_VERSION, - host: host(), - }) - .is_empty() - ); - } - - fn turn(delivery_id: u64) -> SessionCommand { - SessionCommand::SendTurn { - delivery_id, - text: format!("turn {delivery_id}"), - options: None, - attachments: Vec::new(), - } - } - - #[test] - fn a_successful_handshake_exposes_the_host_and_version() { - let mut client = Client::new(config()); - connect(&mut client); - - assert!(client.is_ready()); - assert_eq!(client.negotiated_version(), Some(PROTOCOL_MAX_VERSION)); - assert_eq!( - client.host().map(|host| host.host_id.as_str()), - Some("host-1") - ); - assert_eq!(client.refusal_reason(), None); - } - - #[test] - fn authentication_and_version_refusals_are_distinguishable() { - let mut unauthorized = Client::new(config()); - unauthorized.connect(); - unauthorized.handle(HostFrame::Refused { - reason: RefuseReason::Unauthorized, - }); - assert_eq!( - unauthorized.refusal_reason(), - Some(&RefuseReason::Unauthorized) - ); - - let mut old_app = Client::new(config()); - old_app.connect(); - old_app.handle(HostFrame::Refused { - reason: RefuseReason::UnsupportedVersion { - host_min: PROTOCOL_MAX_VERSION + 1, - host_max: PROTOCOL_MAX_VERSION + 2, - }, - }); - assert!(matches!( - old_app.refusal_reason(), - Some(RefuseReason::UnsupportedVersion { host_min, host_max }) - if *host_min > PROTOCOL_MAX_VERSION && host_max >= host_min - )); - } - - #[test] - fn a_backfill_is_folded_by_the_shared_timeline_reducer() { - let mut client = Client::new(config()); - connect(&mut client); - client.subscribe("s1"); - - client.handle(HostFrame::Events { - session_id: "s1".into(), - events: vec![event( - 1, - AgentEvent::TurnStarted { - turn_id: "turn-1".into(), - }, - )], - caught_up: true, - }); - - let replica = client.session("s1").expect("replica"); - let local = Timeline::fold_events([StoredEvent { - ts: Some(1_001), - seq: Some(1), - event: AgentEvent::TurnStarted { - turn_id: "turn-1".into(), - }, - }]); - assert_eq!(replica.cursor(), Some(1)); - assert!(replica.is_caught_up()); - assert_eq!(replica.timeline().turns.len(), local.turns.len()); - assert_eq!( - replica.timeline().turns[0].provider_turn_id, - local.turns[0].provider_turn_id - ); - assert_eq!(replica.timeline().turn_running, local.turn_running); - } - - #[test] - fn incremental_batches_equal_a_single_whole_history_fold() { - let mut events = Vec::new(); - for turn in 0..384 { - let turn_id = format!("turn-{turn}"); - let seq = events.len() as u64 + 1; - events.push(event( - seq, - AgentEvent::TurnStarted { - turn_id: turn_id.clone(), - }, - )); - events.push(event( - seq + 1, - AgentEvent::TurnCompleted { - turn_id, - status: TurnStatus::Completed, - usage: None, - }, - )); - } - let expected = Timeline::fold_events(events.iter().cloned().map(|event| StoredEvent { - ts: Some(event.ts), - seq: Some(event.seq), - event: event.event, - })); - - let mut client = Client::new(config()); - connect(&mut client); - client.subscribe("s1"); - for (index, batch) in events.chunks(256).enumerate() { - assert!( - client - .handle(HostFrame::Events { - session_id: "s1".into(), - events: batch.to_vec(), - caught_up: index == 2, - }) - .is_empty() - ); - } - - let actual = client.session("s1").expect("replica"); - assert_eq!(actual.cursor(), Some(768)); - assert!(actual.is_caught_up()); - // Timeline has no PartialEq implementation. Its complete Debug form - // includes private reducer bookkeeping, so this compares more than the - // public render fields while keeping that API decision in core. - assert_eq!( - format!("{:#?}", actual.timeline()), - format!("{expected:#?}") - ); - } - - #[test] - fn contiguous_batches_advance_the_cursor() { - let mut client = Client::new(config()); - connect(&mut client); - client.subscribe("s1"); - - for seq in 1..=3 { - assert!( - client - .handle(HostFrame::Events { - session_id: "s1".into(), - events: vec![event(seq, AgentEvent::TurnAccepted { delivery_id: seq },)], - caught_up: seq == 3, - }) - .is_empty() - ); - } - - assert_eq!(client.session("s1").expect("replica").cursor(), Some(3)); - } - - #[test] - fn the_latest_session_list_replaces_the_previous_one() { - let mut client = Client::new(config()); - connect(&mut client); - let summary = SessionSummary { - session_id: "s1".into(), - title: "Fix the parser".into(), - provider: sync_protocol::ProviderKind::Codex, - model: None, - project: Some("tcode".into()), - updated_at: 1_000, - latest_seq: Some(3), - working: true, - awaiting_approval: false, - }; - - client.handle(HostFrame::SessionList { - sessions: vec![summary.clone()], - }); - assert_eq!(client.sessions(), [summary]); - - client.handle(HostFrame::SessionList { - sessions: Vec::new(), - }); - assert!(client.sessions().is_empty()); - } - - #[test] - fn a_gap_is_rejected_and_resubscribed_from_the_known_good_cursor() { - let mut client = Client::new(config()); - connect(&mut client); - client.subscribe("s1"); - client.handle(HostFrame::Events { - session_id: "s1".into(), - events: vec![event( - 1, - AgentEvent::TurnStarted { - turn_id: "good".into(), - }, - )], - caught_up: false, - }); - - let response = client.handle(HostFrame::Events { - session_id: "s1".into(), - events: vec![ - event( - 3, - AgentEvent::TurnStarted { - turn_id: "not-folded".into(), - }, - ), - event(4, AgentEvent::TurnAccepted { delivery_id: 4 }), - ], - caught_up: true, - }); - - assert_eq!( - response, - vec![ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: Some(1), - }] - ); - assert_eq!(client.session("s1").expect("replica").cursor(), Some(1)); - assert_eq!( - client - .session("s1") - .expect("replica") - .timeline() - .turns - .len(), - 1 - ); - assert!(matches!( - client.violations(), - [ProtocolViolation::EventGap { - expected_seq: 2, - received_seq: 3, - cursor: Some(1), - .. - }] - )); - } - - #[test] - fn reconnecting_restores_a_subscription_from_its_cursor() { - let mut client = Client::new(config()); - connect(&mut client); - client.subscribe("s1"); - client.handle(HostFrame::Events { - session_id: "s1".into(), - events: vec![event(1, AgentEvent::TurnAccepted { delivery_id: 1 })], - caught_up: true, - }); - - client.connect(); - let restore = client.handle(HostFrame::Welcome { - version: PROTOCOL_MAX_VERSION, - host: host(), - }); - - assert_eq!( - restore, - vec![ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: Some(1), - }] - ); - } - - #[test] - fn reconnect_retains_only_turns_without_an_observed_acknowledgement() { - let mut client = Client::new(config()); - connect(&mut client); - client.subscribe("s1"); - client.command("s1", turn(41)); - client.command("s1", turn(42)); - client.handle(HostFrame::Events { - session_id: "s1".into(), - events: vec![event(1, AgentEvent::TurnAccepted { delivery_id: 41 })], - caught_up: true, - }); - - client.connect(); - client.handle(HostFrame::Welcome { - version: PROTOCOL_MAX_VERSION, - host: host(), - }); - - let pending: Vec<&SessionCommand> = client.unacknowledged_turns("s1").collect(); - assert_eq!(pending, vec![&turn(42)]); - assert_eq!( - client.resend_unacknowledged_turns("s1"), - vec![ClientFrame::Command { - session_id: "s1".into(), - command: turn(42), - }] - ); - } - - #[test] - fn command_rejections_session_endings_and_pongs_are_observable() { - let mut client = Client::new(config()); - connect(&mut client); - - client.handle(HostFrame::CommandRejected { - session_id: "s1".into(), - command: Some(sync_protocol::RejectedCommand::Approval { - request_id: "approval-1".into(), - }), - reason: CommandRejection::SessionNotLive, - }); - client.handle(HostFrame::SessionEnded { - session_id: "s1".into(), - reason: SessionEndReason::Archived, - }); - client.handle(HostFrame::Pong { nonce: 99 }); - - assert_eq!( - client.command_rejections(), - [RejectedCommand { - session_id: "s1".into(), - command: Some(sync_protocol::RejectedCommand::Approval { - request_id: "approval-1".into(), - }), - reason: CommandRejection::SessionNotLive, - }] - ); - assert_eq!( - client.ended_sessions(), - [EndedSession { - session_id: "s1".into(), - reason: SessionEndReason::Archived, - }] - ); - assert_eq!(client.pongs(), [99]); - } - - #[test] - fn correlated_turn_rejection_removes_only_that_pending_turn() { - let mut client = Client::new(config()); - connect(&mut client); - client.command("s1", turn(41)); - client.command("s1", turn(42)); - - client.handle(HostFrame::CommandRejected { - session_id: "s1".into(), - command: Some(sync_protocol::RejectedCommand::Turn { delivery_id: 41 }), - reason: CommandRejection::SessionNotLive, - }); - - assert_eq!( - client - .unacknowledged_turns("s1") - .collect::>(), - vec![&turn(42)] - ); - } -} diff --git a/crates/sync-host/Cargo.toml b/crates/sync-host/Cargo.toml deleted file mode 100644 index 1cddf090..00000000 --- a/crates/sync-host/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "sync-host" -version = "0.1.0" -edition = "2024" - -[dependencies] -sync-protocol = { path = "../sync-protocol" } -# Types only: this crate must not depend on GPUI or on the provider adapters. -# Phase 1.5 of docs/multiplatform-plan.md reuses it verbatim for a headless -# `tcode-server` by swapping the binary's `main`, which only works if nothing -# here reaches for a UI or a child process. -agent = { path = "../agent", default-features = false } -tcode-core = { path = "../core" } -tcode-services = { path = "../services" } -async-channel = "2" -log = "0.4" -axum = { version = "0.8", features = ["ws"] } -tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "macros"] } -futures-util = "0.3" -uuid = { version = "1", features = ["v4"] } - -[dev-dependencies] -serde_json = "1" -tokio-tungstenite = "0.28" -tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"] } -futures-util = "0.3" -agent = { path = "../agent", default-features = false } -uuid = { version = "1", features = ["v4"] } diff --git a/crates/sync-host/src/lib.rs b/crates/sync-host/src/lib.rs deleted file mode 100644 index 69ea7436..00000000 --- a/crates/sync-host/src/lib.rs +++ /dev/null @@ -1,1011 +0,0 @@ -//! Host side of the tcode sync protocol: the connection state machine, with no -//! I/O in it. -//! -//! [`Connection`] turns one client frame into the host frames it deserves. It -//! never touches a socket, a clock, or a task executor, so the rules that -//! actually matter — you cannot act before a handshake, a bad token gets a -//! refusal and not a session list, a cursor resumes exactly where it left off — -//! are testable without standing up a server. -//! -//! The transport is a thin shell around this: read a text frame, call -//! [`Connection::handle`], write what comes back. -//! -//! Session data arrives through [`SessionSource`], which keeps this crate free -//! of GPUI and of the provider adapters. That is not incidental: phase 1.5 of -//! `docs/multiplatform-plan.md` reuses this crate for a headless `tcode-server` -//! by swapping the binary's `main`, which only works if nothing here reaches -//! for a UI. - -pub mod pairing; -pub mod server; -pub mod store_source; - -pub use pairing::Pairing; -pub use server::{SyncServer, WakeSource, start, start_on}; -pub use store_source::{CommandRequest, CommandSink, LiveFlags, LiveSessions, StoreSource}; - -use std::collections::HashMap; - -use sync_protocol::{ - ClientFrame, ClientInfo, CommandRejection, HostFrame, HostInfo, RefuseReason, RejectedCommand, - SeqEvent, SessionCommand, SessionSummary, negotiate_version, -}; - -/// How many events one `Events` frame carries while replaying backlog. -/// -/// Bounded because a session log can hold tens of thousands of events and a -/// phone on a mobile connection should start rendering the first screenful -/// rather than wait for a single enormous frame. -pub const BACKLOG_BATCH: usize = 256; - -/// Whatever owns the sessions on this host. -/// -/// On the desktop that is the running app; for a headless server it is the -/// session store directly. The state machine cares about neither. -pub trait SessionSource { - /// Sessions this host is willing to serve, newest first. - fn list_sessions(&self) -> Vec; - - /// Events after `from_seq`, at most `limit` of them, ascending. - /// - /// `from_seq` is exclusive: `Some(4)` means "start at 5". `None` means from - /// the beginning. An unknown session yields an empty vector — the caller - /// distinguishes the two cases via [`SessionSource::session_exists`]. - fn read_events(&self, session_id: &str, from_seq: Option, limit: usize) -> Vec; - - fn session_exists(&self, session_id: &str) -> bool; - - /// Deliver a command to a live session. - fn send_command( - &self, - session_id: &str, - command: SessionCommand, - ) -> Result<(), CommandRejection>; -} - -/// Host identity and the credential clients must present. -pub struct HostConfig { - pub host: HostInfo, - /// Outstanding pairing code, if the host offers pairing. - /// - /// `None` means a client must already hold the token — which is right for a - /// headless server nobody is standing in front of to read a code off. - pub pairing: Option, - /// Compared in full. Pairing — how a client comes to hold this — is host UX - /// and deliberately outside the protocol for now. - pub token: String, -} - -/// Per-session streaming position: the highest `seq` this client has been sent. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct Cursor(Option); - -/// One client connection. -/// -/// Lives from the socket opening to it closing. A reconnect is a new -/// `Connection` — resumption is carried by the client's cursor, not by host -/// memory, which is what makes it survive the host restarting too. -pub struct Connection { - source: S, - config: HostConfig, - state: State, - cursors: HashMap, -} - -enum State { - /// Nothing but `Hello` is accepted. - AwaitingHello, - /// Handshake done, carrying the version both peers agreed on. Client - /// identity is logged at handshake rather than retained: nothing here reads - /// it back, and storing it "for later" would be speculation. - Ready { version: u32 }, - /// A refusal was sent. The transport should close; anything further is - /// ignored rather than answered. - Refused, -} - -impl Connection { - pub fn new(source: S, config: HostConfig) -> Self { - Self { - source, - config, - state: State::AwaitingHello, - cursors: HashMap::new(), - } - } - - /// True once the handshake succeeded. - pub fn is_ready(&self) -> bool { - matches!(self.state, State::Ready { .. }) - } - - /// The protocol version both peers agreed on, once the handshake is done. - /// - /// The transport needs this to shape frames when more than one version - /// exists; today it is also what proves the negotiated value is retained - /// rather than computed and dropped. - pub fn negotiated_version(&self) -> Option { - match self.state { - State::Ready { version } => Some(version), - _ => None, - } - } - - /// True when the transport should close the socket. - pub fn is_refused(&self) -> bool { - matches!(self.state, State::Refused) - } - - /// Handle one client frame, returning the frames to send back. - pub fn handle(&mut self, frame: ClientFrame) -> Vec { - match &self.state { - State::Refused => Vec::new(), - State::AwaitingHello => self.handle_unauthenticated(frame), - State::Ready { .. } => self.handle_ready(frame), - } - } - - fn handle_unauthenticated(&mut self, frame: ClientFrame) -> Vec { - if let ClientFrame::Pair { - min_version, - max_version, - code, - client, - } = frame - { - return self.handle_pairing(min_version, max_version, &code, client); - } - let ClientFrame::Hello { - min_version, - max_version, - client, - token, - } = frame - else { - // Anything before a handshake is treated as unauthorized rather - // than as a protocol error: an unauthenticated peer must not be - // able to tell "wrong credential" from "wrong order", or the - // refusal becomes an oracle. - return self.refuse(RefuseReason::Unauthorized); - }; - - let Some(version) = negotiate_version(min_version, max_version) else { - // Version is checked before the token on purpose. A client too old - // to speak our protocol cannot be expected to have a valid token - // format either, and "upgrade" is the more useful thing to say. - return self.refuse(RefuseReason::UnsupportedVersion { - host_min: sync_protocol::PROTOCOL_MIN_VERSION, - host_max: sync_protocol::PROTOCOL_MAX_VERSION, - }); - }; - - if !constant_time_eq(token.as_bytes(), self.config.token.as_bytes()) { - return self.refuse(RefuseReason::Unauthorized); - } - - log::info!( - "sync client connected: {} ({}) protocol v{version}", - client.display_name, - client.platform - ); - self.state = State::Ready { version }; - vec![HostFrame::Welcome { - version, - host: self.config.host.clone(), - }] - } - - /// Trade a pairing code for the durable token. - /// - /// Deliberately does *not* authenticate the connection: the client sends a - /// normal `Hello` afterwards. Pairing then has exactly one job, and a - /// `Paired` frame is worth no more than the token inside it. - fn handle_pairing( - &mut self, - min_version: u32, - max_version: u32, - code: &str, - client: ClientInfo, - ) -> Vec { - let Some(version) = negotiate_version(min_version, max_version) else { - return self.refuse(RefuseReason::UnsupportedVersion { - host_min: sync_protocol::PROTOCOL_MIN_VERSION, - host_max: sync_protocol::PROTOCOL_MAX_VERSION, - }); - }; - let Some(pairing) = &self.config.pairing else { - // A host that offers no pairing says so as a rejection rather than - // as a distinct error: whether pairing is disabled or the code was - // wrong is not something an unauthenticated peer needs to learn. - return self.refuse(RefuseReason::PairingRejected); - }; - if !pairing.redeem(code, std::time::Instant::now()) { - return self.refuse(RefuseReason::PairingRejected); - } - log::info!( - "sync client paired: {} ({})", - client.display_name, - client.platform - ); - // The connection stays unauthenticated; the client reconnects, or sends - // Hello on this one, with the token it just received. - vec![HostFrame::Paired { - version, - token: self.config.token.clone(), - }] - } - - fn handle_ready(&mut self, frame: ClientFrame) -> Vec { - match frame { - // A second handshake is a confused client, not an attack. Refusing - // is still right: silently ignoring it would leave the two sides - // disagreeing about the negotiated version. - // Both are "you already did this": a second handshake, or pairing - // on a connection that is already authenticated. Silently ignoring - // either would leave the two sides disagreeing about state. - ClientFrame::Hello { .. } | ClientFrame::Pair { .. } => { - self.refuse(RefuseReason::Unauthorized) - } - ClientFrame::Ping { nonce } => vec![HostFrame::Pong { nonce }], - ClientFrame::ListSessions => vec![HostFrame::SessionList { - sessions: self.source.list_sessions(), - }], - ClientFrame::Subscribe { - session_id, - from_seq, - } => self.subscribe(session_id, from_seq), - ClientFrame::Unsubscribe { session_id } => { - self.cursors.remove(&session_id); - Vec::new() - } - ClientFrame::Command { - session_id, - command, - } => { - let rejected_command = rejected_command(&command); - match self.source.send_command(&session_id, command) { - Ok(()) => Vec::new(), - Err(reason) => vec![HostFrame::CommandRejected { - session_id, - command: rejected_command, - reason, - }], - } - } - } - } - - fn subscribe(&mut self, session_id: String, from_seq: Option) -> Vec { - if !self.source.session_exists(&session_id) { - return vec![HostFrame::CommandRejected { - session_id, - command: None, - reason: CommandRejection::UnknownSession, - }]; - } - self.cursors.insert(session_id.clone(), Cursor(from_seq)); - self.drain(&session_id) - } - - /// Send everything the host holds past this session's cursor. - /// - /// Also the live path: when new events land, the transport calls this and - /// the client receives whatever it has not seen. One code path for backlog - /// and live traffic means the catch-up boundary cannot drift between them. - pub fn drain(&mut self, session_id: &str) -> Vec { - let Some(Cursor(mut cursor)) = self.cursors.get(session_id).copied() else { - return Vec::new(); - }; - let mut frames = Vec::new(); - loop { - let events = self.source.read_events(session_id, cursor, BACKLOG_BATCH); - if events.is_empty() { - break; - } - let caught_up = events.len() < BACKLOG_BATCH; - cursor = Some(events.last().expect("non-empty").seq); - frames.push(HostFrame::Events { - session_id: session_id.to_owned(), - events, - caught_up, - }); - if caught_up { - break; - } - } - self.cursors.insert(session_id.to_owned(), Cursor(cursor)); - - // A subscribe that had nothing to send still owes the client a frame: - // otherwise it cannot tell "you are up to date" from "the host has not - // answered yet", and would sit showing a spinner forever. - if frames.is_empty() { - frames.push(HostFrame::Events { - session_id: session_id.to_owned(), - events: Vec::new(), - caught_up: true, - }); - } - frames - } - - /// Sessions this connection is streaming, for the transport to consult when - /// deciding whether a newly recorded event concerns this client. - pub fn subscribed_sessions(&self) -> impl Iterator { - self.cursors.keys().map(String::as_str) - } - - fn refuse(&mut self, reason: RefuseReason) -> Vec { - self.state = State::Refused; - vec![HostFrame::Refused { reason }] - } -} - -fn rejected_command(command: &SessionCommand) -> Option { - match command { - SessionCommand::SendTurn { delivery_id, .. } => Some(RejectedCommand::Turn { - delivery_id: *delivery_id, - }), - SessionCommand::RespondApproval { request_id, .. } => Some(RejectedCommand::Approval { - request_id: request_id.clone(), - }), - _ => None, - } -} - -/// Compare two secrets without leaking their common prefix length through -/// timing. Short and dependency-free; the alternative is pulling a crypto crate -/// into a type-level crate for sixteen lines. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter() - .zip(b) - .fold(0_u8, |acc, (x, y)| acc | (x ^ y)) - .eq(&0) -} - -#[cfg(test)] -mod tests { - use super::*; - use agent::AgentEvent; - use std::cell::RefCell; - use sync_protocol::ClientInfo; - - /// A source with a fixed set of sessions and logs. - struct FakeSource { - sessions: Vec, - logs: HashMap>, - sent: RefCell>, - reject: Option, - } - - impl FakeSource { - fn with_log(session_id: &str, len: u64) -> Self { - let events = (1..=len) - .map(|seq| SeqEvent { - seq, - ts: 1_000 + seq, - event: AgentEvent::TurnAccepted { delivery_id: seq }, - }) - .collect(); - Self { - sessions: vec![summary(session_id)], - logs: HashMap::from([(session_id.to_owned(), events)]), - sent: RefCell::new(Vec::new()), - reject: None, - } - } - } - - impl SessionSource for FakeSource { - fn list_sessions(&self) -> Vec { - self.sessions.clone() - } - - fn read_events( - &self, - session_id: &str, - from_seq: Option, - limit: usize, - ) -> Vec { - let after = from_seq.unwrap_or(0); - self.logs - .get(session_id) - .map(|events| { - events - .iter() - .filter(|event| event.seq > after) - .take(limit) - .cloned() - .collect() - }) - .unwrap_or_default() - } - - fn session_exists(&self, session_id: &str) -> bool { - self.logs.contains_key(session_id) - } - - fn send_command( - &self, - session_id: &str, - command: SessionCommand, - ) -> Result<(), CommandRejection> { - if let Some(reason) = self.reject.clone() { - return Err(reason); - } - self.sent - .borrow_mut() - .push((session_id.to_owned(), command)); - Ok(()) - } - } - - fn summary(session_id: &str) -> SessionSummary { - SessionSummary { - session_id: session_id.to_owned(), - title: "Fix the parser".into(), - provider: sync_protocol::ProviderKind::Codex, - model: None, - project: None, - updated_at: 0, - latest_seq: None, - working: false, - awaiting_approval: false, - } - } - - const TOKEN: &str = "correct-horse"; - - fn config() -> HostConfig { - HostConfig { - host: HostInfo { - host_id: "host-1".into(), - display_name: "workstation".into(), - platform: "macos".into(), - app_version: "0.1.0".into(), - }, - token: TOKEN.into(), - // Most tests exercise token auth; pairing gets its own fixtures. - pairing: None, - } - } - - fn hello(token: &str) -> ClientFrame { - ClientFrame::Hello { - min_version: sync_protocol::PROTOCOL_MIN_VERSION, - max_version: sync_protocol::PROTOCOL_MAX_VERSION, - client: ClientInfo { - client_id: "client-1".into(), - display_name: "Pixel".into(), - platform: "android".into(), - app_version: "0.1.0".into(), - }, - token: token.into(), - } - } - - fn connected(source: FakeSource) -> Connection { - let mut connection = Connection::new(source, config()); - let welcome = connection.handle(hello(TOKEN)); - assert!(matches!(welcome.as_slice(), [HostFrame::Welcome { .. }])); - connection - } - - fn events_of(frames: &[HostFrame]) -> Vec { - frames - .iter() - .flat_map(|frame| match frame { - HostFrame::Events { events, .. } => events.iter().map(|e| e.seq).collect(), - _ => Vec::new(), - }) - .collect() - } - - #[test] - fn a_valid_handshake_is_welcomed() { - let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); - let frames = connection.handle(hello(TOKEN)); - match frames.as_slice() { - [HostFrame::Welcome { version, host }] => { - assert_eq!(*version, sync_protocol::PROTOCOL_MAX_VERSION); - assert_eq!(host.host_id, "host-1"); - } - other => panic!("expected a welcome, got {other:?}"), - } - assert!(connection.is_ready()); - assert_eq!( - connection.negotiated_version(), - Some(sync_protocol::PROTOCOL_MAX_VERSION) - ); - } - - #[test] - fn a_wrong_token_is_refused_and_the_connection_is_done() { - let mut connection = Connection::new(FakeSource::with_log("s1", 3), config()); - let frames = connection.handle(hello("wrong")); - assert!(matches!( - frames.as_slice(), - [HostFrame::Refused { - reason: RefuseReason::Unauthorized - }] - )); - assert!(connection.is_refused()); - assert!(!connection.is_ready()); - assert_eq!(connection.negotiated_version(), None); - } - - /// The property that matters after a refusal: the connection goes mute. - /// Answering anything at all would let an unauthenticated peer keep probing - /// on a socket the host has already rejected. - #[test] - fn a_refused_connection_answers_nothing_further() { - let mut connection = Connection::new(FakeSource::with_log("s1", 3), config()); - connection.handle(hello("wrong")); - - assert!(connection.handle(ClientFrame::ListSessions).is_empty()); - assert!(connection.handle(ClientFrame::Ping { nonce: 1 }).is_empty()); - assert!( - connection - .handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - }) - .is_empty() - ); - assert!(connection.handle(hello(TOKEN)).is_empty()); - } - - /// Acting before the handshake must not be distinguishable from presenting - /// a bad credential, or the refusal tells an unauthenticated peer which of - /// the two it got wrong. - #[test] - fn frames_before_the_handshake_are_refused_as_unauthorized() { - for frame in [ - ClientFrame::ListSessions, - ClientFrame::Ping { nonce: 1 }, - ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - }, - ClientFrame::Command { - session_id: "s1".into(), - command: SessionCommand::Interrupt, - }, - ] { - let mut connection = Connection::new(FakeSource::with_log("s1", 3), config()); - let frames = connection.handle(frame); - assert!( - matches!( - frames.as_slice(), - [HostFrame::Refused { - reason: RefuseReason::Unauthorized - }] - ), - "got {frames:?}" - ); - } - } - - #[test] - fn an_unsupported_version_is_refused_with_the_hosts_range() { - let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); - let frames = connection.handle(ClientFrame::Hello { - min_version: sync_protocol::PROTOCOL_MAX_VERSION + 1, - max_version: sync_protocol::PROTOCOL_MAX_VERSION + 3, - client: ClientInfo { - client_id: "c".into(), - display_name: "c".into(), - platform: "web".into(), - app_version: "9.9.9".into(), - }, - token: TOKEN.into(), - }); - match frames.as_slice() { - [ - HostFrame::Refused { - reason: RefuseReason::UnsupportedVersion { host_min, host_max }, - }, - ] => { - assert_eq!(*host_min, sync_protocol::PROTOCOL_MIN_VERSION); - assert_eq!(*host_max, sync_protocol::PROTOCOL_MAX_VERSION); - } - other => panic!("expected a version refusal, got {other:?}"), - } - } - - /// A version mismatch must win over a bad token: a client too old to speak - /// the protocol cannot be expected to hold a well-formed credential, and - /// "you need to upgrade" is the actionable message. - #[test] - fn version_is_checked_before_the_token() { - let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); - let frames = connection.handle(ClientFrame::Hello { - min_version: sync_protocol::PROTOCOL_MAX_VERSION + 1, - max_version: sync_protocol::PROTOCOL_MAX_VERSION + 1, - client: ClientInfo { - client_id: "c".into(), - display_name: "c".into(), - platform: "web".into(), - app_version: "9.9.9".into(), - }, - token: "definitely wrong".into(), - }); - assert!( - matches!( - frames.as_slice(), - [HostFrame::Refused { - reason: RefuseReason::UnsupportedVersion { .. } - }] - ), - "got {frames:?}" - ); - } - - #[test] - fn subscribing_from_scratch_replays_the_whole_log() { - let mut connection = connected(FakeSource::with_log("s1", 5)); - let frames = connection.handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - }); - assert_eq!(events_of(&frames), vec![1, 2, 3, 4, 5]); - assert!(matches!( - frames.last(), - Some(HostFrame::Events { - caught_up: true, - .. - }) - )); - } - - /// The whole point of the cursor: a reconnecting client gets exactly what - /// it missed — no gap, no duplicate. - #[test] - fn subscribing_from_a_cursor_resumes_exactly_after_it() { - let mut connection = connected(FakeSource::with_log("s1", 5)); - let frames = connection.handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: Some(3), - }); - assert_eq!(events_of(&frames), vec![4, 5]); - } - - /// An up-to-date client still gets an answer. Silence is indistinguishable - /// from a host that has not replied, and the client would spin forever. - #[test] - fn subscribing_at_the_end_of_the_log_still_answers() { - let mut connection = connected(FakeSource::with_log("s1", 5)); - let frames = connection.handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: Some(5), - }); - match frames.as_slice() { - [ - HostFrame::Events { - events, caught_up, .. - }, - ] => { - assert!(events.is_empty()); - assert!(*caught_up); - } - other => panic!("expected an empty caught-up frame, got {other:?}"), - } - } - - /// Long logs arrive in bounded batches, with `caught_up` false until the - /// last one, so a client can render progressively instead of waiting. - #[test] - fn a_long_backlog_is_batched_and_only_the_last_frame_is_caught_up() { - let total = (BACKLOG_BATCH * 2 + 7) as u64; - let mut connection = connected(FakeSource::with_log("s1", total)); - let frames = connection.handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - }); - - assert_eq!(frames.len(), 3, "expected three batches"); - let flags: Vec = frames - .iter() - .map(|frame| match frame { - HostFrame::Events { caught_up, .. } => *caught_up, - other => panic!("expected events, got {other:?}"), - }) - .collect(); - assert_eq!(flags, vec![false, false, true]); - - // Contiguous, ascending, complete — the guarantee a cursor rests on. - let seqs = events_of(&frames); - assert_eq!(seqs.len(), total as usize); - assert_eq!(seqs.first(), Some(&1)); - assert_eq!(seqs.last(), Some(&total)); - assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1)); - } - - /// After a subscribe drains the log, new events reach the client through - /// the same path with no repeats — backlog and live traffic share one code - /// path precisely so the boundary between them cannot drift. - #[test] - fn draining_again_sends_only_what_arrived_since() { - let mut source = FakeSource::with_log("s1", 2); - source.logs.get_mut("s1").expect("seeded").push(SeqEvent { - seq: 3, - ts: 1_003, - event: AgentEvent::TurnAccepted { delivery_id: 3 }, - }); - let mut connection = connected(source); - - let first = connection.handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: Some(2), - }); - assert_eq!(events_of(&first), vec![3]); - - let again = connection.drain("s1"); - assert_eq!( - events_of(&again), - Vec::::new(), - "an event must not be delivered twice" - ); - } - - #[test] - fn draining_a_session_that_was_never_subscribed_sends_nothing() { - let mut connection = connected(FakeSource::with_log("s1", 3)); - assert!(connection.drain("s1").is_empty()); - } - - #[test] - fn unsubscribing_stops_the_stream() { - let mut connection = connected(FakeSource::with_log("s1", 3)); - connection.handle(ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - }); - assert_eq!(connection.subscribed_sessions().count(), 1); - - connection.handle(ClientFrame::Unsubscribe { - session_id: "s1".into(), - }); - assert_eq!(connection.subscribed_sessions().count(), 0); - assert!(connection.drain("s1").is_empty()); - } - - #[test] - fn subscribing_to_an_unknown_session_is_rejected() { - let mut connection = connected(FakeSource::with_log("s1", 3)); - let frames = connection.handle(ClientFrame::Subscribe { - session_id: "nope".into(), - from_seq: None, - }); - assert!( - matches!( - frames.as_slice(), - [HostFrame::CommandRejected { - reason: CommandRejection::UnknownSession, - .. - }] - ), - "got {frames:?}" - ); - } - - #[test] - fn an_accepted_command_reaches_the_source_and_is_answered_with_silence() { - let mut connection = connected(FakeSource::with_log("s1", 0)); - let frames = connection.handle(ClientFrame::Command { - session_id: "s1".into(), - command: SessionCommand::Interrupt, - }); - assert!(frames.is_empty(), "got {frames:?}"); - let sent = connection.source.sent.borrow(); - assert_eq!(sent.len(), 1); - assert_eq!(sent[0].0, "s1"); - assert_eq!(sent[0].1, SessionCommand::Interrupt); - } - - /// A rejected command must be reported. Silence would leave the client - /// rendering a turn that never ran. - #[test] - fn a_rejected_command_is_reported_back() { - let mut source = FakeSource::with_log("s1", 0); - source.reject = Some(CommandRejection::SessionNotLive); - let mut connection = connected(source); - - let frames = connection.handle(ClientFrame::Command { - session_id: "s1".into(), - command: SessionCommand::Interrupt, - }); - match frames.as_slice() { - [ - HostFrame::CommandRejected { - session_id, - command, - reason, - }, - ] => { - assert_eq!(session_id, "s1"); - assert_eq!(*command, None); - assert_eq!(*reason, CommandRejection::SessionNotLive); - } - other => panic!("expected a rejection, got {other:?}"), - } - } - - #[test] - fn rejected_identifiable_commands_echo_their_identity() { - let commands = [ - ( - SessionCommand::SendTurn { - delivery_id: 42, - text: "continue".into(), - options: None, - attachments: Vec::new(), - }, - RejectedCommand::Turn { delivery_id: 42 }, - ), - ( - SessionCommand::RespondApproval { - request_id: "approval-1".into(), - decision: sync_protocol::ApprovalDecision::ApproveForSession, - }, - RejectedCommand::Approval { - request_id: "approval-1".into(), - }, - ), - ]; - - for (sent, expected) in commands { - let mut source = FakeSource::with_log("s1", 0); - source.reject = Some(CommandRejection::SessionNotLive); - let mut connection = connected(source); - let frames = connection.handle(ClientFrame::Command { - session_id: "s1".into(), - command: sent, - }); - - match frames.as_slice() { - [HostFrame::CommandRejected { command, .. }] => { - assert_eq!(command.as_ref(), Some(&expected)); - } - other => panic!("expected a rejection, got {other:?}"), - } - } - } - - #[test] - fn ping_is_answered_with_the_same_nonce() { - let mut connection = connected(FakeSource::with_log("s1", 0)); - let frames = connection.handle(ClientFrame::Ping { nonce: 99 }); - assert!(matches!(frames.as_slice(), [HostFrame::Pong { nonce: 99 }])); - } - - #[test] - fn a_second_handshake_is_refused() { - let mut connection = connected(FakeSource::with_log("s1", 0)); - let frames = connection.handle(hello(TOKEN)); - assert!( - matches!(frames.as_slice(), [HostFrame::Refused { .. }]), - "got {frames:?}" - ); - assert!(connection.is_refused()); - } - - #[test] - fn listing_sessions_returns_what_the_source_holds() { - let mut connection = connected(FakeSource::with_log("s1", 0)); - let frames = connection.handle(ClientFrame::ListSessions); - match frames.as_slice() { - [HostFrame::SessionList { sessions }] => { - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].session_id, "s1"); - } - other => panic!("expected a session list, got {other:?}"), - } - } - - fn pair(code: &str) -> ClientFrame { - ClientFrame::Pair { - min_version: sync_protocol::PROTOCOL_MIN_VERSION, - max_version: sync_protocol::PROTOCOL_MAX_VERSION, - code: code.into(), - client: ClientInfo { - client_id: "client-1".into(), - display_name: "Pixel".into(), - platform: "android".into(), - app_version: "0.1.0".into(), - }, - } - } - - fn paired_config(pairing: crate::pairing::Pairing) -> HostConfig { - HostConfig { - pairing: Some(pairing), - ..config() - } - } - - /// The point of pairing: a client with no token can obtain one. - #[test] - fn a_valid_code_yields_the_token() { - let pairing = crate::pairing::Pairing::new(); - let code = pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); - let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); - - match connection.handle(pair(&code)).as_slice() { - [HostFrame::Paired { token, .. }] => assert_eq!(token, TOKEN), - other => panic!("expected a paired frame, got {other:?}"), - } - } - - /// Pairing must not authenticate the connection. Keeping the two steps - /// apart means a `Paired` frame is worth no more than the token in it, and - /// the client still proves possession by sending `Hello`. - #[test] - fn pairing_does_not_authenticate_the_connection() { - let pairing = crate::pairing::Pairing::new(); - let code = pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); - let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); - - connection.handle(pair(&code)); - assert!(!connection.is_ready(), "pairing is not a handshake"); - - // The token it just received is what actually authenticates. - assert!(matches!( - connection.handle(hello(TOKEN)).as_slice(), - [HostFrame::Welcome { .. }] - )); - } - - #[test] - fn a_wrong_code_is_rejected_and_the_connection_is_done() { - let pairing = crate::pairing::Pairing::new(); - pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); - let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); - - assert!(matches!( - connection.handle(pair("ZZZZZZ")).as_slice(), - [HostFrame::Refused { - reason: RefuseReason::PairingRejected - }] - )); - assert!(connection.is_refused()); - } - - /// A host with pairing disabled — a headless server nobody is standing in - /// front of — must answer the same way as a wrong code. Whether pairing is - /// off or the guess was bad is not something an unauthenticated peer needs. - #[test] - fn a_host_without_pairing_rejects_identically() { - let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); - assert!(matches!( - connection.handle(pair("ACDEFG")).as_slice(), - [HostFrame::Refused { - reason: RefuseReason::PairingRejected - }] - )); - } - - #[test] - fn pairing_after_a_handshake_is_refused() { - let pairing = crate::pairing::Pairing::new(); - let code = pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); - let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); - connection.handle(hello(TOKEN)); - - assert!(matches!( - connection.handle(pair(&code)).as_slice(), - [HostFrame::Refused { .. }] - )); - } - - #[test] - fn token_comparison_rejects_prefixes_and_accepts_the_exact_value() { - assert!(constant_time_eq(b"abc", b"abc")); - assert!(!constant_time_eq(b"abc", b"abd")); - assert!(!constant_time_eq(b"abc", b"ab")); - assert!(!constant_time_eq(b"ab", b"abc")); - assert!(constant_time_eq(b"", b"")); - } -} diff --git a/crates/sync-host/src/pairing.rs b/crates/sync-host/src/pairing.rs deleted file mode 100644 index e140486e..00000000 --- a/crates/sync-host/src/pairing.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Short-lived pairing codes. -//! -//! A phone cannot be handed a token the way a query string hands one to a -//! browser, and reading a UUID off another screen is not a flow anyone will -//! use. So the host mints a short code, shows it, and trades it once for the -//! durable token. -//! -//! Three properties make a six-character code defensible where a six-character -//! password would not be: -//! -//! * it expires in minutes, so an attacker has a bounded window rather than -//! forever; -//! * it is single-use, so a correct guess still races the real client; -//! * it is only worth anything to someone who can already reach the host's -//! socket, which on the default loopback binding means local access. -//! -//! Widen the bind address and those assumptions change — which is why `--bind` -//! says so, and why this file is the place to add rate limiting when it does. - -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; - -/// How long a code stays usable. -/// -/// Long enough to walk to another device, short enough that a code left on a -/// screen is not a standing invitation. -pub const CODE_LIFETIME: Duration = Duration::from_secs(180); - -/// Characters a code is drawn from. -/// -/// Digits and uppercase letters minus the pairs people misread aloud or -/// mistype: no O/0, no I/1/L, no S/5, no B/8. A code exists to be read off one -/// screen and typed into another, so ambiguity is a usability bug that reads as -/// "pairing is broken". -const ALPHABET: &[u8] = b"ACDEFGHJKMNPQRTUVWXY23467"; - -/// Length of a generated code. 25^6 is about 244 million, which against a -/// three-minute single-use window is far more than a guesser gets to try. -const CODE_LEN: usize = 6; - -#[derive(Clone)] -struct PendingCode { - code: String, - issued: Instant, -} - -/// The host's pairing state: at most one outstanding code. -/// -/// One, not a set: pairing is a deliberate act a person performs, and a host -/// holding several live codes is a host that cannot tell you which screen the -/// valid one is on. -#[derive(Clone, Default)] -pub struct Pairing { - pending: Arc>>, -} - -impl Pairing { - pub fn new() -> Self { - Self::default() - } - - /// Mint a code, replacing any outstanding one. - /// - /// Replacing rather than refusing: asking again is what a user does when - /// the first code scrolled away, and making them wait out an expiry would - /// be punishing them for the interface's shortcoming. - /// `entropy` supplies the raw bytes a code is drawn from — at least - /// [`CODE_LEN`] of them. Injected rather than sourced here so tests can be - /// deterministic, and so the caller owns the choice of RNG. - /// - /// It must be *random*. An earlier version called the system clock once per - /// character; on macOS the six reads landed in the same microsecond and - /// every code came out `AAAAAA`. A code's security rests on being - /// unguessable within its window, so a low-entropy source does not weaken - /// it — it removes it. - pub fn issue(&self, now: Instant, entropy: impl Fn() -> [u8; CODE_LEN]) -> String { - let bytes = entropy(); - let code: String = bytes - .iter() - .map(|byte| ALPHABET[*byte as usize % ALPHABET.len()] as char) - .collect(); - *self.lock() = Some(PendingCode { - code: code.clone(), - issued: now, - }); - code - } - - /// Consume `candidate` if it matches an unexpired code. - /// - /// Takes the code out of the slot whether or not it matched *when it - /// matched*, so a correct code cannot be replayed. A wrong guess leaves the - /// real code in place, so a wrong attempt cannot be used to cancel someone - /// else's pairing. - pub fn redeem(&self, candidate: &str, now: Instant) -> bool { - let mut slot = self.lock(); - let Some(pending) = slot.as_ref() else { - return false; - }; - if now.duration_since(pending.issued) > CODE_LIFETIME { - // Expired codes are cleared on contact rather than by a timer: there - // is no background task to run, and a stale entry is only reachable - // through this function anyway. - *slot = None; - return false; - } - if !constant_time_eq(candidate.as_bytes(), pending.code.as_bytes()) { - return false; - } - *slot = None; - true - } - - /// The outstanding code, if one is live. For the host to display. - pub fn current(&self, now: Instant) -> Option { - let slot = self.lock(); - slot.as_ref() - .filter(|pending| now.duration_since(pending.issued) <= CODE_LIFETIME) - .map(|pending| pending.code.clone()) - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Option> { - self.pending - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } -} - -/// Compare without leaking the matching prefix length through timing. -fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { - return false; - } - a.iter() - .zip(b) - .fold(0_u8, |acc, (x, y)| acc | (x ^ y)) - .eq(&0) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Deterministic bytes so a test can predict the code. - fn fixed(seed: u8) -> impl Fn() -> [u8; CODE_LEN] { - move || std::array::from_fn(|index| seed.wrapping_add(index as u8)) - } - - #[test] - fn a_fresh_code_redeems_once() { - let pairing = Pairing::new(); - let now = Instant::now(); - let code = pairing.issue(now, fixed(0)); - - assert!(pairing.redeem(&code, now)); - // Single use is the property that makes a six-character code viable: a - // correct guess still has to beat the real client to it. - assert!(!pairing.redeem(&code, now), "a code must not redeem twice"); - } - - #[test] - fn an_expired_code_is_refused() { - let pairing = Pairing::new(); - let now = Instant::now(); - let code = pairing.issue(now, fixed(0)); - let later = now + CODE_LIFETIME + Duration::from_secs(1); - - assert!(!pairing.redeem(&code, later)); - assert_eq!(pairing.current(later), None, "expiry must also hide it"); - } - - /// A wrong guess must not clear the slot, or anyone able to reach the socket - /// could cancel a pairing in progress by typing nonsense. - #[test] - fn a_wrong_guess_leaves_the_real_code_usable() { - let pairing = Pairing::new(); - let now = Instant::now(); - let code = pairing.issue(now, fixed(0)); - - assert!(!pairing.redeem("ZZZZZZ", now)); - assert!(pairing.redeem(&code, now), "the real code must still work"); - } - - #[test] - fn issuing_again_replaces_the_previous_code() { - let pairing = Pairing::new(); - let now = Instant::now(); - let first = pairing.issue(now, fixed(0)); - let second = pairing.issue(now, fixed(9)); - - assert_ne!(first, second); - assert!(!pairing.redeem(&first, now), "the old code must be dead"); - assert!(pairing.redeem(&second, now)); - } - - #[test] - fn redeeming_without_an_outstanding_code_fails() { - let pairing = Pairing::new(); - assert!(!pairing.redeem("ACDEFG", Instant::now())); - } - - /// Codes are read aloud and typed by hand, so the alphabet excludes the - /// characters people confuse. A code containing them would surface as - /// "pairing is broken" rather than as a typo. - #[test] - fn the_alphabet_omits_ambiguous_characters() { - for ambiguous in *b"O0I1LS5B8" { - assert!( - !ALPHABET.contains(&ambiguous), - "{} is easily misread", - ambiguous as char - ); - } - } - - /// The defect this caught in practice: a low-entropy source produced - /// `AAAAAA`. A code whose characters are all equal has no security left, - /// so the generator must spread distinct input bytes across distinct - /// characters. - #[test] - fn distinct_entropy_produces_distinct_characters() { - let pairing = Pairing::new(); - let code = pairing.issue(Instant::now(), fixed(0)); - assert!( - code.chars().collect::>().len() > 1, - "every character identical means the entropy was not used: {code}" - ); - } - - #[test] - fn a_generated_code_uses_only_the_alphabet() { - let pairing = Pairing::new(); - let code = pairing.issue(Instant::now(), fixed(0)); - assert_eq!(code.len(), CODE_LEN); - assert!(code.bytes().all(|byte| ALPHABET.contains(&byte))); - } -} diff --git a/crates/sync-host/src/server.rs b/crates/sync-host/src/server.rs deleted file mode 100644 index fa0a5f82..00000000 --- a/crates/sync-host/src/server.rs +++ /dev/null @@ -1,460 +0,0 @@ -//! WebSocket transport for the host. -//! -//! Deliberately thin: read a text frame, hand it to [`Connection`], write back -//! whatever it returns. All the rules live in the state machine, where they are -//! testable without a socket — the only judgement here is about sockets. -//! -//! Bind happens synchronously so the caller learns the port before [`start`] -//! returns, matching the in-process MCP servers this app already runs. - -use std::collections::HashMap; -use std::net::{SocketAddr, TcpListener}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, SystemTime}; - -use axum::Router; -use axum::extract::State; -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::response::Response; -use axum::routing::any; -use futures_util::{SinkExt as _, StreamExt as _}; -use sync_protocol::{ClientFrame, HostFrame, HostInfo}; -use tcode_services::store::SessionStore; - -use crate::store_source::{CommandRequest, LiveSessions, StoreSource}; -use crate::{Connection, HostConfig}; - -/// How many session-advance notifications may queue before a slow connection -/// starts missing them. -/// -/// Missing one is survivable by design: a notification carries no data, only -/// "this session moved". A connection that lags simply drains from its cursor -/// on the next notification it does see, and catches up on everything at once. -const ADVANCE_BUFFER: usize = 256; - -/// How connections learn that a subscribed session may have advanced. -#[derive(Debug, Clone, Copy)] -pub enum WakeSource { - /// The embedding process calls [`SyncServer::notify_advanced`] after an - /// append, so clients wake immediately without touching the filesystem. - Broadcast, - /// Check event-log metadata periodically for changes. - /// - /// The headless host uses this because its process never appends to the log - /// and therefore has no in-process notification to subscribe to. - Polling { interval: Duration }, -} - -/// A running sync server. -pub struct SyncServer { - /// Issues the short codes a phone trades for the token. - pub pairing: crate::pairing::Pairing, - /// WebSocket endpoint, e.g. `ws://127.0.0.1:53213/sync`. - pub url: String, - /// The credential clients must present, supplied by the embedding app. - pub token: String, - /// Commands from remote clients, for the app to apply. Single consumer. - pub commands: async_channel::Receiver, - /// Per-session liveness for the app to publish into. - pub live: LiveSessions, - advance: Option>, -} - -impl SyncServer { - /// Tell connected clients that a session's log has grown. - /// - /// Carries no payload: subscribers re-read from their own cursor, so a - /// notification cannot deliver an event twice or out of order however many - /// times it fires. Cheap enough to call on every appended event. - pub fn notify_advanced(&self, session_id: &str) { - // An error means nobody is listening, which is the normal case. - if let Some(advance) = &self.advance { - let _ = advance.send(session_id.to_owned()); - } - } -} - -#[derive(Clone)] -struct ServerState { - store: SessionStore, - host: HostInfo, - token: String, - pairing: Option, - live: LiveSessions, - commands: async_channel::Sender, - wake: WakeFactory, -} - -#[derive(Clone)] -enum WakeFactory { - Broadcast(tokio::sync::broadcast::Sender), - Polling { root: PathBuf, interval: Duration }, -} - -enum ConnectionWake { - Broadcast(tokio::sync::broadcast::Receiver), - Polling(Box), -} - -struct PollWake { - root: PathBuf, - stamps: HashMap>, - interval: tokio::time::Interval, -} - -enum WakeEvent { - Session(String), - AllSubscriptions, - Poll, - Closed, -} - -enum ConnectionEvent { - Socket(Option>), - Wake(WakeEvent), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct FileStamp { - len: u64, - modified: Option, -} - -/// Bind the desktop's deliberate ephemeral-loopback default. -/// -/// Kept as the compact compatibility entry point used by socket-level tests. -/// Hosts that choose an address or wake policy use [`start_on`]. -pub fn start(store: SessionStore, host: HostInfo, token: String) -> std::io::Result { - start_on( - store, - host, - token, - SocketAddr::from(([127, 0, 0, 1], 0)), - WakeSource::Broadcast, - ) -} - -/// Bind `address` and serve using the selected connection wake source. -pub fn start_on( - store: SessionStore, - host: HostInfo, - token: String, - address: SocketAddr, - wake_source: WakeSource, -) -> std::io::Result { - let listener = TcpListener::bind(address)?; - listener.set_nonblocking(true)?; - let local_addr = listener.local_addr()?; - let url = format!("ws://{local_addr}/sync"); - // Bounded: an unbounded queue would let a wedged app absorb commands - // forever while every client believes they were delivered. - let (command_tx, command_rx) = async_channel::bounded(256); - let live = LiveSessions::new(); - let pairing = crate::pairing::Pairing::new(); - let (wake, advance) = match wake_source { - WakeSource::Broadcast => { - let (advance, _) = tokio::sync::broadcast::channel(ADVANCE_BUFFER); - (WakeFactory::Broadcast(advance.clone()), Some(advance)) - } - WakeSource::Polling { interval } => ( - WakeFactory::Polling { - root: store.root().clone(), - interval, - }, - None, - ), - }; - - let state = ServerState { - store, - host, - token: token.clone(), - pairing: Some(pairing.clone()), - live: live.clone(), - commands: command_tx, - wake, - }; - - std::thread::Builder::new() - .name("sync-host".into()) - .spawn(move || { - let runtime = match tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build() - { - Ok(runtime) => runtime, - Err(err) => { - log::error!("sync-host: failed to build tokio runtime: {err}"); - return; - } - }; - runtime.block_on(async move { - let listener = match tokio::net::TcpListener::from_std(listener) { - Ok(listener) => listener, - Err(err) => { - log::error!("sync-host: failed to adopt listener: {err}"); - return; - } - }; - let app = Router::new() - .route("/sync", any(upgrade)) - .with_state(Arc::new(state)); - if let Err(err) = axum::serve(listener, app).await { - log::error!("sync-host: server exited with error: {err}"); - } - }); - })?; - - log::info!("sync-host: serving at {url}"); - Ok(SyncServer { - url, - token, - pairing, - commands: command_rx, - live, - advance, - }) -} - -async fn upgrade(upgrade: WebSocketUpgrade, State(state): State>) -> Response { - upgrade.on_upgrade(move |socket| serve_connection(socket, state)) -} - -async fn serve_connection(socket: WebSocket, state: Arc) { - let (mut sink, mut stream) = socket.split(); - let mut wake = state.wake.for_connection(); - let mut connection = Connection::new( - StoreSource::new( - state.store.clone(), - state.live.clone(), - state.commands.clone(), - ), - HostConfig { - host: state.host.clone(), - token: state.token.clone(), - pairing: state.pairing.clone(), - }, - ); - - loop { - let event = tokio::select! { - incoming = stream.next() => ConnectionEvent::Socket(incoming), - wake = wake.next() => ConnectionEvent::Wake(wake), - }; - let outgoing = match event { - ConnectionEvent::Socket(incoming) => { - match incoming { - Some(Ok(Message::Text(text))) => match ClientFrame::decode(&text) { - Ok(frame) => connection.handle(frame), - Err(err) => { - // Undecodable input is a broken or hostile peer. - // There is no frame that means "you sent gibberish" - // and inventing one would only help an attacker - // probe the parser. - log::warn!("sync-host: dropping undecodable frame: {err}"); - break; - } - }, - Some(Ok(Message::Close(_))) | None => break, - // Ping/Pong are handled by axum; binary frames are not part - // of this protocol. - Some(Ok(_)) => Vec::new(), - Some(Err(err)) => { - log::debug!("sync-host: connection closed: {err}"); - break; - } - } - } - ConnectionEvent::Wake(WakeEvent::Session(session_id)) => connection.drain(&session_id), - // A lagged broadcast identifies no particular missed session, so - // cursors are the authority and every subscription is re-drained. - ConnectionEvent::Wake(WakeEvent::AllSubscriptions) => { - drain_all_subscriptions(&mut connection) - } - ConnectionEvent::Wake(WakeEvent::Poll) => wake.drain_changed(&mut connection), - ConnectionEvent::Wake(WakeEvent::Closed) => break, - }; - - for frame in outgoing { - let Ok(text) = frame.encode() else { - log::error!("sync-host: failed to encode a host frame"); - continue; - }; - if sink.send(Message::Text(text.into())).await.is_err() { - return; - } - } - wake.sync_subscriptions(&connection); - - // A refusal is the last thing this connection ever says. - if connection.is_refused() { - let _ = sink.send(Message::Close(None)).await; - return; - } - } -} - -impl WakeFactory { - fn for_connection(&self) -> ConnectionWake { - match self { - Self::Broadcast(advance) => ConnectionWake::Broadcast(advance.subscribe()), - Self::Polling { root, interval } => ConnectionWake::Polling(Box::new(PollWake { - root: root.clone(), - stamps: HashMap::new(), - interval: tokio::time::interval(*interval), - })), - } - } -} - -impl ConnectionWake { - async fn next(&mut self) -> WakeEvent { - match self { - Self::Broadcast(advance) => match advance.recv().await { - Ok(session_id) => WakeEvent::Session(session_id), - Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { - log::debug!("sync-host: connection lagged {missed} notifications"); - WakeEvent::AllSubscriptions - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => WakeEvent::Closed, - }, - Self::Polling(poll) => { - poll.interval.tick().await; - WakeEvent::Poll - } - } - } - - fn drain_changed(&mut self, connection: &mut Connection) -> Vec { - let Self::Polling(poll) = self else { - return Vec::new(); - }; - let session_ids = connection - .subscribed_sessions() - .map(str::to_owned) - .collect::>(); - let mut outgoing = Vec::new(); - for session_id in session_ids { - let current = event_log_stamp(&poll.root, &session_id); - match poll.stamps.insert(session_id.clone(), current) { - Some(previous) if previous != current => { - outgoing.extend(connection.drain(&session_id)); - } - _ => {} - } - } - outgoing - } - - fn sync_subscriptions(&mut self, connection: &Connection) { - let Self::Polling(poll) = self else { - return; - }; - poll.stamps.retain(|session_id, _| { - connection - .subscribed_sessions() - .any(|subscribed| subscribed == session_id) - }); - for session_id in connection.subscribed_sessions() { - poll.stamps - .entry(session_id.to_owned()) - .or_insert_with(|| event_log_stamp(&poll.root, session_id)); - } - } -} - -fn drain_all_subscriptions(connection: &mut Connection) -> Vec { - let sessions = connection - .subscribed_sessions() - .map(str::to_owned) - .collect::>(); - sessions - .iter() - .flat_map(|session_id| connection.drain(session_id)) - .collect() -} - -fn event_log_stamp(root: &Path, session_id: &str) -> Option { - let metadata = std::fs::metadata(root.join(format!("{session_id}.jsonl"))).ok()?; - Some(FileStamp { - len: metadata.len(), - modified: metadata.modified().ok(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn host_info() -> HostInfo { - HostInfo { - host_id: "host-1".into(), - display_name: "workstation".into(), - platform: "test".into(), - app_version: "0.1.0".into(), - } - } - - fn temp_store() -> SessionStore { - let mut root = std::env::temp_dir(); - root.push(format!("tcode-sync-server-test-{}", uuid::Uuid::new_v4())); - SessionStore::open_at(root).expect("temp store") - } - - #[test] - fn start_binds_a_loopback_port_and_uses_the_supplied_token() { - let store = temp_store(); - let server = - start(store.clone(), host_info(), "stable-token".into()).expect("server starts"); - - assert!( - server.url.starts_with("ws://127.0.0.1:"), - "must not bind beyond loopback: {}", - server.url - ); - assert!(server.url.ends_with("/sync")); - assert_eq!(server.token, "stable-token"); - let _ = std::fs::remove_dir_all(store.root()); - } - - #[test] - fn separate_servers_can_share_a_persisted_token() { - let store = temp_store(); - let a = start(store.clone(), host_info(), "stable-token".into()).expect("first"); - let b = start(store.clone(), host_info(), "stable-token".into()).expect("second"); - assert_eq!(a.token, b.token); - assert_ne!(a.url, b.url); - let _ = std::fs::remove_dir_all(store.root()); - } - - #[test] - fn start_on_uses_the_supplied_bind_address() { - let store = temp_store(); - let server = start_on( - store.clone(), - host_info(), - "stable-token".into(), - SocketAddr::from(([127, 0, 0, 1], 0)), - WakeSource::Polling { - interval: Duration::from_millis(250), - }, - ) - .expect("server starts"); - - assert!(server.url.starts_with("ws://127.0.0.1:")); - assert!(server.url.ends_with("/sync")); - let _ = std::fs::remove_dir_all(store.root()); - } - - /// Notifying with nobody connected must not fail or block: the app calls - /// this on every appended event, whether or not a client exists. - #[test] - fn notifying_with_no_subscribers_is_harmless() { - let store = temp_store(); - let server = start(store.clone(), host_info(), "test-token".into()).expect("server starts"); - server.notify_advanced("s1"); - server.notify_advanced("s1"); - let _ = std::fs::remove_dir_all(store.root()); - } -} diff --git a/crates/sync-host/src/store_source.rs b/crates/sync-host/src/store_source.rs deleted file mode 100644 index 2449e2ab..00000000 --- a/crates/sync-host/src/store_source.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! A [`SessionSource`] backed by the on-disk session store. -//! -//! Reads do not go through the running app. `SessionStore` is cheap to clone -//! and safe to share, and the event log is the authoritative record anyway, so -//! the server thread reads it directly — no hop onto the UI thread, no request -//! broker, and nothing that stops working when the UI is busy. It is also what -//! lets a headless server reuse this unchanged: there is no app to ask. -//! -//! Only two things cannot come from disk, and each has its own channel: -//! -//! * **Commands** must reach the live provider actor, which the app owns. They -//! go out on [`CommandSink`]. -//! * **Liveness** — is a turn running, is someone waiting on an approval — is -//! in-memory state the log does not describe. The app publishes it into -//! [`LiveSessions`], which reads see immediately. - -use std::collections::HashMap; -use std::sync::{Arc, RwLock}; - -use sync_protocol::{CommandRejection, SeqEvent, SessionCommand, SessionSummary}; -use tcode_services::store::SessionStore; - -use crate::SessionSource; - -/// A command on its way from a remote client to the app that owns the session. -#[derive(Debug, Clone)] -pub struct CommandRequest { - pub session_id: String, - pub command: SessionCommand, -} - -/// Where commands go. The app drains the receiving end. -pub type CommandSink = async_channel::Sender; - -/// Per-session state that exists only in the running app. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct LiveFlags { - /// A turn is in flight. - pub working: bool, - /// The session is blocked on a human. The state a phone exists to serve. - pub awaiting_approval: bool, -} - -/// Liveness published by the app and read by the server thread. -/// -/// Absent entries are not an error: a session with no live provider is simply -/// idle, which is exactly what `LiveFlags::default()` says. -#[derive(Clone, Default)] -pub struct LiveSessions(Arc>>); - -impl LiveSessions { - pub fn new() -> Self { - Self::default() - } - - pub fn get(&self, session_id: &str) -> LiveFlags { - self.0 - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .get(session_id) - .copied() - .unwrap_or_default() - } - - pub fn set(&self, session_id: &str, flags: LiveFlags) { - let mut map = self - .0 - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if flags == LiveFlags::default() { - // Idle is the default, so storing it would only grow the map for - // every session ever opened. - map.remove(session_id); - } else { - map.insert(session_id.to_owned(), flags); - } - } - - /// Forget a session — it ended, was archived, or was deleted. - pub fn clear(&self, session_id: &str) { - self.0 - .write() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .remove(session_id); - } -} - -pub struct StoreSource { - store: SessionStore, - live: LiveSessions, - commands: CommandSink, -} - -impl StoreSource { - pub fn new(store: SessionStore, live: LiveSessions, commands: CommandSink) -> Self { - Self { - store, - live, - commands, - } - } -} - -impl SessionSource for StoreSource { - fn list_sessions(&self) -> Vec { - self.store - .load_index() - .into_iter() - // Archived threads are hidden in the desktop sidebar; a remote - // client should not be shown what the local UI has put away. - .filter(|meta| meta.archived_at.is_none()) - .map(|meta| { - let live = self.live.get(&meta.id); - SessionSummary { - // `latest_seq` is deliberately not computed here. Answering - // it means reading a log, and a host can hold thousands — - // this machine has 1084. The client learns the real head - // when it subscribes. - latest_seq: None, - session_id: meta.id, - title: meta.title, - provider: meta.provider, - model: meta.model, - // The directory name, not the path: a host-absolute path - // means nothing on a phone, and leaks the host's layout to - // every client that can list sessions. - project: meta - .cwd - .file_name() - .map(|name| name.to_string_lossy().into_owned()), - updated_at: meta.updated_at, - working: live.working, - awaiting_approval: live.awaiting_approval, - } - }) - .collect() - } - - fn read_events(&self, session_id: &str, from_seq: Option, limit: usize) -> Vec { - let after = from_seq.unwrap_or(0); - self.store - .read_events(session_id) - .into_iter() - .filter_map(|stored| { - // `read_events` assigns a seq to every line it returns, so the - // filter_map drops nothing in practice. Skipping rather than - // defaulting keeps that an invariant instead of a silent - // renumbering if it ever stops holding. - let seq = stored.seq?; - (seq > after).then(|| SeqEvent { - seq, - ts: stored.ts.unwrap_or_default(), - event: stored.event, - }) - }) - .take(limit) - .collect() - } - - fn session_exists(&self, session_id: &str) -> bool { - self.store - .load_index() - .iter() - .any(|meta| meta.id == session_id) - } - - fn send_command( - &self, - session_id: &str, - command: SessionCommand, - ) -> Result<(), CommandRejection> { - // Non-blocking on purpose: this runs on the server's thread, and - // blocking it on the app would let one busy session stall every other - // client. Whether the session is actually live is the app's answer to - // give, and it arrives out of band. - self.commands - .try_send(CommandRequest { - session_id: session_id.to_owned(), - command, - }) - .map_err(|err| { - log::warn!("dropping command for {session_id}: {err}"); - CommandRejection::SessionNotLive - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use agent::{AgentEvent, ProviderKind}; - use std::path::PathBuf; - use tcode_core::project::SessionMeta; - - fn temp_store() -> SessionStore { - let mut root = std::env::temp_dir(); - root.push(format!("tcode-sync-host-test-{}", uuid_like())); - SessionStore::open_at(root).expect("temp store") - } - - /// The crate has no uuid dependency and does not need one for a directory - /// name; process id plus a counter is unique enough within one test run. - fn uuid_like() -> String { - use std::sync::atomic::{AtomicU64, Ordering}; - static COUNTER: AtomicU64 = AtomicU64::new(0); - format!( - "{}-{}", - std::process::id(), - COUNTER.fetch_add(1, Ordering::Relaxed) - ) - } - - fn event(n: u64) -> AgentEvent { - AgentEvent::TurnAccepted { delivery_id: n } - } - - fn seeded(store: &SessionStore, cwd: &str, len: u64) -> String { - let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from(cwd), None); - store.upsert_meta(&meta).expect("meta"); - for n in 1..=len { - store - .append_event(&meta.id, 1_000 + n, &event(n)) - .expect("append"); - } - meta.id - } - - fn source(store: SessionStore) -> (StoreSource, async_channel::Receiver) { - let (tx, rx) = async_channel::bounded(8); - (StoreSource::new(store, LiveSessions::new(), tx), rx) - } - - #[test] - fn events_are_read_from_the_cursor_with_their_sequence() { - let store = temp_store(); - let id = seeded(&store, "/work/alpha", 5); - let (source, _rx) = source(store.clone()); - - let all = source.read_events(&id, None, 100); - assert_eq!( - all.iter().map(|e| e.seq).collect::>(), - vec![1, 2, 3, 4, 5] - ); - assert_eq!(all[0].ts, 1_001); - - let tail = source.read_events(&id, Some(3), 100); - assert_eq!(tail.iter().map(|e| e.seq).collect::>(), vec![4, 5]); - - let _ = std::fs::remove_dir_all(store.root()); - } - - #[test] - fn the_limit_bounds_a_batch() { - let store = temp_store(); - let id = seeded(&store, "/work/alpha", 10); - let (source, _rx) = source(store.clone()); - - let batch = source.read_events(&id, None, 4); - assert_eq!( - batch.iter().map(|e| e.seq).collect::>(), - vec![1, 2, 3, 4] - ); - let _ = std::fs::remove_dir_all(store.root()); - } - - /// A host-absolute working directory must not reach a client: it is - /// meaningless on a phone and describes the host's filesystem layout. - #[test] - fn a_summary_carries_the_project_name_not_the_host_path() { - let store = temp_store(); - seeded(&store, "/Users/someone/work/alpha", 1); - let (source, _rx) = source(store.clone()); - - let sessions = source.list_sessions(); - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].project.as_deref(), Some("alpha")); - assert_eq!(sessions[0].latest_seq, None); - - let encoded = serde_json::to_string(&sessions[0]).expect("summary encodes"); - assert!( - !encoded.contains("/Users/someone"), - "host path leaked into a session summary: {encoded}" - ); - let _ = std::fs::remove_dir_all(store.root()); - } - - #[test] - fn archived_sessions_are_not_offered_to_clients() { - let store = temp_store(); - let id = seeded(&store, "/work/alpha", 1); - let mut meta = store - .load_index() - .into_iter() - .find(|m| m.id == id) - .expect("seeded"); - meta.archived_at = Some(42); - store.upsert_meta(&meta).expect("archive"); - - let (source, _rx) = source(store.clone()); - assert!(source.list_sessions().is_empty()); - // Still readable by id: archiving hides a thread, it does not revoke a - // client already streaming it. - assert!(source.session_exists(&id)); - let _ = std::fs::remove_dir_all(store.root()); - } - - #[test] - fn liveness_comes_from_the_app_not_the_log() { - let store = temp_store(); - let id = seeded(&store, "/work/alpha", 1); - let (tx, _rx) = async_channel::bounded(8); - let live = LiveSessions::new(); - let source = StoreSource::new(store.clone(), live.clone(), tx); - - assert!(!source.list_sessions()[0].working); - - live.set( - &id, - LiveFlags { - working: true, - awaiting_approval: true, - }, - ); - let summary = &source.list_sessions()[0]; - assert!(summary.working); - assert!(summary.awaiting_approval); - - live.clear(&id); - assert!(!source.list_sessions()[0].awaiting_approval); - let _ = std::fs::remove_dir_all(store.root()); - } - - #[test] - fn a_command_is_handed_to_the_app() { - let store = temp_store(); - let id = seeded(&store, "/work/alpha", 1); - let (source, rx) = source(store.clone()); - - source - .send_command(&id, SessionCommand::Interrupt) - .expect("queued"); - let request = rx.try_recv().expect("the app receives it"); - assert_eq!(request.session_id, id); - assert_eq!(request.command, SessionCommand::Interrupt); - let _ = std::fs::remove_dir_all(store.root()); - } - - /// Sending must never block the server thread: one wedged consumer would - /// otherwise stall every connected client. - #[test] - fn a_full_command_channel_is_rejected_rather_than_waited_on() { - let store = temp_store(); - let id = seeded(&store, "/work/alpha", 1); - let (tx, _rx) = async_channel::bounded(1); - let source = StoreSource::new(store.clone(), LiveSessions::new(), tx); - - source - .send_command(&id, SessionCommand::Interrupt) - .expect("first fits"); - assert_eq!( - source.send_command(&id, SessionCommand::Interrupt), - Err(CommandRejection::SessionNotLive) - ); - let _ = std::fs::remove_dir_all(store.root()); - } -} diff --git a/crates/sync-host/tests/websocket_end_to_end.rs b/crates/sync-host/tests/websocket_end_to_end.rs deleted file mode 100644 index ca30fc3f..00000000 --- a/crates/sync-host/tests/websocket_end_to_end.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! End-to-end over a real WebSocket. -//! -//! The unit tests cover the state machine without a socket, which is where the -//! protocol rules belong. This file covers the part they cannot: that a client -//! can actually reach the host, that the token is enforced on the wire, and -//! that an event appended after a client subscribed reaches it live. -//! -//! That last property is the one worth a real socket. It spans the store, the -//! sequence numbers, the advance broadcast, and the cursor — every piece has to -//! agree, and a fake transport would let them disagree quietly. - -use std::path::PathBuf; -use std::time::Duration; - -use agent::{AgentEvent, ProviderKind}; -use futures_util::{SinkExt as _, StreamExt as _}; -use sync_host::{SyncServer, start}; -use sync_protocol::{ClientFrame, ClientInfo, HostFrame, HostInfo, RefuseReason}; -use tcode_core::project::SessionMeta; -use tcode_services::store::SessionStore; -use tokio_tungstenite::tungstenite::Message; - -/// Long enough that a loaded machine does not fail the test, short enough that -/// a genuine hang is still a fast failure rather than a stuck suite. -const REPLY_TIMEOUT: Duration = Duration::from_secs(5); - -type Socket = - tokio_tungstenite::WebSocketStream>; - -fn temp_store() -> SessionStore { - let mut root = std::env::temp_dir(); - root.push(format!("tcode-sync-e2e-{}", uuid::Uuid::new_v4())); - SessionStore::open_at(root).expect("temp store") -} - -fn host_info() -> HostInfo { - HostInfo { - host_id: "host-1".into(), - display_name: "workstation".into(), - platform: "test".into(), - app_version: "0.1.0".into(), - } -} - -fn client_info() -> ClientInfo { - ClientInfo { - client_id: "client-1".into(), - display_name: "Pixel".into(), - platform: "android".into(), - app_version: "0.1.0".into(), - } -} - -fn event(n: u64) -> AgentEvent { - AgentEvent::TurnAccepted { delivery_id: n } -} - -fn start_server(store: SessionStore) -> SyncServer { - start(store, host_info(), "correct-horse".into()).expect("server starts") -} - -/// A session with `len` events already logged. -fn seed(store: &SessionStore, len: u64) -> String { - let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/work/alpha"), None); - store.upsert_meta(&meta).expect("meta"); - for n in 1..=len { - store - .append_event(&meta.id, 1_000 + n, &event(n)) - .expect("append"); - } - meta.id -} - -async fn connect(server: &SyncServer) -> Socket { - let (socket, _) = tokio_tungstenite::connect_async(&server.url) - .await - .expect("client connects"); - socket -} - -async fn send(socket: &mut Socket, frame: ClientFrame) { - socket - .send(Message::Text(frame.encode().expect("encodes").into())) - .await - .expect("client sends"); -} - -/// Next host frame, or a failure naming what we were waiting for. -async fn recv(socket: &mut Socket, waiting_for: &str) -> HostFrame { - let message = tokio::time::timeout(REPLY_TIMEOUT, socket.next()) - .await - .unwrap_or_else(|_| panic!("timed out waiting for {waiting_for}")) - .unwrap_or_else(|| panic!("socket closed while waiting for {waiting_for}")) - .expect("socket error"); - match message { - Message::Text(text) => HostFrame::decode(&text).expect("host frame decodes"), - other => panic!("expected a text frame while waiting for {waiting_for}, got {other:?}"), - } -} - -fn hello(token: &str) -> ClientFrame { - ClientFrame::Hello { - min_version: sync_protocol::PROTOCOL_MIN_VERSION, - max_version: sync_protocol::PROTOCOL_MAX_VERSION, - client: client_info(), - token: token.into(), - } -} - -fn seqs(frame: &HostFrame) -> Vec { - match frame { - HostFrame::Events { events, .. } => events.iter().map(|event| event.seq).collect(), - other => panic!("expected events, got {other:?}"), - } -} - -#[tokio::test] -async fn a_client_handshakes_backfills_and_receives_a_live_event() { - let store = temp_store(); - let session_id = seed(&store, 3); - let server = start_server(store.clone()); - - let mut socket = connect(&server).await; - - send(&mut socket, hello(&server.token)).await; - match recv(&mut socket, "welcome").await { - HostFrame::Welcome { version, host } => { - assert_eq!(version, sync_protocol::PROTOCOL_MAX_VERSION); - assert_eq!(host.host_id, "host-1"); - } - other => panic!("expected a welcome, got {other:?}"), - } - - send(&mut socket, ClientFrame::ListSessions).await; - match recv(&mut socket, "session list").await { - HostFrame::SessionList { sessions } => { - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].session_id, session_id); - // The host's working directory must not travel. - assert_eq!(sessions[0].project.as_deref(), Some("alpha")); - } - other => panic!("expected a session list, got {other:?}"), - } - - // Backfill from scratch. - send( - &mut socket, - ClientFrame::Subscribe { - session_id: session_id.clone(), - from_seq: None, - }, - ) - .await; - let backfill = recv(&mut socket, "backfill").await; - assert_eq!(seqs(&backfill), vec![1, 2, 3]); - assert!(matches!( - backfill, - HostFrame::Events { - caught_up: true, - .. - } - )); - - // The live path: append, notify, and the subscriber gets only what is new. - let seq = store - .append_event(&session_id, 2_000, &event(4)) - .expect("append"); - assert_eq!(seq, 4, "the store continues the sequence"); - server.notify_advanced(&session_id); - - let live = recv(&mut socket, "the live event").await; - assert_eq!( - seqs(&live), - vec![4], - "a live event must not repeat backfill" - ); - - let _ = std::fs::remove_dir_all(store.root()); -} - -/// The cursor is what makes a dropped mobile connection cheap: reconnecting -/// costs exactly the events that were missed, not the whole log. -#[tokio::test] -async fn reconnecting_with_a_cursor_replays_only_the_gap() { - let store = temp_store(); - let session_id = seed(&store, 5); - let server = start_server(store.clone()); - - let mut socket = connect(&server).await; - send(&mut socket, hello(&server.token)).await; - recv(&mut socket, "welcome").await; - drop(socket); - - let mut socket = connect(&server).await; - send(&mut socket, hello(&server.token)).await; - recv(&mut socket, "welcome").await; - send( - &mut socket, - ClientFrame::Subscribe { - session_id: session_id.clone(), - from_seq: Some(3), - }, - ) - .await; - assert_eq!(seqs(&recv(&mut socket, "the gap").await), vec![4, 5]); - - let _ = std::fs::remove_dir_all(store.root()); -} - -/// The token must be enforced by the server that actually runs, not only by the -/// state machine in isolation. -#[tokio::test] -async fn a_wrong_token_is_refused_and_the_socket_closes() { - let store = temp_store(); - seed(&store, 1); - let server = start_server(store.clone()); - - let mut socket = connect(&server).await; - send(&mut socket, hello("not-the-token")).await; - - match recv(&mut socket, "refusal").await { - HostFrame::Refused { - reason: RefuseReason::Unauthorized, - } => {} - other => panic!("expected an unauthorized refusal, got {other:?}"), - } - - // The host hangs up rather than leaving a rejected peer holding a socket. - let closed = tokio::time::timeout(REPLY_TIMEOUT, socket.next()) - .await - .expect("host must close the socket"); - assert!( - matches!(closed, None | Some(Ok(Message::Close(_))) | Some(Err(_))), - "expected a close, got {closed:?}" - ); - - let _ = std::fs::remove_dir_all(store.root()); -} - -/// A client that never authenticates must not be able to read anything. -#[tokio::test] -async fn subscribing_without_a_handshake_is_refused() { - let store = temp_store(); - let session_id = seed(&store, 3); - let server = start_server(store.clone()); - - let mut socket = connect(&server).await; - send( - &mut socket, - ClientFrame::Subscribe { - session_id, - from_seq: None, - }, - ) - .await; - - match recv(&mut socket, "refusal").await { - HostFrame::Refused { - reason: RefuseReason::Unauthorized, - } => {} - other => panic!("expected a refusal, got {other:?}"), - } - - let _ = std::fs::remove_dir_all(store.root()); -} - -/// A command from a client must arrive on the channel the app drains, with its -/// session id intact. -#[tokio::test] -async fn a_command_reaches_the_apps_channel() { - let store = temp_store(); - let session_id = seed(&store, 1); - let server = start_server(store.clone()); - - let mut socket = connect(&server).await; - send(&mut socket, hello(&server.token)).await; - recv(&mut socket, "welcome").await; - send( - &mut socket, - ClientFrame::Command { - session_id: session_id.clone(), - command: sync_protocol::SessionCommand::Interrupt, - }, - ) - .await; - - let request = tokio::time::timeout(REPLY_TIMEOUT, server.commands.recv()) - .await - .expect("the app must receive the command") - .expect("channel open"); - assert_eq!(request.session_id, session_id); - assert_eq!(request.command, sync_protocol::SessionCommand::Interrupt); - - let _ = std::fs::remove_dir_all(store.root()); -} diff --git a/crates/sync-protocol/Cargo.toml b/crates/sync-protocol/Cargo.toml deleted file mode 100644 index bc89a0ae..00000000 --- a/crates/sync-protocol/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "sync-protocol" -version = "0.1.0" -edition = "2024" - -[dependencies] -# Types only. The adapters that spawn provider processes live behind agent's -# `local` feature, which this crate must not enable: it has to build for -# wasm32-unknown-unknown, where there are no processes to spawn. -agent = { path = "../agent", default-features = false } -serde = { version = "1", features = ["derive"] } -serde_json = "1" diff --git a/crates/sync-protocol/src/lib.rs b/crates/sync-protocol/src/lib.rs deleted file mode 100644 index 540f392c..00000000 --- a/crates/sync-protocol/src/lib.rs +++ /dev/null @@ -1,525 +0,0 @@ -//! Wire types for the tcode host/client sync protocol. -//! -//! A **host** is a machine that can actually run agents: it spawns provider -//! CLIs, owns the repository, and holds the session log. A **client** is a -//! phone, browser, tablet, or another desktop that cannot — iOS forbids -//! fork/exec and wasm has no process model at all — and therefore drives a -//! host's sessions remotely. -//! -//! See `docs/sync-protocol.md` for the reasoning; this crate is normative. -//! -//! Types and serde only: no I/O, no transport, no async. That keeps the crate -//! buildable for `wasm32-unknown-unknown` and lets the same frames travel over -//! a WebSocket, a unix socket, or a test channel. - -use serde::{Deserialize, Serialize}; - -// `ApprovalDecision` is protocol vocabulary, not an implementation detail: -// `SessionCommand::RespondApproval` carries it, so a client cannot answer an -// approval without naming it. -pub use agent::{AgentEvent, ApprovalDecision, ProviderKind, SessionCommand, WorkspacePath}; - -/// Oldest protocol version this build can speak. -pub const PROTOCOL_MIN_VERSION: u32 = 1; -/// Newest protocol version this build can speak. -pub const PROTOCOL_MAX_VERSION: u32 = 1; - -/// Highest version both peers support, or `None` when the ranges are disjoint. -/// -/// Peers exchange *ranges* rather than a single version because host and client -/// ship separately — an App Store release cannot be upgraded in lockstep with -/// the desktop app, so a protocol that demands equality is a protocol that -/// breaks on every release. -pub fn negotiate_version(client_min: u32, client_max: u32) -> Option { - let low = client_min.max(PROTOCOL_MIN_VERSION); - let high = client_max.min(PROTOCOL_MAX_VERSION); - (low <= high).then_some(high) -} - -/// One event as it crosses the wire: the session's canonical event plus the -/// position that makes resumption exact. -/// -/// Not `PartialEq`: [`AgentEvent`] deliberately is not either, because it -/// carries `ResumeCursor` — opaque provider-owned JSON for which structural -/// equality would be a claim we cannot honour. Compare encoded forms instead; -/// for a wire type that is the equality that actually matters. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SeqEvent { - /// Position in the session's log: 1-based, contiguous, strictly increasing. - /// - /// This — never `ts` — is what a cursor tracks. `ts` is a wall clock the - /// writer tolerates repeating or going backwards, so a `ts` cursor would - /// skip or duplicate events at exactly the moments that matter. - pub seq: u64, - /// Unix milliseconds, for display only. - pub ts: u64, - pub event: AgentEvent, -} - -/// What a client tells a host about itself. Diagnostics and compatibility -/// warnings; the host must not make authorization decisions on it. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ClientInfo { - /// Stable per-installation id, so a host can recognise a returning client - /// across reconnects. - pub client_id: String, - /// Human-readable, for the host's "connected devices" list. - pub display_name: String, - /// e.g. `ios`, `android`, `web`, `macos`. - pub platform: String, - /// Client build version, for support and for telling a user which side is - /// too old. - pub app_version: String, -} - -/// What a host tells a client about itself, once the handshake is accepted. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct HostInfo { - pub host_id: String, - pub display_name: String, - pub platform: String, - pub app_version: String, -} - -/// Enough of a session for a client to render a list and pick one. -/// -/// Deliberately its own type rather than the stored `SessionMeta`: the wire -/// format and the on-disk format have different reasons to change, and a client -/// has no use for host-local fields such as absolute working directories. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SessionSummary { - pub session_id: String, - pub title: String, - pub provider: ProviderKind, - pub model: Option, - /// Project display name. Not a path: a host-absolute path means nothing on - /// a phone (`docs/multiplatform-plan.md` §3.4). - pub project: Option, - /// Unix milliseconds of the most recent activity, for sorting. - pub updated_at: u64, - /// Highest `seq` the host currently holds, when it happens to know. - /// - /// Best-effort and therefore optional: answering it for every session - /// would mean scanning every log on every list, and a host can hold - /// thousands. `None` means "not computed", not "empty" — a client uses it - /// to size a backfill or badge unread counts, and simply omits the badge - /// when it is absent. - pub latest_seq: Option, - /// True while a turn is in flight. - pub working: bool, - /// True when the session is waiting on a human — the state a phone exists - /// to serve, and the one worth a push notification. - pub awaiting_approval: bool, -} - -/// Why a handshake was refused. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum RefuseReason { - /// No overlap between the peers' supported ranges. Carries the host's range - /// so the client can say which side needs upgrading instead of "failed". - UnsupportedVersion { host_min: u32, host_max: u32 }, - /// Missing, malformed, expired, or revoked token. - Unauthorized, - /// The host is shutting down or already serving its connection limit. - Unavailable { detail: String }, - /// The pairing code was wrong, already used, or has expired. - /// - /// Deliberately one reason for all three: distinguishing them would let an - /// attacker learn that a guessed code was *once* valid, which is most of - /// what they need to know. - PairingRejected, -} - -/// Why the host could not apply a command. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum CommandRejection { - UnknownSession, - /// The session exists but has no running provider, so there is nothing to - /// send the command to. - SessionNotLive, - /// The provider does not support this command (steering, rewind, and live - /// mode switches are not universal). - Unsupported { - detail: String, - }, - /// The token does not grant this session. - Forbidden, -} - -/// The command identity needed to reconcile a rejection with optimistic state. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum RejectedCommand { - Turn { delivery_id: u64 }, - Approval { request_id: String }, -} - -/// Why a session stopped producing events. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum SessionEndReason { - /// The provider process exited. The session's log remains readable. - ProviderExited { - detail: String, - }, - Archived, - Deleted, -} - -/// Client to host. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum ClientFrame { - /// Exchange a short-lived pairing code for a durable token. - /// - /// The one frame a client may send before `Hello`, because it is how a - /// client that has no token gets one. A phone cannot be handed a - /// credential the way a query string hands one to a browser, and asking - /// someone to type a UUID off another screen is not a pairing flow. - Pair { - min_version: u32, - max_version: u32, - code: String, - client: ClientInfo, - }, - /// Must be the first frame; a host rejects anything else before it. - Hello { - min_version: u32, - max_version: u32, - client: ClientInfo, - token: String, - }, - ListSessions, - /// Stream a session from `from_seq` exclusive — `Some(n)` means "I hold - /// through n, send n+1 onward". `None` means from the beginning. - Subscribe { - session_id: String, - from_seq: Option, - }, - /// Stop streaming. The session keeps running on the host. - Unsubscribe { - session_id: String, - }, - Command { - session_id: String, - command: SessionCommand, - }, - /// Liveness. Mobile networks drop idle connections without closing them. - Ping { - nonce: u64, - }, -} - -/// Host to client. -/// -/// Not `PartialEq` — see [`SeqEvent`], which it carries. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type", content = "data", rename_all = "snake_case")] -pub enum HostFrame { - /// Handshake accepted. `version` is the agreed one, which both peers use - /// from here on. - Welcome { - version: u32, - host: HostInfo, - }, - Refused { - reason: RefuseReason, - }, - /// A pairing code was accepted. The token is durable: a client stores it and - /// never needs the code again. - /// - /// The connection is *not* authenticated by this — the client still sends - /// `Hello` with the new token. Keeping the two steps separate means pairing - /// has exactly one job, and a stolen `Paired` frame is worth no more than - /// the token it carries. - Paired { - version: u32, - token: String, - }, - SessionList { - sessions: Vec, - }, - /// A batch of events, ascending and contiguous by `seq`. - /// - /// `caught_up` is false while the host is replaying backlog and true once - /// the client is live. Clients use it to hold off rendering "waiting for - /// the agent" states during a long replay. - Events { - session_id: String, - events: Vec, - caught_up: bool, - }, - /// A command could not be applied. Sent even though the client cannot - /// usually recover, because silence would leave it rendering a turn that - /// never ran. - CommandRejected { - session_id: String, - /// Older hosts omit this, so clients must retain a send-order fallback. - #[serde(default, skip_serializing_if = "Option::is_none")] - command: Option, - reason: CommandRejection, - }, - SessionEnded { - session_id: String, - reason: SessionEndReason, - }, - Pong { - nonce: u64, - }, -} - -macro_rules! json_codec { - ($ty:ty) => { - impl $ty { - /// Encode for a WebSocket text frame. - pub fn encode(&self) -> Result { - serde_json::to_string(self) - } - - /// Decode one received frame. - pub fn decode(text: &str) -> Result { - serde_json::from_str(text) - } - } - }; -} - -json_codec!(ClientFrame); -json_codec!(HostFrame); - -#[cfg(test)] -mod tests { - use super::*; - - fn client_info() -> ClientInfo { - ClientInfo { - client_id: "client-1".into(), - display_name: "Pixel".into(), - platform: "android".into(), - app_version: "0.1.0".into(), - } - } - - fn every_client_frame() -> Vec { - vec![ - ClientFrame::Hello { - min_version: PROTOCOL_MIN_VERSION, - max_version: PROTOCOL_MAX_VERSION, - client: client_info(), - token: "secret".into(), - }, - ClientFrame::Pair { - min_version: PROTOCOL_MIN_VERSION, - max_version: PROTOCOL_MAX_VERSION, - code: "K7M2QX".into(), - client: client_info(), - }, - ClientFrame::ListSessions, - ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: Some(42), - }, - ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - }, - ClientFrame::Unsubscribe { - session_id: "s1".into(), - }, - ClientFrame::Command { - session_id: "s1".into(), - command: SessionCommand::Interrupt, - }, - ClientFrame::Ping { nonce: 7 }, - ] - } - - fn every_host_frame() -> Vec { - vec![ - HostFrame::Welcome { - version: PROTOCOL_MAX_VERSION, - host: HostInfo { - host_id: "host-1".into(), - display_name: "workstation".into(), - platform: "macos".into(), - app_version: "0.1.0".into(), - }, - }, - HostFrame::Refused { - reason: RefuseReason::UnsupportedVersion { - host_min: 2, - host_max: 3, - }, - }, - HostFrame::Refused { - reason: RefuseReason::Unauthorized, - }, - HostFrame::Refused { - reason: RefuseReason::PairingRejected, - }, - HostFrame::Paired { - version: PROTOCOL_MAX_VERSION, - token: "durable-token".into(), - }, - HostFrame::SessionList { - sessions: vec![SessionSummary { - session_id: "s1".into(), - title: "Fix the parser".into(), - provider: ProviderKind::Codex, - model: Some("gpt-5".into()), - project: Some("tcode".into()), - updated_at: 1_700_000_000_000, - latest_seq: Some(128), - working: true, - awaiting_approval: false, - }], - }, - HostFrame::Events { - session_id: "s1".into(), - events: vec![SeqEvent { - seq: 1, - ts: 1_700_000_000_000, - event: AgentEvent::TurnAccepted { delivery_id: 9 }, - }], - caught_up: true, - }, - HostFrame::CommandRejected { - session_id: "s1".into(), - command: Some(RejectedCommand::Approval { - request_id: "approval-1".into(), - }), - reason: CommandRejection::SessionNotLive, - }, - HostFrame::SessionEnded { - session_id: "s1".into(), - reason: SessionEndReason::Archived, - }, - HostFrame::Pong { nonce: 7 }, - ] - } - - #[test] - fn client_frames_round_trip() { - for frame in every_client_frame() { - let text = frame.encode().expect("frame must encode"); - let back = ClientFrame::decode(&text) - .unwrap_or_else(|err| panic!("{frame:?} must decode from {text}: {err}")); - assert_eq!(back, frame); - } - } - - /// Compares canonical encodings because `HostFrame` is not `PartialEq` - /// (see its doc comment). Re-encoding what we decoded proves the decode - /// lost nothing, which is the property a wire type owes its peer. - #[test] - fn host_frames_round_trip() { - for frame in every_host_frame() { - let text = frame.encode().expect("frame must encode"); - let reencoded = HostFrame::decode(&text) - .unwrap_or_else(|err| panic!("{frame:?} must decode from {text}: {err}")) - .encode() - .expect("a decoded frame must re-encode"); - assert_eq!(reencoded, text, "round trip changed {frame:?}"); - } - } - - #[test] - fn command_rejection_correlation_is_backward_compatible() { - for command in [ - Some(RejectedCommand::Turn { delivery_id: 9 }), - Some(RejectedCommand::Approval { - request_id: "approval-1".into(), - }), - None, - ] { - let frame = HostFrame::CommandRejected { - session_id: "s1".into(), - command, - reason: CommandRejection::SessionNotLive, - }; - let text = frame.encode().expect("rejection must encode"); - let reencoded = HostFrame::decode(&text) - .expect("rejection must decode") - .encode() - .expect("decoded rejection must encode"); - assert_eq!(reencoded, text); - } - - let older_host = r#"{"type":"command_rejected","data":{"session_id":"s1","reason":{"kind":"session_not_live"}}}"#; - match HostFrame::decode(older_host).expect("older-host rejection must decode") { - HostFrame::CommandRejected { command, .. } => assert_eq!(command, None), - other => panic!("decoded as {other:?}"), - } - } - - /// `from_seq: None` (start from the beginning) and `from_seq: Some(0)` are - /// different requests, and JSON null must not collapse them — a client - /// resuming from nothing would otherwise be indistinguishable from one - /// asking for a full backfill. - #[test] - fn absent_cursor_survives_encoding() { - let text = ClientFrame::Subscribe { - session_id: "s1".into(), - from_seq: None, - } - .encode() - .unwrap(); - match ClientFrame::decode(&text).unwrap() { - ClientFrame::Subscribe { from_seq, .. } => assert_eq!(from_seq, None), - other => panic!("decoded as {other:?}"), - } - } - - #[test] - fn version_negotiation_picks_the_highest_shared_version() { - // Exactly our range. - assert_eq!( - negotiate_version(PROTOCOL_MIN_VERSION, PROTOCOL_MAX_VERSION), - Some(PROTOCOL_MAX_VERSION) - ); - // A newer client that still speaks our version: meet at ours. - assert_eq!( - negotiate_version(PROTOCOL_MIN_VERSION, PROTOCOL_MAX_VERSION + 5), - Some(PROTOCOL_MAX_VERSION) - ); - // A future-only client. - assert_eq!( - negotiate_version(PROTOCOL_MAX_VERSION + 1, PROTOCOL_MAX_VERSION + 5), - None - ); - // A client too old to speak anything we still support. - assert_eq!(negotiate_version(0, PROTOCOL_MIN_VERSION - 1), None); - } - - /// A refusal must be actionable: the host's range travels with it so the - /// client can tell the user which side is out of date. - #[test] - fn version_refusal_carries_the_hosts_range() { - let text = HostFrame::Refused { - reason: RefuseReason::UnsupportedVersion { - host_min: 4, - host_max: 7, - }, - } - .encode() - .unwrap(); - assert!(text.contains("\"host_min\":4"), "{text}"); - assert!(text.contains("\"host_max\":7"), "{text}"); - } - - /// The frame carrying a `SessionCommand` must survive the enum-in-enum - /// nesting. `SessionCommand` is adjacently tagged for its own reasons - /// (newtype variants wrapping string-serializing enums), and nesting two - /// tagged enums is exactly where serde representations tend to break. - #[test] - fn commands_survive_nesting_inside_a_frame() { - let frame = ClientFrame::Command { - session_id: "s1".into(), - command: SessionCommand::SetInteractionMode(agent::InteractionMode::Plan), - }; - let text = frame.encode().unwrap(); - assert_eq!(ClientFrame::decode(&text).unwrap(), frame); - } -} diff --git a/crates/tcode-android/Cargo.toml b/crates/tcode-android/Cargo.toml deleted file mode 100644 index 7b9df824..00000000 --- a/crates/tcode-android/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "tcode-android" -version = "0.1.0" -edition = "2024" -publish = false - -[lib] -# `cdylib` is what Gradle packages into the APK and what `System.loadLibrary` -# opens. `rlib` keeps the crate testable from the host workspace. -crate-type = ["cdylib", "rlib"] - -# Android-only: the JNI bridge and the platform backend it drives cannot exist -# on a host target, and the workspace build must stay green on desktop. -[target.'cfg(target_os = "android")'.dependencies] -gpui = { git = "https://github.com/zed-industries/zed" } -gpui-android = { path = "../gpui-android" } -anyhow = "1" -client-app = { path = "../client-app" } -jni = "0.21" -log = "0.4" -android_logger = "0.15" -ndk = "0.9" -raw-window-handle = "0.6" diff --git a/crates/tcode-android/README.md b/crates/tcode-android/README.md deleted file mode 100644 index a976e1ad..00000000 --- a/crates/tcode-android/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# tcode-android - -The Android app shell: a JNI bridge that drives the `gpui-android` platform -backend, plus the minimal Activity needed to package it. - -The split from `gpui-android` is deliberate. That crate owns the GPUI `Platform` -implementation and knows nothing about Java; this one owns Java and knows -nothing about GPUI's internals. Keeping them apart is what lets the backend be -offered upstream on its own. - -## Building - -An Android C toolchain is required even for `cargo check`, because -`gpui → stacksafe → psm` assembles AArch64 while dependencies are checked. -Verified with Homebrew's NDK 29.0.14206865 and API 26: - -```sh -export ANDROID_NDK_HOME=/opt/homebrew/share/android-ndk -cargo ndk -t arm64-v8a --platform 26 build -p tcode-android -``` - -Note `cargo ndk`'s `-p` is `--package`, not the platform level; the API level is -`--platform`. - -The produced `libtcode_android.so` is what Gradle packages and what -`System.loadLibrary("tcode_android")` opens. - -## What is verified - -The shared library **links** for `aarch64-linux-android`, which a `cargo check` -never establishes — it means the Android linker resolved every symbol, including -those `gpui_wgpu`, `wgpu` and `psm` require. All ten JNI entry points are -exported and confirmed present in the ELF dynamic symbol table. - -## What is not verified - -Nothing has run on a device. There is no emulator or phone in the environment -this was built in, so the first execution is unexercised. The most likely first -failure is `WgpuRenderer::new`: raw-handle validity, Vulkan adapter and device -selection, and surface capabilities have never met real hardware. - -## Constants shared with Kotlin - -`touch_phase` and `lifecycle_phase` in `src/entry.rs` mirror the integer -constants in `TcodeActivity`. They are translated at the boundary on purpose — -an unrecognised value is rejected there rather than becoming some other phase -deeper in, where a dropped `Ended` would leave a pointer stuck down forever. -The two lists must be edited together. - -## Deliberately unimplemented - -These return honest errors rather than pretending to succeed: - -- **Credential storage** needs an Android Keystore-backed host service. A store - that silently discards secrets is worse than one that reports itself absent, - because the caller would believe the secret was saved. -- **`open_path`** needs a FileProvider `content://` URI; Android cannot hand a - raw filesystem path to another app. -- **IME positioning and text-input state** need an InputConnection reporting - caret bounds through `CursorAnchorInfo`. Until then the platform IME places - itself. -- **Thermal state** reports nominal. Android exposes this as a PowerManager - subscription rather than a query, and guessing would make GPUI throttle for - no reason. diff --git a/crates/tcode-android/android/app/src/main/AndroidManifest.xml b/crates/tcode-android/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 566144d9..00000000 --- a/crates/tcode-android/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - diff --git a/crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt b/crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt deleted file mode 100644 index 9242009a..00000000 --- a/crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.tryanks.tcode - -import android.app.Activity -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import android.content.Intent -import android.content.res.Configuration -import android.graphics.PixelFormat -import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.view.Choreographer -import android.view.MotionEvent -import android.view.Surface -import android.view.SurfaceHolder -import android.view.SurfaceView -import android.view.inputmethod.InputMethodManager - -/** - * Host side of the JNI boundary described in `crates/gpui-android/README.md`. - * - * Every native call below happens on the main Looper thread. That is not - * incidental: the Rust event sink is `!Send`, and the surface lifecycle contract - * requires create/destroy/frame to be serialized against each other. - */ -class TcodeActivity : Activity(), SurfaceHolder.Callback2 { - - companion object { - init { - System.loadLibrary("tcode_android") - } - - // Must match `touch_phase` in crates/tcode-android/src/entry.rs. The - // Rust side rejects unknown values rather than guessing, so these two - // lists have to be kept together. - private const val TOUCH_STARTED = 0 - private const val TOUCH_MOVED = 1 - private const val TOUCH_ENDED = 2 - private const val TOUCH_CANCELLED = 3 - - // Must match `lifecycle_phase` in the same file. - private const val LIFECYCLE_ACTIVE = 0 - private const val LIFECYCLE_INACTIVE = 1 - private const val LIFECYCLE_BACKGROUND = 2 - private const val LIFECYCLE_FOREGROUND = 3 - } - - private lateinit var surfaceView: SurfaceView - private val handler = Handler(Looper.getMainLooper()) - private var started = false - private var backEnabled = false - - private external fun nativeStart(surface: Surface, width: Int, height: Int, density: Float) - private external fun nativeSurfaceCreated(surface: Surface, width: Int, height: Int, density: Float) - private external fun nativeSurfaceDestroyed() - private external fun nativeResized(width: Int, height: Int, density: Float) - private external fun nativeFrame() - private external fun nativeDrainMainThread() - private external fun nativeTouch(pointerId: Long, phase: Int, x: Float, y: Float, pressure: Float) - private external fun nativeLifecycle(phase: Int) - private external fun nativeBackPressed() - private external fun nativeMemoryWarning() - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - surfaceView = SurfaceView(this) - surfaceView.holder.setFormat(PixelFormat.RGBA_8888) - surfaceView.holder.addCallback(this) - setContentView(surfaceView) - } - - // -- surface lifecycle --------------------------------------------------- - - override fun surfaceCreated(holder: SurfaceHolder) { - // Dimensions arrive with surfaceChanged; nothing to report yet. - } - - override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { - val density = resources.displayMetrics.density - if (!started) { - // The first surface must stay valid across GPUI's launch. - nativeStart(holder.surface, width, height, density) - started = true - requestFrame() - } else { - nativeSurfaceCreated(holder.surface, width, height, density) - nativeResized(width, height, density) - } - } - - override fun surfaceDestroyed(holder: SurfaceHolder) { - // Must precede Android releasing the surface: the Rust side unconfigures - // rendering and holds the retired ANativeWindow lease until a - // replacement succeeds, so wgpu cannot outlive the window. - nativeSurfaceDestroyed() - } - - override fun surfaceRedrawNeeded(holder: SurfaceHolder) { - nativeFrame() - } - - // -- frame and main-thread pumping -------------------------------------- - - /** Called from Rust (`AndroidHost::request_frame`), already coalesced there. */ - fun requestFrame() { - Choreographer.getInstance().postFrameCallback { - nativeFrame() - } - } - - /** Called from Rust worker threads (`AndroidHost::wake_main_thread`). */ - fun wakeMainThread() { - handler.post { nativeDrainMainThread() } - } - - // -- input -------------------------------------------------------------- - - override fun onTouchEvent(event: MotionEvent): Boolean { - val phase = when (event.actionMasked) { - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> TOUCH_STARTED - MotionEvent.ACTION_MOVE -> TOUCH_MOVED - MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> TOUCH_ENDED - MotionEvent.ACTION_CANCEL -> TOUCH_CANCELLED - else -> return false - } - // ACTION_MOVE batches every active pointer into one event, so all of - // them must be forwarded or a multi-touch gesture loses fingers. - for (index in 0 until event.pointerCount) { - nativeTouch( - event.getPointerId(index).toLong(), - phase, - event.getX(index), - event.getY(index), - event.getPressure(index), - ) - } - return true - } - - override fun onBackPressed() { - if (backEnabled) { - nativeBackPressed() - } else { - super.onBackPressed() - } - } - - // -- lifecycle ---------------------------------------------------------- - - override fun onResume() { - super.onResume() - nativeLifecycle(LIFECYCLE_ACTIVE) - } - - override fun onPause() { - super.onPause() - nativeLifecycle(LIFECYCLE_INACTIVE) - } - - override fun onStart() { - super.onStart() - nativeLifecycle(LIFECYCLE_FOREGROUND) - } - - override fun onStop() { - super.onStop() - nativeLifecycle(LIFECYCLE_BACKGROUND) - } - - override fun onTrimMemory(level: Int) { - super.onTrimMemory(level) - nativeMemoryWarning() - } - - // -- AndroidHost operations, called from Rust --------------------------- - - fun finishActivity() = runOnUiThread { finish() } - - fun openUri(uri: String) { - startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) - } - - fun readClipboardText(): String? { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - return clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text?.toString() - } - - fun writeClipboardText(text: String) { - val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText(null, text)) - } - - fun showSoftKeyboard() = runOnUiThread { - val ime = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - ime.showSoftInput(surfaceView, 0) - } - - fun hideSoftKeyboard() = runOnUiThread { - val ime = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - ime.hideSoftInputFromWindow(surfaceView.windowToken, 0) - } - - fun setBackEnabled(enabled: Boolean) { - backEnabled = enabled - } - - fun isDarkMode(): Boolean = - (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == - Configuration.UI_MODE_NIGHT_YES -} diff --git a/crates/tcode-android/src/bridge.rs b/crates/tcode-android/src/bridge.rs deleted file mode 100644 index 076d2495..00000000 --- a/crates/tcode-android/src/bridge.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! `AndroidHost` implemented over JNI. -//! -//! The one structural rule, from `crates/gpui-android/README.md`: hold a -//! `JavaVM` and global references, never a thread-local `JNIEnv`. GPUI calls -//! `wake_main_thread` from Rust worker threads, and a `JNIEnv` is only valid on -//! the thread that obtained it — so every method here attaches to the VM itself -//! rather than capturing an environment. - -use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; - -use anyhow::{Context as _, Result, anyhow}; -use gpui::{Bounds, Pixels, TextInputStateChange, ThermalState, WindowAppearance}; -use gpui_android::AndroidHost; -use jni::objects::{GlobalRef, JObject, JValue}; -use jni::{JNIEnv, JavaVM}; - -/// Java-side methods this bridge calls. Kept in one place so the Kotlin/Java -/// side has a single list to satisfy, and so a rename shows up as one diff -/// rather than scattered string literals. -mod method { - pub const WAKE: &str = "wakeMainThread"; - pub const REQUEST_FRAME: &str = "requestFrame"; - pub const FINISH: &str = "finishActivity"; - pub const OPEN_URI: &str = "openUri"; - pub const READ_CLIPBOARD: &str = "readClipboardText"; - pub const WRITE_CLIPBOARD: &str = "writeClipboardText"; - pub const SHOW_KEYBOARD: &str = "showSoftKeyboard"; - pub const HIDE_KEYBOARD: &str = "hideSoftKeyboard"; - pub const SET_BACK_ENABLED: &str = "setBackEnabled"; - pub const IS_DARK_MODE: &str = "isDarkMode"; -} - -pub struct JniHost { - vm: JavaVM, - /// The `TcodeActivity` (or any object implementing the methods above). - activity: GlobalRef, - /// True while a Choreographer callback is already scheduled. - /// - /// The contract requires `request_frame` to coalesce: GPUI may ask for a - /// frame many times within one vsync interval, and posting a callback per - /// request would queue redundant frames behind each other. `AndroidEventSink` - /// rearms after it fires, so this only has to suppress duplicates in - /// between. - frame_pending: AtomicBool, -} - -impl JniHost { - pub fn new(env: &JNIEnv<'_>, activity: &JObject<'_>) -> Result { - Ok(Self { - vm: env.get_java_vm().context("retaining the JavaVM")?, - activity: env - .new_global_ref(activity) - .context("retaining a global ref to the activity")?, - frame_pending: AtomicBool::new(false), - }) - } - - /// Attach to the VM and run `body` with a usable environment. - /// - /// `attach_current_thread` is the reason the VM is stored rather than an - /// env: it works from any thread, including the Rust worker threads GPUI's - /// executors run on. - fn with_env( - &self, - body: impl FnOnce(&mut JNIEnv<'_>, &JObject<'_>) -> Result, - ) -> Result { - let mut guard = self - .vm - .attach_current_thread() - .context("attaching the current thread to the JavaVM")?; - let activity = self.activity.as_obj(); - let result = body(&mut guard, activity); - // A pending Java exception would poison every later call on this - // thread, so clear it here and fold it into the error instead. - if guard.exception_check().unwrap_or(false) { - let _ = guard.exception_clear(); - return Err(anyhow!("the Java side threw while handling a host call")); - } - result - } - - fn call_void(&self, name: &'static str) { - let outcome = self.with_env(|env, activity| { - env.call_method(activity, name, "()V", &[]) - .with_context(|| format!("calling {name}()"))?; - Ok(()) - }); - // Void notifications have no caller who could act on a failure, so log - // rather than propagate. Silence here would make a dead Java side look - // like a hung Rust side. - if let Err(error) = outcome { - log::error!("tcode-android: {name} failed: {error:#}"); - } - } - - fn call_bool_arg(&self, name: &'static str, value: bool) { - let outcome = self.with_env(|env, activity| { - env.call_method(activity, name, "(Z)V", &[JValue::Bool(u8::from(value))]) - .with_context(|| format!("calling {name}({value})"))?; - Ok(()) - }); - if let Err(error) = outcome { - log::error!("tcode-android: {name} failed: {error:#}"); - } - } - - fn call_string_arg(&self, name: &'static str, value: &str) -> Result<()> { - self.with_env(|env, activity| { - let argument = env - .new_string(value) - .with_context(|| format!("allocating the {name} argument"))?; - env.call_method( - activity, - name, - "(Ljava/lang/String;)V", - &[JValue::Object(&argument)], - ) - .with_context(|| format!("calling {name}(..)"))?; - Ok(()) - }) - } - - /// Called by the JNI frame entry point once Choreographer has fired, so the - /// next `request_frame` posts a fresh callback. - pub(crate) fn clear_frame_pending(&self) { - self.frame_pending.store(false, Ordering::Release); - } -} - -impl AndroidHost for JniHost { - fn wake_main_thread(&self) { - self.call_void(method::WAKE); - } - - fn request_frame(&self) { - // Only the transition from not-pending to pending posts a callback. - if self - .frame_pending - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - self.call_void(method::REQUEST_FRAME); - } - } - - fn finish_activity(&self) { - self.call_void(method::FINISH); - } - - fn open_uri(&self, uri: &str) -> Result<()> { - self.call_string_arg(method::OPEN_URI, uri) - } - - fn open_path(&self, path: &Path) -> Result<()> { - // Android cannot hand a raw filesystem path to another app; sharing a - // file needs a FileProvider content:// URI, which is app-configuration - // rather than something this bridge can synthesize. Failing honestly is - // better than opening the wrong thing. - Err(anyhow!( - "opening a filesystem path is unsupported on Android without a FileProvider: {}", - path.display() - )) - } - - fn read_clipboard_text(&self) -> Result> { - self.with_env(|env, activity| { - let value = env - .call_method( - activity, - method::READ_CLIPBOARD, - "()Ljava/lang/String;", - &[], - ) - .context("calling readClipboardText()")? - .l() - .context("readClipboardText must return a String or null")?; - if value.is_null() { - return Ok(None); - } - let text: String = env - .get_string(&value.into()) - .context("decoding the clipboard string")? - .into(); - Ok(Some(text)) - }) - } - - fn write_clipboard_text(&self, text: &str) -> Result<()> { - self.call_string_arg(method::WRITE_CLIPBOARD, text) - } - - fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Result<()> { - // Deliberately unimplemented rather than stubbed to success: a - // credential store that silently discards secrets is worse than one - // that says it is absent, because the caller would believe the secret - // was saved. Needs an Android Keystore-backed host service. - Err(anyhow!("credential storage is not wired up on Android yet")) - } - - fn read_credentials(&self, _url: &str) -> Result)>> { - // `None` is honest here: nothing was ever stored, so nothing is found. - Ok(None) - } - - fn delete_credentials(&self, _url: &str) -> Result<()> { - Err(anyhow!("credential storage is not wired up on Android yet")) - } - - fn show_soft_keyboard(&self) { - self.call_void(method::SHOW_KEYBOARD); - } - - fn hide_soft_keyboard(&self) { - self.call_void(method::HIDE_KEYBOARD); - } - - fn update_ime_position(&self, _bounds: Bounds) { - // Positioning the IME needs an InputConnection reporting caret bounds - // through CursorAnchorInfo. Until that exists the platform IME places - // itself, which is wrong but not broken. - } - - fn text_input_state_changed(&self, _change: TextInputStateChange) { - // Same dependency as `update_ime_position`: without an InputConnection - // there is nothing on the Java side to tell. - } - - fn set_back_enabled(&self, enabled: bool) { - self.call_bool_arg(method::SET_BACK_ENABLED, enabled); - } - - fn window_appearance(&self) -> WindowAppearance { - let dark = self - .with_env(|env, activity| { - let value = env - .call_method(activity, method::IS_DARK_MODE, "()Z", &[]) - .context("calling isDarkMode()")? - .z() - .context("isDarkMode must return a boolean")?; - Ok(value) - }) - .unwrap_or(false); - if dark { - WindowAppearance::Dark - } else { - WindowAppearance::Light - } - } - - fn thermal_state(&self) -> ThermalState { - // Android exposes this through PowerManager's thermal status listener, - // which is a Java-side subscription rather than a query. Reporting - // nominal is the honest default until that listener exists; guessing - // would make GPUI throttle for no reason. - ThermalState::Nominal - } -} diff --git a/crates/tcode-android/src/entry.rs b/crates/tcode-android/src/entry.rs deleted file mode 100644 index 5dd44089..00000000 --- a/crates/tcode-android/src/entry.rs +++ /dev/null @@ -1,358 +0,0 @@ -//! The native entry points `TcodeActivity` calls. -//! -//! Every function here runs on the Activity's Looper thread. That is not a -//! convenience — `AndroidEventSink` is `!Send` precisely so this invariant is -//! enforced by the type system rather than by hope, and the surface-lifecycle -//! contract in `crates/gpui-android/README.md` depends on these calls being -//! serialized against each other. - -use std::cell::RefCell; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context as _, Result, anyhow}; -use std::rc::Rc; - -use client_app::native_transport::NativeTransportFactory; -use client_app::{ - ClientApp, ClientConnection, ClientIdentity, ConnectionStore, FileStore, TransportFactory, -}; -use gpui::{App, AppContext as _, AppLifecyclePhase, ApplicationHandle, TouchPhase, WindowOptions}; -use gpui_android::{ - AndroidDisplayMetrics, AndroidEventSink, AndroidNativeWindow, AndroidPlatform, AndroidSurface, -}; -use jni::JNIEnv; -use jni::objects::JObject; -use jni::sys::{jfloat, jint, jlong}; -use ndk::native_window::NativeWindow; - -use crate::bridge::JniHost; - -// GPUI and its event sink, owned for the Activity's lifetime. -// -// Thread-local rather than a global: the sink is `!Send`, and putting it here -// means a call arriving on the wrong thread finds nothing instead of corrupting -// state that only the Looper thread may touch. -// -// The `allow` is for a confirmed clippy bug, not a real finding: this lint -// demands a `const` initializer and then reports the identical error when one is -// present. Verified by testing both forms against clippy 1.95 for -// aarch64-linux-android — `= const { RefCell::new(None) }` and -// `= RefCell::new(None)` produce the same message, so there is nothing to fix. -thread_local! { - #[allow(clippy::missing_const_for_thread_local)] - static RUNTIME: RefCell> = const { RefCell::new(None) }; -} - -struct AndroidRuntime { - host: Arc, - sink: AndroidEventSink, - /// Keeps GPUI alive. `run_embedded` returns once callbacks are installed, - /// because Android — not GPUI — owns the main loop. - _application: ApplicationHandle, -} - -/// Convert a Java `Surface` into the retained native window the backend needs. -/// -/// # Safety -/// `surface` must be a live `android.view.Surface`. `NativeWindow::from_surface` -/// takes ownership of a new ANativeWindow reference, which `AndroidSurface` then -/// holds for the generation's lifetime — the backend releases it only after -/// `replace_surface` succeeds, so wgpu can never outlive the window it renders -/// into. -unsafe fn native_window( - env: &mut JNIEnv<'_>, - surface: &JObject<'_>, -) -> Result { - let window = unsafe { NativeWindow::from_surface(env.get_raw(), surface.as_raw()) } - .ok_or_else(|| anyhow!("android.view.Surface did not yield an ANativeWindow"))?; - Ok(AndroidNativeWindow::new(window)) -} - -/// Java's touch-phase constants, mirrored from `TcodeActivity`. -/// -/// Translated at the boundary on purpose: an unrecognised value from Java must -/// be rejected here rather than silently becoming some other phase deeper in, -/// where a dropped `Ended` would leave a pointer stuck down forever. -fn touch_phase(value: jint) -> Result { - match value { - 0 => Ok(TouchPhase::Started), - 1 => Ok(TouchPhase::Moved), - 2 => Ok(TouchPhase::Ended), - 3 => Ok(TouchPhase::Cancelled), - other => Err(anyhow!("unknown touch phase {other} from the Java side")), - } -} - -/// Java's lifecycle constants, mirrored from `TcodeActivity`. -fn lifecycle_phase(value: jint) -> Result { - match value { - 0 => Ok(AppLifecyclePhase::Active), - 1 => Ok(AppLifecyclePhase::Inactive), - 2 => Ok(AppLifecyclePhase::Background), - 3 => Ok(AppLifecyclePhase::Foreground), - other => Err(anyhow!( - "unknown lifecycle phase {other} from the Java side" - )), - } -} - -fn start( - env: &mut JNIEnv<'_>, - activity: &JObject<'_>, - surface: &JObject<'_>, - width: jint, - height: jint, - density: jfloat, -) -> Result<()> { - let host = Arc::new(JniHost::new(env, activity).context("building the JNI host")?); - let metrics = AndroidDisplayMetrics::new(width.max(0) as u32, height.max(0) as u32, density) - .context("validating display metrics")?; - let window = unsafe { native_window(env, surface) }?; - let surface = AndroidSurface::new(window, metrics).context("building the initial surface")?; - - let platform = AndroidPlatform::new(host.clone(), surface) - .context("constructing the Android GPUI platform")?; - let sink = platform.event_sink(); - // One runtime for the process; the client reconnects through this factory. - let transport: Arc = - Arc::new(NativeTransportFactory::new().context("starting the client's network runtime")?); - // The paired token lands in app-private storage, which the connect screen - // fills by pairing — the phone equivalent of the browser's localStorage. - let files_dir = app_files_dir(env, activity).context("locating app-private storage")?; - let store: Arc = Arc::new(FileStore::new(files_dir)); - - // The initial surface must stay valid across launch, per the contract. - let application = - gpui::Application::with_platform(Rc::new(platform)).run_embedded(move |cx: &mut App| { - cx.open_window(WindowOptions::default(), |window, cx| { - let connection = client_connection(store.clone()); - cx.new(|cx| ClientApp::new(connection, transport.clone(), window, cx)) - }) - .expect("the Android GPUI window must open"); - cx.activate(true); - }); - - RUNTIME.with(|slot| { - *slot.borrow_mut() = Some(AndroidRuntime { - host, - sink, - _application: application, - }); - }); - Ok(()) -} - -/// Run `body` against the live runtime, logging rather than unwinding. -/// -/// A panic crossing the JNI boundary aborts the process with no useful Java -/// stack, so every entry point funnels through here. -/// The app-private files directory, queried from the Activity. -/// -/// Android exposes no environment variable for this; `getFilesDir()` is the -/// source, and it names storage the OS isolates per app — which is where a -/// durable token belongs. -fn app_files_dir(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result { - let dir = env - .call_method(activity, "getFilesDir", "()Ljava/io/File;", &[]) - .context("calling getFilesDir()")? - .l() - .context("getFilesDir must return a File")?; - let path = env - .call_method(&dir, "getAbsolutePath", "()Ljava/lang/String;", &[]) - .context("calling getAbsolutePath()")? - .l() - .context("getAbsolutePath must return a String")?; - let path: String = env - .get_string(&path.into()) - .context("decoding the files-dir path")? - .into(); - Ok(PathBuf::from(path)) -} - -/// Where this client attaches, and what it calls itself. -/// -/// The token comes from pairing on the connect screen and is kept in `store`; -/// the code is typed once. `TCODE_SYNC_URL` survives only as a convenience that -/// prefills the host address — never a secret, exactly like the browser's -/// `?url=`. -fn client_connection(store: Arc) -> ClientConnection { - ClientConnection { - identity: ClientIdentity { - client_id: format!("tcode-android-{}", std::process::id()), - display_name: "tcode android".into(), - platform: "android".into(), - app_version: env!("CARGO_PKG_VERSION").into(), - }, - url: std::env::var("TCODE_SYNC_URL") - .ok() - .filter(|v| !v.is_empty()), - store, - } -} - -fn with_runtime(name: &'static str, body: impl FnOnce(&mut AndroidRuntime) -> Result<()>) { - RUNTIME.with(|slot| { - let mut slot = slot.borrow_mut(); - let Some(runtime) = slot.as_mut() else { - log::warn!("tcode-android: {name} arrived before the runtime was started"); - return; - }; - if let Err(error) = body(runtime) { - log::error!("tcode-android: {name} failed: {error:#}"); - } - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeStart( - mut env: JNIEnv<'_>, - activity: JObject<'_>, - surface: JObject<'_>, - width: jint, - height: jint, - density: jfloat, -) { - android_logger::init_once( - android_logger::Config::default() - .with_max_level(log::LevelFilter::Info) - .with_tag("tcode"), - ); - if let Err(error) = start(&mut env, &activity, &surface, width, height, density) { - log::error!("tcode-android: failed to start: {error:#}"); - } -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeSurfaceCreated( - mut env: JNIEnv<'_>, - _activity: JObject<'_>, - surface: JObject<'_>, - width: jint, - height: jint, - density: jfloat, -) { - let acquired = unsafe { native_window(&mut env, &surface) }; - with_runtime("surfaceCreated", |runtime| { - let metrics = - AndroidDisplayMetrics::new(width.max(0) as u32, height.max(0) as u32, density) - .context("validating display metrics")?; - let surface = AndroidSurface::new(acquired?, metrics)?; - runtime.sink.surface_created(surface)?; - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeSurfaceDestroyed( - _env: JNIEnv<'_>, - _activity: JObject<'_>, -) { - // Must run before Android releases the surface: the backend unconfigures - // rendering and keeps the retired lease until a replacement succeeds. - with_runtime("surfaceDestroyed", |runtime| { - runtime.sink.surface_destroyed(); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeResized( - _env: JNIEnv<'_>, - _activity: JObject<'_>, - width: jint, - height: jint, - density: jfloat, -) { - with_runtime("resized", |runtime| { - let metrics = - AndroidDisplayMetrics::new(width.max(0) as u32, height.max(0) as u32, density) - .context("validating display metrics")?; - runtime.sink.resized(metrics)?; - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeFrame( - _env: JNIEnv<'_>, - _activity: JObject<'_>, -) { - with_runtime("frame", |runtime| { - // Clear before invoking: the sink rearms while the window is active, and - // that rearm must be able to post a new callback. - runtime.host.clear_frame_pending(); - runtime.sink.frame(); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeDrainMainThread( - _env: JNIEnv<'_>, - _activity: JObject<'_>, -) { - with_runtime("drainMainThread", |runtime| { - runtime.sink.drain_main_thread(); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeTouch( - _env: JNIEnv<'_>, - _activity: JObject<'_>, - pointer_id: jlong, - phase: jint, - x: jfloat, - y: jfloat, - pressure: jfloat, -) { - with_runtime("touch", |runtime| { - // A zero pressure is Android's "no force information", not a real zero - // force reading, so it is reported as absent rather than as 0.0. - let force = (pressure > 0.0).then_some(pressure); - runtime - .sink - .touch(pointer_id as u64, touch_phase(phase)?, x, y, force); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeLifecycle( - _env: JNIEnv<'_>, - _activity: JObject<'_>, - phase: jint, -) { - with_runtime("lifecycle", |runtime| { - runtime.sink.lifecycle_changed(lifecycle_phase(phase)?); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeBackPressed( - _env: JNIEnv<'_>, - _activity: JObject<'_>, -) { - with_runtime("backPressed", |runtime| { - // The bool says whether GPUI consumed it. Android needs that answer to - // decide whether to pop its own back stack, which is a later wiring - // step; logging keeps the information visible until then. - let handled = runtime.sink.back_pressed(); - log::debug!("tcode-android: back press handled by gpui: {handled}"); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeMemoryWarning( - _env: JNIEnv<'_>, - _activity: JObject<'_>, -) { - with_runtime("memoryWarning", |runtime| { - runtime.sink.memory_warning(); - Ok(()) - }); -} diff --git a/crates/tcode-android/src/lib.rs b/crates/tcode-android/src/lib.rs deleted file mode 100644 index 11189bf9..00000000 --- a/crates/tcode-android/src/lib.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Android app shell for tcode: the JNI bridge that drives `gpui-android`. -//! -//! The host build exports nothing. Everything here needs a `JavaVM`, an -//! `ANativeWindow` and a Looper, none of which exist off Android, so the whole -//! implementation stays behind `target_os = "android"` and ordinary desktop -//! workspace builds are unaffected. -//! -//! See `crates/gpui-android/README.md` for the contract this implements. The -//! division of labour there is deliberate: `gpui-android` owns the GPUI -//! `Platform` implementation and knows nothing about Java, while this crate owns -//! Java and knows nothing about GPUI's internals. Keeping them separate is what -//! lets the backend be offered upstream on its own. - -#[cfg(target_os = "android")] -mod bridge; -#[cfg(target_os = "android")] -mod entry; - -#[cfg(target_os = "android")] -pub use bridge::JniHost; diff --git a/crates/tcode-android/tools/verify-link.sh b/crates/tcode-android/tools/verify-link.sh deleted file mode 100755 index bccdc617..00000000 --- a/crates/tcode-android/tools/verify-link.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -# Prove the Android shared library links. -# -# The counterpart to crates/tcode-ios/tools/verify-link.sh. A cdylib is linked -# by construction, so unlike iOS this needs no separate harness — but it does -# need the NDK, and the toolchain variables are easy to get wrong. Capturing the -# invocation means the next person does not rediscover them. -# -# `cargo check` is not enough on its own: it never runs the linker, so it cannot -# tell you whether the symbols gpui_wgpu, wgpu and psm need from the NDK's libc -# resolve. -set -euo pipefail - -cd "$(dirname "$0")/../../.." -NDK=${ANDROID_NDK_HOME:-/opt/homebrew/share/android-ndk} -API=${ANDROID_API_LEVEL:-26} -PROFILE=${1:-debug} - -[ -d "$NDK" ] || { echo "no NDK at $NDK; set ANDROID_NDK_HOME"; exit 1; } - -# The prebuilt directory is darwin-x86_64 even on Apple silicon. -BIN=$(echo "$NDK"/toolchains/llvm/prebuilt/*/bin) -export ANDROID_NDK_HOME="$NDK" -export CC_aarch64_linux_android="$BIN/aarch64-linux-android$API-clang" -export AR_aarch64_linux_android="$BIN/llvm-ar" - -ARGS=(-t arm64-v8a --platform "$API" build -p tcode-android) -[ "$PROFILE" = release ] && ARGS+=(--release) -cargo ndk "${ARGS[@]}" 2>&1 | tail -1 - -SO="target/aarch64-linux-android/$PROFILE/libtcode_android.so" -[ -f "$SO" ] || { echo "no shared object at $SO"; exit 1; } - -file "$SO" -echo "JNI entry points exported:" -"$BIN/llvm-nm" -D --defined-only "$SO" \ - | grep -oE "Java_com_tryanks_tcode_TcodeActivity_native[A-Za-z]+" \ - | sort -u | sed 's/^/ /' diff --git a/crates/tcode-ios/Cargo.toml b/crates/tcode-ios/Cargo.toml deleted file mode 100644 index 6da3d14d..00000000 --- a/crates/tcode-ios/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "tcode-ios" -version = "0.1.0" -edition = "2024" -publish = false - -[lib] -# `staticlib` is what Xcode links into the app binary; `rlib` keeps the crate -# usable from the host workspace so `cargo test` still sees it. -crate-type = ["staticlib", "rlib"] - -[target.'cfg(target_os = "ios")'.dependencies] -gpui = { git = "https://github.com/zed-industries/zed" } -gpui-ios = { path = "../gpui-ios" } -anyhow = "1" -client-app = { path = "../client-app" } -log = "0.4" -objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSString", "NSURL", "NSThread", "NSDictionary", "NSArray", "NSData", "NSValue", "NSObject"] } -objc2-ui-kit = { version = "0.3", features = ["UIApplication", "UIPasteboard", "UIResponder", "UITraitCollection", "UIView", "UIScreen"] } diff --git a/crates/tcode-ios/README.md b/crates/tcode-ios/README.md deleted file mode 100644 index 3693542a..00000000 --- a/crates/tcode-ios/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# tcode-ios - -The iOS app shell: a C-ABI bridge that drives the `gpui-ios` platform backend. - -Mirrors `tcode-android`. That crate owns Java and knows nothing about GPUI's -internals; this one owns Objective-C and likewise. The backends stay plain Rust -crates that could be offered upstream on their own. - -## Building - -```sh -cargo build -p tcode-ios --target aarch64-apple-ios --release -``` - -Produces a `staticlib` for Xcode to link into the app binary. No NDK-style -toolchain variables are needed — the Apple toolchain ships with Xcode. - -## The seam - -The shell registers four function pointers at launch and calls the -`tcode_ios_*` entry points. A function-pointer table rather than an -Objective-C protocol object, because these are invoked from arbitrary Rust -threads and a plain `extern "C"` pointer has no retain/release or -thread-affinity semantics to get wrong. - -The shell must: - -1. Own a `UIView` whose `layer` is a `CAMetalLayer`, and keep it alive for as - long as any surface built from it. -2. Call `tcode_ios_start` once with that view, its pixel size, and - `contentScaleFactor`. -3. Hop to the main queue inside `wake_main_thread` and call - `tcode_ios_drain_main_thread`. -4. Schedule **one** `CADisplayLink` tick per `request_frame` and call - `tcode_ios_frame`; the Rust side coalesces, so a tick per request is wrong. -5. Call `tcode_ios_surface_destroyed` **before** UIKit releases the drawable, - and `tcode_ios_surface_created` when a new one arrives. - -Touch and lifecycle constants are translated at the boundary -(`entry.rs`), so an unrecognised value is rejected where it appears rather than -becoming some other phase deeper in — a dropped `Ended` would leave a touch -stuck down forever. The Swift/Objective-C constants must match that list. - -## What is verified - -Type-check and clippy for `aarch64-apple-ios`, and — the part that matters — -**the archive links**: - -```sh -./crates/tcode-ios/tools/verify-link.sh -``` - -`cargo build` for a staticlib only archives object files; it never runs the -linker, so it cannot tell you whether every symbol gpui, gpui_wgpu, Metal and -cosmic-text need resolves. That script links the archive into a real arm64 -Mach-O executable against the iOS SDK, referencing all nine entry points so the -linker must resolve the whole graph rather than proving most of it dead. Android -gets this for free — a cdylib is linked by construction — so on iOS it is done -deliberately. - -One benign warning remains: `psm`'s assembly object carries the installed SDK's -minimum (26.5) while the probe links at 17.0. It is a metadata mismatch, not an -incompatibility; the link succeeds either way. - -## What is not - -No device, no simulator, and no Xcode project. The first unexercised step is -`metal_context`: Metal adapter selection and surface creation have never met -real hardware. - -## Deliberately unimplemented - -- **Keychain credentials.** A store that silently discards secrets is worse than - one reporting itself absent, because the caller would believe the secret was - saved. Needs a Security.framework wrapper. -- **IME positioning and text-input state.** Need a `UITextInput` conformance on - the hosting view reporting caret rects. -- **Thermal state** reports nominal. `ProcessInfo.thermalState` arrives as a - notification rather than a poll; guessing would make GPUI throttle for nothing. -- **`finish_activity` does nothing.** iOS apps do not exit on demand — `exit(0)` - is grounds for App Store rejection and reads as a crash. diff --git a/crates/tcode-ios/src/bridge.rs b/crates/tcode-ios/src/bridge.rs deleted file mode 100644 index a09eae31..00000000 --- a/crates/tcode-ios/src/bridge.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! `IosHost` implemented over UIKit. -//! -//! The structural rule mirrors the Android bridge's: nothing here may assume it -//! is on the main thread. GPUI calls `wake_main_thread` and `request_frame` from -//! worker threads, while UIKit and Core Animation are main-thread-only, so every -//! such call is marshalled rather than performed in place. - -use std::ffi::c_void; -use std::sync::atomic::{AtomicBool, Ordering}; - -use anyhow::{Result, anyhow}; -use gpui::{Bounds, Pixels, WindowAppearance}; -use gpui_ios::IosHost; -use objc2_foundation::{MainThreadMarker, NSDictionary, NSString, NSURL}; -use objc2_ui_kit::{UIApplication, UIPasteboard, UIUserInterfaceStyle}; - -/// Callbacks the Swift/Objective-C shell registers at launch. -/// -/// A function-pointer table rather than a protocol object: these are called from -/// arbitrary Rust threads, and a plain `extern "C"` pointer has no retain/release -/// or thread-affinity semantics to get wrong. The shell is responsible for -/// hopping to the main queue inside them. -#[derive(Clone, Copy)] -pub struct HostCallbacks { - /// Post to the main queue so it calls `tcode_ios_drain_main_thread`. - pub wake_main_thread: extern "C" fn(*mut c_void), - /// Schedule one `CADisplayLink` tick that calls `tcode_ios_frame`. - pub request_frame: extern "C" fn(*mut c_void), - /// Make the hosting view first responder, showing the keyboard. - pub show_keyboard: extern "C" fn(*mut c_void), - /// Resign first responder. - pub hide_keyboard: extern "C" fn(*mut c_void), - /// Opaque shell pointer handed back to every callback. - pub context: *mut c_void, -} - -// SAFETY: the shell must keep `context` alive for the process lifetime and make -// each callback safe to invoke from any thread — which is the documented -// contract for registering them. Without these impls the host could not be -// shared with GPUI's executors, which is the entire point of the type. -unsafe impl Send for HostCallbacks {} -unsafe impl Sync for HostCallbacks {} - -pub struct UiKitHost { - callbacks: HostCallbacks, - /// True while a display-link tick is already scheduled. - /// - /// The trait requires coalescing: GPUI may request a frame several times - /// within one vsync, and each extra tick would queue a redundant frame - /// behind the last. Cleared by the frame entry point before the sink runs, - /// so a rearm during that frame can schedule the next one. - frame_pending: AtomicBool, -} - -impl UiKitHost { - pub fn new(callbacks: HostCallbacks) -> Self { - Self { - callbacks, - frame_pending: AtomicBool::new(false), - } - } - - pub(crate) fn clear_frame_pending(&self) { - self.frame_pending.store(false, Ordering::Release); - } -} - -impl IosHost for UiKitHost { - fn wake_main_thread(&self) { - (self.callbacks.wake_main_thread)(self.callbacks.context); - } - - fn request_frame(&self) { - if self - .frame_pending - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - (self.callbacks.request_frame)(self.callbacks.context); - } - } - - fn finish_activity(&self) { - // iOS apps do not exit on demand: exit(0) is grounds for App Store - // rejection and reads as a crash to the user. The system decides when a - // suspended app dies, so the honest implementation is to do nothing. - } - - fn open_url(&self, url: &str) -> Result<()> { - let string = NSString::from_str(url); - let url = NSURL::URLWithString(&string) - .ok_or_else(|| anyhow!("{url} is not a URL UIKit can open"))?; - // `openURL:` is main-thread-only, and objc2 makes that a type - // requirement rather than a comment. Refusing off-thread is right: - // opening a URL is always user-initiated, so a background caller is a - // bug worth reporting instead of a race worth hiding. - let marker = MainThreadMarker::new() - .ok_or_else(|| anyhow!("openURL must be called on the main thread"))?; - let application = UIApplication::sharedApplication(marker); - // The options/completionHandler form, not the deprecated one-argument - // `openURL:`. Empty options and no handler mean "open with the default - // behaviour and do not tell me whether it worked" — which matches what - // GPUI's `open_url` can express, since its signature returns nothing. - // SAFETY: a main-thread-checked UIApplication with a valid URL and an - // empty options dictionary. - unsafe { application.openURL_options_completionHandler(&url, &NSDictionary::new(), None) }; - Ok(()) - } - - fn read_clipboard_text(&self) -> Result> { - let pasteboard = UIPasteboard::generalPasteboard(); - // SAFETY: UIPasteboard's accessors are main-thread-only; GPUI reads the - // clipboard from its main-thread paste handling. - Ok(unsafe { pasteboard.string() }.map(|value| value.to_string())) - } - - fn write_clipboard_text(&self, text: &str) -> Result<()> { - let pasteboard = UIPasteboard::generalPasteboard(); - // SAFETY: as above. - unsafe { pasteboard.setString(Some(&NSString::from_str(text))) }; - Ok(()) - } - - fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Result<()> { - // Honest failure, not a silent success. A credential store that discards - // secrets is worse than one reporting itself absent, because the caller - // would believe the secret was saved. Needs a Security.framework keychain - // wrapper. - Err(anyhow!("keychain storage is not wired up on iOS yet")) - } - - fn read_credentials(&self, _url: &str) -> Result)>> { - // `None` is truthful: nothing was ever stored, so nothing is found. - Ok(None) - } - - fn delete_credentials(&self, _url: &str) -> Result<()> { - Err(anyhow!("keychain storage is not wired up on iOS yet")) - } - - fn show_soft_keyboard(&self) { - (self.callbacks.show_keyboard)(self.callbacks.context); - } - - fn hide_soft_keyboard(&self) { - (self.callbacks.hide_keyboard)(self.callbacks.context); - } - - fn update_ime_position(&self, _bounds: Bounds) { - // Positioning the candidate bar needs a `UITextInput` conformance - // reporting caret rects. Until the shell has one, iOS places the IME - // itself — wrong, but not broken. - } - - fn thermal_state(&self) -> gpui::ThermalState { - // ProcessInfo.thermalState is delivered as a notification rather than - // polled, so a shell caches the last value. Until it does, nominal is - // the honest answer — guessing would make GPUI throttle for nothing. - gpui::ThermalState::Nominal - } - - fn text_input_state_changed(&self, _change: gpui::TextInputStateChange) { - // Needs a UITextInput conformance on the hosting view; without one there - // is nothing on the UIKit side to tell. - } - - fn set_back_enabled(&self, _enabled: bool) { - // iOS has no system back button. The method exists so the shared window - // code is identical on both backends. - } - - fn window_appearance(&self) -> WindowAppearance { - // `UITraitCollection.current` is main-thread-only; GPUI queries this - // during layout, which is already on the main thread. - // SAFETY: `UITraitCollection.current` is main-thread-only, and GPUI reads - // appearance during layout, which is already on the main thread. - let style = unsafe { objc2_ui_kit::UITraitCollection::currentTraitCollection() }; - match unsafe { style.userInterfaceStyle() } { - UIUserInterfaceStyle::Dark => WindowAppearance::Dark, - _ => WindowAppearance::Light, - } - } -} diff --git a/crates/tcode-ios/src/entry.rs b/crates/tcode-ios/src/entry.rs deleted file mode 100644 index 99073a7e..00000000 --- a/crates/tcode-ios/src/entry.rs +++ /dev/null @@ -1,276 +0,0 @@ -//! The C entry points the Swift/Objective-C shell calls. -//! -//! Every function here must run on the main thread, for the same reason as the -//! Android side: `IosEventSink` is `!Send`, and the surface lifecycle depends on -//! create/destroy/frame being serialized against each other. Storing the runtime -//! thread-locally means a call arriving elsewhere finds nothing rather than -//! corrupting state. - -use std::cell::RefCell; -use std::ffi::{CStr, c_char, c_void}; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context as _, Result}; -use client_app::native_transport::NativeTransportFactory; -use client_app::{ - ClientApp, ClientConnection, ClientIdentity, ConnectionStore, FileStore, TransportFactory, -}; -use gpui::{App, AppContext as _, AppLifecyclePhase, ApplicationHandle, TouchPhase, WindowOptions}; -use gpui_ios::{IosDisplayMetrics, IosEventSink, IosLayer, IosPlatform, IosSurface}; - -use crate::bridge::{HostCallbacks, UiKitHost}; - -thread_local! { - #[allow(clippy::missing_const_for_thread_local)] - static RUNTIME: RefCell> = const { RefCell::new(None) }; -} - -struct IosRuntime { - host: Arc, - sink: IosEventSink, - /// Keeps GPUI alive: `run_embedded` returns once callbacks are installed, - /// because UIKit — not GPUI — owns the run loop. - _application: ApplicationHandle, -} - -/// Java's phases have integer constants; UIKit's do too, and both are translated -/// at the boundary so an unrecognised value is rejected where it appears rather -/// than becoming some other phase deeper in. A dropped `Ended` would leave a -/// touch stuck down forever. -fn touch_phase(value: i32) -> Result { - match value { - 0 => Ok(TouchPhase::Started), - 1 => Ok(TouchPhase::Moved), - 2 => Ok(TouchPhase::Ended), - 3 => Ok(TouchPhase::Cancelled), - other => Err(anyhow::anyhow!("unknown touch phase {other} from UIKit")), - } -} - -fn lifecycle_phase(value: i32) -> Result { - match value { - 0 => Ok(AppLifecyclePhase::Active), - 1 => Ok(AppLifecyclePhase::Inactive), - 2 => Ok(AppLifecyclePhase::Background), - 3 => Ok(AppLifecyclePhase::Foreground), - other => Err(anyhow::anyhow!( - "unknown lifecycle phase {other} from UIKit" - )), - } -} - -/// The app container's Documents directory. -/// -/// On iOS the process `HOME` is the app sandbox, so `$HOME/Documents` is the -/// per-app store the OS isolates — where a durable token belongs. Falls back to -/// the temp dir only if `HOME` is somehow unset, which keeps the app running -/// rather than refusing to start over persistence. -fn documents_dir() -> PathBuf { - std::env::var_os("HOME") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join("Documents") -} - -/// Where this client attaches, and what it calls itself. -/// -/// The token comes from pairing on the connect screen and is kept in `store`; -/// the code is typed once. `TCODE_SYNC_URL` survives only as a convenience that -/// prefills the host address — never a secret, exactly like the browser's -/// `?url=`. -fn client_connection(store: Arc) -> ClientConnection { - ClientConnection { - identity: ClientIdentity { - client_id: format!("tcode-ios-{}", std::process::id()), - display_name: "tcode iOS".into(), - platform: "ios".into(), - app_version: env!("CARGO_PKG_VERSION").into(), - }, - url: std::env::var("TCODE_SYNC_URL") - .ok() - .filter(|v| !v.is_empty()), - store, - } -} - -fn with_runtime(name: &'static str, body: impl FnOnce(&mut IosRuntime) -> Result<()>) { - RUNTIME.with(|slot| { - let mut slot = slot.borrow_mut(); - let Some(runtime) = slot.as_mut() else { - log::warn!("tcode-ios: {name} arrived before the runtime was started"); - return; - }; - if let Err(error) = body(runtime) { - log::error!("tcode-ios: {name} failed: {error:#}"); - } - }); -} - -fn start( - view: *mut c_void, - width: u32, - height: u32, - scale: f32, - callbacks: HostCallbacks, -) -> Result<()> { - // SAFETY: the shell guarantees `view` is a live UIView whose layer is a - // CAMetalLayer, and that it outlives every surface built from it. - let layer = unsafe { IosLayer::from_raw(view) }.context("wrapping the UIKit view")?; - let metrics = - IosDisplayMetrics::new(width, height, scale).context("validating drawable metrics")?; - let surface = IosSurface::new(layer, metrics).context("validating the initial surface")?; - - let host = Arc::new(UiKitHost::new(callbacks)); - // The platform owns its scheduler, exactly as the Android backend does. - let platform = - IosPlatform::new(host.clone(), surface).context("constructing the iOS GPUI platform")?; - let sink = platform.event_sink(); - // One runtime for the process; the client reconnects through this factory. - let transport: Arc = - Arc::new(NativeTransportFactory::new().context("starting the client's network runtime")?); - // The paired token lands in the app's Documents dir, which the connect - // screen fills by pairing — the phone equivalent of the browser's - // localStorage. - let store: Arc = Arc::new(FileStore::new(documents_dir())); - - let handle = gpui::Application::with_platform(std::rc::Rc::new(platform)).run_embedded( - move |cx: &mut App| { - cx.open_window(WindowOptions::default(), |window, cx| { - let connection = client_connection(store.clone()); - cx.new(|cx| ClientApp::new(connection, transport.clone(), window, cx)) - }) - .expect("the iOS GPUI window must open"); - cx.activate(true); - }, - ); - - RUNTIME.with(|slot| { - *slot.borrow_mut() = Some(IosRuntime { - host, - sink, - _application: handle, - }); - }); - Ok(()) -} - -/// # Safety -/// `view` must be a live `UIView*` with a `CAMetalLayer`, and every callback in -/// `callbacks` must be safe to call from any thread for the process lifetime. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn tcode_ios_start( - view: *mut c_void, - width: u32, - height: u32, - scale: f32, - wake_main_thread: extern "C" fn(*mut c_void), - request_frame: extern "C" fn(*mut c_void), - show_keyboard: extern "C" fn(*mut c_void), - hide_keyboard: extern "C" fn(*mut c_void), - context: *mut c_void, -) { - let callbacks = HostCallbacks { - wake_main_thread, - request_frame, - show_keyboard, - hide_keyboard, - context, - }; - if let Err(error) = start(view, width, height, scale, callbacks) { - log::error!("tcode-ios: failed to start: {error:#}"); - } -} - -/// # Safety -/// `view` must satisfy the same contract as [`tcode_ios_start`]. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn tcode_ios_surface_created( - view: *mut c_void, - width: u32, - height: u32, - scale: f32, -) { - // SAFETY: delegated to the caller by this function's own contract. - let layer = unsafe { IosLayer::from_raw(view) }; - with_runtime("surfaceCreated", |runtime| { - let metrics = IosDisplayMetrics::new(width, height, scale)?; - runtime - .sink - .surface_created(IosSurface::new(layer?, metrics)?) - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn tcode_ios_surface_destroyed() { - // Must precede UIKit releasing the drawable: the backend drops its renderer - // so wgpu cannot present into a layer iOS has reclaimed. - with_runtime("surfaceDestroyed", |runtime| { - runtime.sink.surface_destroyed(); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn tcode_ios_resized(width: u32, height: u32, scale: f32) { - with_runtime("resized", |runtime| { - runtime - .sink - .resized(IosDisplayMetrics::new(width, height, scale)?) - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn tcode_ios_frame() { - with_runtime("frame", |runtime| { - // Cleared before the sink runs so a rearm during this frame can schedule - // the next tick. - runtime.host.clear_frame_pending(); - runtime.sink.frame(); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn tcode_ios_drain_main_thread() { - with_runtime("drainMainThread", |runtime| { - runtime.sink.drain_main_thread(); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn tcode_ios_touch(id: u64, phase: i32, x: f32, y: f32, force: f32) { - with_runtime("touch", |runtime| { - // A non-positive force is UIKit's "this device reports no force", not a - // real zero reading. - let force = (force > 0.0).then_some(force); - runtime.sink.touch(id, touch_phase(phase)?, x, y, force); - Ok(()) - }); -} - -#[unsafe(no_mangle)] -pub extern "C" fn tcode_ios_lifecycle(phase: i32) { - with_runtime("lifecycle", |runtime| { - runtime.sink.lifecycle_changed(lifecycle_phase(phase)?); - Ok(()) - }); -} - -/// # Safety -/// `tag` must be a valid NUL-terminated C string. -#[unsafe(no_mangle)] -pub unsafe extern "C" fn tcode_ios_init_logging(tag: *const c_char) { - let _ = tag; - // SAFETY: the caller guarantees a valid C string; the value is only read for - // diagnostics and never retained. - let name = if tag.is_null() { - "tcode".to_owned() - } else { - unsafe { CStr::from_ptr(tag) } - .to_string_lossy() - .into_owned() - }; - log::info!("tcode-ios: logging initialised for {name}"); -} diff --git a/crates/tcode-ios/src/lib.rs b/crates/tcode-ios/src/lib.rs deleted file mode 100644 index 45ae4cfa..00000000 --- a/crates/tcode-ios/src/lib.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! iOS app shell for tcode: the UIKit bridge that drives `gpui-ios`. -//! -//! Mirrors `tcode-android`. That crate owns Java and knows nothing about GPUI's -//! internals; this one owns Objective-C and likewise. The platform backends stay -//! plain Rust crates that could be offered upstream on their own. -//! -//! Everything is behind `target_os = "ios"`: a `UIApplication`, a `CAMetalLayer` -//! and a main run loop do not exist on a host, and the desktop workspace build -//! must stay green. - -#[cfg(target_os = "ios")] -mod bridge; -#[cfg(target_os = "ios")] -mod entry; - -#[cfg(target_os = "ios")] -pub use bridge::UiKitHost; diff --git a/crates/tcode-ios/tools/verify-link.sh b/crates/tcode-ios/tools/verify-link.sh deleted file mode 100755 index 0d3bad14..00000000 --- a/crates/tcode-ios/tools/verify-link.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env bash -# Prove the iOS staticlib links. -# -# `cargo build` for a staticlib only archives object files — it never runs the -# linker, so it cannot tell you whether every symbol gpui, gpui_wgpu, Metal and -# cosmic-text need actually resolves. This links the archive into a real -# executable against the iOS SDK, which does. -# -# The Android side gets this for free: a cdylib is linked by construction. iOS -# needs it done deliberately, which is why this script exists. -set -euo pipefail - -cd "$(dirname "$0")/../../.." -TARGET=aarch64-apple-ios -PROFILE=${1:-release} -DEPLOY=${IOS_DEPLOYMENT_TARGET:-17.0} - -cargo build -p tcode-ios --target "$TARGET" --profile "$PROFILE" 2>&1 | tail -1 -ARCHIVE="target/$TARGET/$PROFILE/libtcode_ios.a" -[ -f "$ARCHIVE" ] || { echo "no archive at $ARCHIVE"; exit 1; } - -WORK=$(mktemp -d) -trap 'rm -rf "$WORK"' EXIT - -# Every entry point is referenced so the linker must resolve the whole graph -# rather than proving most of it dead. -cat > "$WORK/main.m" <<'OBJC' -#import -extern void tcode_ios_start(void *, uint32_t, uint32_t, float, - void (*)(void *), void (*)(void *), - void (*)(void *), void (*)(void *), void *); -extern void tcode_ios_surface_created(void *, uint32_t, uint32_t, float); -extern void tcode_ios_surface_destroyed(void); -extern void tcode_ios_resized(uint32_t, uint32_t, float); -extern void tcode_ios_frame(void); -extern void tcode_ios_drain_main_thread(void); -extern void tcode_ios_touch(uint64_t, int32_t, float, float, float); -extern void tcode_ios_lifecycle(int32_t); -extern void tcode_ios_init_logging(const char *); -static void noop(void *ctx) { (void)ctx; } -int main(int argc, char **argv) { - if (argc > 99) { - tcode_ios_start(NULL, 0, 0, 0.0f, noop, noop, noop, noop, NULL); - tcode_ios_surface_created(NULL, 0, 0, 0.0f); - tcode_ios_surface_destroyed(); - tcode_ios_resized(0, 0, 0.0f); - tcode_ios_frame(); - tcode_ios_drain_main_thread(); - tcode_ios_touch(0, 0, 0, 0, 0); - tcode_ios_lifecycle(0); - tcode_ios_init_logging("tcode"); - } - return 0; -} -OBJC - -SDK=$(xcrun --sdk iphoneos --show-sdk-path) -xcrun --sdk iphoneos clang \ - -target "arm64-apple-ios$DEPLOY" -isysroot "$SDK" \ - "$WORK/main.m" "$ARCHIVE" \ - -framework Foundation -framework UIKit -framework Metal \ - -framework QuartzCore -framework CoreGraphics -framework CoreText \ - -framework Security \ - -o "$WORK/probe" - -file "$WORK/probe" -echo "entry points linked:" -nm -gU "$WORK/probe" | grep -oE "_tcode_ios_[a-z_]+" | sort -u | sed 's/^/ /' diff --git a/crates/tcode-server/Cargo.toml b/crates/tcode-server/Cargo.toml deleted file mode 100644 index 710e0ec9..00000000 --- a/crates/tcode-server/Cargo.toml +++ /dev/null @@ -1,26 +0,0 @@ -[package] -name = "tcode-server" -version = "0.1.0" -edition = "2024" - -[[bin]] -name = "tcode-server" -path = "src/main.rs" - -[dependencies] -sync-host = { path = "../sync-host" } -sync-protocol = { path = "../sync-protocol" } -tcode-services = { path = "../services" } -log = "0.4" -env_logger = "0.11" -async-channel = "2" -axum = { version = "0.8", features = ["ws"] } -futures-util = "0.3" -tokio = { version = "1", features = [ - "macros", - "net", - "rt-multi-thread", - "signal", - "sync", - "time", -] } diff --git a/crates/tcode-server/src/main.rs b/crates/tcode-server/src/main.rs deleted file mode 100644 index 18097341..00000000 --- a/crates/tcode-server/src/main.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! Headless, read-only tcode sync host. - -use std::io; -use std::net::{IpAddr, SocketAddr}; -use std::time::Duration; - -use sync_host::{CommandRequest, WakeSource}; -use sync_protocol::{HostInfo, SessionCommand}; -use tcode_services::settings::SettingsStore; -use tcode_services::store::SessionStore; - -const HELP: &str = "\ -tcode-server — serve tcode session history over WebSocket - -Usage: tcode-server [OPTIONS] - -Options: - --bind Address to listen on [default: 127.0.0.1:0] - Port 0 selects an available port. Widening the IP exposes - unencrypted traffic; the sync token is the only protection. - --print-token Print the sync token to stdout and exit - -h, --help Print help -"; - -const LIVE_POLL_INTERVAL: Duration = Duration::from_millis(250); - -#[derive(Debug, PartialEq, Eq)] -struct Options { - bind: SocketAddr, - print_token: bool, - help: bool, -} - -impl Default for Options { - fn default() -> Self { - Self { - bind: SocketAddr::from(([127, 0, 0, 1], 0)), - print_token: false, - help: false, - } - } -} - -impl Options { - fn parse(args: impl IntoIterator) -> Result { - let mut options = Self::default(); - let mut args = args.into_iter(); - while let Some(arg) = args.next() { - match arg.as_str() { - "-h" | "--help" => options.help = true, - "--print-token" => options.print_token = true, - "--bind" => { - let value = args - .next() - .ok_or_else(|| "--bind requires an IP address".to_owned())?; - options.bind = parse_bind(&value)?; - } - _ => { - if let Some(value) = arg.strip_prefix("--bind=") { - options.bind = parse_bind(value)?; - } else { - return Err(format!("unknown option: {arg}")); - } - } - } - } - Ok(options) - } -} - -fn parse_bind(value: &str) -> Result { - if let Ok(address) = value.parse::() { - return Ok(address); - } - value - .parse::() - .map(|ip| SocketAddr::new(ip, 0)) - .map_err(|_| format!("invalid bind address {value:?}; expected IP[:PORT]")) -} - -#[tokio::main] -async fn main() { - env_logger::init(); - - let options = match Options::parse(std::env::args().skip(1)) { - Ok(options) => options, - Err(err) => { - eprintln!("error: {err}\n\n{HELP}"); - std::process::exit(2); - } - }; - if options.help { - print!("{HELP}"); - return; - } - if let Err(err) = run(options).await { - log::error!("tcode-server: {err}"); - std::process::exit(1); - } -} - -async fn run(options: Options) -> io::Result<()> { - let store = SessionStore::open_default()?; - let settings = SettingsStore::new(store.root().clone()); - let token = settings.load_or_create_sync_token()?; - - if options.print_token { - println!("{token}"); - return Ok(()); - } - - let display_name = std::env::var("HOSTNAME") - .or_else(|_| std::env::var("COMPUTERNAME")) - .unwrap_or_else(|_| "tcode".into()); - let host = HostInfo { - host_id: format!("{display_name}:{}", store.root().display()), - display_name, - platform: std::env::consts::OS.into(), - app_version: env!("CARGO_PKG_VERSION").into(), - }; - - let data_dir = store.root().clone(); - let server = sync_host::start_on( - store, - host, - token, - options.bind, - WakeSource::Polling { - interval: LIVE_POLL_INTERVAL, - }, - )?; - - log::info!("tcode-server: serving at {}", server.url); - log::info!("tcode-server: data directory {}", data_dir.display()); - log::warn!("tcode-server: read-only host; serves history and live events but cannot run turns"); - - let command_drain = tokio::spawn(drain_commands(server.commands.clone())); - shutdown_signal().await; - command_drain.abort(); - Ok(()) -} - -async fn shutdown_signal() { - if let Err(err) = tokio::signal::ctrl_c().await { - log::error!("tcode-server: failed to listen for Ctrl-C: {err}"); - } - log::info!("tcode-server: stopping"); -} - -async fn drain_commands(commands: async_channel::Receiver) { - while let Ok(request) = commands.recv().await { - log::warn!( - "tcode-server: dropped {} command for session {}: no provider process", - command_kind(&request.command), - request.session_id - ); - } -} - -fn command_kind(command: &SessionCommand) -> &'static str { - match command { - SessionCommand::SendTurn { .. } => "send-turn", - SessionCommand::Interrupt => "interrupt", - SessionCommand::RespondApproval { .. } => "approval-response", - SessionCommand::RespondUserInput { .. } => "user-input-response", - SessionCommand::SetApprovalMode(_) => "set-approval-mode", - SessionCommand::Steer { .. } => "steer", - SessionCommand::SetInteractionMode(_) => "set-interaction-mode", - SessionCommand::SetOption { .. } => "set-option", - SessionCommand::Rewind { .. } => "rewind", - SessionCommand::Shutdown => "shutdown", - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn defaults_to_an_ephemeral_loopback_port() { - assert_eq!( - Options::parse(Vec::::new()).unwrap(), - Options::default() - ); - } - - #[test] - fn bind_accepts_an_ip_with_or_without_a_port() { - assert_eq!( - Options::parse(["--bind".into(), "0.0.0.0".into()]) - .unwrap() - .bind, - "0.0.0.0:0".parse().unwrap() - ); - assert_eq!( - Options::parse(["--bind=[::1]:9876".into()]).unwrap().bind, - "[::1]:9876".parse().unwrap() - ); - } - - #[test] - fn unknown_options_are_rejected() { - assert_eq!( - Options::parse(["--listen".into()]).unwrap_err(), - "unknown option: --listen" - ); - } -} diff --git a/crates/tcode-web/.gitignore b/crates/tcode-web/.gitignore deleted file mode 100644 index 178135c2..00000000 --- a/crates/tcode-web/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/dist/ diff --git a/crates/tcode-web/Cargo.toml b/crates/tcode-web/Cargo.toml deleted file mode 100644 index 1c69200e..00000000 --- a/crates/tcode-web/Cargo.toml +++ /dev/null @@ -1,33 +0,0 @@ -[package] -name = "tcode-web" -version = "0.1.0" -edition = "2024" -publish = false - -[lib] -crate-type = ["cdylib", "rlib"] - -[target.'cfg(target_arch = "wasm32")'.dependencies] -gpui = { git = "https://github.com/zed-industries/zed" } -gpui_platform = { git = "https://github.com/zed-industries/zed" } -gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } -gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } -client-app = { path = "../client-app" } -js-sys = "0.3" -sync-client = { path = "../sync-client" } -sync-protocol = { path = "../sync-protocol" } -tcode-ui = { path = "../ui", default-features = false, features = ["portable"] } -wasm-bindgen = "0.2" - -[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys] -version = "0.3" -features = [ - "CloseEvent", - "Event", - "Location", - "MessageEvent", - "Storage", - "UrlSearchParams", - "WebSocket", - "Window", -] diff --git a/crates/tcode-web/README.md b/crates/tcode-web/README.md deleted file mode 100644 index ca9223f0..00000000 --- a/crates/tcode-web/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# tcode web - -This is the browser shell for the full tcode sync client: it can follow a -session, send turns, and answer approval requests. It uses -`gpui_platform::application()`, which selects GPUI's multithreaded WebPlatform -for wasm. - -## Toolchain and serving requirements - -The pinned GPUI revision needs nightly, a locally rebuilt standard library, and -the wasm atomics, bulk-memory, and mutable-globals target features: - -```sh -CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" \ -RUSTC_BOOTSTRAP=1 \ -cargo +nightly -Zbuild-std=std,panic_abort check \ - -p tcode-web --target wasm32-unknown-unknown -``` - -Linking an actual browser module additionally needs shared memory and its -thread-local-storage exports. Trunk does not forward Cargo's `-Z` option, so -the corresponding unstable setting is supplied through Cargo's environment: - -```sh -CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals -C link-arg=--shared-memory -C link-arg=--max-memory=1073741824 -C link-arg=--import-memory -C link-arg=--export=__heap_base -C link-arg=--export=__wasm_init_tls -C link-arg=--export=__tls_size -C link-arg=--export=__tls_align -C link-arg=--export=__tls_base" \ -CARGO_UNSTABLE_BUILD_STD=std,panic_abort \ -RUSTC_BOOTSTRAP=1 \ -RUSTUP_TOOLCHAIN=nightly \ -trunk build --release --locked -``` - -WebAssembly threads require `SharedArrayBuffer`. Browsers expose it only when -the page is cross-origin isolated, so `Trunk.toml` makes `trunk serve` send: - -```text -Cross-Origin-Embedder-Policy: require-corp -Cross-Origin-Opener-Policy: same-origin -``` - -Production hosting must send the same headers. Without them the dispatcher -cannot start its worker threads. - -Serve with the same environment and `trunk serve --release --locked`, then open -the page. It shows a connect screen: enter the host address and the short-lived -pairing code the desktop host prints at startup. Pairing trades the code for a -durable token, which is kept in `localStorage`, so a reload reconnects without -retyping anything; **Sign out** clears it and returns to the connect screen. - -`?url=` is honoured as a convenience — it only prefills the address field, which -is not a secret: - -```text -http://127.0.0.1:8080/?url=ws%3A%2F%2F127.0.0.1%3APORT%2Fsync -``` - -There is deliberately no `?token=`. Pairing is the only credential bootstrap -path, and a durable token has no business in the address bar, in browser -history, or in a referrer header — which is the whole reason pairing exists. - -## Upstream contribution candidate - -At Zed revision `1a246efd`, `crates/gpui_web/src/dispatcher.rs` defines -`shared_memory_supported()` only under `#[cfg(feature = "multithreaded")]` but -calls it unconditionally from `MainThreadMailbox::run_waker_loop`. -Consequently, `gpui_web` does not compile with `default-features = false`: - -```text -error[E0425]: cannot find function `shared_memory_supported` in this scope -``` - -That prevents `gpui_platform::single_threaded_web()` from being built without -also unifying in the default multithreaded feature. The fix belongs upstream; -tcode intentionally carries no GPUI fork or local patch. diff --git a/crates/tcode-web/Trunk.toml b/crates/tcode-web/Trunk.toml deleted file mode 100644 index fd90ce32..00000000 --- a/crates/tcode-web/Trunk.toml +++ /dev/null @@ -1,11 +0,0 @@ -[build] -dist = "dist" - -[serve] -addresses = ["127.0.0.1"] -port = 8080 -open = false - -# WebAssembly threads require SharedArrayBuffer, which browsers expose only in -# a cross-origin-isolated page. -headers = { "Cross-Origin-Embedder-Policy" = "require-corp", "Cross-Origin-Opener-Policy" = "same-origin" } diff --git a/crates/tcode-web/index.html b/crates/tcode-web/index.html deleted file mode 100644 index d209dacb..00000000 --- a/crates/tcode-web/index.html +++ /dev/null @@ -1,36 +0,0 @@ - - - - - - tcode web - - - - - diff --git a/crates/tcode-web/src/lib.rs b/crates/tcode-web/src/lib.rs deleted file mode 100644 index e048dee6..00000000 --- a/crates/tcode-web/src/lib.rs +++ /dev/null @@ -1,2 +0,0 @@ -#[cfg(target_arch = "wasm32")] -mod wasm; diff --git a/crates/tcode-web/src/wasm.rs b/crates/tcode-web/src/wasm.rs deleted file mode 100644 index 192c276b..00000000 --- a/crates/tcode-web/src/wasm.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! Browser shell for the shared tcode client. -//! -//! Everything the user sees lives in `client-app`; this file is the part a -//! browser cannot share — a `web-sys` WebSocket, the page's query string, and -//! the wasm-bindgen entry point. - -use std::{borrow::Cow, cell::RefCell, sync::Arc}; - -use client_app::{ - ClientApp, ClientConnection, ClientIdentity, ConnectionStore, Inbox, StoredConnection, - Transport, TransportEvent, TransportFactory, -}; -use gpui::{App, AppContext as _, ApplicationHandle, WindowOptions}; -use gpui_component::Root; -use wasm_bindgen::{JsCast as _, closure::Closure, prelude::*}; -use web_sys::{CloseEvent, Event, MessageEvent, UrlSearchParams, WebSocket}; - -thread_local! { - // WebPlatform::run returns after installing browser callbacks. This handle - // deliberately owns GPUI for the rest of the page lifetime. - static APPLICATION: RefCell> = const { RefCell::new(None) }; -} - -/// A browser WebSocket. -struct WebTransport { - socket: WebSocket, -} - -impl Transport for WebTransport { - fn send(&self, text: &str) -> Result<(), String> { - self.socket.send_with_str(text).map_err(js_error) - } -} - -struct WebTransportFactory; - -// SAFETY: wasm is single-threaded here, and the factory holds no state. The -// bounds exist because the shared client is written for platforms that do have -// threads. -unsafe impl Send for WebTransportFactory {} -unsafe impl Sync for WebTransportFactory {} - -impl TransportFactory for WebTransportFactory { - fn connect(&self, url: &str, hello: &str, inbox: Inbox) -> Result, String> { - let socket = connect(url, hello, inbox).map_err(js_error)?; - Ok(Box::new(WebTransport { socket })) - } -} - -/// Push one event onto the shared inbox. -/// -/// The inbox is an `Arc>` because native transports push from a socket -/// thread; the browser is single-threaded so the lock is never contended, but -/// the type is shared with those platforms and so is used the same way here. -fn push(inbox: &Inbox, event: TransportEvent) { - inbox - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .push_back(event); -} - -fn connect(url: &str, hello: &str, incoming: Inbox) -> Result { - let socket = WebSocket::new(url)?; - - let on_open = Closure::::new({ - let socket = socket.clone(); - let hello = hello.to_owned(); - let incoming = incoming.clone(); - move |_| { - if socket.send_with_str(&hello).is_err() { - push(&incoming, TransportEvent::Error); - } else { - push(&incoming, TransportEvent::Open); - } - } - }); - socket.set_onopen(Some(on_open.as_ref().unchecked_ref())); - on_open.forget(); - - let on_message = Closure::::new({ - let incoming = incoming.clone(); - move |event: MessageEvent| { - if let Some(text) = event.data().as_string() { - push(&incoming, TransportEvent::Text(text)); - } - } - }); - socket.set_onmessage(Some(on_message.as_ref().unchecked_ref())); - on_message.forget(); - - let on_error = Closure::::new({ - let incoming = incoming.clone(); - move |_| push(&incoming, TransportEvent::Error) - }); - socket.set_onerror(Some(on_error.as_ref().unchecked_ref())); - on_error.forget(); - - let on_close = Closure::::new(move |event: CloseEvent| { - push(&incoming, TransportEvent::Closed(event.reason())); - }); - socket.set_onclose(Some(on_close.as_ref().unchecked_ref())); - on_close.forget(); - - Ok(socket) -} - -/// Persists the paired connection in the browser's `localStorage`. -/// -/// Stateless: each call re-reads `window.localStorage`, so the handle is -/// trivially `Send + Sync` for the shared client's bounds even though `Storage` -/// is neither. The token is the secret this exists to keep out of the address -/// bar; the URL rides along only so a reload need not re-type it. -struct LocalStorageStore; - -const URL_KEY: &str = "tcode.host_url"; -const TOKEN_KEY: &str = "tcode.sync_token"; - -impl LocalStorageStore { - fn storage() -> Option { - web_sys::window()?.local_storage().ok().flatten() - } -} - -impl ConnectionStore for LocalStorageStore { - fn load(&self) -> Option { - let storage = Self::storage()?; - // No token means no pairing; the URL alone is just a prefill and is not - // worth restoring a session for. - let token = storage.get_item(TOKEN_KEY).ok().flatten()?; - let url = storage.get_item(URL_KEY).ok().flatten().unwrap_or_default(); - Some(StoredConnection { url, token }) - } - - fn save(&self, connection: &StoredConnection) { - let Some(storage) = Self::storage() else { - return; - }; - // Best-effort: storage can be disabled or full. Failing to persist is - // not worth interrupting a working session — the user simply pairs - // again next load. - let _ = storage.set_item(URL_KEY, &connection.url); - let _ = storage.set_item(TOKEN_KEY, &connection.token); - } - - fn clear(&self) { - let Some(storage) = Self::storage() else { - return; - }; - let _ = storage.remove_item(URL_KEY); - let _ = storage.remove_item(TOKEN_KEY); - } -} - -fn page_config() -> Result { - let window = web_sys::window().ok_or_else(|| JsValue::from_str("window is unavailable"))?; - let search = window.location().search()?; - let params = UrlSearchParams::new_with_str(&search)?; - Ok(ClientConnection { - identity: ClientIdentity { - // Per page load, not per installation: a browser has no stable - // handle to offer, and claiming one would let a host think two tabs - // are the same device. - client_id: format!("tcode-web-{}", js_sys::Date::now() as u64), - display_name: "tcode web".into(), - platform: "web".into(), - app_version: env!("CARGO_PKG_VERSION").into(), - }, - // `?url=` prefills the address only — a URL is not a secret. The token - // is never read from the page: it is earned by pairing and kept in - // localStorage, deliberately never `?token=`, which would leave a - // durable credential in the address bar, in history, and in referrers. - url: params.get("url").filter(|value| !value.is_empty()), - store: Arc::new(LocalStorageStore), - }) -} - -fn js_error(value: JsValue) -> String { - value.as_string().unwrap_or_else(|| format!("{value:?}")) -} - -#[wasm_bindgen(start)] -pub fn start() -> Result<(), JsValue> { - gpui_platform::web_init(); - let config = page_config()?; - let app = gpui_platform::application().with_assets(gpui_component_assets::Assets::new( - "https://longbridge.github.io/gpui-component/gallery/", - )); - let handle = app.run_embedded(move |cx: &mut App| { - gpui_component::init(cx); - tcode_ui::markdown::init(cx); - let fonts: Vec> = vec![ - Cow::Borrowed(tcode_ui::assets::DM_SANS), - Cow::Borrowed(tcode_ui::assets::LILEX_REGULAR), - Cow::Borrowed(tcode_ui::assets::LILEX_BOLD), - Cow::Borrowed(tcode_ui::assets::LILEX_ITALIC), - Cow::Borrowed(tcode_ui::assets::LILEX_BOLD_ITALIC), - ]; - cx.text_system() - .add_fonts(fonts) - .expect("bundled web fonts must load"); - cx.open_window(WindowOptions::default(), |window, cx| { - let app = - cx.new(|cx| ClientApp::new(config, Arc::new(WebTransportFactory), window, cx)); - cx.new(|cx| Root::new(app, window, cx).bordered(false)) - }) - .expect("the GPUI web window must open"); - cx.activate(true); - }); - APPLICATION.with(|slot| { - *slot.borrow_mut() = Some(handle); - }); - Ok(()) -} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index 987627d2..a00a8dd6 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -3,48 +3,30 @@ name = "tcode-ui" version = "0.1.0" edition = "2024" -[features] -default = ["desktop"] -# Native application shell and controllers. `portable` intentionally does not -# imply this feature: remote clients only need read-model-driven presentation. -desktop = [ - "agent/local", - "dep:async-channel", - "dep:computer-use-mcp", - "dep:preview-mcp", - "dep:smol", - "dep:tcode-runtime", - "dep:term", -] -portable = [] - [dependencies] -agent = { path = "../agent", default-features = false } +agent = { path = "../agent" } tcode-core = { path = "../core" } tcode-i18n = { path = "../i18n" } -tcode-runtime = { path = "../runtime", optional = true } -term = { path = "../term", optional = true } -preview-mcp = { path = "../preview-mcp", optional = true } -computer-use-mcp = { path = "../computer-use-mcp", optional = true } +tcode-runtime = { path = "../runtime" } +term = { path = "../term" } +preview-mcp = { path = "../preview-mcp" } +computer-use-mcp = { path = "../computer-use-mcp" } gpui = { git = "https://github.com/zed-industries/zed" } gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] } -async-channel = { version = "2", optional = true } +async-channel = "2" base64 = "0.22" chrono = "0.4" log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -smol = { version = "2", optional = true } +smol = "2" rushdown = "0.18.0" syntect = { version = "5.3.0", default-features = false, features = ["parsing", "regex-fancy", "dump-load"] } imara-diff = "0.2" -# Platforms with an embeddable system WebView. Spelled out rather than written -# as `not(target_os = "linux")`: that form silently classifies Android, iOS and -# wasm as desktop, pulling native `wry` into targets that cannot use it. -[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] +[target.'cfg(not(target_os = "linux"))'.dependencies] gpui-wry = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } wry = { version = "0.53.3", package = "lb-wry" } raw-window-handle = { version = "0.6", features = ["std"] } diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index 1f9bb2c8..2fc913c0 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -2,11 +2,6 @@ use std::borrow::Cow; use gpui::{AssetSource, Result, SharedString}; use gpui_component_assets::Assets as ComponentAssets; -#[cfg(target_family = "wasm")] -use std::sync::LazyLock; - -#[cfg(target_family = "wasm")] -static COMPONENT_ASSETS: LazyLock = LazyLock::new(ComponentAssets::default); pub const DM_SANS: &[u8] = include_bytes!("../../../assets/fonts/DMSans[wght].ttf"); #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -91,17 +86,11 @@ impl AssetSource for Assets { if let Some((_, bytes)) = EXTRA_ICONS.iter().find(|(name, _)| *name == path) { return Ok(Some(Cow::Borrowed(bytes))); } - #[cfg(not(target_family = "wasm"))] - return ComponentAssets.load(path); - #[cfg(target_family = "wasm")] - COMPONENT_ASSETS.load(path) + ComponentAssets.load(path) } fn list(&self, path: &str) -> Result> { - #[cfg(not(target_family = "wasm"))] let mut paths = ComponentAssets.list(path)?; - #[cfg(target_family = "wasm")] - let mut paths = COMPONENT_ASSETS.list(path)?; if DM_SANS_PATH.starts_with(path) { paths.push(DM_SANS_PATH.into()); } diff --git a/crates/ui/src/chat.rs b/crates/ui/src/chat.rs index d5f5b01f..9e520eb4 100644 --- a/crates/ui/src/chat.rs +++ b/crates/ui/src/chat.rs @@ -7,9 +7,7 @@ use std::time::Duration; use std::path::{Path, PathBuf}; -#[cfg(feature = "desktop")] -use agent::RewindMode; -use agent::{ChangeCompleteness, FileChange, ItemStatus}; +use agent::{ChangeCompleteness, FileChange, ItemStatus, RewindMode}; use gpui::{ Anchor, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Entity, FollowMode, Hsla, InteractiveElement as _, IntoElement, ListAlignment, ListState, ObjectFit, @@ -27,29 +25,20 @@ use gpui_component::{ v_flex, }; -#[cfg(feature = "desktop")] use tcode_core::git::GitAction; -use tcode_core::git::{MenuItem, QuickAction}; use tcode_core::session::{ - EntryContent, OrchestrateCallback, SteeringStatus, Timeline, TimelineEntry, TurnMeta, - TurnTiming, parse_orchestrate_callback, + EntryContent, OrchestrateCallback, SteeringStatus, TimelineEntry, TurnMeta, TurnTiming, + parse_orchestrate_callback, }; -#[cfg(feature = "desktop")] use tcode_runtime::app::{AppState, RightTab}; -#[cfg(feature = "desktop")] use crate::commit_dialog::CommitDialog; -#[cfg(feature = "desktop")] use crate::composer::{Composer, ComposerEvent}; -#[cfg(feature = "desktop")] use crate::git::{git_action_label_key, git_hint_key}; use crate::markdown::{MarkdownState, MarkdownView}; -#[cfg(feature = "desktop")] use crate::terminal_drawer::TerminalDrawer; use crate::time::now_millis; -#[cfg(feature = "desktop")] use crate::window_caption; -#[cfg(feature = "desktop")] use crate::window_drag_area; /// Content-column max width (T3 centers the timeline at ~760px). Shared with @@ -649,114 +638,9 @@ impl MdState { } } -/// Owned data needed to draw the chat surface without borrowing desktop runtime state. -/// -/// Remote clients can construct this from their synced [`Timeline`]. The desktop -/// adapter below also captures its surrounding chrome state so the existing -/// `ChatView` keeps rendering exactly the same controls. -#[derive(Clone, Default)] -pub struct ChatReadModel { - pub active: Option, - pub sidebar_collapsed: bool, - pub diff_showing: bool, - pub plan_showing: bool, - pub preview_showing: bool, - pub terminal_open: bool, - pub git_quick_action: Option, - pub git_menu_items: Vec, - chat_hosts_caption: bool, -} - -impl ChatReadModel { - /// Wrap one synced session with neutral client-chrome defaults. - pub fn new(active: ChatSessionReadModel) -> Self { - Self { - active: Some(active), - ..Self::default() - } - } -} - -/// Domain snapshot for the active chat session. -#[derive(Clone)] -pub struct ChatSessionReadModel { - pub session_id: String, - pub title: String, - pub cwd: PathBuf, - pub is_draft: bool, - pub provider: agent::ProviderKind, - pub timeline: Timeline, - /// Live-only delivery state used to disable native rewind while work is queued. - pub turn_in_flight: bool, - pub has_queued_messages: bool, - pub native_rewind_pending: bool, - /// Desktop-only drawer preference; remote clients can retain the default. - pub terminal_height: f32, -} - -impl ChatSessionReadModel { - /// Construct the portable timeline read path from sync-owned domain data. - pub fn new( - session_id: impl Into, - title: impl Into, - cwd: PathBuf, - provider: agent::ProviderKind, - timeline: Timeline, - ) -> Self { - Self { - session_id: session_id.into(), - title: title.into(), - cwd, - is_draft: false, - provider, - timeline, - turn_in_flight: false, - has_queued_messages: false, - native_rewind_pending: false, - terminal_height: 240., - } - } -} - -#[cfg(feature = "desktop")] -impl From<&AppState> for ChatReadModel { - fn from(state: &AppState) -> Self { - let active = state.active.as_ref().map(|active| ChatSessionReadModel { - session_id: active.meta.id.clone(), - title: active.meta.title.clone(), - cwd: active.meta.cwd.clone(), - is_draft: active.draft, - provider: active.meta.provider, - timeline: active.timeline.clone(), - turn_in_flight: active.is_turn_running(), - has_queued_messages: !active.queued().is_empty(), - native_rewind_pending: state.native_rewind_pending(), - terminal_height: active.terminal_workspace.height, - }); - Self { - active, - sidebar_collapsed: state.sidebar_collapsed, - diff_showing: state.diff_panel_open() && state.right_tab() == RightTab::Diff, - plan_showing: state.plan_panel_showing(), - preview_showing: state.preview_panel_showing(), - terminal_open: state.terminal_panel_open(), - git_quick_action: state.git_quick_action(), - git_menu_items: state.git_menu_items(), - chat_hosts_caption: window_caption::hosts_caption( - window_caption::CaptionSurface::Chat, - state, - ), - } - } -} - pub struct ChatView { - #[cfg(feature = "desktop")] app_state: Entity, - read_model: Arc, - #[cfg(feature = "desktop")] composer: Entity, - #[cfg(feature = "desktop")] terminal_drawer: Entity, list_state: ListState, turn_items: Vec, @@ -771,13 +655,11 @@ pub struct ChatView { copied: Option, _copied_task: Option>, /// The live commit dialog entity while it is open (kept alive across frames). - #[cfg(feature = "desktop")] commit_dialog: Option>, _subscriptions: Vec, } impl ChatView { - #[cfg(feature = "desktop")] pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { let composer = cx.new(|cx| Composer::new(app_state.clone(), window, cx)); let overdraw = timeline_overdraw(f32::from(window.bounds().size.height)); @@ -793,7 +675,7 @@ impl ChatView { cx.notify(); }), cx.observe(&app_state, |this, _, cx| { - this.refresh_read_model(cx); + this.sync_markdown_states(cx); cx.notify(); }), ]; @@ -801,7 +683,6 @@ impl ChatView { let mut this = Self { app_state, - read_model: Arc::default(), composer, terminal_drawer, list_state, @@ -815,68 +696,19 @@ impl ChatView { commit_dialog: None, _subscriptions: subscriptions, }; - this.refresh_read_model(cx); - this - } - - /// Construct the timeline-only surface from state already owned by a remote - /// client. - /// - /// Gated on `not(desktop)` as well: with the desktop feature on, `ChatView` - /// carries an `AppState` and a composer that a remote client has no way to - /// supply. The two modes are alternatives, not layers. - #[cfg(all(feature = "portable", not(feature = "desktop")))] - pub fn from_read_model( - read_model: ChatReadModel, - window: &mut Window, - cx: &mut Context, - ) -> Self { - let overdraw = timeline_overdraw(f32::from(window.bounds().size.height)); - let list_state = ListState::new(0, ListAlignment::Bottom, px(overdraw)); - list_state.set_follow_mode(FollowMode::Tail); - let mut this = Self { - read_model: Arc::new(read_model), - list_state, - turn_items: Vec::new(), - md_states: HashMap::new(), - expanded: HashSet::new(), - session_key: None, - _tick: None, - copied: None, - _copied_task: None, - _subscriptions: Vec::new(), - }; this.sync_markdown_states(cx); this } - /// Replace the remote snapshot without coupling the view to its sync store. - #[cfg(all(feature = "portable", not(feature = "desktop")))] - pub fn set_read_model(&mut self, read_model: ChatReadModel, cx: &mut Context) { - self.read_model = Arc::new(read_model); - self.sync_markdown_states(cx); - cx.notify(); - } - - /// Refresh the desktop adapter. Timeline rendering below consumes only this snapshot. - #[cfg(feature = "desktop")] - fn refresh_read_model(&mut self, cx: &mut Context) { - self.read_model = Arc::new(ChatReadModel::from(self.app_state.read(cx))); - self.sync_markdown_states(cx); - } - /// Mirror timeline markdown text into synchronous [`MarkdownState`] entities. fn sync_markdown_states(&mut self, cx: &mut Context) { let (session_key, texts, running, turn_items) = { - let session_key = self - .read_model - .active - .as_ref() - .map(|active| active.session_id.clone()); + let state = self.app_state.read(cx); + let session_key = state.active_session_id().map(str::to_string); let mut texts: Vec<(String, String)> = Vec::new(); let mut running = false; let mut turn_items = Vec::new(); - if let Some(active) = &self.read_model.active { + if let Some(active) = &state.active { let timeline = &active.timeline; running = timeline.turn_running; turn_items = index_turns( @@ -955,10 +787,7 @@ impl ChatView { if running && self._tick.is_none() { self._tick = Some(cx.spawn(async move |this, cx| { loop { - #[cfg(feature = "desktop")] smol::Timer::after(Duration::from_secs(1)).await; - #[cfg(not(feature = "desktop"))] - cx.background_executor().timer(Duration::from_secs(1)).await; if this.update(cx, |_, cx| cx.notify()).is_err() { break; } @@ -993,7 +822,6 @@ impl ChatView { &self, index: usize, turn: &TurnMeta, - session: &ChatSessionReadModel, cwd: &Path, entries: &[Arc], pinned: (Option<&str>, Option<&str>), @@ -1043,7 +871,6 @@ impl ChatView { index, segment_id, turn, - &session.timeline, cwd, activities, &turn_counts, @@ -1076,7 +903,6 @@ impl ChatView { index, &entry.id, text, - session, cwd, *context_len, attachments, @@ -1118,7 +944,6 @@ impl ChatView { index, &segment_id, turn, - &session.timeline, cwd, &[], &turn_counts, @@ -1128,13 +953,15 @@ impl ChatView { } // Proposed-plan card (the captured plan for this turn). - if let Some((item_id, markdown)) = session - .timeline - .proposed_plan - .as_ref() - .filter(|plan| plan.turn == index) - .map(|plan| (plan.item_id.clone(), plan.markdown.clone())) - { + if let Some((item_id, markdown)) = { + let state = self.app_state.read(cx); + state + .active + .as_ref() + .and_then(|a| a.timeline.proposed_plan.as_ref()) + .filter(|plan| plan.turn == index) + .map(|plan| (plan.item_id.clone(), plan.markdown.clone())) + } { column = column.child(self.render_proposed_plan_card(index, &item_id, &markdown, cwd, cx)); } @@ -1145,10 +972,15 @@ impl ChatView { // silently. Replay marks turns idle (mark_idle), so finished turns from // stored sessions still render the card. if !turn.running { - let (changes, completeness) = turn.changes.as_ref().map_or_else( - || (Vec::new(), ChangeCompleteness::Partial), - |changes| (changes.changes.clone(), changes.completeness), - ); + let (changes, completeness) = { + let state = self.app_state.read(cx); + ( + state.turn_file_changes(index).unwrap_or_default(), + state + .turn_change_completeness(index) + .unwrap_or(ChangeCompleteness::Partial), + ) + }; if !changes.is_empty() { column = column.child(self.render_changed_files(index, cwd, &changes, completeness, cx)); @@ -1179,7 +1011,6 @@ impl ChatView { index, &entry.id, text, - session, cwd, *context_len, attachments, @@ -1228,23 +1059,28 @@ impl ChatView { .into_any_element() } - #[cfg(feature = "desktop")] fn render_native_rewind_button( &self, turn: usize, - session: &ChatSessionReadModel, + cx: &mut Context, ) -> Option { - let available = session.provider == agent::ProviderKind::ClaudeCode - && session - .timeline - .turns - .get(turn) - .and_then(|turn| turn.provider_checkpoint_id.as_ref()) - .is_some(); - let disabled = session.turn_in_flight - || session.timeline.turn_running - || session.has_queued_messages - || session.native_rewind_pending; + let (available, disabled) = { + let state = self.app_state.read(cx); + let active = state.active.as_ref()?; + ( + active.meta.provider == agent::ProviderKind::ClaudeCode + && active + .timeline + .turns + .get(turn) + .and_then(|turn| turn.provider_checkpoint_id.as_ref()) + .is_some(), + active.is_turn_running() + || active.timeline.turn_running + || !active.queued().is_empty() + || state.native_rewind_pending(), + ) + }; if !available { return None; } @@ -1327,27 +1163,17 @@ impl ChatView { ) } - // Native rewind mutates host-owned checkpoints, so remote clients omit it. - #[cfg(not(feature = "desktop"))] - fn render_native_rewind_button( - &self, - _turn: usize, - _session: &ChatSessionReadModel, - ) -> Option { - None - } - /// A user message: the right-aligned bubble plus its action row. The /// row's height is always reserved, so revealing it on hover never shifts /// the timeline; it is revealed for `pinned` (the last user message) so the /// action is reachable without hovering. #[allow(clippy::too_many_arguments)] + #[allow(clippy::too_many_arguments)] fn render_user( &self, turn: usize, entry_id: &str, text: &str, - session: &ChatSessionReadModel, cwd: &Path, context_len: Option, attachments: &[String], @@ -1385,7 +1211,7 @@ impl ChatView { )); } if steering.is_none() - && let Some(rewind) = self.render_native_rewind_button(turn, session) + && let Some(rewind) = self.render_native_rewind_button(turn, cx) { actions = actions.child(rewind); } @@ -1815,7 +1641,6 @@ impl ChatView { index: usize, segment_id: &str, turn: &TurnMeta, - timeline: &Timeline, cwd: &Path, activities: &[&TimelineEntry], turn_counts: &WorkLogCounts, @@ -1881,7 +1706,7 @@ impl ChatView { section = section.child(self.render_file_edit_row(&row, cx)); } } else { - section = section.child(self.render_activity_row(entry, timeline, false, cx)); + section = section.child(self.render_activity_row(entry, false, cx)); } } @@ -1985,12 +1810,11 @@ impl ChatView { fn render_activity_row( &self, entry: &TimelineEntry, - timeline: &Timeline, compact: bool, cx: &mut Context, ) -> AnyElement { if matches!(&entry.content, EntryContent::Subagent { .. }) { - return self.render_subagent_row(entry, timeline, cx); + return self.render_subagent_row(entry, cx); } let muted = cx.theme().muted_foreground; let (icon, summary): (IconName, AnyElement) = match &entry.content { @@ -2096,12 +1920,7 @@ impl ChatView { .into_any_element() } - fn render_subagent_row( - &self, - entry: &TimelineEntry, - timeline: &Timeline, - cx: &mut Context, - ) -> AnyElement { + fn render_subagent_row(&self, entry: &TimelineEntry, cx: &mut Context) -> AnyElement { let EntryContent::Subagent { agent_type, description, @@ -2114,8 +1933,19 @@ impl ChatView { let key = format!("subagent-{}", entry.id); let expanded = self.expanded.contains(&key); let parent_id = entry.id.clone(); - let children = timeline.children(&parent_id).to_vec(); - let truncated = timeline.children_truncated(&parent_id); + let (children, truncated) = { + let state = self.app_state.read(cx); + state + .active + .as_ref() + .map(|active| { + ( + active.timeline.children(&parent_id).to_vec(), + active.timeline.children_truncated(&parent_id), + ) + }) + .unwrap_or_default() + }; let muted = cx.theme().muted_foreground; let finished = !matches!(status, ItemStatus::InProgress); let turn = entry.turn; @@ -2188,19 +2018,14 @@ impl ChatView { ); } for child in &children { - nested = nested.child(self.render_subagent_child(child, timeline, cx)); + nested = nested.child(self.render_subagent_child(child, cx)); } block = block.child(nested); } block.into_any_element() } - fn render_subagent_child( - &self, - entry: &TimelineEntry, - timeline: &Timeline, - cx: &mut Context, - ) -> AnyElement { + fn render_subagent_child(&self, entry: &TimelineEntry, cx: &mut Context) -> AnyElement { let muted = cx.theme().muted_foreground; match &entry.content { EntryContent::User { text, .. } => h_flex() @@ -2236,7 +2061,7 @@ impl ChatView { .text_color(muted) .child(tcode_i18n::tr!("chat.changed_files", count = changes.len())) .into_any_element(), - _ => self.render_activity_row(entry, timeline, true, cx), + _ => self.render_activity_row(entry, true, cx), } } @@ -2258,7 +2083,7 @@ impl ChatView { .map(|c| diff_stats(c.diff.as_deref())) .fold((0, 0), |(a, d), (ca, cd)| (a + ca, d + cd)); - let mut header = h_flex() + let header = h_flex() .w_full() .px_1() .py_1() @@ -2301,22 +2126,18 @@ impl ChatView { .on_click(cx.listener(move |this, _, _, cx| { this.toggle_expanded(index, &card_key, cx); })), - ); - // Opening the native diff panel mutates AppState. Portable clients get - // the complete changed-files summary but no inert host-only control. - #[cfg(feature = "desktop")] - { - header = header.child( + ) + .child( Button::new(("view-diff", index)) .outline() .xsmall() .label(tcode_i18n::tr!("chat.view_diff")) .tooltip(tcode_i18n::tr!("chat.view_diff_tooltip")) .on_click(cx.listener(move |this, _, _, cx| { - this.open_diff_for_turn(index, cx); + this.app_state + .update(cx, |state, cx| state.open_diff_for_turn(index, cx)); })), ); - } let mut content = v_flex().w_full().child(header); @@ -2351,45 +2172,42 @@ impl ChatView { ); } for file in files { - #[cfg(feature = "desktop")] let path = file.path.clone(); - #[cfg(feature = "desktop")] let row_label = format!("{}: {}", tcode_i18n::tr!("chat.view_diff"), file.path); - #[cfg(feature = "desktop")] - let row = crate::material::accessible_clickable( - h_flex(), - SharedString::from(format!("changed-file-{index}-{}", file.path)), - Role::Button, - row_label, - cx, - ) - .on_click(cx.listener(move |this, _, _, cx| { - this.open_diff_for_file(index, path.clone(), cx); - })); - #[cfg(not(feature = "desktop"))] - let row = h_flex(); body = body.child( - row.w_full() - .pl(px(if dir.is_empty() { 8. } else { 28. })) - .pr_2() - .py_1() - .gap_1p5() - .items_center() - .text_size(px(13.)) - .rounded(px(6.)) - .hover(|s| s.bg(cx.theme().list_hover)) - .when(cfg!(feature = "desktop"), |row| row.cursor_pointer()) - .child(Icon::new(IconName::File).xsmall().text_color(muted)) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .font_family(cx.theme().mono_font_family.clone()) - .child(file.name), - ) - .child(diff_counts(file.added, file.deleted, cx)), + crate::material::accessible_clickable( + h_flex(), + SharedString::from(format!("changed-file-{index}-{}", file.path)), + Role::Button, + row_label, + cx, + ) + .w_full() + .pl(px(if dir.is_empty() { 8. } else { 28. })) + .pr_2() + .py_1() + .gap_1p5() + .items_center() + .text_size(px(13.)) + .rounded(px(6.)) + .cursor_pointer() + .hover(|s| s.bg(cx.theme().list_hover)) + .on_click(cx.listener(move |this, _, _, cx| { + this.app_state.update(cx, |state, cx| { + state.open_diff_for_file(index, path.clone(), cx) + }); + })) + .child(Icon::new(IconName::File).xsmall().text_color(muted)) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .font_family(cx.theme().mono_font_family.clone()) + .child(file.name), + ) + .child(diff_counts(file.added, file.deleted, cx)), ); } } @@ -2434,57 +2252,10 @@ impl ChatView { }; let md_copy = markdown.to_string(); - #[cfg(feature = "desktop")] let md_download = markdown.to_string(); - #[cfg(feature = "desktop")] let md_save = markdown.to_string(); let copied = self.copied.as_deref() == Some("plan"); - let mut plan_actions = h_flex().w_full().gap_1().flex_wrap().child( - Button::new(("plan-copy", turn)) - .ghost() - .xsmall() - .icon(IconName::Copy) - .label(if copied { - tcode_i18n::tr!("plan.copied") - } else { - tcode_i18n::tr!("plan.copy") - }) - .on_click(cx.listener(move |this, _, _, cx| { - let md = md_copy.clone(); - this.copy_plan(md, cx); - })), - ); - // Download and workspace save are filesystem operations owned by the - // desktop host; the portable card intentionally exposes Copy only. - #[cfg(feature = "desktop")] - { - plan_actions = plan_actions - .child( - Button::new(("plan-download", turn)) - .ghost() - .xsmall() - .icon(Icon::empty().path("icons/download.svg")) - .label(tcode_i18n::tr!("plan.download")) - .on_click(cx.listener(move |this, _, _, cx| { - let md = md_download.clone(); - let fallback = tcode_i18n::tr!("plan.proposed_plan").into_owned(); - this.download_plan(md, fallback, cx); - })), - ) - .child( - Button::new(("plan-save", turn)) - .ghost() - .xsmall() - .icon(IconName::HardDrive) - .label(tcode_i18n::tr!("plan.save_workspace")) - .on_click(cx.listener(move |this, _, _, cx| { - let md = md_save.clone(); - this.save_plan_to_workspace(md, cx); - })), - ); - } - let content = v_flex() .flex_1() .min_w_0() @@ -2534,7 +2305,53 @@ impl ChatView { }), ) .child(body) - .child(plan_actions); + .child( + h_flex() + .w_full() + .gap_1() + .flex_wrap() + .child( + Button::new(("plan-copy", turn)) + .ghost() + .xsmall() + .icon(IconName::Copy) + .label(if copied { + tcode_i18n::tr!("plan.copied") + } else { + tcode_i18n::tr!("plan.copy") + }) + .on_click(cx.listener(move |this, _, _, cx| { + let md = md_copy.clone(); + this.app_state.update(cx, |s, cx| s.copy_plan(md, cx)); + this.mark_copied("plan".into(), cx); + })), + ) + .child( + Button::new(("plan-download", turn)) + .ghost() + .xsmall() + .icon(Icon::empty().path("icons/download.svg")) + .label(tcode_i18n::tr!("plan.download")) + .on_click(cx.listener(move |this, _, _, cx| { + let md = md_download.clone(); + let fallback = tcode_i18n::tr!("plan.proposed_plan").into_owned(); + this.app_state + .update(cx, |s, cx| s.download_plan(md, fallback, cx)); + })), + ) + .child( + Button::new(("plan-save", turn)) + .ghost() + .xsmall() + .icon(IconName::HardDrive) + .label(tcode_i18n::tr!("plan.save_workspace")) + .on_click(cx.listener(move |this, _, _, cx| { + let md = md_save.clone(); + this.app_state + .update(cx, |s, cx| s.save_plan_to_workspace(md, cx)); + })), + ), + ); h_flex() .w_full() @@ -2560,10 +2377,7 @@ impl ChatView { fn mark_copied(&mut self, key: String, cx: &mut Context) { self.copied = Some(key.clone()); self._copied_task = Some(cx.spawn(async move |this, cx| { - #[cfg(feature = "desktop")] smol::Timer::after(Duration::from_secs(2)).await; - #[cfg(not(feature = "desktop"))] - cx.background_executor().timer(Duration::from_secs(2)).await; let _ = this.update(cx, |this, cx| { if this.copied.as_deref() == Some(key.as_str()) { this.copied = None; @@ -2574,39 +2388,6 @@ impl ChatView { cx.notify(); } - #[cfg(feature = "desktop")] - fn open_diff_for_turn(&mut self, index: usize, cx: &mut Context) { - self.app_state - .update(cx, |state, cx| state.open_diff_for_turn(index, cx)); - } - - #[cfg(feature = "desktop")] - fn open_diff_for_file(&mut self, index: usize, path: String, cx: &mut Context) { - self.app_state - .update(cx, |state, cx| state.open_diff_for_file(index, path, cx)); - } - - fn copy_plan(&mut self, markdown: String, cx: &mut Context) { - #[cfg(feature = "desktop")] - self.app_state - .update(cx, |state, cx| state.copy_plan(markdown, cx)); - #[cfg(not(feature = "desktop"))] - cx.write_to_clipboard(copy_payload(&markdown)); - self.mark_copied("plan".into(), cx); - } - - #[cfg(feature = "desktop")] - fn download_plan(&mut self, markdown: String, fallback: String, cx: &mut Context) { - self.app_state - .update(cx, |state, cx| state.download_plan(markdown, fallback, cx)); - } - - #[cfg(feature = "desktop")] - fn save_plan_to_workspace(&mut self, markdown: String, cx: &mut Context) { - self.app_state - .update(cx, |state, cx| state.save_plan_to_workspace(markdown, cx)); - } - /// The finished turn's bottom row: the local completion clock, followed by /// the wall-clock breakdown when the turn's events supported deriving one. fn render_timestamp( @@ -2624,10 +2405,8 @@ impl ChatView { // -- top-level surfaces ------------------------------------------------- - #[cfg(feature = "desktop")] fn render_header( &self, - read_model: &ChatReadModel, title: Option, is_draft: bool, cwd: Option, @@ -2639,13 +2418,16 @@ impl ChatView { // the row's leading content (the sidebar toggle) is inset past them — // but only when the platform actually draws them: they are hidden in // fullscreen, and other platforms never had them. - let collapsed = read_model.sidebar_collapsed; + let collapsed = self.app_state.read(cx).sidebar_collapsed; let clears_traffic_lights = cfg!(target_os = "macos") && collapsed && !window.is_fullscreen(); // Windows: with no right panel open this header is the window's // top-right corner, so it hosts the caption buttons — flush to the // right edge, past the header's usual inset. - let hosts_caption = read_model.chat_hosts_caption; + let hosts_caption = window_caption::hosts_caption( + window_caption::CaptionSurface::Chat, + self.app_state.read(cx), + ); let base = h_flex() .flex_shrink_0() .h(px(52.)) @@ -2718,15 +2500,18 @@ impl ChatView { // The right-side cluster (Open split-button + panel toggles) shows for // any active thread, including a draft. let show_actions = is_draft || title.is_some(); - let diff_showing = read_model.diff_showing; - let plan_showing = read_model.plan_showing; - let preview_showing = read_model.preview_showing; - let terminal_open = read_model.terminal_open; + let diff_showing = { + let state = self.app_state.read(cx); + state.diff_panel_open() && state.right_tab() == RightTab::Diff + }; + let plan_showing = self.app_state.read(cx).plan_panel_showing(); + let preview_showing = self.app_state.read(cx).preview_panel_showing(); + let terminal_open = self.app_state.read(cx).terminal_panel_open(); window_drag_area("chat-header-drag", base, window, cx) .child(sidebar_toggle) .child(window_caption::drag_region(title_el)) .when(show_actions, |this| { - this.children(self.render_git_button(read_model, cx)) + this.children(self.render_git_button(cx)) .children(cwd.clone().map(|cwd| self.render_open_button(cwd, cx))) .child( h_flex() @@ -2797,15 +2582,10 @@ impl ChatView { /// action follows the background git status (Commit / Commit & push / Push / /// Pull / Publish branch / Initialize Git, or a disabled status hint); the /// chevron lists the applicable subset. Ported from T3's `GitActionsControl`. - #[cfg(feature = "desktop")] - fn render_git_button( - &self, - read_model: &ChatReadModel, - cx: &mut Context, - ) -> Option { - let quick = read_model.git_quick_action.clone()?; + fn render_git_button(&self, cx: &mut Context) -> Option { + let quick = self.app_state.read(cx).git_quick_action()?; let border = cx.theme().border; - let items = read_model.git_menu_items.clone(); + let items = self.app_state.read(cx).git_menu_items(); // Main action segment. let label: SharedString = tcode_i18n::tr!(git_action_label_key(quick.label)) @@ -2937,7 +2717,6 @@ impl ChatView { /// Dispatch a git quick-action: commit-style actions open the commit dialog; /// everything else runs in the background with a progress toast. - #[cfg(feature = "desktop")] fn trigger_git_action( &mut self, action: GitAction, @@ -2954,7 +2733,6 @@ impl ChatView { } /// Open the commit dialog for `action` (Commit or Commit & push). - #[cfg(feature = "desktop")] fn open_commit_dialog( &mut self, action: GitAction, @@ -2979,7 +2757,6 @@ impl ChatView { /// The bordered "Open" split-button: main click opens the session cwd in /// Zed; the chevron opens a menu (Zed / Finder / Copy path). Matches T3's /// header control. - #[cfg(feature = "desktop")] fn render_open_button(&self, cwd: PathBuf, cx: &mut Context) -> AnyElement { let border = cx.theme().border; let main_cwd = cwd.clone(); @@ -3103,7 +2880,6 @@ impl ChatView { .into_any_element() } - #[cfg(feature = "desktop")] fn render_empty_state(&self, cx: &mut Context) -> AnyElement { v_flex() .flex_1() @@ -3148,7 +2924,6 @@ impl ChatView { } } -#[cfg(feature = "desktop")] impl Render for ChatView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Screenshot-only: `--debug-git-dialog` opens the commit dialog once the @@ -3165,24 +2940,35 @@ impl Render for ChatView { self.open_commit_dialog(GitAction::Commit, window, cx); } - let read_model = self.read_model.clone(); + let active = { + let state = self.app_state.read(cx); + state.active.as_ref().map(|active| { + ( + active.meta.title.clone(), + active.meta.cwd.clone(), + active.draft, + ) + }) + }; let root = v_flex().size_full().min_w_0().bg(cx.theme().background); - let Some(active) = read_model.active.as_ref() else { + let Some((title, cwd, is_draft)) = active else { return root - .child(self.render_header(&read_model, None, false, None, window, cx)) + .child(self.render_header(None, false, None, window, cx)) .child(self.render_empty_state(cx)); }; - let cwd = active.cwd.clone(); - let is_draft = active.is_draft; - let title = active.title.clone(); let title = if is_draft { None } else { Some(title) }; - let header = - self.render_header(&read_model, title, is_draft, Some(cwd.clone()), window, cx); - let terminal_open = read_model.terminal_open; - let terminal_height = active.terminal_height; + let header = self.render_header(title, is_draft, Some(cwd.clone()), window, cx); + let terminal_open = self.app_state.read(cx).terminal_panel_open(); + let terminal_height = self + .app_state + .read(cx) + .active + .as_ref() + .map(|a| a.terminal_workspace.height) + .unwrap_or(240.); // Group entries by turn and render each turn section into the centered // content column. The column fills the available width up to @@ -3192,7 +2978,13 @@ impl Render for ChatView { // The newest user / assistant message: their action rows stay visible // (hover is not the only way to reach Copy / native rewind). let (last_user_id, last_assistant_id) = { - let entries = &active.timeline.entries; + let state = self.app_state.read(cx); + let entries = &state + .active + .as_ref() + .expect("active session") + .timeline + .entries; ( entries .iter() @@ -3209,7 +3001,6 @@ impl Render for ChatView { let item_count = self.turn_items.len(); let item_cwd = cwd.clone(); - let item_read_model = read_model.clone(); let timeline = list( self.list_state.clone(), cx.processor(move |this, index: usize, window, cx| { @@ -3217,21 +3008,24 @@ impl Render for ChatView { return div().into_any_element(); }; // Clone only the handful of entries in this visible/overdrawn - // turn. The full read snapshot remains shared by the render path. - let Some(active) = item_read_model.active.as_ref() else { + // turn. The full history remains in AppState and is never + // cloned by the render path. + let Some((turn, entries)) = this.app_state.read(cx).active.as_ref().map(|active| { + ( + active + .timeline + .turns + .get(index) + .cloned() + .unwrap_or_default(), + active.timeline.entries[item.entry_range.clone()].to_vec(), + ) + }) else { return div().into_any_element(); }; - let turn = active - .timeline - .turns - .get(index) - .cloned() - .unwrap_or_default(); - let entries = active.timeline.entries[item.entry_range.clone()].to_vec(); let rendered = this.render_turn( index, &turn, - active, &item_cwd, &entries, (last_user_id.as_deref(), last_assistant_id.as_deref()), @@ -3299,76 +3093,6 @@ impl Render for ChatView { } } -#[cfg(all(feature = "portable", not(feature = "desktop")))] -impl Render for ChatView { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - let read_model = self.read_model.clone(); - let root = v_flex().size_full().min_w_0().bg(cx.theme().background); - let Some(active) = read_model.active.as_ref() else { - return root.into_any_element(); - }; - let cwd = active.cwd.clone(); - let (last_user_id, last_assistant_id) = { - let entries = &active.timeline.entries; - ( - entries - .iter() - .rev() - .find(|entry| matches!(entry.content, EntryContent::User { .. })) - .map(|entry| entry.id.clone()), - entries - .iter() - .rev() - .find(|entry| matches!(entry.content, EntryContent::Assistant { .. })) - .map(|entry| entry.id.clone()), - ) - }; - let item_count = self.turn_items.len(); - let item_cwd = cwd; - let item_read_model = read_model; - let timeline = list( - self.list_state.clone(), - cx.processor(move |this, index: usize, window, cx| { - let Some(item) = this.turn_items.get(index) else { - return div().into_any_element(); - }; - let Some(active) = item_read_model.active.as_ref() else { - return div().into_any_element(); - }; - let turn = active - .timeline - .turns - .get(index) - .cloned() - .unwrap_or_default(); - let entries = active.timeline.entries[item.entry_range.clone()].to_vec(); - let rendered = this.render_turn( - index, - &turn, - active, - &item_cwd, - &entries, - (last_user_id.as_deref(), last_assistant_id.as_deref()), - window, - cx, - ); - h_flex() - .w_full() - .justify_center() - .px(px(CONTENT_MIN_PADDING)) - .when(index + 1 < item_count, |item| item.pb(px(TURN_GAP))) - .child(div().w_full().max_w(px(CONTENT_MAX_WIDTH)).child(rendered)) - .into_any_element() - }), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_1() - .min_h_0() - .py_6(); - root.child(timeline).into_any_element() - } -} - // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -3411,7 +3135,6 @@ fn chevron(open: bool) -> IconName { /// Launch `zed ` detached; surface a notification if the CLI is missing. /// The leading icon for a git quick-action. -#[cfg(feature = "desktop")] fn git_action_icon(action: GitAction) -> Icon { match action { GitAction::Push => Icon::new(IconName::ArrowUp), @@ -3422,7 +3145,6 @@ fn git_action_icon(action: GitAction) -> Icon { /// The commit dialog's footer action row (Cancel / Commit[& push]). Built inside /// the `open_dialog` builder so the buttons can close the dialog on click. -#[cfg(feature = "desktop")] fn render_commit_footer( dialog: &Entity, _window: &mut Window, @@ -3458,7 +3180,6 @@ fn render_commit_footer( .into_any_element() } -#[cfg(feature = "desktop")] fn open_in_zed(cwd: &Path, window: &mut Window, cx: &mut App) { if tcode_runtime::ui_facade::open_in_zed(cwd).is_err() { window.push_notification( @@ -3470,7 +3191,6 @@ fn open_in_zed(cwd: &Path, window: &mut Window, cx: &mut App) { /// Reveal `cwd` in the platform's file manager (Finder / Explorer / the XDG /// file manager). gpui does the platform dispatch, so no shell-out is needed. -#[cfg(feature = "desktop")] fn reveal_in_file_manager(cwd: &Path, cx: &mut App) { cx.reveal_path(cwd); } @@ -3673,7 +3393,7 @@ fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec { changes .iter() .map(|change| LiveEditRow { - path: relativize_to_workspace(&change.path, cwd), + path: tcode_runtime::ui_facade::relativize_to_workspace(&change.path, cwd), counts: live_edit_counts(change.diff.as_deref()), }) .collect() @@ -3717,7 +3437,7 @@ fn work_log_row_entries<'a>( fn group_by_dir(changes: &[FileChange], cwd: &Path) -> Vec<(String, Vec)> { let mut groups: Vec<(String, Vec)> = Vec::new(); for change in changes { - let display = relativize_to_workspace(&change.path, cwd); + let display = tcode_runtime::ui_facade::relativize_to_workspace(&change.path, cwd); let (dir, name) = match display.rsplit_once('/') { Some((dir, name)) => (dir.to_string(), name.to_string()), None => (String::new(), display.clone()), @@ -3738,16 +3458,6 @@ fn group_by_dir(changes: &[FileChange], cwd: &Path) -> Vec<(String, Vec groups } -fn relativize_to_workspace(path: &str, cwd: &Path) -> String { - let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); - let path_buf = Path::new(path); - path_buf - .strip_prefix(cwd) - .or_else(|_| path_buf.strip_prefix(&canonical_cwd)) - .map(|relative| relative.to_string_lossy().into_owned()) - .unwrap_or_else(|_| path.to_string()) -} - fn diff_counts(added: u32, deleted: u32, cx: &Context) -> AnyElement { diff_counts_colored(added, deleted, cx.theme().success, cx.theme().danger).into_any_element() } @@ -4538,7 +4248,6 @@ This begins after the hard break."#; .iter() .map(|path| FileChange { path: (*path).to_string(), - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }) @@ -4676,19 +4385,16 @@ This begins after the hard break."#; let changes = vec![ FileChange { path: "/work/repo/src/foo.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }, FileChange { path: "/work/repo/crates/ui/src/chat.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: None, }, FileChange { path: "/elsewhere/vendor/bar.rs".into(), - workspace_path: None, kind: FileChangeKind::Create, diff: None, }, @@ -4747,13 +4453,11 @@ This begins after the hard break."#; let changes = vec![ FileChange { path: "/work/repo/src/foo.rs".into(), - workspace_path: None, kind: FileChangeKind::Modify, diff: Some(REAL_DIFF.into()), }, FileChange { path: "/work/repo/src/bar.rs".into(), - workspace_path: None, kind: FileChangeKind::Create, diff: Some(String::new()), }, diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index c28eb353..a17a8384 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -1314,7 +1314,6 @@ impl Composer { media_type: mime_from_path(&image.path), data_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), source_path: Some(image.path.to_string_lossy().into_owned()), - workspace_path: None, }) }) .collect() diff --git a/crates/ui/src/diff/mod.rs b/crates/ui/src/diff/mod.rs index 39fb413a..111fa469 100644 --- a/crates/ui/src/diff/mod.rs +++ b/crates/ui/src/diff/mod.rs @@ -1,9 +1,6 @@ pub mod algorithm; pub mod model; pub mod parse; -// The panel owns desktop AppState; portable clients consume the diff model. -#[cfg(feature = "desktop")] mod view; -#[cfg(feature = "desktop")] pub use view::DiffPanel; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 4f57bf8e..92809d0e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,68 +1,38 @@ -#[cfg(feature = "desktop")] mod acp_panel; -#[cfg(feature = "desktop")] mod add_project_dialog; pub mod assets; mod attachments; mod chat; -#[cfg(feature = "desktop")] mod commit_dialog; -#[cfg(feature = "desktop")] mod composer; -#[cfg(feature = "desktop")] mod composer_trigger; -#[cfg(feature = "desktop")] mod context_meter; pub mod diff; -#[cfg(feature = "desktop")] pub mod git; mod highlight; pub mod markdown; pub mod material; -#[cfg(feature = "desktop")] mod orchestrate_settings; -#[cfg(feature = "desktop")] mod palette; -#[cfg(feature = "desktop")] mod pasteboard; -#[cfg(feature = "desktop")] mod plan_panel; -#[cfg(feature = "desktop")] mod preview_panel; -#[cfg(feature = "desktop")] pub mod provider_card; -#[cfg(feature = "desktop")] mod provider_dialog; -#[cfg(feature = "desktop")] mod provider_model_picker; -#[cfg(feature = "desktop")] pub mod provider_models; -#[cfg(feature = "desktop")] pub mod provider_status; -#[cfg(feature = "desktop")] pub mod runtime_event; -#[cfg(feature = "desktop")] pub mod settings; -#[cfg(feature = "desktop")] mod settings_page; -#[cfg(feature = "desktop")] mod shell; -#[cfg(feature = "desktop")] mod shortcut; -#[cfg(feature = "desktop")] mod sidebar; -#[cfg(feature = "desktop")] mod terminal_drawer; pub mod time; -#[cfg(feature = "desktop")] pub(crate) mod toast; -#[cfg(feature = "desktop")] mod window_caption; -#[cfg(feature = "desktop")] mod workspace_walk; -pub use chat::{ChatReadModel, ChatSessionReadModel, ChatView}; -#[cfg(feature = "desktop")] pub(crate) use shell::window_drag_area; -#[cfg(feature = "desktop")] pub use shell::{AppShell, Quit, TogglePalette}; diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs index cb97d8a2..30c2d9ad 100644 --- a/crates/ui/src/markdown/view.rs +++ b/crates/ui/src/markdown/view.rs @@ -206,9 +206,9 @@ impl Element for MarkdownView { ), LinkTarget::Local(path) => { let path = path.to_string_lossy().into_owned(); - let relative_path = markdown - .base_dir() - .map(|base_dir| relativize_to_workspace(&path, base_dir)); + let relative_path = markdown.base_dir().map(|base_dir| { + tcode_runtime::ui_facade::relativize_to_workspace(&path, base_dir) + }); menu.menu( tcode_i18n::tr!("chat.open").into_owned(), Box::new(OpenPath(path.clone())), @@ -289,7 +289,6 @@ impl Element for MarkdownView { } } -#[cfg(feature = "desktop")] fn open_in_zed(path: &Path, window: &mut Window, cx: &mut App) { if tcode_runtime::ui_facade::open_in_zed(path).is_err() { window.push_notification( @@ -299,21 +298,6 @@ fn open_in_zed(path: &Path, window: &mut Window, cx: &mut App) { } } -// Local-path launching is host-owned; portable builds keep link rendering and -// copying but cannot dispatch a desktop editor process. -#[cfg(not(feature = "desktop"))] -fn open_in_zed(_path: &Path, _window: &mut Window, _cx: &mut App) {} - -fn relativize_to_workspace(path: &str, cwd: &Path) -> String { - let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); - let path_buf = Path::new(path); - path_buf - .strip_prefix(cwd) - .or_else(|_| path_buf.strip_prefix(&canonical_cwd)) - .map(|relative| relative.to_string_lossy().into_owned()) - .unwrap_or_else(|_| path.to_string()) -} - #[cfg(test)] mod tests { use gpui::{ diff --git a/crates/ui/src/preview_panel.rs b/crates/ui/src/preview_panel.rs index 2fec3503..65c89c8b 100644 --- a/crates/ui/src/preview_panel.rs +++ b/crates/ui/src/preview_panel.rs @@ -10,23 +10,14 @@ //! //! ## Platform support //! -//! **macOS + Windows get the real WebView; every other target gets the -//! placeholder.** The gate is spelled `any(target_os = "macos", target_os = -//! "windows")` rather than `not(target_os = "linux")` because the latter also -//! matches Android, iOS and wasm — which have no `target_os = "linux"` and -//! would therefore have pulled in native `wry` as "desktop". -//! -//! Linux is excluded because lb-wry's `build_as_child` is X11-only there *and* -//! requires a GTK main loop (`gtk::init` plus `gtk::main_iteration_do` pumped on -//! the UI thread), while gpui's Linux backend runs calloop/xcb and never pumps -//! GTK — the webview would panic at construction and could never be driven. -//! Mobile and wasm are excluded because a native child view drawn over the gpui -//! window is not a concept those platforms offer at all. -//! -//! So `wry`/`gpui-wry` are not even dependencies off macOS/Windows (see the -//! matching target table in Cargo.toml); the tab renders a placeholder and every -//! `preview_*` MCP tool answers with an error. The MCP server itself still -//! starts, harmlessly. +//! macOS + Windows get the real WebView. **Linux does not**: lb-wry's +//! `build_as_child` is X11-only there *and* requires a GTK main loop (`gtk::init` +//! plus `gtk::main_iteration_do` pumped on the UI thread), while gpui's Linux +//! backend runs calloop/xcb and never pumps GTK — the webview would panic at +//! construction and could never be driven. So `wry`/`gpui-wry` are not even +//! dependencies on Linux (see the `[target.'cfg(not(target_os = "linux"))']` +//! table in Cargo.toml); the tab renders a placeholder and every `preview_*` MCP +//! tool answers with an error. The MCP server itself still starts, harmlessly. //! //! ## Known caveat — native overlay //! @@ -40,10 +31,10 @@ //! known limitation (documented, not fixed). use preview_mcp::PreviewReply; -#[cfg(any(target_os = "macos", target_os = "windows", test))] +#[cfg(any(not(target_os = "linux"), test))] use tcode_runtime::app::Route; -#[cfg(any(target_os = "macos", target_os = "windows", test))] +#[cfg(any(not(target_os = "linux"), test))] fn visible_preview_key( active_key: Option<&str>, route: Route, @@ -58,7 +49,7 @@ fn visible_preview_key( /// Resolve an MCP request's physical session id to the stable WebView key. /// Only the active surface can be an unsent project draft; every background /// request therefore keys directly by its stored session id. -#[cfg(any(target_os = "macos", target_os = "windows", test))] +#[cfg(any(not(target_os = "linux"), test))] fn preview_key_for_session( requested_session_id: &str, active_session_id: Option<&str>, @@ -74,13 +65,13 @@ fn preview_key_for_session( /// The reply channel a broker request is answered on. type ReplyTx = async_channel::Sender>; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(not(target_os = "linux"))] pub use native::PreviewPanel; -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +#[cfg(target_os = "linux")] pub use placeholder::PreviewPanel; -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(not(target_os = "linux"))] mod native { use std::collections::{HashMap, HashSet}; use std::time::Duration; @@ -829,11 +820,10 @@ mod native { } } -/// Linux, Android, iOS and wasm: no WebView (see the module docs). The tab still -/// exists — it renders a muted placeholder — and the preview MCP server still -/// starts, but every tool call answers with an error instead of driving a -/// browser that cannot exist. -#[cfg(not(any(target_os = "macos", target_os = "windows")))] +/// Linux: no WebView (see the module docs). The tab still exists — it renders a +/// muted placeholder — and the preview MCP server still starts, but every tool +/// call answers with an error instead of driving a browser that cannot exist. +#[cfg(target_os = "linux")] mod placeholder { use gpui::{Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Window}; use gpui_component::{ActiveTheme as _, v_flex}; @@ -887,8 +877,8 @@ mod placeholder { } /// The error `preview_screenshot` reports where native-webview snapshots have no -/// implementation. Targets without a webview at all never get this far. -#[cfg(any(target_os = "macos", target_os = "windows"))] +/// implementation. Linux has no webview at all, so it never gets this far. +#[cfg(not(target_os = "linux"))] #[cfg_attr(target_os = "macos", allow(dead_code))] const SCREENSHOT_UNSUPPORTED: &str = "preview_screenshot is only supported on macOS"; @@ -921,7 +911,7 @@ fn snapshot_reply(image: &objc2_app_kit::NSImage) -> Result String { format!( "the preview browser is unavailable on this machine \ @@ -930,7 +920,7 @@ fn unavailable_message(err: &str) -> String { } /// Add a scheme to a bare host/port (so `localhost:5173` becomes a real URL). -#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] +#[cfg_attr(target_os = "linux", allow(dead_code))] fn normalize_url(input: &str) -> String { let trimmed = input.trim(); if trimmed.contains("://") || trimmed.starts_with("about:") { @@ -1006,7 +996,7 @@ mod tests { /// Off macOS (but where a webview exists — i.e. Windows) `preview_screenshot` /// surfaces a plain tool error instead of a broken capture. On Linux there is /// no webview at all and the whole panel is a placeholder. - #[cfg(target_os = "windows")] + #[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] #[test] fn screenshot_is_unsupported_off_macos() { assert!(SCREENSHOT_UNSUPPORTED.contains("only supported on macOS")); diff --git a/docs/multiplatform-plan.md b/docs/multiplatform-plan.md deleted file mode 100644 index 535c3088..00000000 --- a/docs/multiplatform-plan.md +++ /dev/null @@ -1,697 +0,0 @@ -# tcode 多端预案:移动端、Web 端与远程协同 - -本文是移动 app(Android/iOS)、wasm 网页版、多端同步三项工作的**总预案**。 -它记录已验证的事实、架构决策、分期与闸门、风险与退出条件。 - -结论先行:**这三件事不是三个并列的功能,而是一条强制串行的依赖链。** -同步层是另外两者的地基,不是收尾。 - -侦察基准:zed rev `1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba`(2026-07-16,zed -v1.13.0,gpui 0.2.2),gpui-component rev `0315556`。本文所有事实性断言均在 -该基准上实测得出;与代码不符时,**明确地**修正其中之一。 - ---- - -## 1. 决定一切的那条事实 - -**手机和浏览器上永远跑不了 agent。** - -tcode 的核心机制是 spawn 本地 CLI 子进程并用 stdio 管道对话。五个 provider -全部如此: - -| provider | 机制 | 位置 | -| --- | --- | --- | -| Claude | `claude --print --input-format stream-json` + 三条管道 | `crates/agent/src/claude.rs:119` | -| Codex | `codex app-server` + NDJSON 管道 | `crates/agent/src/codex.rs:642` | -| pi | `pi --mode rpc` + LF 分帧 JSON | `crates/agent/src/pi.rs:224` | -| OpenCode | spawn `opencode serve` + 回环 TCP + SSE | `crates/agent/src/opencode.rs:1247` | -| ACP | 子进程 stdio 上的 JSON-RPC,且**代其执行命令** | `crates/agent/src/acp.rs:137`、`:2560` | - -iOS 禁止 `fork`/`exec` 且无第三方 CLI 安装模型;wasm 根本没有进程概念。 -`crates/term` 的 PTY + 登录 shell + `tcgetpgrp`(`term/src/lib.rs:322`)、 -`services/git.rs:63` 的 shell-out git、`acp_registry.rs:291` 的 -下载→解压→chmod→执行,同理全部不可移植。 - -所以移动端与 Web 端**只能是远程客户端**,桌面机(或用户自己的服务器)当 host。 - -``` -多端同步(host/client 拆分) - ├──► 移动端 app(Android / iOS)──► 移动端 UI 完善 - └──► wasm 网页版 -``` - -任何"先做移动端、同步以后再补"的排期都会在第一周撞墙:没有 host,移动端 -app 打开后是一个什么都做不了的空壳。 - ---- - -## 2. 好消息:三个前提条件都已经成立 - -侦察推翻了三个原本以为要从零做的假设。 - -### 2.1 上游已有 wasm 后端 - -`crates/gpui_web/` 是一个 3010 行的可运行 `WebPlatform`:异步初始化 WebGPU、 -创建 canvas 与隐藏 input、跟踪 DPR/resize、`requestAnimationFrame` 驱动帧、 -翻译键鼠/滚轮/拖放事件、经 `gpui_wgpu` 渲染 -(`gpui_web/src/platform.rs:60`、`window.rs:73`)。基于隐藏 `` + -DOM composition 事件的 **IME 是真的**(`window.rs:119`、`events.rs:465`)。 -`gpui_platform` 已在 `cfg(target_family = "wasm")` 下分发到它。 -上游 CI 会对 wasm 做编译检查(`.github/workflows/run_tests.yml:663`)。 - -**但它是一个有分量的 spike,不是生产级。** 已确认的缺口: - -- `Platform` 桩:`quit`/`restart`/`activate`/`hide`/`reveal_path`/`set_menus` - 等为空操作;文件对话框与可执行路径返回错误(`platform.rs:138`、`:209`)。 -- 剪贴板读取恒为 `None`;凭据读 `None`、写/删返回错误(`platform.rs:341`、`:355`)。 -- URL/reopen/menu/thermal/layout 回调**存了但无人触发**(`platform.rs:48`)。 -- `PlatformWindow` 桩:`prompt`、最小化、缩放、IME 位置、窗口装饰(`window.rs:566`)。 -- **触摸被无条件转成鼠标事件**,不检查 `pointerType`(`events.rs:133`)。 -- dispatcher 有 5 处未决的 `TODO-Wasm`,含邮箱同步与优先级调度(`dispatcher.rs:31`)。 -- **`crates/gpui_web` 下没有任何 `#[test]` 或浏览器测试。** - -### 2.2 上游已有可移植渲染器与文字系统 - -`crates/gpui_wgpu/`(4081 行)提供 `WgpuContext`/`WgpuRenderer`/`WgpuAtlas` 与 -`CosmicTextSystem`(cosmic-text 分词整形 + swash 度量光栅)。Linux 的 X11/Wayland -后端已经在用它(`gpui_linux/src/linux/x11/window.rs:1703`)。 - -GPUI **没有渲染器 trait**;抽象边界就是 `PlatformWindow::draw(&Scene)` 加 -`sprite_atlas()`(`gpui/src/platform.rs:750`)。原生构造函数接受任意 -`HasWindowHandle + HasDisplayHandle`(`wgpu_renderer.rs:176`)。 - -**关键信号**:`WgpuRenderer` 里已经有 `unconfigure_surface`/`replace_surface`, -注释明写是为 Android 原生窗口销毁/重建准备的(`wgpu_renderer.rs:1690`)。 - -也就是说 **Android 后端不需要重写渲染,也不需要重写文字排版**。这正是 -`gpui-mobile` 当年手搓的那套(wgpu + cosmic-text + swash)——上游现在自带了。 - -### 2.3 上游正在主动为移动端做准备 - -`gpui::Platform` / `PlatformWindow` 里已有成体系的移动端词汇: - -- `AppLifecyclePhase { Active, Inactive, Background, Foreground }`,文档里带 - **iOS/Android 回调对照表**(`platform.rs:647`)。 -- `WindowInsets { safe_area, ime }`,注释直接点名 iOS `safeAreaInsets` 与 - Android `WindowInsets.Type.ime()`(`platform.rs:672`)。 -- `on_memory_warning`,点名 `didReceiveMemoryWarning` / `onTrimMemory`(`platform.rs:211`)。 -- `TouchEvent { id, phase, position, force }`、`TouchId`、`TouchPhase`、 - `PlatformGestures`(`interactive.rs:85`)。 -- Android 返回键、软键盘显隐、文本状态通知(`platform.rs:822`)。 - -**这些绝大多数带默认实现,且核心尚未接线**:`App::new_app` 注册了键盘布局、 -热状态、退出回调,但**没有注册生命周期与内存回调**(`app.rs:858`); -`TouchEvent` 自己的文档写着分发"实现待定",窗口层记录了触摸模态却不派发 -(`window.rs:4721`);`insets`/`on_insets_changed` 在声明之外找不到消费者。 - -**判读**:Zed 正在把 GPUI 往移动端推,词汇先落地、接线在后。这对我们是**顺风**, -但也意味着**移动端 trait 表面是移动靶**。战略含义见 §7。 - -### 2.4 gpui-component 官方支持 WASM - -就在 tcode 当前 pin 的 rev 上:`crates/story-web` 存在,README 支持矩阵写 -`Web | Yes (WASM)`,非 wasm 依赖已做 target 门控。代价:**tree-sitter 在 wasm -下被整体去掉**——但 tcode 用的是 syntect,不受影响。 - ---- - -## 3. 目标架构 - -### 3.1 拆分接缝 - -侦察确认接缝**已经天然存在**,不需要发明: - -```rust -// crates/agent/src/lib.rs:615 -pub struct SessionHandle { - pub provider: ProviderKind, - pub commands: async_channel::Sender, - pub events: async_channel::Receiver, -} -``` - -五个 provider 全部归一化进同一个 `AgentEvent` 流(32 个变体, -`lib.rs:652`),且**已经是 `Serialize + Deserialize`、`#[serde(tag="type")]`**。 -`SessionCommand`(`lib.rs:548`)只差两个 derive,payload 类型均可序列化。 -`Timeline::fold_events`(`core/src/session.rs:558`)是纯 reducer,客户端可以 -原样重放出与本地完全一致的 Timeline。 - -**线协议基本是白送的。** - -### 3.2 crate 布局 - -区分两层,这一点不能混:**GPUI 后端是通用的、可上游化的;app 外壳是 tcode 专属的。** - -``` -crates/ - # ── 现有,需要拆分 ── - agent/ → agent-protocol(纯类型,全平台) + agent-local(spawn,桌面) - services/ → services-core(纯逻辑) + services-desktop(fs/git/进程) - runtime/ → runtime-core(状态与 fold) + runtime-host(进程/PTY/存储属主) - ui/ → ui-core(领域渲染,复用) + ui-desktop + ui-mobile - term/ → 不动,永远桌面专属 - {preview,orchestrate,computer-use}-mcp/ → 逻辑与 HTTP 服务器分离 - - # ── 新增:同步层 ── - sync-protocol/ 纯类型 + 编解码,全平台 - sync-host/ 桌面侧服务端 - sync-client/ 客户端,全平台(含 wasm) - - # ── 新增:GPUI 后端(上游候选,与 tcode 无关)── - gpui-android/ Platform 实现 · NDK/NativeActivity · JNI - gpui-ios/ Platform 实现 · UIKit · Metal - - # ── 新增:三端共用的客户端 UI ── - client-app/ ClientApp + Transport trait,全平台 - - # ── 新增:app 外壳 ── - tcode-android/ cdylib + Gradle 工程 - tcode-ios/ staticlib + Xcode 工程 - tcode-web/ cdylib + Trunk -``` - -**`client-app` 刻意不是工作区成员。** 它依赖 `tcode-ui` 的 `portable` feature, -而那与 `desktop` 是**互斥模式**而非可叠加的层:开了 `desktop` 的 `ChatView` 持有 -`AppState` 和 composer,远程客户端无从提供。Cargo 会在工作区构建内统一 feature, -所以把它列为成员会让 `tcode-ui` 同时开启两者,两个构造函数都无法满足。 - -三个 shell 各自在 **target 表内**按路径依赖它,因此它只为 wasm/Android/iOS 构建, -从不为宿主构建。单独检查:`cargo check -p client-app --target wasm32-unknown-unknown`。 -两个工作区清单守卫为它加了显式豁免——一个刻意的非成员应当被写明,而不是靠沉默。 - -把 `gpui-android`/`gpui-ios` 和 `tcode-android`/`tcode-ios` 分开,是为了让后端 -能独立于 tcode 演进、独立于 tcode 被上游接纳。合在一起会永久绑死。 - -### 3.3 同步模型:单写者,不做 CRDT - -**host 是唯一权威。客户端是瘦的。** - -- host → client:带**单调 `seq`** 的 `AgentEvent` 流(游标续传)。 -- client → host:`SessionCommand` + 新增的 `RuntimeCommand`(app 级操作:开线程、 - 归档、git、设置……候选面在 `AppState` 的 209 个 public 方法里,需筛选)。 -- client 本地只保留纯展示态(滚动位置、面板开合、草稿文本)。 - -**明确不做 CRDT、不做离线编辑合并。** 客户端离线时排队用户输入并在重连后重放, -冲突就报错让用户重试。做 CRDT 会让工程量翻倍且收益存疑——agent 会话本质上是 -单写者的。 - -### 3.4 必须补的两个洞 - -1. **没有序列号。** 事件信封只有 Unix 毫秒 `ts`,且代码明确容忍时间戳相等或 - 回退(`services/src/store.rs:25`)——**时间戳不构成严格序**。必须加持久化的 - per-session 单调 `seq`,否则断线续传无从谈起。这是对现有 JSONL 格式的 - 向后兼容扩展(旧行 `seq` 缺省按文件位置补齐)。 - -2. **机器局部状态泄漏。** 原先以为问题在持久化索引结构里,**实测推翻了这个判断**。 - - 对本机 120 个真实会话日志按事件类型统计"含主机绝对路径"的比例: - - | 事件类型 | 含绝对路径 | 总数 | - | --- | ---: | ---: | - | `approval_requested` | 288 | 300(96%) | - | `item_completed` | 8047 | 27344(29%) | - | `steer_requested` | 31 | 52(60%) | - | `item_started` | 1284 | 15463(8%) | - | `delta` | 955 | 260584 | - | `turn_started` / `turn_completed` / `session_started` / `token_usage` | 0 | — | - - 下表那些字段**从不过线**:`SessionSummary` 已经只传项目目录名(Gate 1 实测断言过 host 路径不出现在编码结果里)。真正的泄漏全部在 **`AgentEvent` 的载荷内部**,而且最集中的是 `approval_requested`——恰好是手机客户端最需要看的那个事件。 - - **因此目标要重述。** "把主机路径藏起来"既做不到也不是重点:助手会在散文里直接写路径,工具调用的 JSON 是自由格式,强行改写会破坏语义。而且能驱动 agent 的客户端本来就已经被授权到那个程度。 - - 真正要解决的是**路径在客户端上没有意义**:手机应该显示 `src/main.rs` 而不是 `/Users/tryanks/proj/src/main.rs`,并且能把路径关联到项目。所以: - - - **类型化字段**(`ApprovalKind::ExecCommand.cwd`、`FileChange.path`)→ 规范化为 host 相对路径 + workspace 标识。可做且正确。 - - **自由格式内容**(工具 JSON、助手散文)→ 原样透传,客户端按原样渲染。这是被接受的现状,不是待办。 - - 下面这些字段仍需处理,但因为**第二台设备打开会话时它们失效**,而不是因为泄漏: - - | 字段 | 位置 | - | --- | --- | - | `Project.root` | `core/src/project.rs:14` | - | `SessionMeta.cwd` | `core/src/project.rs:47` | - | `WorktreeInfo.root_project_path` | `core/src/project.rs:70` | - | provider `binary_path` / `home_path` | `core/src/settings.rs:84` | - | `AcpLaunch::Binary.command` | `agent/src/lib.rs:80` | - | `Attachment.source_path` | `agent/src/lib.rs:435` | - | `ApprovalKind::ExecCommand.cwd` | `agent/src/lib.rs:965` | - | `ResumeCursor`(provider 私有 JSON,绑定 host 安装/账号) | `agent/src/lib.rs:110` | - ---- - -## 4. 明确的非目标(范围围栏) - -写下来是为了以后有人想加时,必须显式推翻它。 - -- **移动端/Web 端不跑本地 agent。** 永远。 -- **手机上没有可交互终端。** 最多只读回滚屏。原因:无键盘时的原始按键透传 + - h/v 分屏是死路(`terminal_drawer.rs:402`、`:1503`)。 -- **手机上没有内嵌 WebView 预览。** 它是 GPUI 不合成的原生 WKWebView,Linux 上 - 本来就已经是占位符;手机上交给系统浏览器。 -- **不做离线编辑与冲突合并。** -- **移动端不做 computer-use。** 后端只有 macOS 有实现,其余平台是恒返回 - unsupported 的桩(`computer-use-mcp/src/backend.rs:9`)。 -- **不 vendor / fork GPUI。** 见 §7。 -- **手机不做代码编辑器。** tcode 是 agent 客户端,不是移动 IDE。 - ---- - -## 5. 分期与闸门 - -每一期都以**可运行的东西**结束,而不是以"重构完成"结束。每期入口有 gate, -不过 gate 不进下一期。 - -### 期 0 — 地基与止血(无用户可见变化) - -1. **锁死 gpui rev。** 现在 `crates/{app,runtime,ui}/Cargo.toml` 的 gpui git - 依赖**没有任何 `rev`**,只有 Cargo.lock 兜着——一次 `cargo update` 就会把 - GPUI 静默跳到 master。移植期间这是不可接受的。显式 pin,并定一个升级节奏 - (建议双周,单独 PR,单独跑全量 gate)。 -2. **修 `cfg(not(target_os = "linux"))`。** `crates/ui/Cargo.toml:29` 用它门控 - `gpui-wry`/`wry`,这个条件把 **Android/iOS/wasm 全算成了桌面**。改成显式 - 列举 macos/windows。移植第一天就会被它绊倒。 -3. 建立 `cargo check --target wasm32-unknown-unknown` 与 - `aarch64-linux-android` 的 CI 任务(**允许失败**,只作为进度计分板)。 -4. **注意 wasm 工具链约束**:上游 CI 用的是 - `cargo -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown` - 加 `-C target-feature=+atomics,+bulk-memory,+mutable-globals`,即 - **需要 nightly + build-std**,且多线程模式依赖 SharedArrayBuffer - → **部署页面必须带 COOP/COEP 响应头**。这条要在期 0 就确认能接受。 - -**Gate 0**:pin 生效;wasm/android 的 check 任务能跑起来并输出一份错误清单 -(此时必然大量失败,这是基线)。 - -### 期 1 — 协议与 host(最大的一块) - -1. `sync-protocol`:给 `SessionCommand` 加 derive;定义带 `seq` 的事件信封; - 定义 `RuntimeCommand`;定义握手/鉴权/游标续传。 -2. 存储加 `seq`(向后兼容)。 -3. 拆 `agent` → protocol / local。`start_session` 增加 `Remote` 分支。 -4. `sync-host`:**独立 crate,不依赖 GPUI**(这样期 1.5 的无头 `tcode-server` - 只需换一个 `main` 即可复用)。桌面进程内起服务端,鉴权(配对码 + 长期 - token),WebSocket 传输(axum 0.8 自带 `ws`,无新增重依赖)。 -5. 路径与机器身份的规范化(§3.4 第 2 条)。 -6. **协议版本号 + 握手兼容性协商**——这是多端版本漂移的正解(见 §8.1)。 - -**Gate 1**:两个**桌面** tcode 实例,一个当 host、一个当 client, -client 能实时看到 host 上跑的会话并成功发送一个 turn、批准一次权限请求。 -这一步完全不碰 GPUI,用桌面验证协议——**这是整个计划里最重要的一个 gate**。 - -### 期 1.5 — 无头 tcode-server(可与期 2 并行) - -复用期 1 的 `sync-host` crate,只换一个 `main` + 配置/日志/进程管理。 -需要额外解决的:provider CLI 在服务器上的安装与鉴权路径、无 GUI 环境下的 -`shell_env::import_login_shell_environment` 等价物、升级策略。 - -**Gate 1.5**:`tcode-server` 跑在一台远程机器上,桌面 client 连上去完成 -读/发/批准。用户笔记本可以合盖。 - -### 期 2 — Web 客户端(第一个真正的新端) - -1. `services`/`runtime` 拆分,把 fs/进程/git 关到 `-desktop` 后面。 -2. `tcode-web` + Trunk 构建。 -3. `ui` 拆出 `ui-core`。**注意**:`chat.rs`(5156 行) 与 `composer.rs`(5139 行) - 把领域渲染和外壳布局**交织在同一批 `render_*` 函数里**——`markdown/*` 和 - `diff/model.rs` 已经分得很干净,这两个没有。**拆这两个文件是整个 UI 移植的 - 前置闸门**,也是"40% 可复用"是真数字还是纸面数字的分水岭。 -4. 补 `gpui_web` 的缺口(剪贴板、文件、触摸类型判别),能上游的上游。 - -**Gate 2**:浏览器里打开一个 host 上的活跃会话,能读、能发 turn、能批准。 -桌面布局,不做响应式。 - -**读的那一半已达成(2026-07-25,目视确认)。** 浏览器加载 `tcode-web`, -经 WebSocket 连上真实 host,渲染出真实会话的时间线:用户消息气泡、 -助手回复、Copy 按钮、回合计时页脚(`Total 5s · AI thinking & response 5s`), -与桌面端一致。 - -关键在于**那是桌面端的同一个 `ChatView`**,编译成 wasm 后由同步来的 `Timeline` -驱动——浏览器侧没有 `AppState`、没有 runtime、没有任何进程。这正是 §5 期 2 -第 3 项那个"拆分重构"要买的东西,现在兑现了。 - -产物体积(多线程 atomics 构建,即今天真能发布的那个配置): - -| | 体积 | -| --- | ---: | -| 裸 wasm | 15.6 MB | -| `wasm-opt -Oz` | 14.3 MB | -| gzip -9 | 6.0 MB | -| **brotli -q11** | **4.1 MB** | - -`wasm-opt` 只砍 8% 裸体积、brotli 后不变——它去掉的冗余 brotli 本来就压掉了。 -**4.1 MB 是终值**,远在"10MB 以内可接受嵌入"的门槛内,且这是悲观值 -(单线程构建会更小,但见下)。 - -**一个上游 bug 决定了配置。** `gpui_web` 的单线程配置在本文 pin 的 rev 上 -编不过:`shared_memory_supported` 定义在 `#[cfg(feature = "multithreaded")]` -下(`dispatcher.rs:130`)却被无条件调用(`:82`)。已在独立 crate 复现。 -根因是上游 CI 只跑 atomics 那条路径——又一条 §2.1「spike 而非生产级」的证据。 -按 §7 不 fork:记为上游贡献候选,本地走多线程。 - -代价是一条真实部署约束:atomics 需要 SharedArrayBuffer, -**页面必须以 COOP/COEP 跨源隔离方式提供**。 - -**实测的起点(2026-07-25)。** `cargo check --target wasm32-unknown-unknown` 错误数: - -| crate | 起始 | 现在 | -| --- | ---: | ---: | -| `tcode-i18n` | 0 | 0 | -| `sync-protocol` / `sync-client` | — | 0 | -| `tcode-core` | 2 | 0 | -| `agent`(`--no-default-features`) | 7 | 0 | -| `tcode-services`(同上) | 多 | 0 | -| `tcode-ui` | — | **51** | - -`tcode-ui` 那 51 个**全部落在 4 个依赖 crate 里**,没有一个来自 `crates/ui/src/`: -`mio` 48(经 tokio 的 `net` feature,由 `preview-mcp` / `computer-use-mcp` 引入)、 -`polling` 与 `errno`(经 `smol`)、`getrandom`(缺 wasm 的 `js`)。 - -**但不能据此断言 UI 自身可移植。** cargo 先编依赖,`mio` 失败后 `tcode-ui` 本体根本没被检查到。 -门控这四条依赖是必要的下一步,之后才谈得上测量 UI 自身的真实代价—— -而那个数字才是 §6 风险台账里"`chat.rs`/`composer.rs` 拆不开"那一条的真正判据。 - - -### 期 3 — Android - -1. `gpui-android`:`Platform`/`PlatformWindow`/`PlatformDispatcher`/`PlatformDisplay` - 实现,复用 `gpui_wgpu` 的 `WgpuRenderer` + `CosmicTextSystem`。 - 参考骨架用 `gpui_web`(架构最接近),不用 Linux headless。 -2. Activity 生命周期 ↔ `AppLifecyclePhase`;surface 销毁/重建 ↔ - `unconfigure_surface`/`replace_surface`。 -3. 触摸合成为鼠标/滚轮/pinch(因为 `TouchEvent` 的核心分发上游尚未实现)。 -4. 软键盘 + `WindowInsets`;IME 桥接 `PlatformInputHandler`。 -5. `tcode-android` cdylib + Gradle。 - -**期 3a 实测进展(2026-07-25)。** `crates/gpui-android` 已落地 2083 行, -`cargo check -p gpui-android --target aarch64-linux-android` **0 错误**—— -且这次编译顺带证明了 **`gpui` 与 `gpui_wgpu` 本身能为 Android 目标通过类型检查**, -这是 §2.2 那个"Android 只缺平台胶水、不缺渲染器"判断的第一份硬证据。 -`Platform` 覆盖 56/59,`PlatformWindow` 覆盖 49/81,零 `todo!()`。 - -**两条侦察修正:** - -1. **`cargo check` 确实需要 Android C 工具链**(本文档此前假设不需要)。 - `gpui → stacksafe → psm` 在依赖检查阶段就要汇编 AArch64。已装 NDK - 29.0.14206865,API 26,env 变量记在 `crates/gpui-android/README.md`。 -2. GPUI 可复用的优先队列模块被 cfg 门控到 Windows/Linux/wasm/test, - **Android 拿不到**,所以 dispatcher 自带了一份。这是上游贡献的候选项。 - -**距离 Gate 3a 还差什么(子线程的诚实评估,未软化):** 明天把它放上真机, -第一个失败发生在**渲染之前**——没有 Activity/SurfaceView、没有 JNI 实现、 -没有原生入口、没有 cdylib/APK 打包。接上之后,第一个未验证的后端失败点是 -`WgpuRenderer::new`(raw handle 有效性、Vulkan 适配器选择、surface 能力)。 -`draw` 本身已接通到 present。 - -**Gate 3a(渲染证明)**:Android 设备上开出窗口并渲染一帧。 -最小可行后端所需的非桩方法:执行器、text system、`run`、display 发现、 -`open_window`、raw handle、bounds/scale、input handler 存储、 -`request_frame`/input/resize/close 回调存储、`draw`、`sprite_atlas`、 -以及**由原生 vsync 回调驱动 `on_request_frame`**——最后这条漏了会开出窗口但 -永不绘制。 - -**Gate 3b**:手机上跑通期 2 的同一条端到端路径(读/发/批准)。 - -### 期 4 — iOS - -同期 3。**此前记录的"额外阻塞"是错的,已实测推翻。** - -原判断:`gpui_wgpu` 的原生 instance 只启用 `Backends::VULKAN | GL` -(`wgpu_context.rs:184`),所以 iOS 需要上游加 Metal 支持。 - -实测结果:那个 `instance()` 只是**便利函数,不是唯一入口**。 -`WgpuContext::new(instance, surface, compositor_gpu)`(`wgpu_context.rs:27`) -接受调用方自己构造的 instance,而 `WgpuRenderer::new` 接受 -`GpuContext = Rc>>`(`wgpu_renderer.rs:105`)—— -一个共享槽位,只在为空时才自建。**预先填入 Metal 的 `WgpuContext` 就绕开了默认值。** - -在一个 scratch crate 里对 `aarch64-apple-ios` 实测,**0 错误**:`gpui` 编译通过、 -`gpui_wgpu` 编译通过、`Backends::METAL` 的 instance 可构造。 - -**所以 iOS 不需要任何上游改动**,形状与 Android 相同:写 `Platform` 胶水, -复用上游的渲染器与文字系统。§7 的"不 fork"策略在这里不需要任何例外。 - -**Gate 4**:iOS 设备上跑通同一条端到端路径。 - -### 期 5 — 移动端 UI - -现在才动 IA。已量化的现状: - -- **161 处 hover/tooltip**(59 + 102),集中在 `composer.rs`(18+21) 与 - `chat.rs`(11+10)。触摸端全部失效。 -- **⌘K 命令面板在手机上完全无法触达**:全 crate 只有 8 个键绑定,其余动作都有 - 指针入口,唯独 palette 只能靠 `on_action`(`shell.rs:318`、`main.rs:296`), - 没有任何按钮能打开它。它还兼任线程搜索/切换——这是导航黑洞。 -- **侧栏折叠态是 hover 触发的浮层**(`shell.rs:533`,12px 左边缘条),触摸无对应手势。 -- **`main.rs:442` 写死 `window_min_size 900×600`**。 -- 大量写死宽度:`palette.rs:492` 640px、`add_project_dialog.rs:48` 680px、 - `settings_page.rs:341` 620px、`shell.rs:126` 右栏 560px、侧栏 255px - (单它就吃掉 390pt 手机宽度的 65%)、`diff/view.rs:981` 分栏最小 190+180px。 - -**IA 决策:** - -- 手机:**底部 tab(Threads · Chat · Changes · More)+ 每 tab 内导航栈 + - bottom sheet 承载决策**。理由:本 app 恰好只有约 3 个同级目的地和 1 个主导 - 目的地(chat),tab 让主导面永远一次拇指点击可达,抽屉做不到; - 批准/提交/选 provider 这类瞬时决策形状天然适配 bottom sheet。 -- chat + composer 是首屏,app 启动即进。 -- diff 改成**全屏 unified**,杀掉 2-up(380pt 分栏塞不进 390pt)。 -- 命令面板 → **Search tab**,从隐藏模态升为一等导航。 -- 批准面板 → bottom sheet,Approve/Reject 拇指区大按钮。这是最时间敏感的交互。 -- hover 类可供性 → 每行常驻 `⋯` 溢出按钮 + 长按菜单。**不允许任何 hover-only。** -- **平板:适配桌面布局,不是放大手机布局。** 1024pt 放得下 sidebar+chat, - diff 并排在平板上是真有用。三栏保留,可拖拽把手换成 2–3 个断点。 - **已定为手机之后**(§8)——它是这三条里最便宜的一条,留作手机 IA 定型后的 - 快速追加。 - -**手机独有的新能力**(桌面不需要):需批准/turn 完成的**推送通知**(桌面假设 -你在看,手机假设你不在)、后台挂起与重连、拇指可达的发送/批准、语音输入、 -连接状态横幅。 - -**复用估算**(基于实测 `crates/ui/src` = **36,929** 行): - -| 桶 | 约 LOC | 占比 | -| --- | ---: | ---: | -| 可直接复用(markdown 引擎 6153 + diff model/parse/algorithm + 高亮/格式化等纯逻辑) | ~15,000 | ~40% | -| 外壳需重写(shell/sidebar/settings/各类 dialog + chat/composer/diff-view 的布局半边) | ~17,000 | ~46% | -| 直接砍掉(terminal_drawer 2239 + preview_panel 1004 + window_caption 283 + 胶水) | ~4,000 | ~11% | - -> 此表按模块行数分桶得出,`chat.rs`/`composer.rs`/`diff/view.rs` 三个混合文件 -> 按 50/50 拆分——这是全表**最不可靠的一个数字**,且它成立的前提正是期 2 第 3 项 -> 的拆分重构。 - -**实测修正(2026-07-25)。** 上面那个"最不可靠的数字"现在有实测了。按模块统计 -对 `AppState` 的引用密度: - -| 模块 | 行数 | `app_state` 引用 | 密度(次/千行) | -| --- | ---: | ---: | ---: | -| `markdown/*`、`diff/model`、`diff/parse` | 10,145 | **0** | 已可移植 | -| `chat.rs` | 5,162 | 42 | 8.1 | -| `diff/view.rs` | 2,153 | 27 | 12.5 | -| `composer.rs` | 5,140 | 93 | 18.1 | -| `settings_page.rs` | 1,792 | 46 | 25.7 | -| `sidebar.rs` | 1,710 | 67 | 39.2 | - -而深层渲染函数**本来就是数据驱动的**: - -```rust -// crates/ui/src/chat.rs:821 -fn render_turn(&self, index: usize, turn: &TurnMeta, cwd: &Path, - entries: &[Arc], ...) -> AnyElement -``` - -取的是 `tcode-core` 的 `TurnMeta` / `TimelineEntry`,不是 `AppState`。 - -**所以 §6 风险台账里"`chat.rs`/`composer.rs` 拆不开"那一条应当降级。** 审计报告 -描述的"渲染与布局交织在同一批 `render_*` 函数里"在 `chat.rs` 上不成立:8 次 -引用/千行不是交织,是顶层的一道薄接缝——`ChatView` 持 `Entity` 取数据、 -触发动作,往下传的全是领域数据。40% 的复用估计**偏保守**。 - -耦合真正重的是导航与设置外壳(`sidebar` 39.2、`settings_page` 25.7),而那两块 -按 §5 期 5 的 IA 决策本来就要重写。 - -**Gate 5**:手机上完成一次真实工作流:收到批准推送 → 打开 → 读 diff → 批准 → -发下一个 turn。 - ---- - -## 6. 风险台账与退出条件 - -| 风险 | 判据 | 预案 | -| --- | --- | --- | -| **GPUI 移动端表面漂移**(上游正在活跃改动) | 双周升级 PR 的修复成本 | 若单次升级修复 > 2 天,改为季度升级并接受落后 | -| **`gpui_web` 不够生产级**(0 测试、5 个 TODO-Wasm、回调空转) | 期 2 中缺陷密度 | 补齐并上游;上游不接则维护薄补丁层(**不 fork 整个 GPUI**) | -| **iOS Metal 未启用**(已确认) | 上游 PR 是否被接纳 | 本地补丁 `gpui_wgpu`,成本可控 | -| **`AppState` 上帝对象**(69 字段、impl 块 7,416 行、209 个 public 方法) | 期 1 中 `RuntimeCommand` 面的抽取难度 | 只抽取移动端**真正需要**的子集,桌面路径保持直调不变 | -| **`chat.rs`/`composer.rs` 拆不开** | 期 2 第 3 项耗时 | **这是硬闸门。** 若拆分失败,手机端 UI 只能整体重写,复用率从 40% 掉到 ~15%,期 5 工作量翻倍——此时应重新评估是否继续 | -| **wasm 需 nightly + build-std + COOP/COEP** | 期 0 确认 | 若部署环境给不了 COOP/COEP,退到 `single_threaded_web()` 单线程模式,接受性能损失 | -| **wasm 包体积** | **已实测,见 §6.1** | **已排除"降级为只读"这条退路**(§8 定为完整客户端)。预案:`wasm-opt -Oz` + brotli 预压缩 + 剥离 syntect 语法集与 `image` 多余解码器 + 代码分割/懒加载。若仍不可接受,退到"首屏骨架 + 渐进加载",而非砍功能 | -| **触摸分发上游未实现** | 期 3 手感 | 合成鼠标事件(`gpui_web` 已是此路线);滚动手感不达标则自研惯性层 | -| **移动端 IME/软键盘不可用** | 期 3a 之后立刻验证 | **这是移动端最大的未知。**必须在期 3 早期用一个"能打字的输入框"独立验证,不要等到期 5 | - -### 6.1 wasm 体积:已实测 - -在本 rev 上实际构建两个代理产物测得(非估算)。tcode 自身尚不能编译到 wasm, -故取上下界:`hello_web` 是 GPUI + `gpui_wgpu` + cosmic-text 的**地板**, -`story-web` 是**最接近 tcode 依赖面的代理**。 - -| 产物 | 裸 wasm | `wasm-opt -Oz` | gzip -9 | **brotli -q 11** | 首屏总载荷(含 JS 胶水与资源) | -| --- | ---: | ---: | ---: | ---: | ---: | -| `hello_web`(地板) | 9.60 MB | 7.24 MB | 2.84 MB | **1.93 MB** | 1.94 MB (br) | -| `story-web`(代理) | 31.55 MB | 20.97 MB | 7.55 MB | **5.18 MB** | 5.22 MB (br) | - -工具链:rustc 1.99.0-nightly、trunk 0.21.14、wasm-bindgen 0.2.126/0.2.121、 -binaryen 131、brotli 1.2.0。两个 `-Oz` 产物均通过 `wasm-dis` 复核可解析。 - -**对"能否嵌入桌面 app"的结论:取决于嵌未压缩还是嵌预压缩。** - -- 嵌 `-Oz` 未压缩:**21 MB 起,超出 10MB 预算**,且 tcode 只会更大。 -- 嵌 **brotli 预压缩产物并以 `Content-Encoding: br` 直接吐出**:**5.2 MB**, - tcode 预计落在 6–8 MB,**在 10MB 预算之内**。浏览器本来就要解压,服务端 - 不需要持有原始字节。 - -tcode 相对 `story-web` 的增量:syntect 语法/主题 dump、`image` 的六个解码器 -(`ui/Cargo.toml:17`)、markdown 引擎、打包字体与主题;同时会去掉 story 的 -演示内容。**前两项是最肥也最容易砍的**——wasm 构建只需保留实际用到的语法集 -与 png/jpeg。 - -两点必须记住的前提: - -1. 以上均为**多线程构建**(`+atomics` + `--shared-memory`),部署需 COOP/COEP - 响应头。切到 `single_threaded_web()` 会更小且无需特殊响应头,代价是性能—— - **卡在预算边缘时这是第一个该拉的杠杆**。 -2. **上游两个 wasm 完整构建目前都不是开箱即用的**,实测均需绕过:`hello_web` - 缺 `--export=__heap_base` 导致 Trunk 线程注入失败;`story-web` 需要 - `getrandom` 的 `js` feature(经 `rand 0.8` 传入),外加一个 `psm` 的 git - patch 不可达问题。**注意 `getrandom` 这一类错误在本仓库的 wasm 基线检查中 - 同样出现**(见 §6.2)——这是期 2 会反复遇到的一类工作,不是个例。 - -### 6.2 移植基线:已实测 - -期 0 就地测得的 `wasm32-unknown-unknown` 首个基线(`cargo check`,非 build-std): - -| crate | 错误数 | 备注 | -| --- | ---: | --- | -| `tcode-i18n` | **0** | 干净通过,验证了"直接可移植"的判定 | -| `tcode-core` | 2 | 经 `agent` 传递 | -| `agent` | 7 | `ring` 构建脚本、`getrandom` 缺 `js`、`errno`/`polling` 不支持该 OS | -| `tcode-services` | 7 | 同上量级 | - -错误性质集中在**传递依赖的 target 支持**,而非 tcode 自身逻辑——这与 §2 的 -判断一致,也说明这些数字会随依赖门控快速下降。 -`.github/workflows/port-scoreboard.yml` 会持续跟踪这个数列。 - -**整体退出条件(要不要继续做):** - -- **Gate 1 拿不下**(两个桌面实例互联失败)→ 停。协议都跑不通,端就别谈了。 -- **Gate 3a 拿不下**(Android 开不出窗口渲染不出帧)→ 移动端搁置, - 只交付 Web 端(Web 已被上游 CI 覆盖,风险低一个量级)。 -- **两次分期计划都失败** → 停下来重新评估整体可行性,而不是加人。 - ---- - -## 7. 上游策略:跟随,不分叉 - -参考项目的教训: - -- **`gpui-mobile`**(itsbalamurali):pin 在另一个 zed rev,自己搓 - wgpu + cosmic-text + swash——**正是上游 `gpui_wgpu` 现在提供的东西**。 - 它的参考价值在**平台胶水层**(NDK/UIKit 桥接、生命周期、触摸), - **不在渲染层**。 -- **`gpui-toolkit`**(pierreaubert):pin 在 **zed v1.9.0**,且 - **vendor 了 19 个 zed crate(含 gpui 本体)**。整体采用等于把 tcode 从 - v1.13.0 倒退回 v1.9.0 并接管一个 fork。**代价不可接受。** - 只作为 `gpui-ios`/`gpui-android` 的实现参考。 - -**我们的策略**:显式 pin rev + 定期升级;把 `gpui-android`/`gpui-ios` 写成 -**干净的、与 tcode 无关的**后端 crate,主动向上游投递。上游正在为移动端铺路 -(§2.3),这是投递窗口期。被接纳则维护成本归零,不被接纳我们也只维护两个 -独立 crate,而不是一个 GPUI 分叉。 - -依赖可达性已确认:`gpui_web`/`gpui_wgpu` 都是 workspace 包,有公开库根与公开 -入口类型,下游可按 git 依赖。但两者继承 `publish = false`,**不会上 crates.io**; -且 `gpui_platform::current_platform` **没有 Android/iOS 分支** -(`gpui_platform.rs:35`),移动端 crate 应直接依赖 `gpui` + `gpui_wgpu`, -绕开该 facade。 - ---- - -## 8. 已定决策 - -| 议题 | 决定 | 影响 | -| --- | --- | --- | -| **host 形态** | **两者都做,内嵌优先。** 期 1 用桌面内嵌 host 拿下 Gate 1,但 `sync-host` 从第一天就写成**不依赖 GPUI 的独立 crate**;无头 `tcode-server` 作为期 1.5 复用同一套代码,只换一个 `main`。 | 内嵌的增量体积近乎为零——app 已内嵌三个 MCP 服务器,`axum` + `tokio` 早已链入(当前 release 二进制 30.3 MB),axum 0.8 自带 `ws` feature,无新增重依赖。 | -| **Web 端定位** | **完整客户端**(读 / 发 turn / 批准 / 看 diff),不是只读查看器。 | 包体积风险**不能**再靠"降级成只读"化解。期 2 必须实测首屏体积并准备代码分割/懒加载预案。见 §6。 | -| **手机 vs 平板** | **手机优先**,平板靠后。 | 与"先拿便宜胜利"的建议相反,但有真实好处:**把 `chat.rs`/`composer.rs` 的拆分硬闸门与触摸/IME 的真实手感提前引爆**。坏消息早来好过晚来。代价是更长时间没有可展示成果。 | -| **wasm 资产分发** | **独立静态托管**(GitHub Pages / CDN),桌面二进制不托管 wasm。 | 桌面二进制不受影响。纯内网场景需自行搭静态服务。 | -| **桌面 UI 是否也由 wasm 驱动** | **否。** 见 §8.1。 | UI 一致性改由源码共享 + 协议版本协商保证。 | - -### 8.1 被否决的方案:桌面 UI 也加载同一份 wasm - -提案:桌面 app 内嵌 wasm 运行时(不用 webview)执行与浏览器端相同的 UI wasm, -实现"一份 UI 产物跑所有端"。 - -**否决,理由如下(均已实测核实):** - -1. **浏览器那份 wasm 不可移植。** `gpui_web` 深度绑定 wasm-bindgen / js-sys / - web-sys(仅 `window.rs` + `platform.rs` 两文件即 42 处引用),示例经 Trunk - 以 `data-bindgen-target="web"` 构建。该模块导入的是 wasm-bindgen 生成的 - JS shim 函数面。在 wasmtime 中运行它 = 用 Rust 实现一个无头浏览器 - (canvas / WebGPU / DOM 事件 / ResizeObserver / 剪贴板)。且 wasm-bindgen - ABI 跨版本不稳定,生成的 JS 胶水不可省略。 -2. **改走"自定义 UI 协议 + GPUI 留在宿主侧"也不成立。** 唯一抽象边界是 - `PlatformWindow::draw(&Scene)`,而 `Scene`(`gpui/src/scene.rs:41`)的 - `paint_operations` / `primitive_bounds` / `layer_stack` 为私有或 `pub(crate)`, - 整个类型**无 `Serialize`**,且 `monochrome_sprites` / `polychrome_sprites` - 引用的是**图集贴片句柄(GPU 常驻状态)**。跨边界传 Scene 需连纹理图集共享 - 一并解决。该类型不是为跨进程设计的。 -3. **反向放大体积问题。** 该方案要求桌面内嵌 wasm 运行时(wasmtime 数 MB), - 比托管一个 wasm blob 更重——而提案的出发点正是控制桌面二进制体积。 -4. **iOS 大概率过不了审。** App Store 审核指南 2.5.2 限制下载并执行可执行代码; - 自带运行时执行下载来的模块历史上属高风险被拒区。 - -**它识别的真问题是有效的**:多端 UI 版本漂移。改用便宜解法—— -**协议版本号 + 握手时兼容性协商**,不兼容即明确拒绝并提示升级(成本约几百行)。 -而"一份 UI 代码跑所有端"**已经免费拥有**:tcode 全栈 Rust,`ui-core` 的**源码** -同时编译到 macOS / Windows / Linux / Android / iOS / wasm,源码级复用已提供 -100% 的代码共享收益。二进制级共享额外只买到 UI 热更新,而那在 iOS 上本就是禁区。 - -若日后仍要推进,作为**独立探索性专题**排期,不进主线关键路径,且须先完成 -最小可行性验证。 - -### 8.2 被否决的方案:用 OAuth2 登录身份提供方来准入设备 - -提案:设备不再读配对码,改为登录一个 OAuth2 身份提供方(当时具体到自建 -Gitea),host 验证 token、查白名单,然后签发它自己那枚长期凭证。 - -**已实现到可用程度后整体回退。** 回退理由不是技术缺陷——它是能跑的, -接缝也留得干净:协议里传的是不透明的 `provider_token`,host 侧是 -`IdentityVerifier` trait,配对码始终是默认路径。**理由是产品定位**: -仓库里唯一的实现绑着某一个人的自建实例,端点路径、userinfo 的字段名、 -设置项的形状全都是那一家的形状。这会把一个通用开源工具变成一份私人定制品—— -任何拿到源码的人,看到的是"配置你的 Gitea",而不是"配置你的身份提供方"。 - -**通用化本来是可行的**(改成 OIDC discovery + `preferred_username`, -把 Gitea/GitHub/GitLab 降级成预设数据),但那意味着主线要开始维护一套 -身份提供方兼容性矩阵,而这不是本项目的问题域。 - -**它识别的真问题依然存在**:每台新设备都要人工读一次六位码。 -但这在同类工具里是常态而非缺陷——Syncthing 的设备 ID、Tailscale 的 -pre-auth key、SSH 的 `authorized_keys` 都是一次性的手工准入动作, -且都刻意不依赖第三方在线。配对码的成本是"每台设备一次", -不是"每次连接一次",这个量级不值得引入一个外部依赖来消除。 - -若日后要重开,正确形态是:**只发布 `IdentityVerifier` 这个接缝**, -具体 provider 作为第三方 crate 或插件由使用者自己提供,主线一个都不带。 - ---- - -## 9. 仍待拍板 - -1. **网络模型?** 鉴权已定:配对码,且只有配对码(§8.2 记录了被否决的 - OAuth2 方案)。剩下的是可达性——局域网直连最简单,广域网需要中继或打洞。 - 先做局域网、后加中继,还是一开始就上中继?注意 host 默认只绑 loopback, - `pairing.rs` 里"六位码够用"那套论证正建立在这个前提上;一旦放开绑定地址, - 限流必须同时补上。 -2. **iOS 分发?** App Store 会不会因"远程执行代码"条款有麻烦(注意:即使按 - §8.1 否决了 wasm 方案,"手机遥控桌面执行命令"本身也可能触碰审核红线), - 还是只走 TestFlight/企业签名?**这个法务问题应在期 4 之前问清楚**, - 别等做完才发现。 diff --git a/docs/remote-and-mobile.md b/docs/remote-and-mobile.md deleted file mode 100644 index 2e65a6d1..00000000 --- a/docs/remote-and-mobile.md +++ /dev/null @@ -1,212 +0,0 @@ -# Working remotely: the browser and phone clients - -Tcode runs agents on a computer. A phone cannot run Claude Code, and neither can -a browser tab — there is no process to spawn, no filesystem to edit, no PTY. - -So the other clients are not smaller copies of the desktop app. They are -**remote controls for a machine that is already running one**. The desktop app -(or a headless server) keeps being the place where work happens; the browser and -the phone watch it, send turns to it, and answer its approval prompts. - -Everything on this page follows from that. If the host is asleep, there is -nothing to connect to. - ---- - -## What actually works today - -Be precise about this, because "it compiles" and "somebody used it" are very -different claims. - -| | Status | -| --- | --- | -| Desktop host, embedded | Working. Exercised end to end: a remote client sent a turn and approved a command, and the agent ran it. | -| Headless host (`tcode-server`) | Working. Same, driven from the CLI probe. | -| Browser client | Builds and runs. Follows a session, sends turns, answers approvals. | -| Android client | **Links** for `aarch64-linux-android`, all JNI entry points exported. Has never run on a device. | -| iOS client | **Links** for `aarch64-apple-ios` against the iOS SDK. Has never run on a device or simulator. | - -The two mobile rows are the honest ones. Linking is a real result — it proves -every symbol that GPUI, `gpui_wgpu`, `wgpu`, Metal, Vulkan and `psm` need -resolves, which a `cargo check` never establishes. It is not the same as having -seen a frame. The most likely first failure on both is renderer creation meeting -real hardware for the first time. See -[`crates/tcode-android/README.md`](../crates/tcode-android/README.md) and -[`crates/tcode-ios/README.md`](../crates/tcode-ios/README.md) for exactly what -each one does and does not implement. - ---- - -## Turning on the host - -### From the desktop app - -Nothing to enable. The app starts a sync host on loopback at launch and writes -two things to its log: - -```text -sync host listening at ws://127.0.0.1:54321/sync -pair a device within 180s with code K7M2QX -``` - -The address is what a client connects to. The code is what it connects *with* — -see [Pairing](#pairing) below. - -### Headless - -`tcode-server` is the same host with a different `main`. Use it when the machine -doing the work has no display, or when you want the agent to keep running after -you close the laptop lid on the app. - -```sh -cargo run -p tcode-server -cargo run -p tcode-server -- --bind 0.0.0.0:7777 -cargo run -p tcode-server -- --print-token -``` - -It has no screen to print a pairing code onto, so it hands out the durable token -directly with `--print-token`. - -### A word about `--bind` - -The default is `127.0.0.1`, and that default is load-bearing. - -The sync protocol runs over plain WebSocket. There is no TLS in it, and the -token is the only thing protecting the connection. On loopback that is fine: to -reach the socket you already have local access, and if you have local access you -could have run the agent yourself. - -Widen the bind address and both assumptions break at once. The token now crosses -a network in the clear, and the six-character pairing code — which is defensible -precisely because an attacker must first reach the socket — becomes something -anyone on the network can start guessing at. - -If you need this across machines, put it behind something that provides -transport security and access control: a WireGuard or Tailscale network, an SSH -tunnel, or a reverse proxy terminating TLS. Do not simply bind `0.0.0.0` on a -network you do not control. - ---- - -## Pairing - -A new device gets its credential by trading a short code for a durable token, -once. After that it reconnects with the token and never sees a code again. - -1. Read the six-character code off the host. -2. Enter the host address and the code on the client's connect screen. -3. The client stores the token it gets back and goes straight to the session - list from then on. **Sign out** discards it. - -The code is deliberately small and deliberately weak on its own: - -- it expires in **three minutes**; -- it is **single use**, so even a correct guess still has to beat the real - client to it; -- it is worth nothing to anyone who cannot already reach the host's socket. - -Its alphabet omits the characters people misread aloud or mistype — no `O`/`0`, -no `I`/`1`/`L`, no `S`/`5`, no `B`/`8` — because a code exists to be read off one -screen and typed into another. Case and separators do not matter: `k7 m2 qx` is -the same code as `K7M2QX`. - -There is no account, no identity provider, and no sign-in. That was tried and -removed on purpose; the reasoning is in -[`docs/multiplatform-plan.md`](multiplatform-plan.md) §8.2. The short version: -device pairing is the same shape as Syncthing's device IDs, Tailscale's -pre-auth keys, or `authorized_keys` — a one-time manual admission per device, -which is a cost you pay once and which never depends on a third party being -reachable. - ---- - -## The browser client - -The web client is a **full** client, not a viewer: it follows a session, sends -turns, and answers approval requests. - -It is not a web page wrapping the desktop UI. It is the same Rust UI code -compiled to WebAssembly and drawn with WebGPU, which is why it looks and behaves -like the app rather than like a website. - -### Building and serving it - -This one has real prerequisites. The pinned GPUI revision needs nightly Rust, a -locally rebuilt standard library, and the wasm atomics features: - -```sh -CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" \ -RUSTC_BOOTSTRAP=1 \ -cargo +nightly -Zbuild-std=std,panic_abort check \ - -p tcode-web --target wasm32-unknown-unknown -``` - -The full link and serve invocations are in -[`crates/tcode-web/README.md`](../crates/tcode-web/README.md); they are long -enough that copying them from one place is better than maintaining two. - -### The header requirement that will bite you - -WebAssembly threads need `SharedArrayBuffer`, and browsers only expose it to a -cross-origin isolated page. Whatever serves the built assets **must** send: - -```text -Cross-Origin-Embedder-Policy: require-corp -Cross-Origin-Opener-Policy: same-origin -``` - -Without them the dispatcher cannot start its worker threads and the client will -not run. `trunk serve` is already configured to send them; a plain static file -server is not. This is the single most likely reason a working build fails to -start in a browser. - -### Credentials - -The token lives in `localStorage`, so a reload reconnects without retyping. - -`?url=` prefills the host address as a convenience — an address is not a secret. -There is deliberately no `?token=`: a durable credential has no business in an -address bar, in browser history, or in a referrer header. - ---- - -## The phone clients - -Android and iOS mount the same shared client the browser does. Each platform -crate is small on purpose: it owns the window, the touch events and the -lifecycle, and nothing else. - -The split is two layers: - -- **`gpui-android` / `gpui-ios`** implement GPUI's `Platform` trait for those - targets — window, display, dispatcher, text system, input. They know nothing - about tcode and could be offered upstream on their own. -- **`tcode-android` / `tcode-ios`** are the app shells. One owns Java and JNI, - the other Objective-C and UIKit. Neither knows GPUI's internals. - -Build instructions and prerequisites — including the Android NDK revision, and -why an NDK is required even for a `cargo check` — are in each crate's README. - -### What they do not do yet - -Both are honest about this rather than pretending: credential storage returns an -error instead of silently discarding secrets (it needs Keystore on Android and -Keychain on iOS), IME caret positioning is left to the platform, and thermal -state reports nominal rather than guessing. Neither has run on hardware. - ---- - -## Under the hood - -A tcode session is already an event log — an append-only sequence of -`AgentEvent`s that a pure reducer folds into the visible timeline. That is what -makes remote work cheap: a client does not need a parallel implementation of the -app's state, it needs the same events and the same reducer. - -So the wire protocol is thin. Clients send commands, the host sends events, and -each event carries a per-session sequence number that a reconnecting client uses -as a cursor. Frames, version negotiation and delivery guarantees are specified -in [`docs/sync-protocol.md`](sync-protocol.md). - -The architecture, the decisions taken, and the approaches rejected along the way -are recorded in [`docs/multiplatform-plan.md`](multiplatform-plan.md). diff --git a/docs/sync-protocol.md b/docs/sync-protocol.md deleted file mode 100644 index 32b4db23..00000000 --- a/docs/sync-protocol.md +++ /dev/null @@ -1,142 +0,0 @@ -# tcode sync protocol - -The wire contract between a **host** (a machine that can actually run agents: -spawn provider CLIs, own the repo, hold the session log) and a **client** (a -phone, browser, tablet, or another desktop that cannot). - -`crates/sync-protocol` is normative. This document explains the shape and the -reasoning; when the two disagree, fix one of them — deliberately. - -Background and the decision to make sync a prerequisite rather than a third -feature: `docs/multiplatform-plan.md` §1, §3. - ---- - -## 1. Shape - -One host, many clients, **single writer**. The host owns all state; clients -hold only presentation state (scroll offset, which panel is open, unsent draft -text). There is no offline editing and no merge: a client that cannot reach its -host can queue user input and replay it on reconnect, and a conflict is an -error the user retries. Agent sessions are inherently single-writer, so a CRDT -would double the work to model a problem we do not have. - -Transport is WebSocket with JSON text frames. Nothing in the frame definitions -depends on that choice; the crate is transport-agnostic types plus serde, with -no I/O, so it builds for wasm. - -## 2. Why these types already exist - -The protocol did not need a new vocabulary. All five providers — Codex, Claude, -pi, OpenCode, ACP — already normalize into one canonical stream, and the -existing seam is literally commands-in / events-out: - -```rust -// crates/agent/src/lib.rs -pub struct SessionHandle { - pub commands: async_channel::Sender, - pub events: async_channel::Receiver, -} -``` - -So the wire is `SessionCommand` one way and `AgentEvent` the other. Both are -serde types. A remote session becomes another `start_session` backend rather -than a parallel universe, and `Timeline::fold_events` — a pure reducer — folds -replicated events into exactly the timeline a local replay would produce. - -## 3. Ordering and cursors - -Every persisted event carries a **per-session `seq`**: 1-based, contiguous, -strictly increasing (`crates/services/src/store.rs`). - -`seq` exists because `ts` cannot do this job. `ts` is a wall clock that the -writer explicitly tolerates repeating or going backwards, so two events can -share a millisecond and a clock adjustment can invert a pair. A cursor built on -`ts` would silently skip or duplicate events at exactly the moments that matter. - -A cursor is therefore just "the last `seq` I have". Resume is exact, not -best-effort: the log is append-only and the sequence is dense, so `from_seq: N` -means "send `N+1` onward" with no ambiguity about what is missing. - -## 4. Frames - -### Client → host - -| frame | meaning | -| --- | --- | -| `Hello { min_version, max_version, client, token }` | First frame on every connection. Nothing else is accepted before it. | -| `ListSessions` | Enumerate sessions this token may see. | -| `Subscribe { session_id, from_seq }` | Stream a session. `from_seq: None` means from the beginning. | -| `Unsubscribe { session_id }` | Stop streaming; the session keeps running on the host. | -| `Command { session_id, command }` | A `SessionCommand` — send a turn, approve, interrupt, steer, rewind. | -| `Ping { nonce }` | Liveness. Mobile networks drop idle connections silently. | - -### Host → client - -| frame | meaning | -| --- | --- | -| `Welcome { version, host }` | Handshake accepted; `version` is the agreed one. | -| `Refused { reason }` | Handshake rejected. Carries the host's supported range on a version mismatch, so the client can say something better than "failed". | -| `SessionList { sessions }` | Answer to `ListSessions`. | -| `Events { session_id, events, caught_up }` | A batch. `caught_up: false` while replaying backlog, `true` once live. | -| `CommandRejected { session_id, command_ref, reason }` | The host could not apply a command. Silence would leave the client showing a turn that never ran. | -| `SessionEnded { session_id, reason }` | The provider exited or the session was archived/deleted. | -| `Pong { nonce }` | | - -## 5. Version negotiation - -The client sends a **range**, not a version, and the host picks the highest -value both support. A single version forces host and client to upgrade in -lockstep, which is not a thing you can ask of an App Store release. - -On no overlap the host answers `Refused { reason: UnsupportedVersion { -host_min, host_max } }` — enough for the client to tell the user which side is -too old. - -This is the answer to multi-device version drift, and it is why the -"ship one wasm UI artifact to every client" idea was rejected rather than -adopted (`docs/multiplatform-plan.md` §8.1): drift is a compatibility problem, -and it is far cheaper to negotiate compatibility than to make every client -execute the same binary. - -## 6. Delivery guarantees - -**Host → client is exactly-once, by construction.** Events are numbered and the -client tracks its cursor, so a duplicate batch is discarded by `seq` and a gap -is impossible without the client noticing. - -**Client → host is at-least-once, made idempotent by the existing -`delivery_id`.** `SessionCommand::SendTurn` already carries one, and -`AgentEvent::TurnAccepted` echoes it back — a mechanism that predates this -protocol and exists because the local UI has the same question after a provider -restart. A client that reconnects mid-turn compares the `delivery_id`s it sent -against the `TurnAccepted` events it can now see, and resends only what is -genuinely missing. No new dedup layer. - -For that to work the acknowledgement has to be **durable**, and originally it -was not: the runtime consumed `TurnAccepted` as control-plane state and returned -before persisting it, so it never entered the log the host streams from and no -remote client could ever observe one. Found by running the real app rather than -by any test — 770 of them passed over the broken design. - -The fix is to persist it, not to invent an out-of-band acknowledgement frame. -The question this answers is asked *after a disconnect*, so an ack that lives -only on the connection is lost at exactly the moment it is needed; one in the -log is visible on reconnect with no extra machinery and survives the host -restarting too. `Timeline` already folds `TurnAccepted` to nothing, so the extra -line costs a log entry and changes no rendering. - -Commands without a natural idempotency token are either harmless to repeat or -scoped by a `request_id` the host matches against state it already holds. - -## 7. Deliberately not in v1 - -- **Pairing.** v1 carries a pre-shared token. The pairing-code exchange is host - UX, and putting it in the wire format before that UX exists would be guessing. -- **A general app-level command surface.** `AppState` exposes 209 public - methods; the protocol should carry the ones a remote client is shown to need, - discovered by building the client, not by translating the whole surface up - front. -- **Binary framing.** JSON is legible and debuggable. Revisit when a measurement - says to, not before. -- **Multi-host federation.** One client talks to one host at a time. From 4496b7981b75de9e5d032003981314ca67d28bdc Mon Sep 17 00:00:00 2001 From: Tryanks Date: Sun, 26 Jul 2026 14:48:42 +0800 Subject: [PATCH 2/2] Reapply the multiplatform work Reverts the revert in #174, restoring the remote sync host, the browser and phone clients, the shared client UI, and the multiplatform CI gates. This is the only way back. Re-merging `feat/multiplatform-groundwork` would not work: its commits remain ancestors of `main` through the merge that #174 reverted, so git treats them as already applied and the diff comes out empty. Everything here was green on all seven CI jobs before it was parked. Future work continues on this branch. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 139 + .github/workflows/port-scoreboard.yml | 115 + Cargo.lock | 284 +- Cargo.toml | 40 + README.md | 31 + crates/agent/Cargo.toml | 29 +- crates/agent/examples/image_probe.rs | 1 + crates/agent/src/acp.rs | 4 + crates/agent/src/claude.rs | 6 + crates/agent/src/codex.rs | 3 + crates/agent/src/lib.rs | 391 +- crates/agent/src/opencode.rs | 4 + crates/agent/src/pi.rs | 3 + crates/app/Cargo.toml | 3 + crates/app/src/gpui_pin.rs | 131 + crates/app/src/main.rs | 55 +- crates/client-app/.gitignore | 3 + crates/client-app/Cargo.lock | 8692 +++++++++++++++++ crates/client-app/Cargo.toml | 23 + crates/client-app/src/lib.rs | 1396 +++ crates/client-app/src/native_transport.rs | 128 + crates/client-app/src/pairing.rs | 136 + crates/client-app/src/storage.rs | 194 + crates/core/Cargo.toml | 11 +- crates/core/src/git.rs | 3 + crates/core/src/session.rs | 35 +- crates/gpui-android/Cargo.toml | 17 + crates/gpui-android/README.md | 56 + crates/gpui-android/src/dispatcher.rs | 346 + crates/gpui-android/src/display.rs | 59 + crates/gpui-android/src/keyboard.rs | 13 + crates/gpui-android/src/lib.rs | 25 + crates/gpui-android/src/native.rs | 179 + crates/gpui-android/src/platform.rs | 735 ++ crates/gpui-android/src/window.rs | 670 ++ crates/gpui-ios/Cargo.toml | 18 + crates/gpui-ios/README.md | 53 + crates/gpui-ios/src/dispatcher.rs | 160 + crates/gpui-ios/src/display.rs | 59 + crates/gpui-ios/src/keyboard.rs | 13 + crates/gpui-ios/src/lib.rs | 34 + crates/gpui-ios/src/native.rs | 248 + crates/gpui-ios/src/platform.rs | 736 ++ crates/gpui-ios/src/window.rs | 667 ++ crates/runtime/Cargo.toml | 1 + crates/runtime/src/app.rs | 237 +- crates/runtime/src/blocking.rs | 20 +- crates/services/Cargo.toml | 44 +- crates/services/src/git.rs | 1 + crates/services/src/lib.rs | 26 + crates/services/src/process.rs | 20 +- crates/services/src/settings.rs | 49 + crates/services/src/store.rs | 324 +- crates/sync-client/Cargo.toml | 19 + crates/sync-client/src/bin/sync-probe.rs | 535 + crates/sync-client/src/lib.rs | 835 ++ crates/sync-host/Cargo.toml | 28 + crates/sync-host/src/lib.rs | 1011 ++ crates/sync-host/src/pairing.rs | 237 + crates/sync-host/src/server.rs | 460 + crates/sync-host/src/store_source.rs | 366 + .../sync-host/tests/websocket_end_to_end.rs | 295 + crates/sync-protocol/Cargo.toml | 12 + crates/sync-protocol/src/lib.rs | 525 + crates/tcode-android/Cargo.toml | 23 + crates/tcode-android/README.md | 64 + .../android/app/src/main/AndroidManifest.xml | 18 + .../java/com/tryanks/tcode/TcodeActivity.kt | 212 + crates/tcode-android/src/bridge.rs | 258 + crates/tcode-android/src/entry.rs | 358 + crates/tcode-android/src/lib.rs | 20 + crates/tcode-android/tools/verify-link.sh | 38 + crates/tcode-ios/Cargo.toml | 20 + crates/tcode-ios/README.md | 81 + crates/tcode-ios/src/bridge.rs | 183 + crates/tcode-ios/src/entry.rs | 276 + crates/tcode-ios/src/lib.rs | 17 + crates/tcode-ios/tools/verify-link.sh | 68 + crates/tcode-server/Cargo.toml | 26 + crates/tcode-server/src/main.rs | 208 + crates/tcode-web/.gitignore | 1 + crates/tcode-web/Cargo.toml | 33 + crates/tcode-web/README.md | 73 + crates/tcode-web/Trunk.toml | 11 + crates/tcode-web/index.html | 36 + crates/tcode-web/src/lib.rs | 2 + crates/tcode-web/src/wasm.rs | 213 + crates/ui/Cargo.toml | 34 +- crates/ui/src/assets.rs | 13 +- crates/ui/src/chat.rs | 712 +- crates/ui/src/composer.rs | 1 + crates/ui/src/diff/mod.rs | 3 + crates/ui/src/lib.rs | 30 + crates/ui/src/markdown/view.rs | 22 +- crates/ui/src/preview_panel.rs | 56 +- docs/multiplatform-plan.md | 697 ++ docs/remote-and-mobile.md | 212 + docs/sync-protocol.md | 142 + 98 files changed, 24859 insertions(+), 295 deletions(-) create mode 100644 .github/workflows/port-scoreboard.yml create mode 100644 crates/app/src/gpui_pin.rs create mode 100644 crates/client-app/.gitignore create mode 100644 crates/client-app/Cargo.lock create mode 100644 crates/client-app/Cargo.toml create mode 100644 crates/client-app/src/lib.rs create mode 100644 crates/client-app/src/native_transport.rs create mode 100644 crates/client-app/src/pairing.rs create mode 100644 crates/client-app/src/storage.rs create mode 100644 crates/gpui-android/Cargo.toml create mode 100644 crates/gpui-android/README.md create mode 100644 crates/gpui-android/src/dispatcher.rs create mode 100644 crates/gpui-android/src/display.rs create mode 100644 crates/gpui-android/src/keyboard.rs create mode 100644 crates/gpui-android/src/lib.rs create mode 100644 crates/gpui-android/src/native.rs create mode 100644 crates/gpui-android/src/platform.rs create mode 100644 crates/gpui-android/src/window.rs create mode 100644 crates/gpui-ios/Cargo.toml create mode 100644 crates/gpui-ios/README.md create mode 100644 crates/gpui-ios/src/dispatcher.rs create mode 100644 crates/gpui-ios/src/display.rs create mode 100644 crates/gpui-ios/src/keyboard.rs create mode 100644 crates/gpui-ios/src/lib.rs create mode 100644 crates/gpui-ios/src/native.rs create mode 100644 crates/gpui-ios/src/platform.rs create mode 100644 crates/gpui-ios/src/window.rs create mode 100644 crates/sync-client/Cargo.toml create mode 100644 crates/sync-client/src/bin/sync-probe.rs create mode 100644 crates/sync-client/src/lib.rs create mode 100644 crates/sync-host/Cargo.toml create mode 100644 crates/sync-host/src/lib.rs create mode 100644 crates/sync-host/src/pairing.rs create mode 100644 crates/sync-host/src/server.rs create mode 100644 crates/sync-host/src/store_source.rs create mode 100644 crates/sync-host/tests/websocket_end_to_end.rs create mode 100644 crates/sync-protocol/Cargo.toml create mode 100644 crates/sync-protocol/src/lib.rs create mode 100644 crates/tcode-android/Cargo.toml create mode 100644 crates/tcode-android/README.md create mode 100644 crates/tcode-android/android/app/src/main/AndroidManifest.xml create mode 100644 crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt create mode 100644 crates/tcode-android/src/bridge.rs create mode 100644 crates/tcode-android/src/entry.rs create mode 100644 crates/tcode-android/src/lib.rs create mode 100755 crates/tcode-android/tools/verify-link.sh create mode 100644 crates/tcode-ios/Cargo.toml create mode 100644 crates/tcode-ios/README.md create mode 100644 crates/tcode-ios/src/bridge.rs create mode 100644 crates/tcode-ios/src/entry.rs create mode 100644 crates/tcode-ios/src/lib.rs create mode 100755 crates/tcode-ios/tools/verify-link.sh create mode 100644 crates/tcode-server/Cargo.toml create mode 100644 crates/tcode-server/src/main.rs create mode 100644 crates/tcode-web/.gitignore create mode 100644 crates/tcode-web/Cargo.toml create mode 100644 crates/tcode-web/README.md create mode 100644 crates/tcode-web/Trunk.toml create mode 100644 crates/tcode-web/index.html create mode 100644 crates/tcode-web/src/lib.rs create mode 100644 crates/tcode-web/src/wasm.rs create mode 100644 docs/multiplatform-plan.md create mode 100644 docs/remote-and-mobile.md create mode 100644 docs/sync-protocol.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 380f3b21..d8747e13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,3 +82,142 @@ jobs: - name: Run workspace tests if: runner.os != 'Linux' run: cargo test --workspace --locked + + client-app: + name: Shared client + runs-on: ubuntu-24.04 + env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: line-tables-only + + steps: + - uses: actions/checkout@v7 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang cmake pkg-config libssl-dev + + - name: Cache Cargo build data + uses: Swatinem/rust-cache@v2 + with: + # client-app is intentionally excluded from the root workspace, so + # its build data lives under its own manifest directory. + workspaces: crates/client-app -> target + shared-key: multiplatform-client-host + + - name: Run shared client tests + run: cargo test --manifest-path crates/client-app/Cargo.toml --locked + + - name: Lint the shared client + run: cargo clippy --manifest-path crates/client-app/Cargo.toml --locked -- -D warnings + + wasm: + name: Web targets + runs-on: ubuntu-24.04 + env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: line-tables-only + # GPUI's web dispatcher assumes wasm threads and shared memory, which + # require both a rebuilt standard library and these target features. + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: "-C target-feature=+atomics,+bulk-memory,+mutable-globals" + RUSTC_BOOTSTRAP: "1" + + steps: + - uses: actions/checkout@v7 + + - name: Install nightly Rust for wasm + uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + targets: wasm32-unknown-unknown + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang cmake pkg-config libssl-dev + + - name: Cache Cargo build data + uses: Swatinem/rust-cache@v2 + with: + # The excluded client manifest and root web crate use separate target + # directories even though they compile for the same platform. + workspaces: | + . -> target + crates/client-app -> target + shared-key: multiplatform-wasm32-unknown-unknown + + - name: Check the shared client for wasm + run: cargo check --manifest-path crates/client-app/Cargo.toml --target wasm32-unknown-unknown --locked + + - name: Check the browser shell + run: cargo +nightly -Zbuild-std=std,panic_abort check -p tcode-web --target wasm32-unknown-unknown --locked + + ios: + name: iOS targets + runs-on: macos-15 + env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: line-tables-only + + steps: + - uses: actions/checkout@v7 + + - name: Install stable Rust for iOS + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-ios + + - name: Cache Cargo build data + uses: Swatinem/rust-cache@v2 + with: + shared-key: multiplatform-aarch64-apple-ios + + - name: Check the iOS backends + run: cargo check -p gpui-ios -p tcode-ios --target aarch64-apple-ios --locked + + android: + name: Android targets + runs-on: ubuntu-24.04 + env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: line-tables-only + + steps: + - uses: actions/checkout@v7 + + - name: Install stable Rust for Android + uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-linux-android + + - name: Install Linux system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang cmake pkg-config libssl-dev + + - name: Cache Cargo build data + uses: Swatinem/rust-cache@v2 + with: + shared-key: multiplatform-aarch64-linux-android + + - name: Configure the Android NDK + run: | + # psm assembles AArch64 while Cargo checks GPUI, so host Clang cannot + # stand in for the Android API 26 toolchain verified by this project. + toolchain="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin" + test -x "$toolchain/aarch64-linux-android26-clang" + test -x "$toolchain/llvm-ar" + echo "CC_aarch64_linux_android=$toolchain/aarch64-linux-android26-clang" >> "$GITHUB_ENV" + echo "AR_aarch64_linux_android=$toolchain/llvm-ar" >> "$GITHUB_ENV" + + - name: Check the Android backends + run: cargo check -p gpui-android -p tcode-android --target aarch64-linux-android --locked diff --git a/.github/workflows/port-scoreboard.yml b/.github/workflows/port-scoreboard.yml new file mode 100644 index 00000000..3bf4332d --- /dev/null +++ b/.github/workflows/port-scoreboard.yml @@ -0,0 +1,115 @@ +# This workflow is a measurement, not a gate. It never fails the build. +name: Port scoreboard + +# Tracks how far each crate is from compiling for the mobile and web targets +# (docs/multiplatform-plan.md, phase 0). Every job is expected to fail today — +# the number we care about is *how many* errors, trending down as crates get +# split and feature-gated. +# +# Deliberately not run on pull requests: a permanently-red job on every PR +# teaches people to ignore CI. Weekly, plus on demand. +on: + workflow_dispatch: + schedule: + # Mondays, 04:00 UTC. + - cron: "0 4 * * 1" + +permissions: + contents: read + +concurrency: + group: port-scoreboard-${{ github.ref }} + cancel-in-progress: true + +jobs: + scoreboard: + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner || 'ubuntu-24.04' }} + strategy: + fail-fast: false + matrix: + include: + - target: wasm32-unknown-unknown + # Matches Zed's own wasm CI: gpui needs a rebuilt std plus the + # atomics/shared-memory features its multithreaded web dispatcher + # assumes (crates/gpui_web/src/dispatcher.rs). + extra_flags: "-Zbuild-std=std,panic_abort" + - target: aarch64-linux-android + extra_flags: "" + - target: aarch64-apple-ios + extra_flags: "" + # iOS needs Apple's toolchain, so this row runs on macOS. + runner: macos-15 + env: + CARGO_TERM_COLOR: never + RUSTC_BOOTSTRAP: "1" + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: "-C target-feature=+atomics,+bulk-memory,+mutable-globals" + + steps: + - uses: actions/checkout@v7 + + - name: Install nightly Rust with ${{ matrix.target }} + uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + targets: ${{ matrix.target }} + + - name: Install Linux system dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + build-essential clang cmake pkg-config libssl-dev + + - name: Check each crate and count errors + run: | + # Crates the plan expects to become portable, cheapest first. `term` + # is absent on purpose: it is desktop-only forever (PTY + login shell + # + tcgetpgrp), so counting its errors would only add noise. + crates="tcode-i18n sync-protocol sync-client tcode-core agent tcode-services tcode-runtime tcode-ui" + + # The platform backends only exist for one target each, and are added + # to their own row. gpui-android additionally needs an NDK this job + # does not install — psm assembles AArch64 during dependency checking + # — so it is deliberately absent rather than counted as broken. + case "${{ matrix.target }}" in + aarch64-apple-ios) crates="$crates gpui-ios tcode-ios" ;; + esac + + { + echo "## ${{ matrix.target }}" + echo + echo "| crate | errors |" + echo "| --- | ---: |" + } >> "$GITHUB_STEP_SUMMARY" + + total=0 + for crate in $crates; do + log="/tmp/${crate}.log" + # `|| true`: every one of these is expected to fail right now. + # `-Z` flags go before the subcommand, matching the invocation + # Zed's own wasm CI uses (.github/workflows/run_tests.yml there). + cargo ${{ matrix.extra_flags }} check -p "$crate" \ + --target "${{ matrix.target }}" --locked > "$log" 2>&1 || true + + # Count distinct rustc errors, not lines: one error spans many. + errors=$(grep -cE '^error(\[E[0-9]+\])?:' "$log" || true) + total=$((total + errors)) + + if [ "$errors" -eq 0 ]; then + echo "| \`$crate\` | ✅ 0 |" >> "$GITHUB_STEP_SUMMARY" + else + echo "| \`$crate\` | $errors |" >> "$GITHUB_STEP_SUMMARY" + fi + + echo "::group::$crate ($errors errors)" + cat "$log" + echo "::endgroup::" + done + + { + echo "| **total** | **$total** |" + echo + echo "Expected to be non-zero until the crate split lands." + echo "See \`docs/multiplatform-plan.md\` §3.2." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/Cargo.lock b/Cargo.lock index da8600b6..1e3c84a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,23 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_log-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" + +[[package]] +name = "android_logger" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" +dependencies = [ + "android_log-sys", + "env_filter 0.1.4", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -731,6 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", + "base64", "bytes", "form_urlencoded", "futures-util", @@ -749,8 +767,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -1303,6 +1323,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "client-app" +version = "0.1.0" +dependencies = [ + "futures-util", + "gpui", + "gpui-component", + "log", + "sync-client", + "sync-protocol", + "tcode-ui", + "tokio", + "tokio-tungstenite 0.28.0", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -1892,6 +1927,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "data-url" version = "0.3.2" @@ -2232,6 +2273,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + [[package]] name = "env_filter" version = "2.0.0" @@ -2250,7 +2301,7 @@ checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", - "env_filter", + "env_filter 2.0.0", "jiff", "log", ] @@ -3190,6 +3241,20 @@ dependencies = [ "zed-scap", ] +[[package]] +name = "gpui-android" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "gpui", + "gpui_wgpu", + "log", + "ndk", + "raw-window-handle", + "uuid", +] + [[package]] name = "gpui-component" version = "0.5.2" @@ -3262,6 +3327,19 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "gpui-ios" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures", + "gpui", + "gpui_wgpu", + "log", + "raw-window-handle", + "uuid", +] + [[package]] name = "gpui-wry" version = "0.5.0" @@ -5437,6 +5515,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-media" version = "0.3.2" @@ -5658,9 +5746,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.0", + "block2 0.6.2", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-location", + "objc2-core-text", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-user-notifications", ] [[package]] @@ -5673,6 +5770,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-web-kit" version = "0.3.2" @@ -7744,6 +7851,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -8297,6 +8415,48 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync-client" +version = "0.1.0" +dependencies = [ + "agent", + "futures-util", + "log", + "serde_json", + "sync-protocol", + "tcode-core", + "tcode-services", + "tokio", + "tokio-tungstenite 0.28.0", +] + +[[package]] +name = "sync-host" +version = "0.1.0" +dependencies = [ + "agent", + "async-channel", + "axum", + "futures-util", + "log", + "serde_json", + "sync-protocol", + "tcode-core", + "tcode-services", + "tokio", + "tokio-tungstenite 0.28.0", + "uuid", +] + +[[package]] +name = "sync-protocol" +version = "0.1.0" +dependencies = [ + "agent", + "serde", + "serde_json", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -8473,10 +8633,28 @@ dependencies = [ "log", "orchestrate-mcp", "preview-mcp", + "sync-host", + "sync-protocol", "tcode-i18n", "tcode-runtime", "tcode-services", "tcode-ui", + "uuid", +] + +[[package]] +name = "tcode-android" +version = "0.1.0" +dependencies = [ + "android_logger", + "anyhow", + "client-app", + "gpui", + "gpui-android", + "jni", + "log", + "ndk", + "raw-window-handle", ] [[package]] @@ -8498,6 +8676,20 @@ dependencies = [ "sys-locale", ] +[[package]] +name = "tcode-ios" +version = "0.1.0" +dependencies = [ + "anyhow", + "client-app", + "gpui", + "gpui-ios", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit", +] + [[package]] name = "tcode-runtime" version = "0.1.0" @@ -8512,12 +8704,28 @@ dependencies = [ "serde", "serde_json", "smol", + "sync-host", "tcode-core", "tcode-services", "term", "uuid", ] +[[package]] +name = "tcode-server" +version = "0.1.0" +dependencies = [ + "async-channel", + "axum", + "env_logger", + "futures-util", + "log", + "sync-host", + "sync-protocol", + "tcode-services", + "tokio", +] + [[package]] name = "tcode-services" version = "0.1.0" @@ -8580,6 +8788,23 @@ dependencies = [ "term", ] +[[package]] +name = "tcode-web" +version = "0.1.0" +dependencies = [ + "client-app", + "gpui", + "gpui-component", + "gpui-component-assets", + "gpui_platform", + "js-sys", + "sync-client", + "sync-protocol", + "tcode-ui", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -8846,6 +9071,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.28.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -9085,6 +9334,39 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 017e7eb5..1d65a739 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,29 @@ members = [ "crates/preview-mcp", "crates/orchestrate-mcp", "crates/computer-use-mcp", + "crates/gpui-android", + "crates/gpui-ios", + "crates/sync-client", + "crates/sync-host", + "crates/sync-protocol", + "crates/tcode-android", + "crates/tcode-ios", + "crates/tcode-server", + "crates/tcode-web", "crates/ui", "crates/app", ] +# client-app is deliberately not a member. It is the *client* half of the app — +# the UI a phone or browser mounts — and it depends on tcode-ui's `portable` +# feature, which is the mutually exclusive opposite of `desktop`. Cargo unifies +# features across a workspace build, so listing it here would build tcode-ui +# with both at once and neither constructor would be satisfiable. +# +# The three shells depend on it by path, inside target tables, so it is built +# for wasm/Android/iOS and never for the host. Check it directly: +# cargo check -p client-app --target wasm32-unknown-unknown +exclude = ["crates/client-app"] + resolver = "3" default-members = ["crates/app"] @@ -21,3 +41,23 @@ lto = "fat" codegen-units = 1 strip = "symbols" panic = "unwind" + +# On pinning gpui: the Zed dependency deliberately carries no `rev` in any +# manifest, and Cargo.lock is the pin. Two things rule out the alternatives. +# +# Putting `rev = ...` on our own `gpui` entries does NOT pin the tree: +# `gpui-component` depends on the same repository without a rev, so the graph +# ends up with two distinct source ids (`git+…#rev` and `git+…?rev=…#rev`) — +# two copies of `gpui` in one binary, and gpui-component's gpui types stop +# being our gpui types. +# +# Collapsing them with `[patch."https://github.com/zed-industries/zed"]` is +# also rejected: Cargo requires a patch to point at a *different* source, and +# the only correct revision lives at that same URL. (Patching would additionally +# need `version = "=0.2.2"`, because the tree carries a second package named +# `gpui` — the 0.0.0 lint fixture under `tooling/lints/test_fixture/gpui`.) +# +# So the pin is Cargo.lock, enforced two ways: CI runs every cargo command with +# `--locked`, and `crates/app/src/gpui_pin.rs` asserts the locked commit matches +# a constant recorded there. Bumping gpui = update the lock and that constant in +# one deliberate commit. diff --git a/README.md b/README.md index 224a9a7a..9c17934f 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,14 @@ promoted to a steer with one click. send it as context), an embedded preview browser the agent can drive over MCP, and a live plan/task panel. +**Check in from elsewhere.** A running Tcode can be followed from a browser — +same session, same timeline, and you can send turns and answer approval prompts +from it. A phone reads the six-character code off your screen once and never +asks again. The agent still runs on your machine; the other clients are remote +controls, not copies. [`docs/remote-and-mobile.md`](docs/remote-and-mobile.md) +covers setup, and is candid about what has been exercised versus what has only +been built — the Android and iOS shells link but have not yet run on hardware. +
Diff panel Queued messages above the composer @@ -169,6 +177,29 @@ details. The normal source command remains `cargo run` because `crates/app` is the workspace's sole default binary package. [`docs/DESIGN.md`](docs/DESIGN.md) is the visual contract. +Remote work adds a second group. It hangs off the same event log the desktop UI +already folds into a timeline, which is why it costs so little: + +``` +crates/sync-protocol the wire vocabulary, shared by both ends +crates/sync-host host state machine, with no I/O and no GPUI in it +crates/sync-client the client half of the same conversation +crates/tcode-server headless host: sync-host with a different main +crates/client-app the client UI the browser and both phones mount +crates/tcode-web browser shell (wasm) +crates/gpui-android GPUI Platform backend for Android +crates/gpui-ios GPUI Platform backend for iOS +crates/tcode-android Android app shell (JNI) +crates/tcode-ios iOS app shell (UIKit) +``` + +`client-app` is deliberately outside the workspace — it needs `tcode-ui`'s +`portable` feature, which is the mutually exclusive opposite of `desktop`, and +Cargo unifies features across a workspace build. The root `Cargo.toml` says so +at the exclusion. See [`docs/remote-and-mobile.md`](docs/remote-and-mobile.md) +for how to run any of it and [`docs/sync-protocol.md`](docs/sync-protocol.md) +for the protocol itself. + ## Contributing Issues and pull requests are welcome. [CONTRIBUTING.md](CONTRIBUTING.md) covers diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index 1bb0da2c..cc1cf80a 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -3,21 +3,34 @@ name = "agent" version = "0.1.0" edition = "2024" +[features] +default = ["local"] +local = [ + "dep:agent-client-protocol", + "dep:async-channel", + "dep:base64", + "dep:futures-lite", + "dep:log", + "dep:smol", + "dep:ureq", + "dep:uuid", +] + [dependencies] agent-client-protocol = { version = "1.2", features = [ "unstable_auth_methods", "unstable_end_turn_token_usage", -] } -async-channel = "2" -futures-lite = "2" -log = "0.4" -base64 = "0.22" +], optional = true } +async-channel = { version = "2", optional = true } +futures-lite = { version = "2", optional = true } +log = { version = "0.4", optional = true } +base64 = { version = "0.22", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" -smol = "2" +smol = { version = "2", optional = true } thiserror = "2" -ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } -uuid = { version = "1", features = ["v4"] } +ureq = { version = "2", default-features = false, features = ["tls", "gzip"], optional = true } +uuid = { version = "1", features = ["v4"], optional = true } [dev-dependencies] env_logger = "0.11" diff --git a/crates/agent/examples/image_probe.rs b/crates/agent/examples/image_probe.rs index 066da326..fccfdf2e 100644 --- a/crates/agent/examples/image_probe.rs +++ b/crates/agent/examples/image_probe.rs @@ -106,6 +106,7 @@ fn main() { media_type: mime_from_path(&image_path), data_base64: base64_encode(&bytes), source_path: None, + workspace_path: None, }; eprintln!( "image_probe: provider={provider:?} image={} ({} bytes, {}) prompt={prompt:?}", diff --git a/crates/agent/src/acp.rs b/crates/agent/src/acp.rs index 2bbdc709..30c148ca 100644 --- a/crates/agent/src/acp.rs +++ b/crates/agent/src/acp.rs @@ -2138,6 +2138,7 @@ fn file_changes(tool: &ToolState) -> Vec { acp::ToolCallContent::Diff(diff) => { let path = diff.path.to_string_lossy().into_owned(); Some(FileChange { + workspace_path: None, kind: match (tool.kind, diff.old_text.as_deref()) { (acp::ToolKind::Delete, _) => FileChangeKind::Delete, (acp::ToolKind::Move, _) => FileChangeKind::Rename, @@ -2162,6 +2163,7 @@ fn file_changes(tool: &ToolState) -> Vec { .iter() .map(|location| FileChange { path: location.path.to_string_lossy().into_owned(), + workspace_path: None, kind: match tool.kind { acp::ToolKind::Delete => FileChangeKind::Delete, _ => FileChangeKind::Rename, @@ -2281,6 +2283,7 @@ pub(crate) fn approval_request( ..Default::default() }), cwd: None, + workspace_cwd: None, reason: None, }, acp::ToolKind::Edit | acp::ToolKind::Delete | acp::ToolKind::Move => { @@ -3516,6 +3519,7 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: None, + workspace_path: None, }], ); let value = serde_json::to_value(&blocks).unwrap(); diff --git a/crates/agent/src/claude.rs b/crates/agent/src/claude.rs index 0e3905cb..6b405477 100644 --- a/crates/agent/src/claude.rs +++ b/crates/agent/src/claude.rs @@ -2213,6 +2213,7 @@ impl Mapper { .unwrap_or("") .to_string(), cwd: input.get("cwd").and_then(Value::as_str).map(str::to_string), + workspace_cwd: None, reason, }, ClaudeRequestType::FileChange => ApprovalKind::FileChange { @@ -2489,6 +2490,7 @@ fn file_changes(name: &str, input: &Value) -> Vec { let content = input.get("content").and_then(Value::as_str).unwrap_or(""); vec![FileChange { path, + workspace_path: None, kind: FileChangeKind::Create, diff: (!content.is_empty()).then(|| { content @@ -2521,12 +2523,14 @@ fn file_changes(name: &str, input: &Value) -> Vec { } vec![FileChange { path, + workspace_path: None, kind: FileChangeKind::Modify, diff: (!diff.is_empty()).then(|| diff.trim_end().to_string()), }] } _ => vec![FileChange { path, + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }], @@ -3461,11 +3465,13 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: None, + workspace_path: None, }, Attachment { media_type: "image/jpeg".into(), data_base64: "BBBB".into(), source_path: None, + workspace_path: None, }, ]; let msg = user_message("what color is this?", &attachments); diff --git a/crates/agent/src/codex.rs b/crates/agent/src/codex.rs index d2c6fbc2..89184dcd 100644 --- a/crates/agent/src/codex.rs +++ b/crates/agent/src/codex.rs @@ -1409,6 +1409,7 @@ impl Actor { .unwrap_or_default() .into(), cwd: params.get("cwd").and_then(Value::as_str).map(str::to_owned), + workspace_cwd: None, reason: params .get("reason") .and_then(Value::as_str) @@ -2360,6 +2361,7 @@ fn map_file_change(change: &Value) -> Option { }; Some(FileChange { path: change.get("path").and_then(Value::as_str)?.into(), + workspace_path: None, kind, diff: change .get("diff") @@ -2673,6 +2675,7 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: None, + workspace_path: None, }]; let params = actor.build_turn_params("what color?", None, &attachments); let input = params["input"].as_array().unwrap(); diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 9abf7eea..2e6ffac0 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -6,12 +6,21 @@ //! types; nothing provider-shaped leaks past this crate except [`ResumeCursor`], //! which is intentionally opaque. +// Provider adapters are local-only because they spawn and supervise child processes. +#[cfg(feature = "local")] pub mod acp; +#[cfg(feature = "local")] pub mod claude; +#[cfg(feature = "local")] pub mod codex; +#[cfg(feature = "local")] pub mod opencode; +#[cfg(feature = "local")] pub mod pi; +#[cfg(feature = "local")] mod process; +// Subagent tailing is local-only because it reads provider transcript files. +#[cfg(feature = "local")] mod subagent_tail; use std::path::PathBuf; @@ -289,6 +298,8 @@ pub struct SelectOption { /// working-directory change makes PATH resolution unreliable (it fails outright /// when PATH holds unexpanded entries such as `~/.dotnet/tools`). Resolving the /// binary ourselves against the parent's PATH keeps the lookup deterministic. +// Binary resolution is local-only because it probes PATH and the filesystem before spawning. +#[cfg(feature = "local")] pub(crate) fn resolve_binary( binary_path: Option<&std::path::Path>, default_name: &str, @@ -323,6 +334,8 @@ pub(crate) fn resolve_binary( /// /// Shared with the app crate (`crate::process`), which routes every bare-name /// spawn (npm, pnpm, bun, git, …) through it on Windows. +// PATH lookup is local-only because it inspects host environment and filesystem state. +#[cfg(feature = "local")] pub fn find_on_path(name: &str) -> Option { let path_var = std::env::var_os("PATH")?; find_in_dirs( @@ -335,6 +348,7 @@ pub fn find_on_path(name: &str) -> Option { /// The executable extensions to try for a bare name: `PATHEXT` on Windows /// (falling back to its documented default), and nothing anywhere else. +#[cfg(feature = "local")] fn path_extensions() -> Vec { if !cfg!(windows) { return Vec::new(); @@ -355,6 +369,7 @@ fn path_extensions() -> Vec { /// extensionless name tried last (so a Unix-style extensionless binary dropped /// on a Windows PATH still resolves). With an empty `pathext` (every non-Windows /// target) this is just `[name]`. +#[cfg(feature = "local")] fn candidate_names(name: &str, pathext: &[String]) -> Vec { if std::path::Path::new(name).extension().is_some() { return vec![name.to_string()]; @@ -367,6 +382,7 @@ fn candidate_names(name: &str, pathext: &[String]) -> Vec { /// The PATH walk itself, parameterized on the directories, the extension list /// and the "is this executable" predicate so both platform branches are testable /// from any host (see `resolve_binary_tests`). +#[cfg(feature = "local")] fn find_in_dirs( dirs: impl IntoIterator, name: &str, @@ -390,7 +406,7 @@ fn find_in_dirs( None } -#[cfg(unix)] +#[cfg(all(feature = "local", unix))] fn is_executable(path: &std::path::Path) -> bool { use std::os::unix::fs::PermissionsExt as _; std::fs::metadata(path) @@ -400,7 +416,7 @@ fn is_executable(path: &std::path::Path) -> bool { /// Windows has no exec bit: a regular file whose name matched a `PATHEXT` /// candidate *is* the executable. -#[cfg(not(unix))] +#[cfg(all(feature = "local", not(unix)))] fn is_executable(path: &std::path::Path) -> bool { path.is_file() } @@ -448,6 +464,11 @@ pub struct Attachment { /// the timeline can render the image without re-encoding the base64. #[serde(default, skip_serializing_if = "Option::is_none")] pub source_path: Option, + /// Workspace-relative counterpart of `source_path`, when the source lives + /// inside the session workspace. The absolute path remains authoritative + /// for local rendering; remote clients use this additive form. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, } /// A provider-native command or skill surfaced to the composer's `/` and `$` @@ -481,7 +502,7 @@ pub enum InteractionMode { /// Per-turn overrides layered on top of the session's persisted options. /// Codex and OpenCode apply effort/variant per turn; Claude and pi use their /// session-level option selection. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct TurnOptions { pub effort: Option, pub interaction_mode: Option, @@ -490,6 +511,8 @@ pub struct TurnOptions { /// List the provider's models (spawn, query, teardown). `launch_env` carries the /// provider's configured environment/home so the catalog reflects the same CLI /// (and account) a session would actually run against. +// Model discovery is local-only because it launches or queries provider processes. +#[cfg(feature = "local")] pub async fn list_models( provider: ProviderKind, binary_path: Option, @@ -545,7 +568,17 @@ pub enum AgentError { } /// Commands the UI sends into a live session's actor loop. -#[derive(Debug, Clone)] +/// +/// Serializable because this is also the client-to-host half of the remote +/// protocol (`docs/multiplatform-plan.md` §3.1): a phone or browser client +/// cannot spawn a provider, so it sends these to whichever machine can. +/// +/// Adjacently tagged rather than internally tagged like [`AgentEvent`]: +/// `SetApprovalMode` and `SetInteractionMode` are newtype variants wrapping +/// enums that serialize to strings, and Serde cannot represent those inside an +/// internally tagged enum — it fails at runtime, not compile time. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum SessionCommand { SendTurn { /// Runtime queue id echoed by [`AgentEvent::TurnAccepted`] after the @@ -614,6 +647,8 @@ pub enum SessionCommand { /// A live provider session: send commands in, read canonical events out. /// Dropping both channels (or sending `Shutdown`) tears the child process down. +// Session handles are local-only because their channels are owned by provider actors. +#[cfg(feature = "local")] pub struct SessionHandle { pub provider: ProviderKind, pub commands: async_channel::Sender, @@ -621,6 +656,8 @@ pub struct SessionHandle { } /// Start a new (or resumed) session with the given provider. +// Session startup is local-only because every adapter launches a child process. +#[cfg(feature = "local")] pub async fn start_session( provider: ProviderKind, opts: SessionOptions, @@ -821,6 +858,51 @@ pub enum AgentEvent { }, } +impl AgentEvent { + /// Add portable siblings to every typed workspace path in this event. + /// + /// This is intentionally enrichment rather than rewriting: local consumers + /// keep the exact host paths while persisted and synced events also carry + /// the form a remote client can resolve. + pub fn add_workspace_paths(&mut self, workspace_id: &str, workspace_root: &std::path::Path) { + match self { + Self::TurnChangesUpdated { changes, .. } => { + add_file_change_workspace_paths(changes, workspace_id, workspace_root); + } + Self::ItemStarted(item) | Self::ItemUpdated(item) | Self::ItemCompleted(item) => { + if let ItemContent::FileChange { changes, .. } = &mut item.content { + add_file_change_workspace_paths(changes, workspace_id, workspace_root); + } + } + Self::ApprovalRequested(request) => match &mut request.kind { + ApprovalKind::ExecCommand { + cwd, workspace_cwd, .. + } => { + *workspace_cwd = cwd.as_deref().and_then(|cwd| { + WorkspacePath::from_host_path(workspace_id, workspace_root, cwd) + }); + } + ApprovalKind::FileChange { changes, .. } => { + add_file_change_workspace_paths(changes, workspace_id, workspace_root); + } + ApprovalKind::FileRead { .. } | ApprovalKind::ToolUse { .. } => {} + }, + _ => {} + } + } +} + +fn add_file_change_workspace_paths( + changes: &mut [FileChange], + workspace_id: &str, + workspace_root: &std::path::Path, +) { + for change in changes { + change.workspace_path = + WorkspacePath::from_host_path(workspace_id, workspace_root, &change.path); + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlanStep { pub step: String, @@ -965,11 +1047,80 @@ pub enum ItemContent { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileChange { pub path: String, + /// Portable counterpart of `path`. Kept separate so old logs and the local + /// UI continue to use the provider's exact path unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, pub kind: FileChangeKind, /// Unified diff for this file when the provider supplies one. pub diff: Option, } +/// A path a remote client can interpret without knowing the host filesystem. +/// +/// `workspace_id` is the persisted project id, not its display name: basenames +/// are neither unique nor stable enough to correlate paths across sessions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkspacePath { + pub workspace_id: String, + pub relative_path: String, +} + +impl WorkspacePath { + /// Build a portable path only when `path` is relative already or is inside + /// `workspace_root`. Outside paths deliberately stay host-only rather than + /// acquiring misleading `..` segments. + pub fn from_host_path( + workspace_id: &str, + workspace_root: &std::path::Path, + path: &str, + ) -> Option { + let path = std::path::Path::new(path); + // Not `is_absolute()`: on Windows that is false for a path beginning + // `/`, which has a root but no drive letter. Such a path would then take + // the relative branch and be emitted verbatim — claiming an absolute + // path is workspace-relative, which is exactly the misleading output + // this function refuses to produce for `..`. A drive prefix without a + // root (`C:file`) is not workspace-relative either, so both count. + let rooted = path.components().next().is_some_and(|first| { + matches!( + first, + std::path::Component::Prefix(_) | std::path::Component::RootDir + ) + }); + let relative = if rooted { + path.strip_prefix(workspace_root).ok()? + } else { + path + }; + if relative + .components() + .any(|component| component == std::path::Component::ParentDir) + { + return None; + } + let relative_path = if relative.as_os_str().is_empty() { + ".".to_owned() + } else { + relative.to_string_lossy().into_owned() + }; + Some(Self { + workspace_id: workspace_id.to_owned(), + relative_path, + }) + } +} + +impl Attachment { + /// Add a portable sibling when this local source is a workspace file. + pub fn add_workspace_path(&mut self, workspace_id: &str, workspace_root: &std::path::Path) { + self.workspace_path = self + .source_path + .as_deref() + .and_then(|path| WorkspacePath::from_host_path(workspace_id, workspace_root, path)); + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum FileChangeKind { @@ -1057,6 +1208,7 @@ pub fn file_changes_from_unified_diff(diff: &str) -> Result, Str Ok(FileChange { path, + workspace_path: None, kind, diff: Some(section), }) @@ -1102,6 +1254,10 @@ pub enum ApprovalKind { ExecCommand { command: String, cwd: Option, + /// Portable counterpart of `cwd`; absent for legacy events and for + /// commands whose working directory is outside the workspace. + #[serde(default, skip_serializing_if = "Option::is_none")] + workspace_cwd: Option, reason: Option, }, /// A read-only file/search operation (Claude's `file_read_approval` family: @@ -1232,7 +1388,7 @@ mod launch_env_tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "local"))] mod resolve_binary_tests { use super::*; @@ -1410,7 +1566,7 @@ mod resolve_binary_tests { } } -#[cfg(test)] +#[cfg(all(test, feature = "local"))] mod pathext_logic_tests { use super::*; use std::path::PathBuf; @@ -1546,6 +1702,7 @@ mod turn_diff_tests { turn_id: "turn-7".into(), changes: vec![FileChange { path: "src/main.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("@@ -1 +1 @@\n-old\n+new\n".into()), }], @@ -1562,6 +1719,98 @@ mod turn_diff_tests { } if turn_id == "turn-7" && changes[0].path == "src/main.rs" )); } + + #[test] + fn typed_paths_gain_portable_siblings_and_legacy_json_still_parses() { + let legacy = r#"{"type":"approval_requested","id":"approval-1","turn_id":null,"kind":{"kind":"exec_command","command":"cargo test","cwd":"/work/tcode","reason":null},"options":[]}"#; + let mut event: AgentEvent = serde_json::from_str(legacy).unwrap(); + + assert!(matches!( + &event, + AgentEvent::ApprovalRequested(ApprovalRequest { + kind: ApprovalKind::ExecCommand { + workspace_cwd: None, + .. + }, + .. + }) + )); + + event.add_workspace_paths("project-stable-id", std::path::Path::new("/work/tcode")); + let json = serde_json::to_value(&event).unwrap(); + assert_eq!(json["kind"]["cwd"], "/work/tcode"); + assert_eq!( + json["kind"]["workspace_cwd"], + serde_json::json!({ + "workspace_id": "project-stable-id", + "relative_path": "." + }) + ); + + let mut change = AgentEvent::TurnChangesUpdated { + turn_id: "turn-1".into(), + changes: vec![FileChange { + path: "/work/tcode/src/main.rs".into(), + workspace_path: None, + kind: FileChangeKind::Modify, + diff: None, + }], + completeness: ChangeCompleteness::Exact, + }; + change.add_workspace_paths("project-stable-id", std::path::Path::new("/work/tcode")); + let AgentEvent::TurnChangesUpdated { changes, .. } = change else { + unreachable!() + }; + assert_eq!( + changes[0].workspace_path, + Some(WorkspacePath { + workspace_id: "project-stable-id".into(), + relative_path: "src/main.rs".into(), + }) + ); + } + + #[test] + fn attachment_source_path_gets_a_workspace_relative_sibling() { + let mut attachment = Attachment { + media_type: "image/png".into(), + data_base64: "AAAA".into(), + source_path: Some("/work/tcode/assets/diagram.png".into()), + workspace_path: None, + }; + attachment.add_workspace_path("project-stable-id", std::path::Path::new("/work/tcode")); + assert_eq!( + attachment.workspace_path, + Some(WorkspacePath { + workspace_id: "project-stable-id".into(), + relative_path: "assets/diagram.png".into(), + }) + ); + + let legacy = r#"{"media_type":"image/png","data_base64":"AAAA","source_path":"/work/tcode/assets/diagram.png"}"#; + let decoded: Attachment = serde_json::from_str(legacy).unwrap(); + assert_eq!(decoded.workspace_path, None); + } + + /// A path outside the workspace must produce nothing rather than a portable + /// path that lies about where it points. + /// + /// This caught a real Windows-only defect: the branch was chosen with + /// `is_absolute()`, which is false there for a rooted path carrying no drive + /// letter, so `/elsewhere/secret.txt` took the relative branch and came back + /// as a "workspace-relative" path to a file in neither the workspace nor any + /// relative location. + #[test] + fn a_path_outside_the_workspace_has_no_portable_form() { + assert_eq!( + WorkspacePath::from_host_path( + "project-stable-id", + std::path::Path::new("/work/tcode"), + "/elsewhere/secret.txt", + ), + None + ); + } } #[cfg(test)] @@ -1622,3 +1871,133 @@ mod thread_item_serde_tests { )); } } + +#[cfg(test)] +mod session_command_wire_tests { + use super::*; + + /// One fixture per [`SessionCommand`] variant. + /// + /// Kept honest by `discriminant`, whose match is exhaustive: adding a + /// variant stops this module compiling until the new command is covered + /// here as well. A command that cannot cross the wire would strand a + /// remote client on an action the desktop UI still offers, and that is + /// exactly the kind of gap a type system can close for us. + fn every_variant() -> Vec { + vec![ + SessionCommand::SendTurn { + delivery_id: 7, + text: "hello".into(), + options: Some(TurnOptions { + effort: Some("high".into()), + interaction_mode: Some(InteractionMode::Plan), + }), + attachments: vec![Attachment { + media_type: "image/png".into(), + data_base64: "iVBORw0=".into(), + source_path: None, + workspace_path: None, + }], + }, + SessionCommand::Interrupt, + SessionCommand::RespondApproval { + request_id: "req-1".into(), + decision: ApprovalDecision::Approve, + }, + SessionCommand::RespondUserInput { + request_id: "req-2".into(), + answers: serde_json::json!({ "q1": "yes", "q2": ["a", "b"] }) + .as_object() + .cloned() + .expect("fixture is an object"), + }, + SessionCommand::SetApprovalMode(ApprovalMode::default()), + SessionCommand::Steer { + request_id: "req-3".into(), + text: "actually, use ripgrep".into(), + attachments: Vec::new(), + }, + SessionCommand::SetInteractionMode(InteractionMode::Plan), + SessionCommand::SetOption { + id: "reasoning".into(), + value: serde_json::json!({ "depth": 3 }), + }, + SessionCommand::Rewind { + checkpoint_id: "ckpt-9".into(), + mode: RewindMode::Conversation, + }, + SessionCommand::Shutdown, + ] + } + + fn discriminant(command: &SessionCommand) -> &'static str { + match command { + SessionCommand::SendTurn { .. } => "send_turn", + SessionCommand::Interrupt => "interrupt", + SessionCommand::RespondApproval { .. } => "respond_approval", + SessionCommand::RespondUserInput { .. } => "respond_user_input", + SessionCommand::SetApprovalMode(_) => "set_approval_mode", + SessionCommand::Steer { .. } => "steer", + SessionCommand::SetInteractionMode(_) => "set_interaction_mode", + SessionCommand::SetOption { .. } => "set_option", + SessionCommand::Rewind { .. } => "rewind", + SessionCommand::Shutdown => "shutdown", + } + } + + #[test] + fn every_variant_round_trips() { + for command in every_variant() { + let json = serde_json::to_string(&command) + .unwrap_or_else(|err| panic!("{command:?} must serialize: {err}")); + let back: SessionCommand = serde_json::from_str(&json) + .unwrap_or_else(|err| panic!("{command:?} must deserialize from {json}: {err}")); + assert_eq!(back, command, "round trip changed the command via {json}"); + } + } + + #[test] + fn fixtures_cover_every_variant() { + let mut seen: Vec<&str> = every_variant().iter().map(discriminant).collect(); + seen.sort_unstable(); + let mut deduped = seen.clone(); + deduped.dedup(); + assert_eq!(seen, deduped, "a variant is fixtured twice: {seen:?}"); + assert_eq!( + seen.len(), + 10, + "fixtures no longer cover every SessionCommand variant: {seen:?}" + ); + } + + /// The two newtype variants are the reason this enum is adjacently tagged + /// rather than internally tagged like `AgentEvent`. Serde only fails on + /// them at runtime, so pin the shape here: an internally tagged enum could + /// not produce a string `data` field at all. + #[test] + fn newtype_variants_survive_the_tagging_scheme() { + let json = serde_json::to_value(SessionCommand::SetInteractionMode(InteractionMode::Plan)) + .expect("newtype variant must serialize"); + assert_eq!(json["type"], "set_interaction_mode"); + assert!( + json["data"].is_string(), + "expected an adjacently tagged string payload, got {json}" + ); + } + + /// Struct variants share that shape, so a decoder never has to branch on + /// variant kind. + #[test] + fn struct_variants_nest_their_fields_under_data() { + let json = serde_json::to_value(SessionCommand::Interrupt).expect("unit variant"); + assert_eq!(json["type"], "interrupt"); + + let json = serde_json::to_value(SessionCommand::RespondApproval { + request_id: "req-1".into(), + decision: ApprovalDecision::Approve, + }) + .expect("struct variant"); + assert_eq!(json["type"], "respond_approval"); + assert_eq!(json["data"]["request_id"], "req-1"); + } +} diff --git a/crates/agent/src/opencode.rs b/crates/agent/src/opencode.rs index b145e715..675a9283 100644 --- a/crates/agent/src/opencode.rs +++ b/crates/agent/src/opencode.rs @@ -749,6 +749,7 @@ impl OpenCodeMapper { .filter_map(Value::as_str) .map(|path| FileChange { path: path.to_owned(), + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }) @@ -943,6 +944,7 @@ fn map_permission(properties: &Value) -> Option { .get("cwd") .and_then(Value::as_str) .map(str::to_owned), + workspace_cwd: None, reason: metadata .get("reason") .and_then(Value::as_str) @@ -953,6 +955,7 @@ fn map_permission(properties: &Value) -> Option { .iter() .map(|path| FileChange { path: path.clone(), + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }) @@ -1026,6 +1029,7 @@ fn map_snapshot_diffs(value: &Value) -> Vec { }; Some(FileChange { path, + workspace_path: None, kind, diff: diff.get("patch").and_then(Value::as_str).map(str::to_owned), }) diff --git a/crates/agent/src/pi.rs b/crates/agent/src/pi.rs index c425ad66..1270f274 100644 --- a/crates/agent/src/pi.rs +++ b/crates/agent/src/pi.rs @@ -1110,6 +1110,7 @@ fn tool_item(id: &str, name: &str, input: Value, output: String, status: ItemSta .map(|path| { vec![FileChange { path: path.to_owned(), + workspace_path: None, kind: if name == "write" { FileChangeKind::Create } else { @@ -1152,6 +1153,7 @@ fn approval_kind(tool_name: &str, payload: &Value) -> ApprovalKind { .get("cwd") .and_then(Value::as_str) .map(str::to_owned), + workspace_cwd: None, reason, }, "edit" | "write" => ApprovalKind::FileChange { @@ -1161,6 +1163,7 @@ fn approval_kind(tool_name: &str, payload: &Value) -> ApprovalKind { .map(|path| { vec![FileChange { path: path.to_owned(), + workspace_path: None, kind: if tool_name == "write" { FileChangeKind::Create } else { diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index f20eb4bd..36612b52 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -21,10 +21,13 @@ tcode-ui = { path = "../ui" } preview-mcp = { path = "../preview-mcp" } orchestrate-mcp = { path = "../orchestrate-mcp" } computer-use-mcp = { path = "../computer-use-mcp" } +sync-host = { path = "../sync-host" } +sync-protocol = { path = "../sync-protocol" } gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "wayland"] } gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } env_logger = "0.11" +uuid = { version = "1", features = ["v4"] } log = "0.4" [features] diff --git a/crates/app/src/gpui_pin.rs b/crates/app/src/gpui_pin.rs new file mode 100644 index 00000000..9206a078 --- /dev/null +++ b/crates/app/src/gpui_pin.rs @@ -0,0 +1,131 @@ +//! Guards the pinned Zed/gpui revision. +//! +//! gpui comes from a git dependency with no `rev` in any manifest (the root +//! `Cargo.toml` explains why neither a manifest `rev` nor a `[patch]` can work +//! here), so Cargo.lock is the only thing holding the revision still. A bare +//! `cargo update` would move it to whatever Zed's default branch points at that +//! day, and the resulting lockfile diff is large enough that the one line that +//! matters is easy to miss in review. +//! +//! These tests make that move loud: bumping gpui means editing [`EXPECTED_ZED_REV`] +//! in the same commit as the lockfile, which is exactly the deliberate, +//! separately-reviewed bump the multiplatform plan asks for +//! (`docs/multiplatform-plan.md`, phase 0). + +/// The Zed commit every `zed-industries/zed` package must resolve to. +/// +/// zed v1.13.0 · gpui 0.2.2 · 2026-07-16. +const EXPECTED_ZED_REV: &str = "1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba"; + +const ZED_REPO: &str = "git+https://github.com/zed-industries/zed"; + +/// Every distinct commit the lockfile resolves `zed-industries/zed` packages to. +/// +/// A `source` line looks like +/// `source = "git+https://github.com/zed-industries/zed#<40-hex>"`, and gains a +/// `?rev=…` query when a manifest pins one. +fn locked_zed_revs(lockfile: &str) -> Vec { + let mut revs: Vec = locked_zed_sources(lockfile) + .iter() + .filter_map(|source| Some(source.rsplit_once('#')?.1.to_owned())) + .collect(); + revs.sort(); + revs.dedup(); + revs +} + +/// Every distinct `zed-industries/zed` *source id* the lockfile mentions. +/// +/// Distinct from [`locked_zed_revs`]: one commit can still appear under two +/// source ids, which is the duplicate-crate failure described on +/// `zed_packages_share_one_source_id`. +fn locked_zed_sources(lockfile: &str) -> Vec { + let mut sources: Vec = lockfile + .lines() + .filter_map(|line| { + let value = line.trim().strip_prefix("source = \"")?.strip_suffix('"')?; + value.starts_with(ZED_REPO).then(|| value.to_owned()) + }) + .collect(); + sources.sort(); + sources.dedup(); + sources +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lockfile() -> String { + // crates/app -> crates -> workspace root + let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(std::path::Path::parent) + .expect("app manifest directory must be nested under workspace/crates"); + let path = workspace_root.join("Cargo.lock"); + std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("workspace lockfile must be readable at {path:?}: {err}")) + } + + #[test] + fn locked_gpui_matches_the_recorded_revision() { + let revs = locked_zed_revs(&lockfile()); + assert_eq!( + revs, + vec![EXPECTED_ZED_REV.to_owned()], + "Cargo.lock resolves zed-industries/zed to a different commit than \ + EXPECTED_ZED_REV in crates/app/src/gpui_pin.rs. If this is a deliberate \ + gpui bump, update that constant in the same commit as the lockfile; \ + otherwise restore the lockfile (`git checkout Cargo.lock`)." + ); + } + + /// A single commit is not enough — the packages must also share one *source + /// id*. Adding `rev = ...` to our own gpui entries while `gpui-component` + /// keeps depending on the unpinned URL yields `git+…#rev` and + /// `git+…?rev=…#rev` side by side: two `gpui` crates at the same commit, + /// whose types are nonetheless incompatible. That failure surfaces as a wall + /// of unrelated-looking type errors, so name it here instead. + #[test] + fn zed_packages_share_one_source_id() { + let sources = locked_zed_sources(&lockfile()); + assert_eq!( + sources.len(), + 1, + "zed-industries/zed resolved to {} distinct source ids, so the build \ + carries duplicate gpui crates whose types will not unify: {sources:#?}", + sources.len() + ); + } + + /// The lockfile shape both predicates parse. The first two lines are the + /// duplicate-source state: one commit, two source ids. + const SPLIT_SOURCE_LOCKFILE: &str = concat!( + "source = \"git+https://github.com/zed-industries/zed#aaa\"\n", + "source = \"git+https://github.com/zed-industries/zed?rev=aaa#aaa\"\n", + "source = \"git+https://github.com/longbridge/gpui-component?rev=bbb#bbb\"\n", + "source = \"registry+https://github.com/rust-lang/crates.io-index\"\n", + ); + + #[test] + fn rev_extraction_handles_both_pinned_and_unpinned_sources() { + assert_eq!( + locked_zed_revs(SPLIT_SOURCE_LOCKFILE), + vec!["aaa".to_owned()] + ); + } + + /// The duplicate-source guard must actually fire on the state it names — + /// one commit reached through two source ids — rather than only ever seeing + /// the healthy lockfile and passing vacuously. + #[test] + fn source_id_extraction_separates_pinned_from_unpinned() { + let sources = locked_zed_sources(SPLIT_SOURCE_LOCKFILE); + assert_eq!( + sources.len(), + 2, + "one commit under two source ids must count as two: {sources:#?}" + ); + assert_eq!(locked_zed_revs(SPLIT_SOURCE_LOCKFILE).len(), 1); + } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 5859c96c..c5a1b81e 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,6 +2,9 @@ // Debug builds keep the console so `RUST_LOG` output stays visible. #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] +// Lockfile guard only; nothing here is reachable from the binary. +#[cfg(test)] +mod gpui_pin; mod smoke; use std::{borrow::Cow, time::Duration}; @@ -10,8 +13,9 @@ use gpui::{ App, AppContext as _, Entity, KeyBinding, ParentElement as _, Styled as _, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowOptions, point, px, size, }; +use sync_protocol::HostInfo; use tcode_runtime::app::AppState; -use tcode_services::{shell_env, store::SessionStore}; +use tcode_services::{settings::SettingsStore, shell_env, store::SessionStore}; use tcode_ui::{AppShell, Quit, TogglePalette}; use tcode_ui::{assets, settings}; @@ -327,7 +331,7 @@ fn main() { Theme::global_mut(cx).apply_config(&light); Theme::global_mut(cx).apply_config(&dark); - let app_state = cx.new(|_| AppState::new(store)); + let app_state = cx.new(|_| AppState::new(store.clone())); cx.on_action::({ let app_state = app_state.clone(); move |action, cx| handle_quit(action, &app_state, cx) @@ -348,6 +352,53 @@ fn main() { } Err(err) => log::warn!("orchestrate MCP server failed to start: {err}"), } + let display_name = std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "tcode".into()); + let host_info = HostInfo { + host_id: format!("{display_name}:{}", store.root().display()), + display_name, + platform: std::env::consts::OS.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }; + let sync_server = SettingsStore::new(store.root().clone()) + .load_or_create_sync_token() + .and_then(|token| { + sync_host::start_on( + store.clone(), + host_info, + token, + std::net::SocketAddr::from(([127, 0, 0, 1], 0)), + sync_host::WakeSource::Broadcast, + ) + }); + match sync_server { + Ok(server) => { + log::info!("sync host listening at {}", server.url); + // A pairing code, not the token: a phone cannot be handed a + // credential the way a query string hands one to a browser, + // and the code is what a person can read off this screen and + // type into another. Single-use and short-lived, so logging + // it is not the exposure logging the token would be. + let code = server.pairing.issue(std::time::Instant::now(), || { + // A v4 UUID's bytes, because they come from the system + // CSPRNG. The clock is not an acceptable substitute: an + // earlier attempt read it once per character and every + // code came out `AAAAAA`. + let bytes = *uuid::Uuid::new_v4().as_bytes(); + std::array::from_fn(|index| bytes[index]) + }); + log::info!( + "pair a device within {}s with code {code}", + sync_host::pairing::CODE_LIFETIME.as_secs() + ); + app_state.update(cx, |state, cx| { + state.attach_sync_host(server); + state.pump_sync_commands(cx); + }); + } + Err(err) => log::warn!("sync host failed to start: {err}"), + } match computer_use_mcp::start() { Ok(server) => { log::info!("computer-use MCP server listening at {}", server.url); diff --git a/crates/client-app/.gitignore b/crates/client-app/.gitignore new file mode 100644 index 00000000..d120ce0a --- /dev/null +++ b/crates/client-app/.gitignore @@ -0,0 +1,3 @@ +# Not a workspace member, so it builds into its own target/ rather than +# inheriting the root one that the top-level .gitignore covers. +/target/ diff --git a/crates/client-app/Cargo.lock b/crates/client-app/Cargo.lock new file mode 100644 index 00000000..9febe4a2 --- /dev/null +++ b/crates/client-app/Cargo.lock @@ -0,0 +1,8692 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "uuid", +] + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agent" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "async-channel", + "base64", + "futures-lite", + "log", + "serde", + "serde_json", + "smol", + "thiserror 2.0.19", + "ureq", + "uuid", +] + +[[package]] +name = "agent-client-protocol" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d386d6a58f4dbe0ebfa98e915caa54005dabdefd419de3d4678b28cd5752444" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash", + "rustix 1.1.4", + "schemars 1.2.1", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b94934d118a69e921d14e94d4af5b3076563318c52662c89a0ecb3f139e1da" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06679e1542356341f4550ccfb16338b64f37f6af70de2105446ef6fbb078234c" +dependencies = [ + "anyhow", + "derive_more 2.1.1", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_with", + "strum 0.28.0", + "tracing", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +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 = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" +dependencies = [ + "object", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "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.119", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.4", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.4", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.4", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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.19", + "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 = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +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 = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +dependencies = [ + "memchr", + "serde_core", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap 2.14.0", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex 2.0.1", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "cgl" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" +dependencies = [ + "libc", +] + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "client-app" +version = "0.1.0" +dependencies = [ + "futures-util", + "gpui", + "gpui-component", + "log", + "sync-client", + "sync-protocol", + "tcode-ui", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" +dependencies = [ + "bitflags 2.13.1", + "block", + "cocoa-foundation 0.2.0", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "libc", + "objc", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "gpui_util", + "indexmap 2.14.0", + "rustc-hash", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "bzip2 0.6.1", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-graphics2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" +dependencies = [ + "bitflags 2.13.1", + "block", + "cfg-if", + "core-foundation 0.10.1", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-video" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" +dependencies = [ + "block", + "core-foundation 0.10.1", + "core-graphics2", + "io-surface", + "libc", + "metal", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e30e509674ef0ec91e21a7735766db37d163d46151b6a361d8b83dd79116bd" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[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 2.0.119", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[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 0.10.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.3+spec-1.1.0", + "vswhom", + "winreg", +] + +[[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 = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[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 = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +dependencies = [ + "getrandom 0.4.3", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[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 = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.9", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "fastrand", + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[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 = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[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 = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[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 0.11.1+wasi-snapshot-preview1", + "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + +[[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 = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.0", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gpui" +version = "0.2.2" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "bindgen", + "bitflags 2.13.1", + "block", + "cbindgen", + "chrono", + "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "collections", + "core-foundation 0.10.1", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "ctor", + "derive_more 2.1.1", + "embed-resource", + "etagere", + "foreign-types", + "futures", + "futures-concurrency", + "getrandom 0.3.4", + "gpui_macros", + "gpui_shared_string", + "gpui_util", + "heapless", + "http_client", + "image", + "inventory", + "itertools 0.14.0", + "log", + "lyon", + "mach2", + "media", + "metal", + "num_cpus", + "objc", + "parking", + "parking_lot", + "pathfinder_geometry", + "pin-project", + "pollster 0.4.0", + "postage", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "refineable", + "regex", + "resvg 0.46.0", + "scheduler", + "schemars 1.2.1", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "stacksafe", + "strum 0.27.2", + "sum_tree", + "taffy", + "thiserror 2.0.19", + "ttf-parser", + "url", + "usvg 0.46.0", + "util_macros", + "uuid", + "waker-fn", + "web-time", + "windows 0.61.3", + "zed-font-kit", + "zed-scap", +] + +[[package]] +name = "gpui-component" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" +dependencies = [ + "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "core-text", + "enum-iterator", + "futures", + "gpui", + "gpui-component-assets", + "gpui-component-macros", + "gpui_macros", + "html5ever 0.27.0", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "notify", + "num-traits", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "paste", + "raw-window-handle", + "regex", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars 1.2.1", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "unicode-segmentation", + "uuid", + "windows 0.58.0", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component-assets" +version = "0.5.1" +source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" +dependencies = [ + "anyhow", + "gpui", + "log", + "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", + "zed-reqwest", +] + +[[package]] +name = "gpui-component-macros" +version = "0.5.1" +source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-wry" +version = "0.5.0" +source = "git+https://github.com/longbridge/gpui-component?rev=031555662e99a1b5a549990b47f246d475b8288a#031555662e99a1b5a549990b47f246d475b8288a" +dependencies = [ + "anyhow", + "gpui", + "lb-wry", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "schemars 1.2.1", + "serde", + "smol_str", +] + +[[package]] +name = "gpui_util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "granit-parser" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "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.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "anyhow", + "async-compression", + "bytes", + "derive_more 2.1.1", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "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", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "ignore" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8a7b8211e695a1d0cd91cace480d4d0bd57667ab10277cc412c5f7f4884f83" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[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 0.14.2", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imara-diff" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f01d462f766df78ab820dd06f5eb700233c51f0f4c2e846520eaf4ba6aa5c5c" +dependencies = [ + "hashbrown 0.15.5", + "memchr", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[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.119", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "io-surface" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" +dependencies = [ + "cgl", + "core-foundation 0.10.1", + "core-foundation-sys", + "leaky-cow", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[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 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser", + "html5ever 0.29.1", + "indexmap 2.14.0", + "selectors", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lb-wry" +version = "0.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9cfe72bff8acf9af0d6d276569be5b9cb3f313f9882761ada5a50d3044214d4" +dependencies = [ + "base64", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dpi", + "dunce", + "flume 0.11.1", + "gdkx11", + "gtk", + "html5ever 0.29.1", + "http", + "javascriptcore-rs", + "jni", + "kuchikiki", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2 0.10.9", + "soup3", + "tao-macros", + "thiserror 2.0.19", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "leak" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" + +[[package]] +name = "leaky-cow" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" +dependencies = [ + "leak", +] + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "link-section" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc98458dfe90986c5e2f6ddcf68360c7e5c4252600153e06aa4ee8176c0f8d1" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + +[[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 = "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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +dependencies = [ + "serde_core", + "value-bag", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever 0.27.0", + "markup5ever 0.12.1", + "tendril", + "xml5ever", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[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 = "media" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.1", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metal" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" +dependencies = [ + "bitflags 2.13.1", + "block", + "core-graphics-types 0.2.0", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "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 = "nanorand" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a51313c5820b0b02bd422f4b44776fbf47961755c74ce64afc73bfad10226c3" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "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.13.1", + "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.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch2", + "libc", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch2", + "libc", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", + "objc2-metal", +] + +[[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.13.1", + "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.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-image-io" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +dependencies = [ + "block2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-javascript-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586" +dependencies = [ + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-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.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-javascript-core", + "objc2-security", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +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", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "collections", + "serde", + "serde_json", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.7", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.3", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "postage" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" +dependencies = [ + "atomic", + "crossbeam-queue", + "futures", + "log", + "parking_lot", + "pin-project", + "pollster 0.2.5", + "static_assertions", + "thiserror 1.0.69", +] + +[[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 = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8" +dependencies = [ + "toml_edit 0.20.7", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[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.119", +] + +[[package]] +name = "psm" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645dbe486e346d9b5de3ef16ede18c26e6c70ad97418f4874b8b1889d6e761ea" +dependencies = [ + "ar_archive_writer", + "cc", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.19", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.19", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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 = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg 0.2.1", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +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", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +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_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "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.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.19", + "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", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[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 = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.19", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "derive_refineable", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif 0.14.2", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.16.1", + "tiny-skia", + "usvg 0.46.0", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +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 = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rushdown" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57358ca9b61ea373ec6bf1c9e916f521f19a919587d9179624d47c40da2ffc5d" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "phf 0.13.1", + "phf_codegen 0.13.1", +] + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde-saphyr", + "serde_json", + "siphasher 1.0.3", + "toml 0.8.23", + "triomphe", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "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 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume 0.12.0", + "futures", + "parking_lot", + "rand 0.9.5", + "web-time", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "indexmap 2.14.0", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-saphyr" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stacker" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c8cdd92b6b12f5bcb1803ca3bbf5ab96e5e6b6b96b9ab77dabe9e880b3190" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.61.2", +] + +[[package]] +name = "stacksafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090" +dependencies = [ + "stacker", + "stacksafe-macro", +] + +[[package]] +name = "stacksafe-macro" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" +dependencies = [ + "proc-macro-error2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "heapless", + "log", + "rayon", + "tracing", + "ztracing", +] + +[[package]] +name = "sval" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fb9efbae90f97301f4d25f3be63dfd99d6b7af9d088228a52ec960d649b2e7d" + +[[package]] +name = "sval_buffer" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff5c0280ea0af40b3a1fd0b532680b5067482ffb81412ee66ec04b1d9952b49a" +dependencies = [ + "sval", + "sval_ref", +] + +[[package]] +name = "sval_dynamic" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59b9067f2e68f58e110cf8019a268057e3791cf74b0fdb1b3c7c6e49104f44e6" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96ebdbf0e4b175884aa587fcf551c16dabe245ea04235abdd8cbbd160b27ed5a" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e448d9fa216a6c16670b28d624fbbcf5c04e41eb187bd7c52e01222ffa72a12" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "021de5b5c26efd544c694cef9b8a9abe8633481bf3be1ea145a18a740818b291" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ef5ffec8bc52ded04ee424ab8d959e25e64fd40a48a16d21f8991ce824c1bb" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b983833a8a2390f89ebcf9b9acd06883017014b4ffd72ee28e0c9de6852039f5" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher 1.0.3", +] + +[[package]] +name = "svgtypes" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" +dependencies = [ + "kurbo 0.13.1", + "siphasher 1.0.3", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync-client" +version = "0.1.0" +dependencies = [ + "agent", + "futures-util", + "log", + "serde_json", + "sync-protocol", + "tcode-core", + "tcode-services", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "sync-protocol" +version = "0.1.0" +dependencies = [ + "agent", + "serde", + "serde_json", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.19", + "walkdir", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.1", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "taffy" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tcode-core" +version = "0.1.0" +dependencies = [ + "agent", + "log", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "tcode-i18n" +version = "0.1.0" +dependencies = [ + "rust-i18n", + "sys-locale", +] + +[[package]] +name = "tcode-services" +version = "0.1.0" +dependencies = [ + "agent", + "base64", + "bzip2 0.6.1", + "chrono", + "dirs", + "flate2", + "log", + "serde", + "serde_json", + "sha2 0.11.0", + "smol", + "tar", + "tcode-core", + "thiserror 2.0.19", + "ureq", + "uuid", + "zip", +] + +[[package]] +name = "tcode-ui" +version = "0.1.0" +dependencies = [ + "agent", + "base64", + "block2", + "chrono", + "gpui", + "gpui-component", + "gpui-component-assets", + "gpui-wry", + "image", + "imara-diff", + "lb-wry", + "log", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-image-io", + "objc2-web-kit", + "raw-window-handle", + "rushdown", + "serde", + "serde_json", + "syntect", + "tcode-core", + "tcode-i18n", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thiserror" +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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl 2.0.19", +] + +[[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 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +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", + "weezl", + "zune-jpeg 0.5.15", +] + +[[package]] +name = "time" +version = "0.3.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[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_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.11", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +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-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror 2.0.19", + "utf-8", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "flate2", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher 1.0.3", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.14.0", + "kurbo 0.13.1", + "log", + "pico-args", + "roxmltree 0.21.1", + "rustybuzz", + "simplecss", + "siphasher 1.0.3", + "strict-num", + "svgtypes 0.16.1", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "perf", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef73bfbaf3216cb59c205d7176bee1194e0d84348979da31f4a71fefe3c2054e" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b92170db3db8a6354f12a5b7f13a5453928433e08fc46aee51eedfa8f7a28a1" +dependencies = [ + "erased-serde", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf9832097ca044466ae3f1aa43a943d4e450b7c3b8cdf65363875df07da79a0" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "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 = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-implement 0.60.2", + "windows-interface 0.59.3", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.19", + "windows 0.61.3", + "windows-core 0.61.2", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +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.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[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" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.19", + "windows 0.61.3", + "windows-future", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[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 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[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.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +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 0.2.1", + "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", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +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.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.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.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.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.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.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.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[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 = "xcb" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +dependencies = [ + "bitflags 1.3.2", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever 0.12.1", +] + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-reqwest" +version = "0.12.15-zed" +source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.7", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zed-sum-tree" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" +dependencies = [ + "arrayvec", + "log", + "rayon", + "workspace-hack", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "bzip2 0.5.2", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.19", +] + +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#a3ac036eb6b73e0a50af4a44c96a43f1abf1b989" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", +] diff --git a/crates/client-app/Cargo.toml b/crates/client-app/Cargo.toml new file mode 100644 index 00000000..4ef11d97 --- /dev/null +++ b/crates/client-app/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "client-app" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +# Everything here must build for wasm, Android and iOS: this is the client UI +# all three shells mount. The only platform-specific piece is the transport, +# which each shell supplies through the `Transport` trait. +gpui = { git = "https://github.com/zed-industries/zed" } +gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } +sync-client = { path = "../sync-client" } +sync-protocol = { path = "../sync-protocol" } +tcode-ui = { path = "../ui", default-features = false, features = ["portable"] } +log = "0.4" + +# Native shells only. The browser brings its own WebSocket, and pulling tokio +# into a wasm build would not compile. +[target.'cfg(not(target_family = "wasm"))'.dependencies] +futures-util = "0.3" +tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "macros"] } +tokio-tungstenite = "0.28" diff --git a/crates/client-app/src/lib.rs b/crates/client-app/src/lib.rs new file mode 100644 index 00000000..e0866edb --- /dev/null +++ b/crates/client-app/src/lib.rs @@ -0,0 +1,1396 @@ +//! The tcode remote client, shared by every shell that hosts it. +//! +//! Web, Android and iOS mount this same view. They differ only in how bytes +//! reach a host — a browser `WebSocket`, or a native one — which is what +//! [`TransportFactory`] abstracts, and in where a token is kept between loads, +//! which is what [`ConnectionStore`] abstracts. Everything above those two +//! seams is identical, so a fix to pairing, the session list, or the approval +//! panel lands on all three at once. +//! +//! A device joins a host once, by typing a short pairing code the host shows on +//! startup; the token that buys is stored and the code is never needed again. +//! Portable by construction: `sync-client` folds the protocol, `tcode-ui`'s +//! `portable` feature supplies the timeline rendering, and nothing here touches +//! a filesystem or a process — which is the whole reason a phone can run it. + +use std::{ + collections::VecDeque, + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; + +use gpui::{ + AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + Render, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, + prelude::FluentBuilder as _, px, +}; +use gpui_component::{ + ActiveTheme as _, Disableable as _, StyledExt as _, + button::{Button, ButtonVariants as _}, + input::{Input, InputEvent, InputState}, +}; +use sync_client::{Client, ClientConfig}; +use sync_protocol::{ + AgentEvent, ApprovalDecision, ClientFrame, ClientInfo, HostFrame, RejectedCommand, + SessionCommand, SessionSummary, +}; +use tcode_ui::{ChatReadModel, ChatSessionReadModel, ChatView}; + +pub mod pairing; +pub mod storage; + +#[cfg(not(target_family = "wasm"))] +pub use storage::FileStore; +pub use storage::{ConnectionStore, MemoryStore, StoredConnection}; + +// Native shells (Android, iOS) share one WebSocket implementation; the browser +// supplies its own, since it has no sockets of this kind. +#[cfg(not(target_family = "wasm"))] +pub mod native_transport; + +/// Monotonic within this process. `SendTurn` carries the id and `TurnAccepted` +/// echoes it back, so two turns sharing one would be indistinguishable to the +/// acknowledgement tracking — which is what makes a reconnect able to tell what +/// actually landed. +fn next_delivery_id() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, Ordering::Relaxed) +} + +fn turn_command(delivery_id: u64, text: String) -> SessionCommand { + SessionCommand::SendTurn { + delivery_id, + text, + options: None, + attachments: Vec::new(), + } +} + +fn approval_command(request_id: String, decision: ApprovalDecision) -> SessionCommand { + SessionCommand::RespondApproval { + request_id, + decision, + } +} + +fn provider_approval_decision(option_id: String) -> ApprovalDecision { + ApprovalDecision::Option(option_id) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LocalTurnStatus { + AwaitingAcceptance, + Rejected(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LocalTurn { + session_id: String, + delivery_id: u64, + text: String, + status: LocalTurnStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum CommandAttemptKind { + Turn(u64), + Approval(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct CommandAttempt { + session_id: String, + kind: CommandAttemptKind, +} + +#[derive(Default)] +struct CommandUiState { + attempts: VecDeque, + turns: Vec, +} + +impl CommandUiState { + fn sent_turn(&mut self, session_id: String, delivery_id: u64, text: String) { + self.attempts.push_back(CommandAttempt { + session_id: session_id.clone(), + kind: CommandAttemptKind::Turn(delivery_id), + }); + self.turns.push(LocalTurn { + session_id, + delivery_id, + text, + status: LocalTurnStatus::AwaitingAcceptance, + }); + } + + fn sent_approval(&mut self, session_id: String, request_id: String) { + self.attempts.push_back(CommandAttempt { + session_id, + kind: CommandAttemptKind::Approval(request_id), + }); + } + + fn accepted_turn(&mut self, delivery_id: u64) { + self.attempts.retain( + |attempt| !matches!(attempt.kind, CommandAttemptKind::Turn(id) if id == delivery_id), + ); + // The host records the canonical user-message event before acknowledging + // delivery, so keeping the optimistic copy would render the turn twice. + self.turns.retain(|turn| turn.delivery_id != delivery_id); + } + + fn resolved_approvals<'a>( + &mut self, + session_id: &str, + pending_request_ids: impl Iterator, + ) { + let pending: Vec<&str> = pending_request_ids.collect(); + self.attempts.retain(|attempt| { + attempt.session_id != session_id + || !matches!( + &attempt.kind, + CommandAttemptKind::Approval(request_id) + if !pending.contains(&request_id.as_str()) + ) + }); + } + + fn rejected(&mut self, session_id: &str, command: Option<&RejectedCommand>, reason: String) { + let index = match command { + Some(RejectedCommand::Turn { delivery_id }) => { + self.attempts.iter().position(|attempt| { + attempt.session_id == session_id + && matches!( + attempt.kind, + CommandAttemptKind::Turn(id) if id == *delivery_id + ) + }) + } + Some(RejectedCommand::Approval { request_id }) => { + self.attempts.iter().position(|attempt| { + attempt.session_id == session_id + && matches!( + &attempt.kind, + CommandAttemptKind::Approval(id) if id == request_id + ) + }) + } + // Hosts predating correlation leave no safer choice than send order; + // consuming one attempt prevents optimistic state from hanging forever. + None => self + .attempts + .iter() + .position(|attempt| attempt.session_id == session_id), + }; + let Some(index) = index else { + return; + }; + let Some(attempt) = self.attempts.remove(index) else { + return; + }; + if let CommandAttemptKind::Turn(delivery_id) = attempt.kind + && let Some(turn) = self + .turns + .iter_mut() + .find(|turn| turn.delivery_id == delivery_id) + { + turn.status = LocalTurnStatus::Rejected(reason); + } + } +} + +/// A live connection to a host, owned by the shell. +pub trait Transport { + /// Send one encoded frame. The error is surfaced to the user, so it should + /// say what went wrong rather than just that something did. + fn send(&self, text: &str) -> Result<(), String>; +} + +/// How a shell opens connections. +/// +/// Taken as a factory rather than a single connection because the client +/// reconnects on its own: a dropped socket is normal on a phone, and the +/// reconnect logic belongs here — with the cursors — rather than in three +/// separate shells. +pub trait TransportFactory: Send + Sync { + /// Open a connection, send `hello` once it is ready, and push every + /// incoming frame onto `inbox`. + fn connect(&self, url: &str, hello: &str, inbox: Inbox) -> Result, String>; +} + +/// Where a transport delivers what it receives. +/// +/// A queue rather than a callback: transports deliver on whatever thread or +/// event loop they live on, while GPUI state may only be touched from its own +/// context, so the client drains this on a timer instead. +/// +/// `Arc>` rather than `Rc>` because a native transport +/// pushes from its socket thread. The browser pays nothing for that — it is +/// single-threaded, so the lock is never contended — and it is the difference +/// between one shared client and two. +pub type Inbox = Arc>>; + +/// What a shell reports about itself. +pub struct ClientIdentity { + pub client_id: String, + pub display_name: String, + pub platform: String, + pub app_version: String, +} + +impl ClientIdentity { + fn info(&self) -> ClientInfo { + ClientInfo { + client_id: self.client_id.clone(), + display_name: self.display_name.clone(), + platform: self.platform.clone(), + app_version: self.app_version.clone(), + } + } +} + +/// True when nothing is waiting. Split out so the poison handling lives in one +/// place: a panicked producer must not stop the client draining what it already +/// received. +fn inbox_is_empty(inbox: &Inbox) -> bool { + lock_inbox(inbox).is_empty() +} + +fn lock_inbox(inbox: &Inbox) -> std::sync::MutexGuard<'_, VecDeque> { + inbox + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +pub enum TransportEvent { + Open, + Text(String), + Error, + Closed(String), +} + +/// What a shell supplies to start the client. +pub struct ClientConnection { + pub identity: ClientIdentity, + /// Optional prefill for the host-address field. A URL is not a secret, so a + /// shell may pass one taken from its page URL; the token is never taken + /// that way. + pub url: Option, + /// Where the token earned by pairing is kept between loads. + pub store: Arc, +} + +/// Which screen the client is on. +/// +/// A device with no stored token starts on `Connect`; one that has paired goes +/// straight to `Session`. There is no third state — a running session is the +/// only thing worth showing once a token exists. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Phase { + /// Collecting a host address and a pairing code. `pairing` is true from the + /// moment Connect is pressed until a `Paired` or a refusal comes back, so + /// the button can disable itself while a code is in flight. + Connect { + pairing: bool, + }, + Session, +} + +pub struct ClientApp { + identity: ClientIdentity, + store: Arc, + client: Client, + transport: Arc, + socket: Option>, + incoming: Inbox, + phase: Phase, + // Connect screen. + url_input: Entity, + code_input: Entity, + /// In-progress or refusal text on the connect screen. Held apart from + /// `status` so returning to pairing does not inherit a session's last line. + connect_status: SharedString, + // Session screen. + sessions: Vec, + selected: Option, + chat: Entity, + composer: Entity, + status: SharedString, + /// Retained so a dropped socket can be re-established. Mobile and laptop + /// networks drop connections constantly; a client that cannot reconnect is + /// a client that works once. + url: Option, + hello: String, + /// Consecutive failed attempts, for backoff. Reset on a successful open. + reconnect_attempts: u32, + /// How many rejections had been reported the last time we looked, so a new + /// one can be surfaced. The host only sends these when a command could not + /// be applied, and staying silent would leave a turn rendered that never ran. + rejections_seen: usize, + command_ui: CommandUiState, + _subscriptions: Vec, + _poll: gpui::Task<()>, + _reconnect: Option>, +} + +impl ClientApp { + pub fn new( + config: ClientConnection, + transport: Arc, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let ClientConnection { + identity, + url: url_prefill, + store, + } = config; + let incoming: Inbox = Arc::new(Mutex::new(VecDeque::new())); + let chat = cx.new(|cx| ChatView::from_read_model(ChatReadModel::default(), window, cx)); + let composer = + cx.new(|cx| InputState::new(window, cx).placeholder("Send a message to this session…")); + let url_input = cx.new(|cx| InputState::new(window, cx).placeholder("ws://host:port/sync")); + let code_input = + cx.new(|cx| InputState::new(window, cx).placeholder("6-character code from the host")); + + let subscriptions = vec![ + // Upper-case the code as it is typed. A code is read off one screen + // and typed into another; failing that on caps reads as "pairing is + // broken" rather than as a typo. `set_value` suppresses its own + // Change event, so this does not recurse. + cx.subscribe_in( + &code_input, + window, + |this, input, event, window, cx| match event { + InputEvent::Change => { + let raw = input.read(cx).value().to_string(); + let upper = raw.to_ascii_uppercase(); + if upper != raw { + input.update(cx, |state, cx| state.set_value(upper, window, cx)); + } + } + InputEvent::PressEnter { .. } => this.start_pairing(cx), + _ => {} + }, + ), + cx.subscribe_in(&url_input, window, |this, _input, event, _window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.start_pairing(cx); + } + }), + ]; + + let stored = storage::usable_connection(store.load()); + if let Some(prefill) = stored + .as_ref() + .map(|connection| connection.url.clone()) + .or(url_prefill) + { + url_input.update(cx, |state, cx| state.set_value(prefill, window, cx)); + } + + // A stored token skips the connect screen entirely and opens the + // session; its absence leaves the client on Connect awaiting a code. + let mut client = Client::new(ClientConfig { + client: identity.info(), + token: String::new(), + }); + let mut phase = Phase::Connect { pairing: false }; + let mut retained_hello = String::new(); + let mut socket = None; + let mut retained_url = None; + let mut status = SharedString::default(); + if let Some(connection) = stored { + client = Client::new(ClientConfig { + client: identity.info(), + token: connection.token, + }); + retained_hello = client + .connect() + .encode() + .expect("the fixed hello frame must encode"); + retained_url = Some(connection.url.clone()); + phase = Phase::Session; + status = "Connecting…".into(); + match transport.connect(&connection.url, &retained_hello, incoming.clone()) { + Ok(open) => socket = Some(open), + Err(error) => { + status = format!("Could not reach {}: {error}", connection.url).into() + } + } + } + + let poll = cx.spawn({ + let incoming = incoming.clone(); + async move |this, cx| loop { + cx.background_executor() + .timer(Duration::from_millis(16)) + .await; + if inbox_is_empty(&incoming) { + continue; + } + if this + .update(cx, |this, cx| this.drain_transport(cx)) + .is_err() + { + break; + } + } + }); + + Self { + identity, + store, + client, + transport, + socket, + incoming, + phase, + url_input, + code_input, + connect_status: SharedString::default(), + sessions: Vec::new(), + selected: None, + chat, + composer, + status, + url: retained_url, + hello: retained_hello, + reconnect_attempts: 0, + rejections_seen: 0, + command_ui: CommandUiState::default(), + _subscriptions: subscriptions, + _poll: poll, + _reconnect: None, + } + } + + /// Trade the typed code for a token: open a connection whose first frame is + /// `Pair` rather than `Hello`. + fn start_pairing(&mut self, cx: &mut Context) { + if matches!(self.phase, Phase::Connect { pairing: true }) { + return; + } + let url = self.url_input.read(cx).value().trim().to_string(); + let code = pairing::normalize_code(&self.code_input.read(cx).value()); + if url.is_empty() { + self.connect_status = "Enter the host address.".into(); + cx.notify(); + return; + } + if !pairing::is_complete_code(&code) { + self.connect_status = + format!("A pairing code is {} characters.", pairing::CODE_LEN).into(); + cx.notify(); + return; + } + + // A pairing client carries no token — the code stands in for one — so it + // must not fall back to any stored secret. The `Pair` frame is the + // connection's first, before any `Hello`. + self.client = Client::new(ClientConfig { + client: self.identity.info(), + token: String::new(), + }); + let pair = self + .client + .pair(code) + .encode() + .expect("the pair frame must encode"); + self.url = Some(url.clone()); + self.socket = None; + lock_inbox(&self.incoming).clear(); + self.phase = Phase::Connect { pairing: true }; + self.connect_status = "Pairing…".into(); + match self.transport.connect(&url, &pair, self.incoming.clone()) { + Ok(socket) => self.socket = Some(socket), + Err(error) => { + self.connect_status = format!("Could not reach {url}: {error}").into(); + self.phase = Phase::Connect { pairing: false }; + } + } + cx.notify(); + } + + /// A code was accepted: persist the token so the code is never needed again, + /// then open the session with an ordinary `Hello`. + fn complete_pairing(&mut self, token: String, cx: &mut Context) { + let url = self.url.clone().unwrap_or_default(); + self.store.save(&StoredConnection { + url: url.clone(), + token: token.clone(), + }); + self.open_session(url, token, cx); + } + + /// Open (or reopen) the session connection with a token in hand. + /// + /// Shared by first pairing and by a stored-token start: both recreate the + /// client with the token, cache the `Hello` for reconnects, and connect. + fn open_session(&mut self, url: String, token: String, cx: &mut Context) { + self.client = Client::new(ClientConfig { + client: self.identity.info(), + token, + }); + self.hello = self + .client + .connect() + .encode() + .expect("the fixed hello frame must encode"); + self.url = Some(url.clone()); + self.phase = Phase::Session; + self.reconnect_attempts = 0; + self.rejections_seen = 0; + self.sessions.clear(); + self.selected = None; + self.command_ui = CommandUiState::default(); + // Drop the pairing socket and discard anything it left queued before the + // session socket opens, so a pairing-socket close cannot be mistaken for + // a session drop. + self.socket = None; + lock_inbox(&self.incoming).clear(); + self.status = "Connecting…".into(); + match self + .transport + .connect(&url, &self.hello, self.incoming.clone()) + { + Ok(socket) => self.socket = Some(socket), + Err(error) => self.status = format!("Could not reach {url}: {error}").into(), + } + cx.notify(); + } + + /// Forget the stored token and return to the connect screen. + fn sign_out(&mut self, cx: &mut Context) { + self.store.clear(); + self.socket = None; + self._reconnect = None; + lock_inbox(&self.incoming).clear(); + self.client = Client::new(ClientConfig { + client: self.identity.info(), + token: String::new(), + }); + self.sessions.clear(); + self.selected = None; + self.command_ui = CommandUiState::default(); + self.phase = Phase::Connect { pairing: false }; + self.status = SharedString::default(); + // The address field keeps its value: signing out is not a reason to + // make the user retype where the host is. + self.connect_status = "Signed out.".into(); + cx.notify(); + } + + fn drain_transport(&mut self, cx: &mut Context) { + let events: Vec<_> = lock_inbox(&self.incoming).drain(..).collect(); + for event in events { + match event { + TransportEvent::Open => match self.phase { + Phase::Session => { + self.status = "Authenticating…".into(); + self.reconnect_attempts = 0; + } + Phase::Connect { .. } => self.connect_status = "Pairing…".into(), + }, + TransportEvent::Text(text) => self.handle_text(&text, cx), + TransportEvent::Error => match self.phase { + Phase::Session => self.status = "WebSocket transport error".into(), + Phase::Connect { pairing } if pairing => { + self.connect_status = + "Could not reach the host — check the address.".into(); + self.phase = Phase::Connect { pairing: false }; + self.socket = None; + } + Phase::Connect { .. } => {} + }, + TransportEvent::Closed(reason) => match self.phase { + Phase::Session => { + self.status = if reason.is_empty() { + "Disconnected — reconnecting…".into() + } else { + format!("Disconnected: {reason} — reconnecting…").into() + }; + self.schedule_reconnect(cx); + } + Phase::Connect { pairing } if pairing => { + self.connect_status = + "The host closed the connection before pairing — check the address." + .into(); + self.phase = Phase::Connect { pairing: false }; + self.socket = None; + } + Phase::Connect { .. } => {} + }, + } + } + cx.notify(); + } + + fn handle_text(&mut self, text: &str, cx: &mut Context) { + let frame = match HostFrame::decode(text) { + Ok(frame) => frame, + Err(error) => { + let message: SharedString = format!("Protocol decode error: {error}").into(); + match self.phase { + Phase::Session => self.status = message, + Phase::Connect { .. } => self.connect_status = message, + } + return; + } + }; + + // Before a token exists the shared `Client` cannot fold these frames — + // its connection is not yet in a handshake — so pairing is handled here, + // exactly as `sync-probe`'s `pair` command does. + if matches!(self.phase, Phase::Connect { .. }) { + match frame { + HostFrame::Paired { token, .. } => self.complete_pairing(token, cx), + HostFrame::Refused { reason } => { + self.connect_status = pairing::refusal_message(&reason).into(); + self.phase = Phase::Connect { pairing: false }; + self.socket = None; + } + _ => {} + } + return; + } + + let was_ready = self.client.is_ready(); + let is_session_list = matches!(frame, HostFrame::SessionList { .. }); + let affects_selected = matches!( + &frame, + HostFrame::Events { session_id, .. } | HostFrame::SessionEnded { session_id, .. } + if self.selected.as_deref() == Some(session_id) + ); + + let accepted_delivery_ids: Vec = match &frame { + HostFrame::Events { events, .. } => events + .iter() + .filter_map(|event| match event.event { + AgentEvent::TurnAccepted { delivery_id } => Some(delivery_id), + _ => None, + }) + .collect(), + _ => Vec::new(), + }; + let rejected = match &frame { + HostFrame::CommandRejected { + session_id, + command, + reason, + } => Some((session_id.clone(), command.clone(), format!("{reason:?}"))), + _ => None, + }; + + let outgoing = self.client.handle(frame); + self.send_all(outgoing); + for delivery_id in accepted_delivery_ids { + self.command_ui.accepted_turn(delivery_id); + self.status = "Turn accepted by host".into(); + } + if let Some((session_id, command, reason)) = rejected { + self.command_ui + .rejected(&session_id, command.as_ref(), reason); + } + + if !was_ready && self.client.is_ready() { + let host = self + .client + .host() + .map(|host| host.display_name.clone()) + .unwrap_or_else(|| "host".into()); + self.status = format!("Connected to {host}").into(); + self.send(self.client.request_sessions()); + } else if let Some(reason) = self.client.refusal_reason().cloned() { + // A stored token the host no longer honours cannot be salvaged by + // reconnecting, so clear it and send the user back to pairing rather + // than into a loop. Other refusals land there too — the connect + // screen is where a fresh code is entered. + self.store.clear(); + self.socket = None; + self._reconnect = None; + self.phase = Phase::Connect { pairing: false }; + self.connect_status = pairing::refusal_message(&reason).into(); + return; + } + + if is_session_list { + self.sessions = self.client.sessions().to_vec(); + if self.selected.is_none() + && let Some(session_id) = self + .sessions + .first() + .map(|session| session.session_id.clone()) + { + self.select(session_id, cx); + } + } + if affects_selected { + self.refresh_chat(cx); + if let Some(session_id) = self.selected.as_deref() + && let Some(state) = self.client.session(session_id) + { + self.command_ui.resolved_approvals( + session_id, + state + .timeline() + .pending_approvals + .iter() + .map(|request| request.id.as_str()), + ); + } + } + + // A rejected command is the one failure the timeline cannot show: the + // host applied nothing, so no event will ever arrive to explain it. + let rejections = self.client.command_rejections(); + if rejections.len() > self.rejections_seen { + if let Some(latest) = rejections.last() { + self.status = format!("Command rejected: {:?}", latest.reason).into(); + } + self.rejections_seen = rejections.len(); + } + } + + fn send_all(&mut self, frames: Vec) { + for frame in frames { + self.send(frame); + } + } + + fn send(&mut self, frame: ClientFrame) -> bool { + let Some(socket) = &self.socket else { + return false; + }; + let result = frame + .encode() + .map_err(|error| error.to_string()) + .and_then(|text| socket.send(&text)); + if let Err(error) = result { + self.status = format!("WebSocket send error: {error}").into(); + false + } else { + true + } + } + + fn select(&mut self, session_id: String, cx: &mut Context) { + if self.selected.as_deref() == Some(&session_id) { + return; + } + if let Some(previous) = self.selected.replace(session_id.clone()) { + let unsubscribe = self.client.unsubscribe(previous); + self.send(unsubscribe); + } + let subscribe = self.client.subscribe(session_id); + self.send(subscribe); + self.refresh_chat(cx); + } + + /// Reconnect after a backoff, then resume every subscription from the + /// cursor this client already holds — so a reconnect costs the events that + /// were missed, not the whole log. + fn schedule_reconnect(&mut self, cx: &mut Context) { + let Some(url) = self.url.clone() else { + return; + }; + self.socket = None; + self.reconnect_attempts = self.reconnect_attempts.saturating_add(1); + // Capped exponential backoff: a host that is down should not be + // hammered, but a transient drop should recover in well under a second. + let delay = Duration::from_millis(250u64 << self.reconnect_attempts.min(6)); + let hello = self.hello.clone(); + let incoming = self.incoming.clone(); + self._reconnect = Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(delay).await; + let _ = this.update(cx, |this, cx| { + match this.transport.connect(&url, &hello, incoming) { + Ok(socket) => { + this.socket = Some(socket); + // The client keeps its cursors across the drop, so the + // resubscribe below asks only for the gap. + let resume: Vec = this + .selected + .clone() + .map(|session_id| vec![this.client.subscribe(session_id)]) + .unwrap_or_default(); + this.send_all(resume); + } + Err(error) => { + this.status = format!("Reconnect failed: {error:?}").into(); + this.schedule_reconnect(cx); + } + } + cx.notify(); + }); + })); + } + + /// Send whatever is in the composer as a turn. + /// + /// `Client::command` retains the allocated `delivery_id`; the turn is not + /// accepted until `AgentEvent::TurnAccepted` echoes it back, which is what + /// the status line reports. + fn send_turn(&mut self, window: &mut Window, cx: &mut Context) { + let Some(session_id) = self.selected.clone() else { + return; + }; + let text = self.composer.read(cx).value().to_string(); + if text.trim().is_empty() { + return; + } + // Monotonic within the page: `Date::now` alone can repeat inside one + // millisecond, and a repeated delivery id would make two turns + // indistinguishable to the acknowledgement tracking. + let delivery_id = next_delivery_id(); + let command = turn_command(delivery_id, text.clone()); + let frame = self.client.command(session_id.clone(), command); + self.command_ui + .sent_turn(session_id.clone(), delivery_id, text); + let sent = self.send(frame); + self.composer + .update(cx, |state, cx| state.set_value("", window, cx)); + if sent { + self.status = "Turn sent — awaiting acceptance…".into(); + } else { + self.command_ui.rejected( + &session_id, + Some(&RejectedCommand::Turn { delivery_id }), + "transport could not send the command".into(), + ); + } + cx.notify(); + } + + fn respond_approval( + &mut self, + request_id: String, + decision: ApprovalDecision, + cx: &mut Context, + ) { + let Some(session_id) = self.selected.clone() else { + return; + }; + let command = approval_command(request_id.clone(), decision); + let frame = self.client.command(session_id.clone(), command); + self.command_ui + .sent_approval(session_id.clone(), request_id.clone()); + if !self.send(frame) { + self.command_ui.rejected( + &session_id, + Some(&RejectedCommand::Approval { request_id }), + "transport could not send the command".into(), + ); + } + cx.notify(); + } + + /// Return to the session list on a narrow layout. + /// + /// The subscription is left in place: the host keeps streaming, so coming + /// back is instant and no backfill is re-fetched. Only the selection — + /// which is presentation state — is cleared. + fn deselect(&mut self, cx: &mut Context) { + self.selected = None; + cx.notify(); + } + + fn refresh_chat(&mut self, cx: &mut Context) { + let Some(session_id) = self.selected.as_deref() else { + return; + }; + let Some(summary) = self + .sessions + .iter() + .find(|summary| summary.session_id == session_id) + else { + return; + }; + let timeline = self + .client + .session(session_id) + .map(|state| state.timeline().clone()) + .unwrap_or_default(); + // SessionSummary intentionally carries a display name rather than a + // meaningless host-absolute path. Relative file labels still render. + let cwd = PathBuf::from(summary.project.as_deref().unwrap_or(".")); + let model = ChatReadModel::new(ChatSessionReadModel::new( + &summary.session_id, + &summary.title, + cwd, + summary.provider, + timeline, + )); + self.chat + .update(cx, |chat, cx| chat.set_read_model(model, cx)); + } +} + +impl Render for ClientApp { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + match self.phase { + Phase::Connect { pairing } => self.render_connect(pairing, cx).into_any_element(), + Phase::Session => self.render_session(window, cx).into_any_element(), + } + } +} + +impl ClientApp { + /// The connect screen: a host address, a pairing code, and one action. + fn render_connect(&self, pairing: bool, cx: &mut Context) -> gpui::AnyElement { + let muted = cx.theme().muted_foreground; + let field = |label: &str, input: &Entity| { + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_xs() + .text_color(muted) + .child(SharedString::from(label.to_string())), + ) + .child(Input::new(input)) + }; + + div() + .flex() + .flex_col() + .size_full() + .items_center() + .justify_center() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child( + div() + .flex() + .flex_col() + .gap_4() + .w(px(320.)) + .child(div().text_xl().font_semibold().child("Connect to a host")) + .child(div().text_sm().text_color(muted).child( + "On the host computer, tcode shows a short pairing code when it starts. \ + Enter the host's address and that code to pair this device once.", + )) + .child(field("Host address", &self.url_input)) + .child(field("Pairing code", &self.code_input)) + .child( + Button::new("connect") + .primary() + .label(if pairing { "Pairing…" } else { "Connect" }) + .disabled(pairing) + .on_click(cx.listener(|this, _, _, cx| this.start_pairing(cx))), + ) + .when(!self.connect_status.is_empty(), |column| { + column.child( + div() + .text_sm() + .text_color(muted) + .child(self.connect_status.clone()), + ) + }), + ) + .into_any_element() + } + + fn render_session(&self, window: &mut Window, cx: &mut Context) -> gpui::AnyElement { + // One breakpoint, not a scale factor. Below it a phone cannot show two + // panes at once — a 280px sidebar eats 72% of a 390pt screen — so the + // shell switches from side-by-side to one-at-a-time rather than + // shrinking both into uselessness. + // + // 640 logical pixels is where a sidebar plus a readable chat column + // stops fitting, not a device class: a narrow desktop window gets the + // same treatment, which is the honest generalisation and also makes the + // layout testable without a phone. + let narrow = window.viewport_size().width < px(640.0); + let showing_chat = self.selected.is_some(); + let sidebar = + self.sessions + .iter() + .fold(div().flex().flex_col().gap_1(), |column, session| { + let session_id = session.session_id.clone(); + let selected = self.selected.as_deref() == Some(session_id.as_str()); + let meta: SharedString = format!( + "{:?}{}{}", + session.provider, + if session.working { " · working" } else { "" }, + if session.awaiting_approval { + " · awaiting approval" + } else { + "" + } + ) + .into(); + column.child( + div() + .id(session_id.clone()) + .flex() + .flex_col() + .gap_1() + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .when(selected, |item| item.bg(cx.theme().accent.opacity(0.18))) + .on_click(cx.listener(move |this, _, _, cx| { + this.select(session_id.clone(), cx); + })) + .child( + div() + .text_sm() + .font_semibold() + .text_color(cx.theme().foreground) + .child(session.title.clone()), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(meta), + ), + ) + }); + + let session_list = div() + .flex() + .flex_col() + .h_full() + .when(!narrow, |column| { + column + .w(px(280.)) + .flex_shrink_0() + .border_r_1() + .border_color(cx.theme().border) + }) + .when(narrow, |column| column.size_full()) + .p_3() + .gap_3() + .child( + // The host name and the one control that leaves it: sign-out + // clears the stored token and returns to pairing. + div() + .flex() + .items_center() + .justify_between() + .child(div().text_lg().font_semibold().child("tcode")) + .child( + Button::new("sign-out") + .ghost() + .label("Sign out") + .on_click(cx.listener(|this, _, _, cx| this.sign_out(cx))), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.status.clone()), + ) + .child(sidebar); + + let conversation = div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .h_full() + // On a phone the only way back to the list is an explicit control: + // there is no second pane to click, and hiding it behind a gesture + // would make the app's one navigation action undiscoverable. + .when(narrow, |column| { + column.child( + div() + .flex() + .p_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + Button::new("back-to-sessions") + .ghost() + .label("‹ Sessions") + .on_click(cx.listener(|this, _, _, cx| this.deselect(cx))), + ), + ) + }) + .child(div().flex_1().min_h_0().child(self.chat.clone())) + .children(self.render_local_turns(cx)) + .children(self.render_approvals(cx)) + .child(self.render_composer(cx)); + + div() + .flex() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + // Narrow shows exactly one pane, chosen by whether a session is + // open; wide shows both. Collected first because each pane can only + // be placed once. + .children(if narrow { + if showing_chat { + vec![conversation.into_any_element()] + } else { + vec![session_list.into_any_element()] + } + } else { + vec![ + session_list.into_any_element(), + conversation.into_any_element(), + ] + }) + .into_any_element() + } + + fn render_local_turns(&self, cx: &mut Context) -> Vec { + let Some(session_id) = self.selected.as_deref() else { + return Vec::new(); + }; + self.command_ui + .turns + .iter() + .filter(|turn| turn.session_id == session_id) + .map(|turn| { + let (label, color) = match &turn.status { + LocalTurnStatus::AwaitingAcceptance => ( + "Sending — awaiting host acceptance".to_owned(), + cx.theme().warning, + ), + LocalTurnStatus::Rejected(reason) => ( + format!("Not sent — host rejected the command: {reason}"), + cx.theme().danger, + ), + }; + div() + .flex() + .flex_col() + .items_end() + .gap_1() + .px_3() + .py_2() + .child( + div() + .max_w(px(680.)) + .rounded_lg() + .px_3() + .py_2() + .bg(cx.theme().accent.opacity(0.16)) + .child(turn.text.clone()), + ) + .child(div().text_xs().text_color(color).child(label)) + .into_any_element() + }) + .collect() + } + + /// Pending approvals, rendered above the composer rather than inline in the + /// timeline. + /// + /// Approving a command while away from the desk is the reason a remote + /// client exists, so this must be impossible to scroll past. The requests + /// come from the synced `Timeline`, which the shared reducer already + /// maintains — the browser does not track them itself. + fn render_approvals(&self, cx: &mut Context) -> Vec { + let Some(session_id) = self.selected.as_deref() else { + return Vec::new(); + }; + let Some(state) = self.client.session(session_id) else { + return Vec::new(); + }; + state + .timeline() + .pending_approvals + .iter() + .map(|request| { + let id = request.id.clone(); + let request_id = request.id.clone(); + let options = request.options.clone(); + div() + .flex() + .flex_col() + .gap_2() + .m_3() + .p_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().warning) + .bg(cx.theme().warning.opacity(0.08)) + .child(div().text_sm().font_semibold().child("Approval needed")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(format!("{:?}", request.kind))), + ) + .child(if options.is_empty() { + self.render_fixed_approval_choices(id, cx) + } else { + options + .into_iter() + .fold(div().flex().gap_2(), |row, option| { + let request_id = request_id.clone(); + let option_id = option.id.clone(); + row.child( + Button::new(SharedString::from(format!( + "approval-option-{}", + option.id + ))) + .label(option.label) + .on_click(cx.listener(move |this, _, _, cx| { + this.respond_approval( + request_id.clone(), + provider_approval_decision(option_id.clone()), + cx, + ); + })), + ) + }) + }) + .into_any_element() + }) + .collect() + } + + fn render_fixed_approval_choices( + &self, + request_id: String, + cx: &mut Context, + ) -> gpui::Div { + [ + ("Cancel turn", ApprovalDecision::Cancel), + ("Deny", ApprovalDecision::Deny), + ("Always allow", ApprovalDecision::ApproveForSession), + ("Approve", ApprovalDecision::Approve), + ] + .into_iter() + .fold(div().flex().gap_2(), |row, (label, decision)| { + let request_id = request_id.clone(); + row.child( + Button::new(SharedString::from(format!("approval-{label}-{request_id}"))) + .label(label) + .on_click(cx.listener(move |this, _, _, cx| { + this.respond_approval(request_id.clone(), decision.clone(), cx); + })), + ) + }) + } + + fn render_composer(&self, cx: &mut Context) -> gpui::AnyElement { + let live = self.selected.is_some() && self.socket.is_some(); + div() + .flex() + .gap_2() + .p_3() + .border_t_1() + .border_color(cx.theme().border) + .child(div().flex_1().min_w_0().child(Input::new(&self.composer))) + .child( + Button::new("send") + .primary() + .label("Send") + .disabled(!live) + .on_click(cx.listener(|this, _, window, cx| this.send_turn(window, cx))), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constructs_text_turn_command() { + assert!(matches!( + turn_command(42, "ship it".into()), + SessionCommand::SendTurn { + delivery_id: 42, + text, + options: None, + attachments, + } if text == "ship it" && attachments.is_empty() + )); + } + + #[test] + fn maps_provider_choice_to_opaque_option_id() { + assert_eq!( + provider_approval_decision("allow-worktree".into()), + ApprovalDecision::Option("allow-worktree".into()) + ); + } + + #[test] + fn constructs_approval_response_command() { + assert_eq!( + approval_command("request-7".into(), ApprovalDecision::ApproveForSession), + SessionCommand::RespondApproval { + request_id: "request-7".into(), + decision: ApprovalDecision::ApproveForSession, + } + ); + } + + #[test] + fn rejection_marks_the_matching_optimistic_turn() { + let mut state = CommandUiState::default(); + state.sent_turn("s1".into(), 7, "run tests".into()); + state.rejected("s1", None, "session ended".into()); + + assert_eq!( + state.turns[0].status, + LocalTurnStatus::Rejected("session ended".into()) + ); + } + + #[test] + fn acceptance_replaces_the_optimistic_turn_with_canonical_timeline() { + let mut state = CommandUiState::default(); + state.sent_turn("s1".into(), 7, "run tests".into()); + state.accepted_turn(7); + + assert!(state.turns.is_empty()); + assert!(state.attempts.is_empty()); + } + + #[test] + fn approval_rejection_does_not_mark_a_turn_rejected() { + let mut state = CommandUiState::default(); + state.sent_approval("s1".into(), "approval-1".into()); + state.sent_turn("s1".into(), 8, "continue".into()); + state.rejected("s1", None, "approval expired".into()); + + assert_eq!(state.turns[0].status, LocalTurnStatus::AwaitingAcceptance); + } + + #[test] + fn correlated_approval_rejection_does_not_reject_an_earlier_turn() { + let mut state = CommandUiState::default(); + state.sent_turn("s1".into(), 8, "continue".into()); + state.sent_approval("s1".into(), "approval-1".into()); + state.rejected( + "s1", + Some(&RejectedCommand::Approval { + request_id: "approval-1".into(), + }), + "approval expired".into(), + ); + + assert_eq!(state.turns[0].status, LocalTurnStatus::AwaitingAcceptance); + } + + #[test] + fn uncorrelated_rejection_still_consumes_the_oldest_attempt() { + let mut state = CommandUiState::default(); + state.sent_turn("s1".into(), 8, "continue".into()); + state.sent_approval("s1".into(), "approval-1".into()); + state.rejected("s1", None, "older host refused command".into()); + + assert_eq!( + state.turns[0].status, + LocalTurnStatus::Rejected("older host refused command".into()) + ); + assert_eq!(state.attempts.len(), 1); + } +} diff --git a/crates/client-app/src/native_transport.rs b/crates/client-app/src/native_transport.rs new file mode 100644 index 00000000..bac17ad3 --- /dev/null +++ b/crates/client-app/src/native_transport.rs @@ -0,0 +1,128 @@ +//! A native WebSocket transport, shared by the Android and iOS shells. +//! +//! Both are ordinary native targets with threads and sockets, so unlike the +//! browser they can use the same tokio-tungstenite client the desktop probe +//! uses. Keeping it here rather than in two shells means the framing and +//! disconnect behaviour cannot drift between the two phones. + +use std::sync::Arc; + +use futures_util::{SinkExt as _, StreamExt as _}; +use tokio::runtime::Runtime; +use tokio::sync::mpsc; +use tokio_tungstenite::tungstenite::Message; + +use crate::{Inbox, Transport, TransportEvent, TransportFactory}; + +/// Owns the tokio runtime every socket runs on. +/// +/// One runtime for the process, built once. A phone has few cores, and giving +/// each reconnect its own runtime would spawn a fresh thread pool every time a +/// train enters a tunnel. +pub struct NativeTransportFactory { + runtime: Arc, +} + +impl NativeTransportFactory { + pub fn new() -> std::io::Result { + Ok(Self { + runtime: Arc::new( + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build()?, + ), + }) + } +} + +/// A connected socket. +/// +/// Sends are queued rather than awaited: the caller is GPUI's render thread and +/// must never block on the network, however briefly. +pub struct NativeTransport { + outgoing: mpsc::UnboundedSender, +} + +impl Transport for NativeTransport { + fn send(&self, text: &str) -> Result<(), String> { + self.outgoing + .send(text.to_owned()) + .map_err(|_| "the connection is closed".to_owned()) + } +} + +fn deliver(inbox: &Inbox, event: TransportEvent) { + inbox + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push_back(event); +} + +impl TransportFactory for NativeTransportFactory { + fn connect(&self, url: &str, hello: &str, inbox: Inbox) -> Result, String> { + let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); + let url = url.to_owned(); + let hello = hello.to_owned(); + + self.runtime.spawn(async move { + let (socket, _) = match tokio_tungstenite::connect_async(&url).await { + Ok(connected) => connected, + Err(error) => { + // Reported as a close rather than an error so the client's + // reconnect path handles it: a host that is not up yet is + // the same situation as one that went away. + deliver(&inbox, TransportEvent::Closed(error.to_string())); + return; + } + }; + let (mut sink, mut stream) = socket.split(); + + if sink.send(Message::Text(hello.into())).await.is_err() { + deliver(&inbox, TransportEvent::Error); + return; + } + deliver(&inbox, TransportEvent::Open); + + loop { + tokio::select! { + outgoing = outgoing_rx.recv() => { + let Some(text) = outgoing else { + // The client dropped its handle: close cleanly so + // the host frees the subscription instead of + // waiting for a timeout. + let _ = sink.close().await; + break; + }; + if sink.send(Message::Text(text.into())).await.is_err() { + deliver(&inbox, TransportEvent::Error); + break; + } + } + incoming = stream.next() => { + match incoming { + Some(Ok(Message::Text(text))) => { + deliver(&inbox, TransportEvent::Text(text.to_string())); + } + Some(Ok(Message::Close(_))) | None => { + deliver(&inbox, TransportEvent::Closed(String::new())); + break; + } + // Ping/pong are handled by tungstenite; binary + // frames are not part of this protocol. + Some(Ok(_)) => {} + Some(Err(error)) => { + deliver(&inbox, TransportEvent::Closed(error.to_string())); + break; + } + } + } + } + } + }); + + Ok(Box::new(NativeTransport { + outgoing: outgoing_tx, + })) + } +} diff --git a/crates/client-app/src/pairing.rs b/crates/client-app/src/pairing.rs new file mode 100644 index 00000000..451db0c4 --- /dev/null +++ b/crates/client-app/src/pairing.rs @@ -0,0 +1,136 @@ +//! Pure helpers for the connect screen: turning what a user types into a code +//! the host will recognise, and turning a host's refusal into a sentence that +//! tells them what to do about it. +//! +//! Kept free of any UI or platform type so it is testable without a browser — +//! the mistakes this guards against (a code that fails only because it was typed +//! in lower case; a refusal shown as "network error") are exactly the ones a +//! headless test can pin down. + +use sync_protocol::RefuseReason; + +/// How many characters the host mints. Mirrors `sync-host`'s `CODE_LEN`; a code +/// of any other length cannot be one this host issued. +pub const CODE_LEN: usize = 6; + +/// Canonicalise a code as typed into the form the host issued. +/// +/// A code is read off one screen and typed into another, so people add spaces +/// or dashes ("K7 M2 QX") and type in whatever case is convenient. Dropping the +/// separators and upper-casing makes those the same code rather than three +/// different failures — the host's alphabet is upper-case only, so a lower-case +/// letter is never a real character, just a typed one. +pub fn normalize_code(raw: &str) -> String { + raw.chars() + .filter(char::is_ascii_alphanumeric) + .map(|c| c.to_ascii_uppercase()) + .collect() +} + +/// Whether a normalised code is worth sending. +/// +/// Length only, deliberately not the alphabet: the exact set of characters is a +/// host detail (`sync-host/src/pairing.rs`), and mirroring it here would make +/// this crate reject valid codes the day the host widens it. A wrong-but-well- +/// formed code is the host's to refuse, and it does so with one reason on +/// purpose. +pub fn is_complete_code(code: &str) -> bool { + code.chars().count() == CODE_LEN +} + +/// Phrase a handshake refusal as an instruction, not a stack trace. +/// +/// The one thing the user must learn from a refusal is whether to retype the +/// code or check the address — so none of these read as a transport error, and +/// the pairing case names the code explicitly. +pub fn refusal_message(reason: &RefuseReason) -> String { + match reason { + RefuseReason::PairingRejected => { + "That code was wrong or has expired. Read the current code off the host and try again." + .to_string() + } + RefuseReason::Unauthorized => { + "The host no longer accepts this device's saved token. Pair again to get a new one." + .to_string() + } + RefuseReason::UnsupportedVersion { host_min, host_max } => format!( + "This app and that host do not share a protocol version (host speaks {host_min}–{host_max}). Update whichever is older." + ), + RefuseReason::Unavailable { detail } => { + format!("The host is not accepting connections right now: {detail}") + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn case_and_separators_do_not_change_the_code() { + // The bug this exists to prevent: the same code typed three ways + // resolving to three different strings, one of which the host rejects. + assert_eq!(normalize_code("k7m2qx"), "K7M2QX"); + assert_eq!(normalize_code("K7 M2 QX"), "K7M2QX"); + assert_eq!(normalize_code(" k7-m2-qx "), "K7M2QX"); + } + + #[test] + fn completeness_is_measured_after_normalising() { + assert!(is_complete_code(&normalize_code("k7-m2-qx"))); + assert!(!is_complete_code(&normalize_code("k7m2"))); + assert!(!is_complete_code(&normalize_code("k7m2qxx"))); + } + + #[test] + fn a_wrong_code_reads_as_a_typo_not_a_network_fault() { + let message = refusal_message(&RefuseReason::PairingRejected); + assert!(message.to_lowercase().contains("code")); + assert!(!mentions_transport(&message)); + } + + #[test] + fn every_refusal_is_actionable_and_never_a_transport_error() { + for reason in [ + RefuseReason::PairingRejected, + RefuseReason::Unauthorized, + RefuseReason::UnsupportedVersion { + host_min: 2, + host_max: 3, + }, + RefuseReason::Unavailable { + detail: "at capacity".into(), + }, + ] { + let message = refusal_message(&reason); + assert!(!message.is_empty()); + assert!( + !mentions_transport(&message), + "a refusal must not read as a connection failure: {message}" + ); + } + } + + /// The version mismatch has to say which side is behind, not just "refused". + #[test] + fn a_version_mismatch_names_the_hosts_range() { + let message = refusal_message(&RefuseReason::UnsupportedVersion { + host_min: 4, + host_max: 7, + }); + assert!(message.contains('4') && message.contains('7')); + } + + fn mentions_transport(message: &str) -> bool { + let lower = message.to_lowercase(); + [ + "network", + "socket", + "websocket", + "connection error", + "transport", + ] + .iter() + .any(|term| lower.contains(term)) + } +} diff --git a/crates/client-app/src/storage.rs b/crates/client-app/src/storage.rs new file mode 100644 index 00000000..05326712 --- /dev/null +++ b/crates/client-app/src/storage.rs @@ -0,0 +1,194 @@ +//! Where a shell persists the result of pairing, so a device pairs once rather +//! than every load. +//! +//! The concrete backend is injected — never chosen here — so this crate does +//! not learn what platform it runs on: the browser backs [`ConnectionStore`] +//! with `localStorage`, a phone will back it with a file, and neither has to +//! fork the client to do it. + +use std::sync::{Arc, Mutex}; + +/// The durable outcome of pairing: which host, and the token that authenticates +/// to it. +/// +/// The token is the secret. The URL is kept beside it only so a reload need not +/// re-type the address — a URL in an address bar was never the problem pairing +/// set out to solve. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredConnection { + pub url: String, + pub token: String, +} + +/// A place to keep one [`StoredConnection`] across loads. +pub trait ConnectionStore: Send + Sync { + /// The saved connection, or `None` if this device has not paired. + fn load(&self) -> Option; + /// Persist `connection`, replacing any previous one. + fn save(&self, connection: &StoredConnection); + /// Forget the saved connection. Backs the sign-out action. + fn clear(&self); +} + +/// Drop a stored connection whose token cannot authenticate. +/// +/// A blank token is no token: some backends return `Some("")` for a missing key +/// rather than `None`, and an empty token can only produce a doomed handshake — +/// so it must land the user on the connect screen, not in a reconnect loop. +pub fn usable_connection(stored: Option) -> Option { + stored.filter(|connection| !connection.token.trim().is_empty()) +} + +/// A store that keeps its value in memory only. +/// +/// For tests, and for any shell that deliberately does not persist. Cloneable +/// so a test can hold a handle to inspect what the client wrote. +#[derive(Clone, Default)] +pub struct MemoryStore { + slot: Arc>>, +} + +impl MemoryStore { + pub fn new() -> Self { + Self::default() + } + + /// Seed a store as if the device had already paired. + pub fn with(connection: StoredConnection) -> Self { + Self { + slot: Arc::new(Mutex::new(Some(connection))), + } + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.slot + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// A [`ConnectionStore`] backed by a single file. +/// +/// The native shells' equivalent of the browser's `localStorage`: Android hands +/// it the app-private files dir, iOS the app container's Documents dir, both of +/// which the OS already isolates per app. Not built for wasm, which has no +/// filesystem — the browser uses `localStorage` instead. +/// +/// Format is deliberately trivial: the URL on the first line, the token on the +/// second. Neither contains a newline (a URL cannot, and the host mints tokens +/// from a newline-free alphabet), so no escaping or serialization dependency is +/// needed to round-trip them. +#[cfg(not(target_family = "wasm"))] +pub struct FileStore { + path: std::path::PathBuf, +} + +#[cfg(not(target_family = "wasm"))] +impl FileStore { + /// Keep the connection under `dir`, which the shell supplies (its app-private + /// storage). The directory is created on save if it does not yet exist. + pub fn new(dir: impl Into) -> Self { + Self { + path: dir.into().join("sync-connection"), + } + } +} + +#[cfg(not(target_family = "wasm"))] +impl ConnectionStore for FileStore { + fn load(&self) -> Option { + let contents = std::fs::read_to_string(&self.path).ok()?; + // A file without the second line is corrupt, not a valid empty + // connection, so it is treated as absent rather than half-read. + let (url, token) = contents.split_once('\n')?; + Some(StoredConnection { + url: url.to_string(), + token: token.trim_end_matches('\n').to_string(), + }) + } + + fn save(&self, connection: &StoredConnection) { + if let Some(parent) = self.path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Best-effort, like the browser: failing to persist is not worth + // interrupting a working session — the user simply pairs again next + // launch. + let _ = std::fs::write( + &self.path, + format!("{}\n{}", connection.url, connection.token), + ); + } + + fn clear(&self) { + let _ = std::fs::remove_file(&self.path); + } +} + +impl ConnectionStore for MemoryStore { + fn load(&self) -> Option { + self.lock().clone() + } + + fn save(&self, connection: &StoredConnection) { + *self.lock() = Some(connection.clone()); + } + + fn clear(&self) { + *self.lock() = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connection(token: &str) -> StoredConnection { + StoredConnection { + url: "ws://host:1234/sync".into(), + token: token.into(), + } + } + + #[test] + fn a_saved_connection_survives_until_cleared() { + let store = MemoryStore::new(); + assert_eq!(store.load(), None); + + store.save(&connection("durable")); + assert_eq!(store.load(), Some(connection("durable"))); + + store.clear(); + assert_eq!(store.load(), None, "sign-out must leave nothing behind"); + } + + #[test] + fn an_empty_token_is_treated_as_no_connection() { + // A backend that returns "" for a missing key must not send the client + // into a handshake it cannot pass. + assert_eq!(usable_connection(Some(connection(""))), None); + assert_eq!(usable_connection(Some(connection(" "))), None); + assert_eq!( + usable_connection(Some(connection("real"))), + Some(connection("real")) + ); + } + + #[test] + fn a_file_store_round_trips_and_clears() { + // This test owns this directory name, so the process id is enough to + // keep concurrent test binaries apart without a temp-file dependency. + let dir = + std::env::temp_dir().join(format!("tcode-filestore-roundtrip-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let store = FileStore::new(&dir); + + assert_eq!(store.load(), None, "nothing is stored before a save"); + store.save(&connection("durable")); + assert_eq!(store.load(), Some(connection("durable"))); + + store.clear(); + assert_eq!(store.load(), None, "clearing removes the file"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 1b309116..42dc9867 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -4,8 +4,17 @@ version = "0.1.0" edition = "2024" [dependencies] -agent = { path = "../agent" } +# Types only: core holds domain data and pure reducers, none of which needs a +# provider process. Keeping `local` off is what lets it — and the sync client +# that folds timelines from it — build for wasm. +agent = { path = "../agent", default-features = false } log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4", "serde"] } + +# `uuid::new_v4` needs a randomness source, and on wasm that is the browser's +# crypto API rather than the OS. Scoped to wasm so native builds keep their +# current dependency set unchanged. +[target.'cfg(target_family = "wasm")'.dependencies] +uuid = { version = "1", features = ["v4", "serde", "js"] } diff --git a/crates/core/src/git.rs b/crates/core/src/git.rs index 58d792dc..8843ebe1 100644 --- a/crates/core/src/git.rs +++ b/crates/core/src/git.rs @@ -599,16 +599,19 @@ mod tests { let changes = [ FileChange { path: "/repo/src/lib.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("--- a/src/lib.rs\n+++ b/src/lib.rs\n-old\n+middle\n".into()), }, FileChange { path: "/repo/other.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-gone\n+new\n".into()), }, FileChange { path: "/repo/src/lib.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-middle\n-final\n+replacement\n".into()), }, diff --git a/crates/core/src/session.rs b/crates/core/src/session.rs index da881e65..b6d99494 100644 --- a/crates/core/src/session.rs +++ b/crates/core/src/session.rs @@ -136,12 +136,29 @@ pub fn append_review_comments_to_prompt(prompt: &str, comments: &[ReviewComment] #[derive(Debug, Clone)] pub struct StoredEvent { pub ts: Option, + /// Position in the session's log: 1-based, contiguous, and strictly + /// increasing. This — not `ts` — is the session's total order. + /// + /// `ts` cannot order events: it is a wall clock the writer explicitly + /// tolerates repeating or going backwards, so two events can share one + /// millisecond and a clock adjustment can invert a pair. Remote clients + /// resume from a `seq` cursor, which requires an order that actually holds. + /// + /// `None` means the event did not come from a log — a synthetic or + /// in-memory event. Everything `SessionStore::read_events` returns carries + /// `Some`, including lines written before the field existed (they are + /// numbered by position). + pub seq: Option, pub event: AgentEvent, } impl From for StoredEvent { fn from(event: AgentEvent) -> Self { - StoredEvent { ts: None, event } + StoredEvent { + ts: None, + seq: None, + event, + } } } @@ -1566,6 +1583,7 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/lib.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-old\n+new\n".into()), }], @@ -1578,6 +1596,7 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/child.rs".into(), + workspace_path: None, kind: FileChangeKind::Create, diff: Some("+child\n".into()), }], @@ -1606,6 +1625,7 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/lib.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-intermediate\n+value\n".into()), }], @@ -1620,6 +1640,7 @@ mod tests { turn_id: "turn-1".into(), changes: vec![FileChange { path: "src/lib.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some("-before\n+after\n".into()), }], @@ -1634,6 +1655,7 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "late.txt".into(), + workspace_path: None, kind: FileChangeKind::Create, diff: Some("+late\n".into()), }], @@ -1675,6 +1697,7 @@ mod tests { turn_id: "turn-1".into(), changes: vec![FileChange { path: "first.txt".into(), + workspace_path: None, kind: FileChangeKind::Create, diff: Some("+first\n".into()), }], @@ -1699,6 +1722,7 @@ mod tests { content: ItemContent::FileChange { changes: vec![FileChange { path: "src/lib.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }], @@ -2023,6 +2047,7 @@ mod tests { fn fold_codex_style_trace_with_approval() { let changes = vec![FileChange { path: "/tmp/probe-codex/hello.txt".into(), + workspace_path: None, kind: FileChangeKind::Create, diff: Some("hi\n".into()), }]; @@ -2195,16 +2220,19 @@ mod tests { let stored = vec![ StoredEvent { ts: Some(1_000_000), + seq: None, event: user_msg("u1", "first"), }, StoredEvent { ts: Some(1_000_500), + seq: None, event: AgentEvent::TurnStarted { turn_id: "t1".into(), }, }, StoredEvent { ts: Some(1_002_000), + seq: None, event: AgentEvent::ItemCompleted(ThreadItem { id: "a1".into(), parent_item_id: None, @@ -2213,6 +2241,7 @@ mod tests { }, StoredEvent { ts: Some(1_005_500), + seq: None, event: AgentEvent::TurnCompleted { turn_id: "t1".into(), status: TurnStatus::Completed, @@ -2221,10 +2250,12 @@ mod tests { }, StoredEvent { ts: Some(2_000_000), + seq: None, event: user_msg("u2", "second"), }, StoredEvent { ts: Some(2_000_400), + seq: None, event: AgentEvent::TurnStarted { turn_id: "t2".into(), }, @@ -2564,6 +2595,7 @@ mod tests { kind: ApprovalKind::ExecCommand { command: "rm -rf /".into(), cwd: None, + workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -2769,6 +2801,7 @@ mod tests { fn at(ts: u64, event: AgentEvent) -> StoredEvent { StoredEvent { ts: Some(ts), + seq: None, event, } } diff --git a/crates/gpui-android/Cargo.toml b/crates/gpui-android/Cargo.toml new file mode 100644 index 00000000..a55c0bcc --- /dev/null +++ b/crates/gpui-android/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gpui-android" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["lib"] + +[target.'cfg(target_os = "android")'.dependencies] +anyhow = "1" +futures = "0.3" +gpui = { git = "https://github.com/zed-industries/zed", default-features = false } +gpui_wgpu = { git = "https://github.com/zed-industries/zed" } +log = "0.4" +ndk = "0.9" +raw-window-handle = { version = "0.6", features = ["std"] } +uuid = "1" diff --git a/crates/gpui-android/README.md b/crates/gpui-android/README.md new file mode 100644 index 00000000..2b75414f --- /dev/null +++ b/crates/gpui-android/README.md @@ -0,0 +1,56 @@ +# gpui-android + +This crate is the Android `gpui::Platform` backend. Its Rust implementation is +compiled only for `target_os = "android"` so ordinary workspace builds remain +host-only. + +## Android check prerequisites + +An Android C toolchain is required even for `cargo check`. GPUI depends on +`stacksafe`, which depends on `psm`; `psm`'s build script assembles an AArch64 +source file while dependencies are being checked. Installing only the Rust +`aarch64-linux-android` standard library is therefore insufficient. + +The supported floor is Android API 26. This was verified with Homebrew's Android +NDK revision 29.0.14206865. A verification shell can be configured without +committing toolchain paths: + +```sh +export CC_aarch64_linux_android=/opt/homebrew/share/android-ndk/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android26-clang +export AR_aarch64_linux_android=/opt/homebrew/share/android-ndk/toolchains/llvm/prebuilt/darwin-x86_64/bin/llvm-ar +cargo check -p gpui-android --target aarch64-linux-android +``` + +The prebuilt directory may be named `darwin-arm64` in other NDK distributions. +Do not point these variables at host Clang for a real Android build: host Clang +is useful only as an explicitly reported type-check fallback and is not an +Android linker/toolchain. + +## JNI/NDK boundary + +`AndroidHost` is the outbound, thread-safe half of the boundary. A bridge should +hold a `JavaVM` and global references rather than a thread-local `JNIEnv`, because +`wake_main_thread` can be called by Rust worker threads. It must post that wake +to the designated Android Looper, coalesce `request_frame` calls into one pending +Choreographer callback, and implement the Activity, intent, clipboard, credential, +back, and IME operations declared by the trait. + +`AndroidEventSink` is the inbound, UI-thread-only half. Start in this order: + +1. Convert the Java `Surface` to an owned `ndk::NativeWindow`, wrap it in + `AndroidNativeWindow`, and pair it with validated physical dimensions and + density in `AndroidSurface`. +2. Construct `AndroidPlatform` on the same Looper thread and retain its + `event_sink`; launch GPUI while that initial surface remains valid. +3. After `wake_main_thread`, call `drain_main_thread`. After Choreographer fires, + call `frame`; the sink invokes GPUI's frame callback and rearms while active. +4. Translate lifecycle, input, insets, back, memory, appearance, thermal, and + InputConnection callbacks into the matching sink methods. Coordinates passed + to `touch` are physical; insets and IME ranges must already use GPUI units. + +Before Android releases a surface generation, call `surface_destroyed`. This +unconfigures rendering and retains the old `ANativeWindow` lease so wgpu cannot +outlive it. On recreation, acquire a new owned `NativeWindow` and call +`surface_created`; the renderer replaces its surface using the existing wgpu +instance, then releases the retired lease. Serialize these calls on the original +Looper thread and do not deliver a frame between destroy and recreate. diff --git a/crates/gpui-android/src/dispatcher.rs b/crates/gpui-android/src/dispatcher.rs new file mode 100644 index 00000000..20f65658 --- /dev/null +++ b/crates/gpui-android/src/dispatcher.rs @@ -0,0 +1,346 @@ +use std::{ + cmp::Ordering, + collections::{BinaryHeap, VecDeque}, + mem, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering}, + mpsc, + }, + thread::{self, JoinHandle, ThreadId}, + time::{Duration, Instant}, +}; + +use anyhow::{Context as _, Result}; +use gpui::{PlatformDispatcher, Priority, RunnableVariant}; + +use crate::AndroidHost; + +const MIN_BACKGROUND_THREADS: usize = 2; +const MAX_BACKGROUND_THREADS: usize = 4; +const MAX_TIMER_DELAY: Duration = Duration::from_millis(i32::MAX as u64); + +struct ScheduledRunnable { + deadline: Instant, + sequence: u64, + runnable: RunnableVariant, +} + +impl PartialEq for ScheduledRunnable { + fn eq(&self, other: &Self) -> bool { + self.deadline == other.deadline && self.sequence == other.sequence + } +} + +impl Eq for ScheduledRunnable {} + +impl PartialOrd for ScheduledRunnable { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ScheduledRunnable { + fn cmp(&self, other: &Self) -> Ordering { + other + .deadline + .cmp(&self.deadline) + .then_with(|| other.sequence.cmp(&self.sequence)) + } +} + +struct PriorityWorkQueue { + state: Mutex>, + available: Condvar, +} + +struct PriorityWorkQueueState { + high: VecDeque, + medium: VecDeque, + low: VecDeque, + closed: bool, +} + +impl PriorityWorkQueue { + fn new() -> Self { + Self { + state: Mutex::new(PriorityWorkQueueState { + high: VecDeque::new(), + medium: VecDeque::new(), + low: VecDeque::new(), + closed: false, + }), + available: Condvar::new(), + } + } + + fn push(&self, priority: Priority, item: T) -> Result<(), T> { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.closed { + return Err(item); + } + match priority { + Priority::RealtimeAudio => { + panic!("RealtimeAudio priority must use spawn_realtime") + } + Priority::High => state.high.push_back(item), + Priority::Medium => state.medium.push_back(item), + Priority::Low => state.low.push_back(item), + } + drop(state); + self.available.notify_one(); + Ok(()) + } + + fn try_pop(&self) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + pop_highest_priority(&mut state) + } + + fn pop(&self) -> Option { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + loop { + if let Some(item) = pop_highest_priority(&mut state) { + return Some(item); + } + if state.closed { + return None; + } + state = self + .available + .wait(state) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + } + } + + fn is_empty(&self) -> bool { + let state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.high.is_empty() && state.medium.is_empty() && state.low.is_empty() + } + + fn close(&self) { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .closed = true; + self.available.notify_all(); + } +} + +fn pop_highest_priority(state: &mut PriorityWorkQueueState) -> Option { + state + .high + .pop_front() + .or_else(|| state.medium.pop_front()) + .or_else(|| state.low.pop_front()) +} + +/// GPUI dispatcher backed by Android's one UI Looper plus Rust worker threads. +pub struct AndroidDispatcher { + host: Arc, + main_thread_id: ThreadId, + main_queue: Arc>, + main_wake_pending: AtomicBool, + background_queue: Arc>, + timer_sender: mpsc::Sender, + timer_sequence: AtomicU64, + _background_threads: Vec>, + _timer_thread: JoinHandle<()>, +} + +impl AndroidDispatcher { + /// Creates worker queues while recording the current thread as Android's UI thread. + pub fn new(host: Arc) -> Result { + let main_queue = Arc::new(PriorityWorkQueue::new()); + let background_queue = Arc::new(PriorityWorkQueue::new()); + + let thread_count = thread::available_parallelism() + .map(|count| count.get().saturating_sub(1)) + .unwrap_or(MIN_BACKGROUND_THREADS) + .clamp(MIN_BACKGROUND_THREADS, MAX_BACKGROUND_THREADS); + + let mut background_threads = Vec::with_capacity(thread_count); + for index in 0..thread_count { + let queue = background_queue.clone(); + let worker = thread::Builder::new() + .name(format!("gpui-worker-{index}")) + .spawn(move || { + while let Some(runnable) = queue.pop() { + execute_runnable(runnable); + } + }) + .with_context(|| format!("failed to spawn GPUI worker {index}"))?; + background_threads.push(worker); + } + + let (timer_sender, timer_receiver) = mpsc::channel(); + let timer_thread = thread::Builder::new() + .name("gpui-timer".into()) + .spawn(move || run_timer_thread(timer_receiver)) + .context("failed to spawn GPUI timer thread")?; + + Ok(Self { + host, + main_thread_id: thread::current().id(), + main_queue, + main_wake_pending: AtomicBool::new(false), + background_queue, + timer_sender, + timer_sequence: AtomicU64::new(0), + _background_threads: background_threads, + _timer_thread: timer_thread, + }) + } + + /// Runs all queued foreground work. The Android main Looper calls this after a wake. + pub fn drain_main_thread(&self) { + assert!( + self.is_main_thread(), + "Android main-thread queue drained from a non-main thread" + ); + + loop { + loop { + let runnable = self.main_queue.try_pop(); + let Some(runnable) = runnable else { + break; + }; + execute_runnable(runnable); + } + + self.main_wake_pending.store(false, AtomicOrdering::Release); + if self.main_queue.is_empty() { + break; + } + + // A producer raced with the flag reset. Drain its item here; its posted + // Looper wake may later find an empty queue, which is harmless. + self.main_wake_pending.store(true, AtomicOrdering::Release); + } + } + + fn enqueue_main(&self, runnable: RunnableVariant, priority: Priority) { + if let Err(runnable) = self.main_queue.push(priority, runnable) { + log::error!("Android main-thread queue disconnected during shutdown"); + // A foreground runnable may own !Send state and cannot be dropped here. + mem::forget(runnable); + return; + } + + if !self.main_wake_pending.swap(true, AtomicOrdering::AcqRel) { + self.host.wake_main_thread(); + } + } +} + +impl PlatformDispatcher for AndroidDispatcher { + fn is_main_thread(&self) -> bool { + thread::current().id() == self.main_thread_id + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + if priority == Priority::RealtimeAudio { + panic!("RealtimeAudio priority must use spawn_realtime"); + } + if let Err(runnable) = self.background_queue.push(priority, runnable) { + log::error!("Android background queue disconnected during shutdown"); + mem::forget(runnable); + } + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + self.enqueue_main(runnable, priority); + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let duration = duration.min(MAX_TIMER_DELAY); + let scheduled = ScheduledRunnable { + deadline: Instant::now() + duration, + sequence: self.timer_sequence.fetch_add(1, AtomicOrdering::Relaxed), + runnable, + }; + if let Err(error) = self.timer_sender.send(scheduled) { + log::error!("Android timer queue disconnected during shutdown"); + mem::forget(error.0.runnable); + } + } + + fn spawn_realtime(&self, function: Box) { + // Android realtime scheduling needs audio-specific Java/AAudio policy; a + // dedicated thread preserves isolation until that policy is supplied. + if let Err(error) = thread::Builder::new() + .name("gpui-realtime".into()) + .spawn(function) + { + log::error!("failed to spawn Android realtime thread: {error}"); + } + } +} + +impl Drop for AndroidDispatcher { + fn drop(&mut self) { + self.main_queue.close(); + // The final dispatcher Arc can be released by a worker; leak queued + // foreground futures rather than dropping their !Send state there. + while let Some(runnable) = self.main_queue.try_pop() { + mem::forget(runnable); + } + self.background_queue.close(); + } +} + +fn execute_runnable(runnable: RunnableVariant) { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + gpui::profiler::update_running_task(spawned, location); + runnable.run(); + gpui::profiler::save_task_timing(); +} + +fn run_timer_thread(receiver: mpsc::Receiver) { + let mut pending = BinaryHeap::new(); + loop { + let next = pending.peek().map(|entry: &ScheduledRunnable| { + entry.deadline.saturating_duration_since(Instant::now()) + }); + + let received = match next { + Some(timeout) => receiver.recv_timeout(timeout), + None => match receiver.recv() { + Ok(item) => { + pending.push(item); + continue; + } + Err(_) => break, + }, + }; + + match received { + Ok(item) => pending.push(item), + Err(mpsc::RecvTimeoutError::Timeout) => { + if let Some(item) = pending.pop() { + execute_runnable(item.runnable); + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => break, + } + } + + // Dropping scheduled runnables cancels awaiters; during shutdown they must + // remain pending instead. + for item in pending { + mem::forget(item.runnable); + } +} diff --git a/crates/gpui-android/src/display.rs b/crates/gpui-android/src/display.rs new file mode 100644 index 00000000..f3395e4c --- /dev/null +++ b/crates/gpui-android/src/display.rs @@ -0,0 +1,59 @@ +use std::{cell::Cell, fmt}; + +use anyhow::Result; +use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay}; + +use crate::AndroidDisplayMetrics; + +pub(crate) struct AndroidDisplay { + metrics: Cell, +} + +impl AndroidDisplay { + pub(crate) fn new(metrics: AndroidDisplayMetrics) -> Self { + Self { + metrics: Cell::new(metrics), + } + } + + pub(crate) fn update(&self, metrics: AndroidDisplayMetrics) { + self.metrics.set(metrics); + } +} + +impl fmt::Debug for AndroidDisplay { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AndroidDisplay") + .field("id", &DisplayId::new(1)) + .field("metrics", &self.metrics.get()) + .finish() + } +} + +impl PlatformDisplay for AndroidDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(1) + } + + fn uuid(&self) -> Result { + // Android's activity window is modeled as one stable logical display. + Ok(uuid::Uuid::from_u128( + 0x69d9_8b2a_37c3_4dca_a37d_726f_6964_0001, + )) + } + + fn bounds(&self) -> Bounds { + self.metrics.get().logical_bounds() + } + + fn visible_bounds(&self) -> Bounds { + // System bars and cutouts are overlays represented by WindowInsets. + self.bounds() + } + + fn default_bounds(&self) -> Bounds { + // Android activities own a single full-surface window. + self.bounds() + } +} diff --git a/crates/gpui-android/src/keyboard.rs b/crates/gpui-android/src/keyboard.rs new file mode 100644 index 00000000..76cdc504 --- /dev/null +++ b/crates/gpui-android/src/keyboard.rs @@ -0,0 +1,13 @@ +use gpui::PlatformKeyboardLayout; + +pub(crate) struct AndroidKeyboardLayout; + +impl PlatformKeyboardLayout for AndroidKeyboardLayout { + fn id(&self) -> &str { + "android" + } + + fn name(&self) -> &str { + "Android" + } +} diff --git a/crates/gpui-android/src/lib.rs b/crates/gpui-android/src/lib.rs new file mode 100644 index 00000000..96ad13fc --- /dev/null +++ b/crates/gpui-android/src/lib.rs @@ -0,0 +1,25 @@ +//! GPUI platform backend for Android. +//! +//! The host build intentionally exports no backend. Android-only dependencies and +//! implementation details stay behind `target_os = "android"` so this workspace can +//! continue to build on desktop hosts. + +#[cfg(target_os = "android")] +mod dispatcher; +#[cfg(target_os = "android")] +mod display; +#[cfg(target_os = "android")] +mod keyboard; +#[cfg(target_os = "android")] +mod native; +#[cfg(target_os = "android")] +mod platform; +#[cfg(target_os = "android")] +mod window; + +#[cfg(target_os = "android")] +pub use dispatcher::AndroidDispatcher; +#[cfg(target_os = "android")] +pub use native::{AndroidDisplayMetrics, AndroidHost, AndroidNativeWindow, AndroidSurface}; +#[cfg(target_os = "android")] +pub use platform::{AndroidEventSink, AndroidPlatform}; diff --git a/crates/gpui-android/src/native.rs b/crates/gpui-android/src/native.rs new file mode 100644 index 00000000..03eb1cdb --- /dev/null +++ b/crates/gpui-android/src/native.rs @@ -0,0 +1,179 @@ +use std::path::Path; + +use anyhow::{Result, bail}; +use gpui::{ + Bounds, Pixels, Size, TextInputStateChange, ThermalState, WindowAppearance, point, px, size, +}; +use ndk::native_window::NativeWindow; +use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, +}; + +/// Physical display information supplied by the Android activity. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AndroidDisplayMetrics { + /// Width of the current surface in physical pixels. + pub width_px: u32, + /// Height of the current surface in physical pixels. + pub height_px: u32, + /// Number of physical pixels per GPUI logical pixel. + pub scale_factor: f32, +} + +impl AndroidDisplayMetrics { + /// Creates validated display metrics. + pub fn new(width_px: u32, height_px: u32, scale_factor: f32) -> Result { + let metrics = Self { + width_px, + height_px, + scale_factor, + }; + metrics.validate()?; + Ok(metrics) + } + + pub(crate) fn validate(self) -> Result<()> { + if self.width_px == 0 || self.height_px == 0 { + bail!("Android surface dimensions must be non-zero"); + } + if self.width_px > i32::MAX as u32 || self.height_px > i32::MAX as u32 { + bail!("Android surface dimensions exceed GPUI's device-pixel range"); + } + if !self.scale_factor.is_finite() || self.scale_factor <= 0.0 { + bail!("Android display scale factor must be finite and positive"); + } + Ok(()) + } + + pub(crate) fn logical_size(self) -> Size { + size( + px(self.width_px as f32 / self.scale_factor), + px(self.height_px as f32 / self.scale_factor), + ) + } + + pub(crate) fn logical_bounds(self) -> Bounds { + Bounds::new(point(px(0.0), px(0.0)), self.logical_size()) + } +} + +/// A retained `ANativeWindow` suitable for raw-window-handle and wgpu. +/// +/// `ndk::NativeWindow` owns an `ANativeWindow_acquire` reference, so cloning +/// this value keeps the pointer alive until every renderer/surface lease drops. +#[derive(Clone, Debug)] +pub struct AndroidNativeWindow { + inner: NativeWindow, +} + +impl AndroidNativeWindow { + /// Wraps a retained native window acquired by the JNI/NDK integration. + pub fn new(inner: NativeWindow) -> Self { + Self { inner } + } + + /// Returns the retained NDK window for integrations that need NDK APIs. + pub fn as_ndk(&self) -> &NativeWindow { + &self.inner + } +} + +impl From for AndroidNativeWindow { + fn from(inner: NativeWindow) -> Self { + Self::new(inner) + } +} + +impl HasWindowHandle for AndroidNativeWindow { + fn window_handle(&self) -> Result, HandleError> { + self.inner.window_handle() + } +} + +impl HasDisplayHandle for AndroidNativeWindow { + fn display_handle(&self) -> Result, HandleError> { + Ok(DisplayHandle::android()) + } +} + +/// One Android surface generation and its matching display metrics. +#[derive(Clone, Debug)] +pub struct AndroidSurface { + pub(crate) window: AndroidNativeWindow, + pub(crate) metrics: AndroidDisplayMetrics, +} + +impl AndroidSurface { + /// Creates a surface generation from a retained native window. + pub fn new(window: AndroidNativeWindow, metrics: AndroidDisplayMetrics) -> Result { + metrics.validate()?; + Ok(Self { window, metrics }) + } + + /// Returns the physical display metrics associated with this surface. + pub fn metrics(&self) -> AndroidDisplayMetrics { + self.metrics + } +} + +/// Outbound half of the Android JNI/NDK boundary. +/// +/// The Java/Kotlin activity (or `android-activity` glue) implements this trait. +/// Its matching inbound half is [`crate::AndroidEventSink`]. Keeping OS calls +/// here lets surface, lifecycle, Choreographer, clipboard, back, and IME glue be +/// added without changing the GPUI trait implementations. +pub trait AndroidHost: Send + Sync { + /// Wake the activity's main Looper so it calls `drain_main_thread`. + fn wake_main_thread(&self); + + /// Schedule one Choreographer callback that calls `frame`. + /// + /// The implementation must coalesce calls while a callback is pending. + /// `AndroidEventSink::frame` rearms the next callback while the window is active. + fn request_frame(&self); + + /// Finish the owning activity. + fn finish_activity(&self); + + /// Launch an Android intent for the URI. + fn open_uri(&self, uri: &str) -> Result<()>; + + /// Launch an Android intent for a filesystem path, if the app can expose it. + fn open_path(&self, path: &Path) -> Result<()>; + + /// Read plain text through Android's ClipboardManager. + fn read_clipboard_text(&self) -> Result>; + + /// Write plain text through Android's ClipboardManager. + fn write_clipboard_text(&self, text: &str) -> Result<()>; + + /// Store a credential through the Android Keystore-backed host service. + fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Result<()>; + + /// Read a credential through the Android Keystore-backed host service. + fn read_credentials(&self, url: &str) -> Result)>>; + + /// Delete a credential through the Android Keystore-backed host service. + fn delete_credentials(&self, url: &str) -> Result<()>; + + /// Show the activity's input method. + fn show_soft_keyboard(&self); + + /// Hide the activity's input method. + fn hide_soft_keyboard(&self); + + /// Update the logical caret bounds used to position the IME. + fn update_ime_position(&self, bounds: Bounds); + + /// Notify the host that GPUI's focused text-input state changed. + fn text_input_state_changed(&self, change: TextInputStateChange); + + /// Enable or disable the activity's registered back callback. + fn set_back_enabled(&self, enabled: bool); + + /// Query the current Android UI mode. + fn window_appearance(&self) -> WindowAppearance; + + /// Query Android's current thermal status. + fn thermal_state(&self) -> ThermalState; +} diff --git a/crates/gpui-android/src/platform.rs b/crates/gpui-android/src/platform.rs new file mode 100644 index 00000000..b3f944e0 --- /dev/null +++ b/crates/gpui-android/src/platform.rs @@ -0,0 +1,735 @@ +use std::{ + borrow::Cow, + cell::RefCell, + ops::Range, + path::{Path, PathBuf}, + rc::{Rc, Weak}, + sync::Arc, +}; + +use anyhow::{Context as _, Result, anyhow, bail}; +use futures::channel::oneshot; +use gpui::{ + Action, AnyWindowHandle, AppLifecyclePhase, BackgroundExecutor, ClipboardItem, CursorStyle, + DispatchEventResult, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, + OwnedMenu, PathPromptOptions, Platform, PlatformDispatcher, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, + ScreenCaptureSource, Task, ThermalState, TouchEvent, TouchId, TouchPhase, WindowAppearance, + WindowInsets, WindowKind, WindowParams, point, popup::PopupNotSupportedError, px, +}; +use gpui_wgpu::{CosmicTextSystem, GpuContext}; + +use crate::{ + AndroidDisplayMetrics, AndroidHost, AndroidSurface, + dispatcher::AndroidDispatcher, + display::AndroidDisplay, + keyboard::AndroidKeyboardLayout, + window::{AndroidWindow, AndroidWindowInner}, +}; + +static BUNDLED_FONTS: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/DMSans[wght].ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), +]; + +#[derive(Default)] +struct AndroidPlatformCallbacks { + open_urls: Option)>>, + quit: Option>, + reopen: Option>, + system_wake: Option>, + lifecycle: Option>, + memory_warning: Option>, + thermal_state_change: Option>, + keyboard_layout_change: Option>, +} + +struct AndroidPlatformState { + surface: Option, + active_window: Option, + window: Option>, + appearance: WindowAppearance, + thermal_state: ThermalState, + callbacks: AndroidPlatformCallbacks, +} + +/// GPUI's Android platform implementation. +pub struct AndroidPlatform { + host: Arc, + dispatcher: Arc, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + display: Rc, + state: Rc>, + gpu_context: GpuContext, +} + +impl AndroidPlatform { + /// Creates the backend on Android's UI thread with the current surface generation. + pub fn new(host: Arc, surface: AndroidSurface) -> Result { + surface.metrics.validate()?; + + let dispatcher = Arc::new(AndroidDispatcher::new(host.clone())?); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); + + let text_system = Arc::new(CosmicTextSystem::new_without_system_fonts("DM Sans")); + text_system.add_fonts( + BUNDLED_FONTS + .iter() + .map(|bytes| Cow::Borrowed(*bytes)) + .collect(), + )?; + let text_system: Arc = text_system; + + let display = Rc::new(AndroidDisplay::new(surface.metrics)); + let appearance = host.window_appearance(); + let thermal_state = host.thermal_state(); + + Ok(Self { + host, + dispatcher, + background_executor, + foreground_executor, + text_system, + display, + state: Rc::new(RefCell::new(AndroidPlatformState { + surface: Some(surface), + active_window: None, + window: None, + appearance, + thermal_state, + callbacks: AndroidPlatformCallbacks::default(), + })), + gpu_context: Rc::new(RefCell::new(None)), + }) + } + + /// Returns the inbound JNI/NDK event boundary for the owning activity. + pub fn event_sink(&self) -> AndroidEventSink { + AndroidEventSink { + host: self.host.clone(), + dispatcher: self.dispatcher.clone(), + display: self.display.clone(), + state: self.state.clone(), + } + } +} + +/// Main-thread entry points called by the Android activity and Choreographer. +/// +/// This type is deliberately `!Send`: Android UI, lifecycle, input, and surface +/// callbacks must enter GPUI on the same thread that constructed the platform. +#[derive(Clone)] +pub struct AndroidEventSink { + host: Arc, + dispatcher: Arc, + display: Rc, + state: Rc>, +} + +impl AndroidEventSink { + /// Drains foreground tasks after `AndroidHost::wake_main_thread`. + pub fn drain_main_thread(&self) { + self.dispatcher.drain_main_thread(); + } + + /// Replaces a surface after Android creates a new `ANativeWindow`. + pub fn surface_created(&self, surface: AndroidSurface) -> Result<()> { + self.assert_main_thread(); + surface.metrics.validate()?; + + if let Some(window) = self.window() { + window.replace_surface(surface.clone())?; + } + self.display.update(surface.metrics); + self.state.borrow_mut().surface = Some(surface); + + if self + .window() + .is_some_and(|window| window.should_schedule_frames()) + { + self.host.request_frame(); + } + Ok(()) + } + + /// Unconfigures wgpu before Android releases the current native window. + pub fn surface_destroyed(&self) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.detach_surface(); + } + self.state.borrow_mut().surface = None; + } + + /// Applies a native surface resize or density change. + pub fn resized(&self, metrics: AndroidDisplayMetrics) -> Result<()> { + self.assert_main_thread(); + metrics.validate()?; + if let Some(surface) = self.state.borrow_mut().surface.as_mut() { + surface.metrics = metrics; + } + self.display.update(metrics); + if let Some(window) = self.window() { + window.resize_from_native(metrics); + } + Ok(()) + } + + /// Delivers one Choreographer frame and schedules the next while visible. + pub fn frame(&self) { + self.assert_main_thread(); + let Some(window) = self.window() else { + return; + }; + window.frame(); + if window.should_schedule_frames() { + self.host.request_frame(); + } + } + + /// Delivers an already translated GPUI input event. + pub fn dispatch_input(&self, input: gpui::PlatformInput) -> Option { + self.assert_main_thread(); + self.window() + .and_then(|window| window.dispatch_input(input)) + } + + /// Converts a physical Android touch into logical coordinates and mouse input. + /// + /// GPUI core does not yet dispatch `TouchEvent` to elements, so the primary + /// contact is synthesized as left-button mouse input for the mobile MVP. + pub fn touch( + &self, + id: u64, + phase: TouchPhase, + x_px: f32, + y_px: f32, + force: Option, + ) -> Option { + self.assert_main_thread(); + let window = self.window()?; + let scale = window.scale_factor(); + window.dispatch_touch(TouchEvent { + id: TouchId(id), + phase, + position: point(px(x_px / scale), px(y_px / scale)), + force, + }) + } + + /// Updates focus from Android lifecycle or pointer-device callbacks. + pub fn active_changed(&self, active: bool) { + self.assert_main_thread(); + if let Some(window) = self.window() { + let changed = window.set_active(active); + if changed && active && window.should_schedule_frames() { + // A surface can survive pause/resume, so restart the frame chain explicitly. + self.host.request_frame(); + } + } + } + + /// Updates hover state for attached mouse or stylus devices. + pub fn hovered_changed(&self, hovered: bool) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.set_hovered(hovered); + } + } + + /// Notifies GPUI that Android moved the activity to another display. + pub fn moved(&self) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.moved(); + } + } + + /// Runs GPUI's registered window-control hit test, if any. + pub fn hit_test_window_control(&self) -> Option { + self.assert_main_thread(); + self.window() + .and_then(|window| window.hit_test_window_control()) + } + + /// Delivers Android's application lifecycle vocabulary. + pub fn lifecycle_changed(&self, phase: AppLifecyclePhase) { + self.assert_main_thread(); + if let Some(window) = self.window() { + let active = matches!(phase, AppLifecyclePhase::Active); + let changed = window.set_active(active); + if changed && active && window.should_schedule_frames() { + // Android may resume without recreating the surface. + self.host.request_frame(); + } + } + if let Some(callback) = self.state.borrow_mut().callbacks.lifecycle.as_mut() { + callback(phase); + } + } + + /// Delivers `onTrimMemory`/memory-pressure notification. + pub fn memory_warning(&self) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.memory_warning.as_mut() { + callback(); + } + } + + /// Delivers animated safe-area and IME inset updates in logical pixels. + pub fn insets_changed(&self, insets: WindowInsets) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.set_insets(insets); + } + } + + /// Runs GPUI's back callback. Returns false when Android should use its default. + pub fn back_pressed(&self) -> bool { + self.assert_main_thread(); + self.window().is_some_and(|window| window.back_pressed()) + } + + /// Consults GPUI's close veto, delivers close, and finishes the activity. + pub fn close_requested(&self) -> bool { + self.assert_main_thread(); + if let Some(window) = self.window() + && !window.close_requested() + { + return false; + } + + { + let mut state = self.state.borrow_mut(); + state.active_window = None; + state.window = None; + } + self.host.finish_activity(); + true + } + + /// Delivers Android UI-mode changes. + pub fn appearance_changed(&self, appearance: WindowAppearance) { + self.assert_main_thread(); + self.state.borrow_mut().appearance = appearance; + if let Some(window) = self.window() { + window.set_appearance(appearance); + } + } + + /// Delivers Android thermal-status changes. + pub fn thermal_state_changed(&self, thermal_state: ThermalState) { + self.assert_main_thread(); + let changed = self.state.borrow().thermal_state != thermal_state; + self.state.borrow_mut().thermal_state = thermal_state; + if changed + && let Some(callback) = self + .state + .borrow_mut() + .callbacks + .thermal_state_change + .as_mut() + { + callback(); + } + } + + /// Delivers Android keyboard-layout changes. + pub fn keyboard_layout_changed(&self) { + self.assert_main_thread(); + if let Some(callback) = self + .state + .borrow_mut() + .callbacks + .keyboard_layout_change + .as_mut() + { + callback(); + } + } + + /// Delivers VIEW intents registered by the Android manifest. + pub fn open_urls(&self, urls: Vec) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.open_urls.as_mut() { + callback(urls); + } + } + + /// Delivers a host-driven reopen request. + pub fn reopen(&self) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.reopen.as_mut() { + callback(); + } + } + + /// Delivers a system-wake notification. + pub fn system_wake(&self) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.system_wake.as_mut() { + callback(); + } + } + + /// Commits text from Android's InputConnection. + pub fn ime_replace_text(&self, range: Option>, text: &str) -> bool { + self.assert_main_thread(); + self.window() + .is_some_and(|window| window.ime_replace_text(range, text)) + } + + /// Updates composing text from Android's InputConnection. + pub fn ime_set_composing_text( + &self, + range: Option>, + text: &str, + selected_range: Option>, + ) -> bool { + self.assert_main_thread(); + self.window() + .is_some_and(|window| window.ime_set_composing_text(range, text, selected_range)) + } + + /// Finishes the current IME composition. + pub fn ime_finish_composing(&self) -> bool { + self.assert_main_thread(); + self.window() + .is_some_and(|window| window.ime_finish_composing()) + } + + fn window(&self) -> Option> { + self.state.borrow().window.as_ref().and_then(Weak::upgrade) + } + + fn assert_main_thread(&self) { + assert!( + self.dispatcher.is_main_thread(), + "Android event delivered outside the main thread" + ); + } +} + +impl Platform for AndroidPlatform { + fn background_executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + fn text_system(&self) -> Arc { + self.text_system.clone() + } + + fn run(&self, on_finish_launching: Box) { + assert!( + self.dispatcher.is_main_thread(), + "AndroidPlatform::run must be called on the activity UI thread" + ); + // Android's Looper already owns the event loop; GPUI launches inside it. + on_finish_launching(); + } + + fn quit(&self) { + if let Some(callback) = self.state.borrow_mut().callbacks.quit.as_mut() { + callback(); + } + self.host.finish_activity(); + } + + fn restart(&self, _binary_path: Option) { + // Android packages cannot restart by executing a replacement binary path. + log::warn!("Android does not support Platform::restart"); + } + + fn activate(&self, _ignoring_other_apps: bool) { + // Android owns activity activation through its lifecycle. + } + + fn hide(&self) { + // Android activities are backgrounded by the OS, not hidden as windows. + } + + fn hide_other_apps(&self) { + // Android apps cannot hide other applications. + } + + fn unhide_other_apps(&self) { + // Android apps cannot change other applications' visibility. + } + + fn displays(&self) -> Vec> { + vec![self.display.clone()] + } + + fn primary_display(&self) -> Option> { + Some(self.display.clone()) + } + + fn active_window(&self) -> Option { + self.state.borrow().active_window + } + + fn window_stack(&self) -> Option> { + self.state.borrow().active_window.map(|window| vec![window]) + } + + fn is_screen_capture_supported(&self) -> bool { + false + } + + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + // Android MediaProjection is consent/activity-result based and not this synchronous API. + unsupported_receiver("screen capture is not supported by gpui-android") + } + + fn open_window( + &self, + handle: AnyWindowHandle, + params: WindowParams, + ) -> Result> { + if matches!(¶ms.kind, WindowKind::AnchoredPopup(_)) { + return Err(PopupNotSupportedError.into()); + } + if !matches!(¶ms.kind, WindowKind::Normal) { + bail!("Android currently supports only its single normal activity window"); + } + if self + .state + .borrow() + .window + .as_ref() + .and_then(Weak::upgrade) + .is_some() + { + bail!("Android currently supports only one GPUI platform window"); + } + + let surface = self + .state + .borrow() + .surface + .clone() + .context("Android native surface is not currently available")?; + let appearance = self.state.borrow().appearance; + let window = AndroidWindow::new( + self.host.clone(), + self.gpu_context.clone(), + self.display.clone(), + surface, + appearance, + )?; + + { + let mut state = self.state.borrow_mut(); + state.active_window = Some(handle); + state.window = Some(Rc::downgrade(&window.inner)); + } + Ok(Box::new(window)) + } + + fn window_appearance(&self) -> WindowAppearance { + self.state.borrow().appearance + } + + fn open_url(&self, url: &str) { + if let Err(error) = self.host.open_uri(url) { + log::warn!("failed to open Android URI {url:?}: {error:#}"); + } + } + + fn on_open_urls(&self, callback: Box)>) { + self.state.borrow_mut().callbacks.open_urls = Some(callback); + } + + fn register_url_scheme(&self, _url: &str) -> Task> { + // Android URL schemes are declared statically as manifest intent filters. + Task::ready(Err(anyhow!( + "Android URL schemes must be registered in AndroidManifest.xml" + ))) + } + + fn prompt_for_paths( + &self, + _options: PathPromptOptions, + ) -> oneshot::Receiver>>> { + // Android's Storage Access Framework requires an Activity result contract. + unsupported_receiver("file selection is not supported by gpui-android") + } + + fn prompt_for_new_path( + &self, + _directory: &Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + // Android's Storage Access Framework returns content URIs, not writable Paths. + unsupported_receiver("new-path prompts are not supported by gpui-android") + } + + fn can_select_mixed_files_and_dirs(&self) -> bool { + false + } + + fn reveal_path(&self, _path: &Path) { + // Android has no system file-manager reveal operation for arbitrary Paths. + } + + fn open_with_system(&self, path: &Path) { + if let Err(error) = self.host.open_path(path) { + log::warn!("failed to open Android path {path:?}: {error:#}"); + } + } + + fn on_quit(&self, callback: Box) { + self.state.borrow_mut().callbacks.quit = Some(callback); + } + + fn on_reopen(&self, callback: Box) { + self.state.borrow_mut().callbacks.reopen = Some(callback); + } + + fn on_system_wake(&self, callback: Box) { + self.state.borrow_mut().callbacks.system_wake = Some(callback); + } + + fn on_app_lifecycle(&self, callback: Box) { + self.state.borrow_mut().callbacks.lifecycle = Some(callback); + } + + fn on_memory_warning(&self, callback: Box) { + self.state.borrow_mut().callbacks.memory_warning = Some(callback); + } + + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) { + // Android has no process-wide desktop menu bar. + } + + fn get_menus(&self) -> Option> { + // Android has no process-wide desktop menu bar to query. + None + } + + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) { + // Android has no desktop dock menu. + } + + fn perform_dock_menu_action(&self, _action: usize) { + // Android has no desktop dock menu action source. + } + + fn add_recent_document(&self, _path: &Path) { + // Android's recents screen tracks activities, not application documents. + } + + fn on_app_menu_action(&self, _callback: Box) { + // Android has no GPUI app-menu action source. + } + + fn on_will_open_app_menu(&self, _callback: Box) { + // Android has no process-wide application menu. + } + + fn on_validate_app_menu_command(&self, _callback: Box bool>) { + // Android has no process-wide application-menu commands to validate. + } + + fn thermal_state(&self) -> ThermalState { + self.state.borrow().thermal_state + } + + fn on_thermal_state_change(&self, callback: Box) { + self.state.borrow_mut().callbacks.thermal_state_change = Some(callback); + } + + fn compositor_name(&self) -> &'static str { + "Android SurfaceFlinger" + } + + fn app_path(&self) -> Result { + // Android applications are APKs, not executable filesystem bundles. + bail!("application paths are not available on Android") + } + + fn path_for_auxiliary_executable(&self, _name: &str) -> Result { + // Android packages cannot ship independently executed auxiliary binaries. + bail!("auxiliary executable paths are not available on Android") + } + + fn set_cursor_style(&self, _style: CursorStyle) { + // The mobile MVP has touch input and no application-controlled cursor. + } + + fn hide_cursor_until_mouse_moves(&self) { + // The mobile MVP has no visible mouse cursor to hide. + } + + fn is_cursor_visible(&self) -> bool { + false + } + + fn should_auto_hide_scrollbars(&self) -> bool { + true + } + + fn read_from_clipboard(&self) -> Option { + match self.host.read_clipboard_text() { + Ok(Some(text)) => Some(ClipboardItem::new_string(text)), + Ok(None) => None, + Err(error) => { + log::warn!("failed to read Android clipboard: {error:#}"); + None + } + } + } + + fn write_to_clipboard(&self, item: ClipboardItem) { + let Some(text) = item.text() else { + // Android host seam currently supports GPUI's plain-text clipboard entries only. + log::warn!("gpui-android cannot write a non-text clipboard item"); + return; + }; + if let Err(error) = self.host.write_clipboard_text(&text) { + log::warn!("failed to write Android clipboard: {error:#}"); + } + } + + fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { + Task::ready(self.host.write_credentials(url, username, password)) + } + + fn read_credentials(&self, url: &str) -> Task)>>> { + Task::ready(self.host.read_credentials(url)) + } + + fn delete_credentials(&self, url: &str) -> Task> { + Task::ready(self.host.delete_credentials(url)) + } + + fn keyboard_layout(&self) -> Box { + Box::new(AndroidKeyboardLayout) + } + + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + + fn on_keyboard_layout_change(&self, callback: Box) { + self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); + } +} + +fn unsupported_receiver(message: &'static str) -> oneshot::Receiver> { + let (sender, receiver) = oneshot::channel(); + sender.send(Err(anyhow!(message))).ok(); + receiver +} diff --git a/crates/gpui-android/src/window.rs b/crates/gpui-android/src/window.rs new file mode 100644 index 00000000..fed5e4b8 --- /dev/null +++ b/crates/gpui-android/src/window.rs @@ -0,0 +1,670 @@ +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; + +use anyhow::{Context as _, Result}; +use futures::channel::oneshot; +use gpui::{ + Bounds, Capslock, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, + PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, + RequestFrameOptions, Scene, Size, TextInputStateChange, TouchEvent, TouchId, TouchPhase, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, + WindowInsets, +}; +use gpui_wgpu::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, +}; + +use crate::{ + AndroidDisplayMetrics, AndroidHost, AndroidNativeWindow, AndroidSurface, + display::AndroidDisplay, +}; + +type InputCallback = Box DispatchEventResult>; +type ResizeCallback = Box, f32)>; +type HitTestCallback = Box Option>; + +#[derive(Default)] +struct AndroidWindowCallbacks { + request_frame: Option>, + input: Option, + active_status_change: Option>, + hover_status_change: Option>, + resize: Option, + moved: Option>, + should_close: Option bool>>, + hit_test_window_control: Option, + close: Option>, + appearance_changed: Option>, + insets_changed: Option>, + back: Option>, +} + +struct AndroidWindowState { + // Drop the renderer before either retained ANativeWindow lease. + renderer: WgpuRenderer, + native_window: Option, + retired_native_window: Option, + bounds: Bounds, + scale_factor: f32, + appearance: WindowAppearance, + background_appearance: WindowBackgroundAppearance, + title: String, + input_handler: Option, + active: bool, + hovered: bool, + mouse_position: Point, + modifiers: Modifiers, + capslock: Capslock, + pressed_button: Option, + primary_touch: Option, + insets: WindowInsets, + back_enabled: bool, +} + +pub(crate) struct AndroidWindowInner { + host: Arc, + gpu_context: GpuContext, + state: RefCell, + callbacks: RefCell, +} + +pub(crate) struct AndroidWindow { + pub(crate) inner: Rc, + display: Rc, +} + +impl AndroidWindow { + pub(crate) fn new( + host: Arc, + gpu_context: GpuContext, + display: Rc, + surface: AndroidSurface, + appearance: WindowAppearance, + ) -> Result { + let renderer = WgpuRenderer::new( + gpu_context.clone(), + &surface.window, + surface_config(surface.metrics, false), + None, + ) + .context("failed to create Android wgpu renderer")?; + + let state = AndroidWindowState { + renderer, + native_window: Some(surface.window), + retired_native_window: None, + bounds: surface.metrics.logical_bounds(), + scale_factor: surface.metrics.scale_factor, + appearance, + background_appearance: WindowBackgroundAppearance::Opaque, + title: String::new(), + input_handler: None, + active: true, + hovered: false, + mouse_position: Point::default(), + modifiers: Modifiers::default(), + capslock: Capslock::default(), + pressed_button: None, + primary_touch: None, + insets: WindowInsets::default(), + back_enabled: false, + }; + + Ok(Self { + inner: Rc::new(AndroidWindowInner { + host, + gpu_context, + state: RefCell::new(state), + callbacks: RefCell::new(AndroidWindowCallbacks::default()), + }), + display, + }) + } +} + +impl AndroidWindowInner { + pub(crate) fn replace_surface(&self, surface: AndroidSurface) -> Result<()> { + let instance = self + .gpu_context + .borrow() + .as_ref() + .context("wgpu context missing while replacing Android surface")? + .instance + .clone(); + + { + let mut state = self.state.borrow_mut(); + let transparent = state.background_appearance != WindowBackgroundAppearance::Opaque; + state.renderer.replace_surface( + &surface.window, + surface_config(surface.metrics, transparent), + &instance, + )?; + state.native_window = Some(surface.window); + state.retired_native_window = None; + state.bounds = surface.metrics.logical_bounds(); + state.scale_factor = surface.metrics.scale_factor; + } + + self.notify_resize(surface.metrics); + Ok(()) + } + + pub(crate) fn detach_surface(&self) { + let mut state = self.state.borrow_mut(); + if state.native_window.is_some() { + state.renderer.unconfigure_surface(); + state.retired_native_window = state.native_window.take(); + } + } + + pub(crate) fn resize_from_native(&self, metrics: AndroidDisplayMetrics) { + { + let mut state = self.state.borrow_mut(); + state.bounds = metrics.logical_bounds(); + state.scale_factor = metrics.scale_factor; + if state.native_window.is_some() { + state.renderer.update_drawable_size(Size { + width: DevicePixels(metrics.width_px as i32), + height: DevicePixels(metrics.height_px as i32), + }); + } + } + self.notify_resize(metrics); + } + + fn notify_resize(&self, metrics: AndroidDisplayMetrics) { + if let Some(callback) = self.callbacks.borrow_mut().resize.as_mut() { + callback(metrics.logical_size(), metrics.scale_factor); + } + } + + pub(crate) fn frame(&self) { + if let Some(callback) = self.callbacks.borrow_mut().request_frame.as_mut() { + callback(RequestFrameOptions { + require_presentation: true, + force_render: false, + }); + } + } + + pub(crate) fn should_schedule_frames(&self) -> bool { + let state = self.state.borrow(); + state.active && state.native_window.is_some() + } + + pub(crate) fn dispatch_input(&self, input: PlatformInput) -> Option { + { + let mut state = self.state.borrow_mut(); + match &input { + PlatformInput::MouseDown(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + state.pressed_button = Some(event.button); + } + PlatformInput::MouseUp(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + state.pressed_button = None; + } + PlatformInput::MouseMove(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + state.pressed_button = event.pressed_button; + } + PlatformInput::ModifiersChanged(event) => { + state.modifiers = event.modifiers; + state.capslock = event.capslock; + } + PlatformInput::ScrollWheel(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + } + PlatformInput::Pinch(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + } + PlatformInput::KeyDown(_) + | PlatformInput::KeyUp(_) + | PlatformInput::MousePressure(_) + | PlatformInput::MouseExited(_) + | PlatformInput::FileDrop(_) + | PlatformInput::Touch(_) => {} + } + } + + self.callbacks + .borrow_mut() + .input + .as_mut() + .map(|callback| callback(input)) + } + + pub(crate) fn dispatch_touch(&self, touch: TouchEvent) -> Option { + let input = { + let mut state = self.state.borrow_mut(); + match touch.phase { + TouchPhase::Started if state.primary_touch.is_none() => { + state.primary_touch = Some(touch.id); + Some(PlatformInput::MouseDown(MouseDownEvent { + button: MouseButton::Left, + position: touch.position, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + })) + } + TouchPhase::Moved if state.primary_touch == Some(touch.id) => { + Some(PlatformInput::MouseMove(MouseMoveEvent { + position: touch.position, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + })) + } + TouchPhase::Ended | TouchPhase::Cancelled + if state.primary_touch == Some(touch.id) => + { + state.primary_touch = None; + // GPUI has no pointer-cancel mouse event yet; MouseUp is the + // only portable way to release captures for a cancelled touch. + Some(PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: touch.position, + modifiers: Modifiers::default(), + click_count: 1, + })) + } + TouchPhase::Started + | TouchPhase::Moved + | TouchPhase::Ended + | TouchPhase::Cancelled => None, + } + }; + input.and_then(|input| self.dispatch_input(input)) + } + + pub(crate) fn scale_factor(&self) -> f32 { + self.state.borrow().scale_factor + } + + pub(crate) fn set_active(&self, active: bool) -> bool { + let changed = { + let mut state = self.state.borrow_mut(); + if state.active == active { + false + } else { + state.active = active; + true + } + }; + if changed && let Some(callback) = self.callbacks.borrow_mut().active_status_change.as_mut() + { + callback(active); + } + changed + } + + pub(crate) fn set_hovered(&self, hovered: bool) { + let changed = { + let mut state = self.state.borrow_mut(); + if state.hovered == hovered { + false + } else { + state.hovered = hovered; + true + } + }; + if changed && let Some(callback) = self.callbacks.borrow_mut().hover_status_change.as_mut() + { + callback(hovered); + } + } + + pub(crate) fn moved(&self) { + if let Some(callback) = self.callbacks.borrow_mut().moved.as_mut() { + callback(); + } + } + + pub(crate) fn hit_test_window_control(&self) -> Option { + self.callbacks + .borrow_mut() + .hit_test_window_control + .as_mut() + .and_then(|callback| callback()) + } + + pub(crate) fn close_requested(&self) -> bool { + let should_close = self + .callbacks + .borrow_mut() + .should_close + .as_mut() + .is_none_or(|callback| callback()); + if !should_close { + return false; + } + + if let Some(callback) = self.callbacks.borrow_mut().close.take() { + callback(); + } + true + } + + pub(crate) fn set_appearance(&self, appearance: WindowAppearance) { + let changed = self.state.borrow().appearance != appearance; + self.state.borrow_mut().appearance = appearance; + if changed && let Some(callback) = self.callbacks.borrow_mut().appearance_changed.as_mut() { + callback(); + } + } + + pub(crate) fn set_insets(&self, insets: WindowInsets) { + let changed = self.state.borrow().insets != insets; + self.state.borrow_mut().insets = insets.clone(); + if changed && let Some(callback) = self.callbacks.borrow_mut().insets_changed.as_mut() { + callback(insets); + } + } + + pub(crate) fn back_pressed(&self) -> bool { + if !self.state.borrow().back_enabled { + return false; + } + if let Some(callback) = self.callbacks.borrow_mut().back.as_mut() { + callback(); + true + } else { + false + } + } + + pub(crate) fn ime_replace_text(&self, range: Option>, text: &str) -> bool { + self.with_input_handler(|handler| handler.replace_text_in_range(range, text)) + .is_some() + } + + pub(crate) fn ime_set_composing_text( + &self, + range: Option>, + text: &str, + selected_range: Option>, + ) -> bool { + self.with_input_handler(|handler| { + handler.replace_and_mark_text_in_range(range, text, selected_range); + }) + .is_some() + } + + pub(crate) fn ime_finish_composing(&self) -> bool { + self.with_input_handler(PlatformInputHandler::unmark_text) + .is_some() + } + + fn with_input_handler( + &self, + callback: impl FnOnce(&mut PlatformInputHandler) -> T, + ) -> Option { + let mut handler = self.state.borrow_mut().input_handler.take()?; + let result = callback(&mut handler); + self.state.borrow_mut().input_handler = Some(handler); + Some(result) + } +} + +impl HasWindowHandle for AndroidWindow { + fn window_handle(&self) -> Result, HandleError> { + let raw = { + let state = self.inner.state.borrow(); + let native_window = state + .native_window + .as_ref() + .ok_or(HandleError::Unavailable)?; + native_window.window_handle()?.as_raw() + }; + + // SAFETY: AndroidWindowInner retains the corresponding NativeWindow + // lease, and all lifecycle mutation is confined to the UI thread. + Ok(unsafe { WindowHandle::borrow_raw(raw) }) + } +} + +impl HasDisplayHandle for AndroidWindow { + fn display_handle(&self) -> Result, HandleError> { + Ok(DisplayHandle::android()) + } +} + +impl PlatformWindow for AndroidWindow { + fn bounds(&self) -> Bounds { + self.inner.state.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Fullscreen(self.bounds()) + } + + fn content_size(&self) -> Size { + self.bounds().size + } + + fn resize(&mut self, _size: Size) { + // Android owns activity surface dimensions; native resize callbacks update GPUI. + } + + fn scale_factor(&self) -> f32 { + self.inner.state.borrow().scale_factor + } + + fn appearance(&self) -> WindowAppearance { + self.inner.state.borrow().appearance + } + + fn display(&self) -> Option> { + Some(self.display.clone()) + } + + fn mouse_position(&self) -> Point { + self.inner.state.borrow().mouse_position + } + + fn modifiers(&self) -> Modifiers { + self.inner.state.borrow().modifiers + } + + fn capslock(&self) -> Capslock { + self.inner.state.borrow().capslock + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.inner.state.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.inner.state.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + // Android native prompts need an Activity result contract, absent from GPUI's sync API. + None + } + + fn activate(&self) { + // Android, not an application window, owns foreground activation. + } + + fn is_active(&self) -> bool { + self.inner.state.borrow().active + } + + fn is_hovered(&self) -> bool { + self.inner.state.borrow().hovered + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + self.inner.state.borrow().background_appearance + } + + fn set_title(&mut self, title: &str) { + // Android task labels come from the manifest; retain the title for GPUI queries. + self.inner.state.borrow_mut().title = title.to_owned(); + } + + fn set_background_appearance(&self, appearance: WindowBackgroundAppearance) { + let mut state = self.inner.state.borrow_mut(); + state.background_appearance = appearance; + if state.native_window.is_some() { + // Reconfiguring the retired surface after surfaceDestroyed can hang Android drivers. + state + .renderer + .update_transparency(appearance != WindowBackgroundAppearance::Opaque); + } + } + + fn minimize(&self) { + // Android activity visibility is lifecycle-owned, not window-minimized. + } + + fn zoom(&self) { + // Android has no desktop maximize/zoom window operation. + } + + fn toggle_fullscreen(&self) { + // The activity surface is the backend's single full-screen GPUI window. + } + + fn is_fullscreen(&self) -> bool { + true + } + + fn on_request_frame(&self, callback: Box) { + self.inner.callbacks.borrow_mut().request_frame = Some(callback); + self.inner.host.request_frame(); + } + + fn on_input(&self, callback: Box DispatchEventResult>) { + self.inner.callbacks.borrow_mut().input = Some(callback); + } + + fn on_active_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().active_status_change = Some(callback); + } + + fn on_hover_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); + } + + fn on_resize(&self, callback: Box, f32)>) { + self.inner.callbacks.borrow_mut().resize = Some(callback); + } + + fn on_moved(&self, callback: Box) { + self.inner.callbacks.borrow_mut().moved = Some(callback); + } + + fn on_should_close(&self, callback: Box bool>) { + self.inner.callbacks.borrow_mut().should_close = Some(callback); + } + + fn on_hit_test_window_control(&self, callback: Box Option>) { + self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); + } + + fn on_close(&self, callback: Box) { + self.inner.callbacks.borrow_mut().close = Some(callback); + } + + fn on_appearance_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); + } + + fn draw(&self, scene: &Scene) { + self.inner.state.borrow_mut().renderer.draw(scene); + } + + fn completed_frame(&self) { + // WgpuRenderer presents the Android surface as part of draw. + } + + fn sprite_atlas(&self) -> Arc { + self.inner.state.borrow().renderer.sprite_atlas().clone() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + // Android display rotation makes RGB stripe order unstable; use grayscale glyphs. + false + } + + fn get_title(&self) -> String { + self.inner.state.borrow().title.clone() + } + + fn window_controls(&self) -> WindowControls { + WindowControls { + fullscreen: false, + maximize: false, + minimize: false, + window_menu: false, + } + } + + fn gpu_specs(&self) -> Option { + Some(self.inner.state.borrow().renderer.gpu_specs()) + } + + fn update_ime_position(&self, bounds: Bounds) { + self.inner.host.update_ime_position(bounds); + } + + fn insets(&self) -> WindowInsets { + self.inner.state.borrow().insets.clone() + } + + fn on_insets_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().insets_changed = Some(callback); + } + + fn set_back_handler(&self, callback: Box) { + self.inner.callbacks.borrow_mut().back = Some(callback); + } + + fn set_back_enabled(&self, enabled: bool) { + self.inner.state.borrow_mut().back_enabled = enabled; + self.inner.host.set_back_enabled(enabled); + } + + fn show_soft_keyboard(&self) { + self.inner.host.show_soft_keyboard(); + } + + fn hide_soft_keyboard(&self) { + self.inner.host.hide_soft_keyboard(); + } + + fn text_input_state_changed(&self, change: TextInputStateChange) { + self.inner.host.text_input_state_changed(change); + } +} + +fn surface_config(metrics: AndroidDisplayMetrics, transparent: bool) -> WgpuSurfaceConfig { + WgpuSurfaceConfig { + size: Size { + width: DevicePixels(metrics.width_px as i32), + height: DevicePixels(metrics.height_px as i32), + }, + transparent, + // FIFO is universally available and remains valid across native-window replacement. + preferred_present_mode: None, + } +} diff --git a/crates/gpui-ios/Cargo.toml b/crates/gpui-ios/Cargo.toml new file mode 100644 index 00000000..869ea6db --- /dev/null +++ b/crates/gpui-ios/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gpui-ios" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +log = "0.4" + +# iOS-only. Like gpui-android, this crate exports nothing on a host build so +# ordinary desktop workspace builds are unaffected by an in-progress backend. +[target.'cfg(target_os = "ios")'.dependencies] +gpui = { git = "https://github.com/zed-industries/zed" } +gpui_wgpu = { git = "https://github.com/zed-industries/zed" } +anyhow = "1" +futures = "0.3" +raw-window-handle = "0.6" +uuid = { version = "1", features = ["v4"] } diff --git a/crates/gpui-ios/README.md b/crates/gpui-ios/README.md new file mode 100644 index 00000000..2803ffdc --- /dev/null +++ b/crates/gpui-ios/README.md @@ -0,0 +1,53 @@ +# gpui-ios + +The iOS `gpui::Platform` backend. Compiled only for `target_os = "ios"`, so host +workspace builds are unaffected. + +Same division of labour as `gpui-android`: this crate owns the platform +implementation and the wgpu/Metal plumbing and links no Objective-C. The UIKit +shell implements `IosHost`, which keeps the backend a plain Rust crate that could +be offered upstream on its own. + +## iOS needs no upstream change + +An earlier plan recorded that it did, because `gpui_wgpu::WgpuContext::instance` +hardcodes `Backends::VULKAN | GL`. That was wrong, and the correction is the most +useful thing in this file. + +That helper is a convenience, not the only entry point: + +- `WgpuContext::new(instance, surface, compositor_gpu)` accepts an instance the + caller builds. +- `WgpuRenderer::new` takes `GpuContext = Rc>>` — a + shared slot it populates *only when empty*. + +So `metal_context()` builds a `Backends::METAL` instance, selects an adapter, and +hands back a pre-filled slot. The renderer then uses it and never reaches the +VULKAN|GL default. Backend selection happens once, in code we own. + +Measured against `aarch64-apple-ios`: `gpui` compiles, `gpui_wgpu` compiles, and +this crate compiles, all with zero errors. + +## What is verified + +Type-checking and clippy for `aarch64-apple-ios`. Nothing more. + +## What is not + +No device, no simulator, no linked binary. Unlike the Android side — where +`libtcode_android.so` links and so proves symbol resolution — nothing here has +been through a linker. The first unexercised step is `metal_context` itself: +adapter selection and surface creation have never met a real Metal device. + +Touch is also parked. GPUI's core touch dispatch is still marked +"implementation pending" upstream, so `IosEventSink::touch` converts +coordinates but does not deliver; a usable app must synthesize mouse events the +way `gpui_web` does. + +## Still needed for a running app + +- `tcode-ios`: a staticlib plus an Xcode project, `UIViewController` and + `CAMetalLayer`, and an `IosHost` implementation. +- A `CADisplayLink` driving `frame`, coalesced per the trait's contract. +- Mouse-event synthesis from touches. +- Keychain-backed credentials and a `UITextInput` conformance for IME. diff --git a/crates/gpui-ios/src/dispatcher.rs b/crates/gpui-ios/src/dispatcher.rs new file mode 100644 index 00000000..ca09e675 --- /dev/null +++ b/crates/gpui-ios/src/dispatcher.rs @@ -0,0 +1,160 @@ +//! `PlatformDispatcher` over Grand Central Dispatch. +//! +//! The Android backend hand-rolls a priority queue and thread pool, because +//! GPUI's reusable one is cfg-gated away from that target and Android has no +//! equivalent system facility. iOS does: libdispatch is part of libSystem, it is +//! already the scheduler UIKit itself runs on, and its global queues map +//! directly onto GPUI's priorities. Reusing it is both less code and better +//! behaved than a second thread pool competing with the system's. + +use std::ffi::c_void; +use std::time::{Duration, Instant}; + +use gpui::{PlatformDispatcher, Priority, RunnableVariant}; + +// libdispatch, from libSystem. Declared rather than pulled in as a crate: these +// five symbols are the entire surface used, and a dependency would add a +// version to track for no benefit. +#[allow(non_camel_case_types)] +type dispatch_queue_t = *mut c_void; +#[allow(non_camel_case_types)] +type dispatch_function_t = extern "C" fn(*mut c_void); + +unsafe extern "C" { + static _dispatch_main_q: c_void; + + fn dispatch_get_global_queue(identifier: isize, flags: usize) -> dispatch_queue_t; + fn dispatch_async_f(queue: dispatch_queue_t, context: *mut c_void, work: dispatch_function_t); + fn dispatch_after_f( + when: u64, + queue: dispatch_queue_t, + context: *mut c_void, + work: dispatch_function_t, + ); + fn dispatch_time(base: u64, delta: i64) -> u64; +} + +/// libdispatch's quality-of-service classes, from `dispatch/queue.h`. +const QOS_CLASS_USER_INTERACTIVE: isize = 0x21; +const QOS_CLASS_USER_INITIATED: isize = 0x19; +const QOS_CLASS_UTILITY: isize = 0x11; + +/// `DISPATCH_TIME_NOW`. +const DISPATCH_TIME_NOW: u64 = 0; + +fn main_queue() -> dispatch_queue_t { + // Taking the address of an extern static is how libdispatch's own C macro + // resolves the main queue, and `&raw const` needs no unsafe block: nothing + // is read, only the pointer formed. + &raw const _dispatch_main_q as dispatch_queue_t +} + +fn global_queue(priority: Priority) -> dispatch_queue_t { + // Mapped onto QoS rather than collapsed to one queue: the OS uses these to + // decide thread priority and, on battery, whether to run the work at all. + // Telling it everything is user-interactive would be a lie that costs + // battery. + let qos = match priority { + // Audio work asks for the strongest guarantee an app can request. iOS + // reserves true realtime threads for AVAudioSession clients, so + // user-interactive is the honest ceiling rather than a promise the + // platform will not keep. + Priority::RealtimeAudio | Priority::High => QOS_CLASS_USER_INTERACTIVE, + Priority::Medium => QOS_CLASS_USER_INITIATED, + Priority::Low => QOS_CLASS_UTILITY, + }; + // SAFETY: a well-known QoS constant with no flags. + unsafe { dispatch_get_global_queue(qos, 0) } +} + +/// Trampoline: libdispatch calls a C function with an opaque pointer, so the +/// runnable crosses as a leaked box and is reclaimed exactly once here. +extern "C" fn run_boxed(context: *mut c_void) { + // SAFETY: `context` came from `Box::into_raw` in the paired dispatch call, + // and libdispatch invokes this exactly once per submission. + let runnable = unsafe { Box::from_raw(context.cast::()) }; + runnable.run(); +} + +fn submit(queue: dispatch_queue_t, runnable: RunnableVariant) { + let context = Box::into_raw(Box::new(runnable)).cast::(); + // SAFETY: the queue is a libSystem global, and `run_boxed` reclaims the box. + unsafe { dispatch_async_f(queue, context, run_boxed) }; +} + +pub struct IosDispatcher; + +impl IosDispatcher { + pub fn new() -> Self { + Self + } +} + +impl Default for IosDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl PlatformDispatcher for IosDispatcher { + fn is_main_thread(&self) -> bool { + // `pthread_main_np` rather than `NSThread.isMainThread`: it needs no + // Objective-C runtime call and is valid from any thread, including ones + // GPUI spawned that never touched UIKit. + unsafe extern "C" { + fn pthread_main_np() -> i32; + } + // SAFETY: a libSystem call with no arguments and no preconditions. + unsafe { pthread_main_np() != 0 } + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + submit(global_queue(priority), runnable); + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { + // The main queue is serial and has no QoS to choose; priority is + // meaningful only in ordering against other main-queue work, which + // libdispatch already handles FIFO. + submit(main_queue(), runnable); + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let context = Box::into_raw(Box::new(runnable)).cast::(); + // Saturating: a delay beyond i64 nanoseconds is ~292 years, and + // wrapping would fire the timer immediately — the opposite of intent. + let delta = i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX); + // SAFETY: `run_boxed` reclaims the box exactly once when the timer fires. + unsafe { + let when = dispatch_time(DISPATCH_TIME_NOW, delta); + dispatch_after_f(when, global_queue(Priority::Medium), context, run_boxed); + } + } + + fn spawn_realtime(&self, f: Box) { + // iOS has no public realtime thread API for apps — that is reserved for + // audio units through `AVAudioSession`. User-interactive QoS is the + // highest an app can honestly ask for, so it is what this returns rather + // than pretending to a guarantee the platform does not offer. + extern "C" fn run_closure(context: *mut c_void) { + // SAFETY: paired with the `Box::into_raw` below. + let closure = unsafe { Box::from_raw(context.cast::>()) }; + closure(); + } + let context = Box::into_raw(Box::new(f)).cast::(); + // SAFETY: `run_closure` reclaims the box exactly once. + unsafe { dispatch_async_f(global_queue(Priority::High), context, run_closure) }; + } + + fn now(&self) -> Instant { + Instant::now() + } +} + +impl IosDispatcher { + /// Symmetry with the Android dispatcher, which owns a hand-rolled main-queue + /// and must be pumped. libdispatch delivers to the main queue itself, so + /// there is nothing to drain — the entry point exists so the shell's wake + /// callback has the same shape on both platforms. + pub fn drain_main_thread(&self) {} +} diff --git a/crates/gpui-ios/src/display.rs b/crates/gpui-ios/src/display.rs new file mode 100644 index 00000000..196bb714 --- /dev/null +++ b/crates/gpui-ios/src/display.rs @@ -0,0 +1,59 @@ +use std::{cell::Cell, fmt}; + +use anyhow::Result; +use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay}; + +use crate::IosDisplayMetrics; + +pub(crate) struct IosDisplay { + metrics: Cell, +} + +impl IosDisplay { + pub(crate) fn new(metrics: IosDisplayMetrics) -> Self { + Self { + metrics: Cell::new(metrics), + } + } + + pub(crate) fn update(&self, metrics: IosDisplayMetrics) { + self.metrics.set(metrics); + } +} + +impl fmt::Debug for IosDisplay { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("IosDisplay") + .field("id", &DisplayId::new(1)) + .field("metrics", &self.metrics.get()) + .finish() + } +} + +impl PlatformDisplay for IosDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(1) + } + + fn uuid(&self) -> Result { + // iOS exposes one scene per app, modeled as a single stable logical display. + Ok(uuid::Uuid::from_u128( + 0x69d9_8b2a_37c3_4dca_a37d_726f_6964_0001, + )) + } + + fn bounds(&self) -> Bounds { + self.metrics.get().logical_bounds() + } + + fn visible_bounds(&self) -> Bounds { + // System bars and cutouts are overlays represented by WindowInsets. + self.bounds() + } + + fn default_bounds(&self) -> Bounds { + // An iOS scene owns a single full-screen window. + self.bounds() + } +} diff --git a/crates/gpui-ios/src/keyboard.rs b/crates/gpui-ios/src/keyboard.rs new file mode 100644 index 00000000..3c155155 --- /dev/null +++ b/crates/gpui-ios/src/keyboard.rs @@ -0,0 +1,13 @@ +use gpui::PlatformKeyboardLayout; + +pub(crate) struct IosKeyboardLayout; + +impl PlatformKeyboardLayout for IosKeyboardLayout { + fn id(&self) -> &str { + "android" + } + + fn name(&self) -> &str { + "Ios" + } +} diff --git a/crates/gpui-ios/src/lib.rs b/crates/gpui-ios/src/lib.rs new file mode 100644 index 00000000..31d5b1bb --- /dev/null +++ b/crates/gpui-ios/src/lib.rs @@ -0,0 +1,34 @@ +//! GPUI platform backend for iOS. +//! +//! Deliberately the same module layout as `gpui-android`: `platform`, `window`, +//! `display`, `keyboard`, `dispatcher`, `native`. The two platforms differ in +//! their system APIs, not in what a GPUI backend must provide, so keeping the +//! shapes identical means a reader who knows one knows the other — and a fix to +//! one is trivially checkable against the other. +//! +//! **iOS needs no upstream change.** An earlier plan recorded that it did, +//! because `gpui_wgpu::WgpuContext::instance` hardcodes `VULKAN | GL`. That +//! helper is not the only entry point: `WgpuContext::new` accepts an instance +//! the caller builds, and `WgpuRenderer::new` takes a `GpuContext` slot it fills +//! only when empty. Pre-filling that slot with a Metal context bypasses the +//! default — which is what [`metal_context`] does. + +#[cfg(target_os = "ios")] +mod dispatcher; +#[cfg(target_os = "ios")] +mod display; +#[cfg(target_os = "ios")] +mod keyboard; +#[cfg(target_os = "ios")] +mod native; +#[cfg(target_os = "ios")] +mod platform; +#[cfg(target_os = "ios")] +mod window; + +#[cfg(target_os = "ios")] +pub use dispatcher::IosDispatcher; +#[cfg(target_os = "ios")] +pub use native::{IosDisplayMetrics, IosHost, IosLayer, IosSurface, metal_context}; +#[cfg(target_os = "ios")] +pub use platform::{IosEventSink, IosPlatform}; diff --git a/crates/gpui-ios/src/native.rs b/crates/gpui-ios/src/native.rs new file mode 100644 index 00000000..576a7026 --- /dev/null +++ b/crates/gpui-ios/src/native.rs @@ -0,0 +1,248 @@ +//! The UIKit seam, and the Metal context that makes iOS work without an +//! upstream change. +//! +//! Everything UIKit-shaped is behind [`IosHost`]. This crate never links +//! Objective-C itself: the shell that owns a `UIViewController` implements the +//! trait, so the backend stays a plain Rust crate that could be offered +//! upstream. + +use std::cell::RefCell; +use std::rc::Rc; + +use anyhow::{Context as _, Result, anyhow, bail}; +use gpui::{Bounds, Pixels, Size, WindowAppearance, point, px, size}; +use gpui_wgpu::wgpu; +use gpui_wgpu::{GpuContext, WgpuContext}; +use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, RawWindowHandle, + UiKitWindowHandle, WindowHandle, +}; + +/// Physical size and scale of the `CAMetalLayer` GPUI draws into. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct IosDisplayMetrics { + /// Width of the current surface in physical pixels. + pub width_px: u32, + /// Height of the current surface in physical pixels. + pub height_px: u32, + /// Number of physical pixels per GPUI logical pixel. + pub scale_factor: f32, +} + +impl IosDisplayMetrics { + /// Creates validated display metrics. + pub fn new(width_px: u32, height_px: u32, scale_factor: f32) -> Result { + let metrics = Self { + width_px, + height_px, + scale_factor, + }; + metrics.validate()?; + Ok(metrics) + } + + pub(crate) fn validate(self) -> Result<()> { + if self.width_px == 0 || self.height_px == 0 { + bail!("iOS drawable dimensions must be non-zero"); + } + if self.width_px > i32::MAX as u32 || self.height_px > i32::MAX as u32 { + bail!("iOS drawable dimensions exceed GPUI's device-pixel range"); + } + if !self.scale_factor.is_finite() || self.scale_factor <= 0.0 { + bail!("iOS contentScaleFactor must be finite and positive"); + } + Ok(()) + } + + pub(crate) fn logical_size(self) -> Size { + size( + px(self.width_px as f32 / self.scale_factor), + px(self.height_px as f32 / self.scale_factor), + ) + } + + pub(crate) fn logical_bounds(self) -> Bounds { + Bounds::new(point(px(0.0), px(0.0)), self.logical_size()) + } +} + +/// A retained `CAMetalLayer` pointer, owned by the UIKit shell. +/// +/// wgpu needs a `RawWindowHandle::UiKit` carrying the *view*, and iOS keeps the +/// layer alive as long as its view lives. This type therefore borrows rather +/// than owns: the shell must guarantee the pointer outlives the surface, which +/// is why [`IosSurface`] is created and destroyed around UIKit's own +/// view-lifecycle callbacks. +#[derive(Debug, Clone, Copy)] +pub struct IosLayer { + /// `UIView*` whose layer is a `CAMetalLayer`. + view: *mut std::ffi::c_void, +} + +// SAFETY: the pointer is only dereferenced by wgpu on the thread that created +// the surface, and the shell keeps the view alive across that window. Marking it +// Send/Sync is what lets the surface be held in GPUI's window state, which is +// not itself thread-local. +unsafe impl Send for IosLayer {} +unsafe impl Sync for IosLayer {} + +impl IosLayer { + /// # Safety + /// `view` must be a live `UIView*` whose `layer` is a `CAMetalLayer`, and it + /// must outlive every surface created from it. + pub unsafe fn from_raw(view: *mut std::ffi::c_void) -> Result { + if view.is_null() { + return Err(anyhow!("a null UIView cannot back a Metal surface")); + } + Ok(Self { view }) + } +} + +impl HasWindowHandle for IosLayer { + fn window_handle(&self) -> std::result::Result, HandleError> { + let pointer = std::ptr::NonNull::new(self.view).ok_or(HandleError::Unavailable)?; + let handle = UiKitWindowHandle::new(pointer); + // SAFETY: the handle borrows `self`, and `from_raw` established that the + // pointer is a live view the shell keeps alive. + Ok(unsafe { WindowHandle::borrow_raw(RawWindowHandle::UiKit(handle)) }) + } +} + +impl HasDisplayHandle for IosLayer { + fn display_handle(&self) -> std::result::Result, HandleError> { + // UIKit has no display object to hand out; the window handle carries + // everything Metal needs. + Ok(DisplayHandle::uikit()) + } +} + +/// One drawable generation: a layer plus the metrics it was measured at. +#[derive(Debug, Clone, Copy)] +pub struct IosSurface { + /// The view whose layer is a `CAMetalLayer`. + pub window: IosLayer, + /// Size and scale this generation was measured at. + pub metrics: IosDisplayMetrics, +} + +impl IosSurface { + pub fn new(layer: IosLayer, metrics: IosDisplayMetrics) -> Result { + metrics.validate()?; + Ok(Self { + window: layer, + metrics, + }) + } + + pub fn layer(&self) -> IosLayer { + self.window + } +} + +/// A `GpuContext` pre-filled with a Metal-backed `WgpuContext`. +/// +/// This is the whole reason iOS needs no upstream patch. `WgpuContext::instance` +/// hardcodes `VULKAN | GL`, but it is only a convenience: `WgpuContext::new` +/// accepts any instance, and `WgpuRenderer::new` uses the context already in the +/// slot rather than building its own. So the backend selection happens here, +/// once, in code we own. +pub fn metal_context(surface_target: &IosSurface) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + // Metal only. Listing anything else would let wgpu silently pick a + // software or GL path on a platform where Metal is the only real answer, + // and a slow success is harder to diagnose than a clean failure. + backends: wgpu::Backends::METAL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: None, + raw_window_handle: surface_target + .layer() + .window_handle() + .map_err(|error| anyhow!("the UIKit view has no usable window handle: {error}"))? + .as_raw(), + }; + // SAFETY: the handle came from a live view the shell keeps alive for at + // least as long as this surface. + let probe = unsafe { instance.create_surface_unsafe(target) } + .context("creating a Metal surface for adapter selection")?; + + let context = + WgpuContext::new(instance, &probe, None).context("selecting a Metal adapter and device")?; + Ok(Rc::new(RefCell::new(Some(context)))) +} + +/// Everything the UIKit shell must provide. +/// +/// Mirrors `gpui-android`'s `AndroidHost` on purpose — the two platforms differ +/// in their callback names, not in what a GPUI backend needs from them, and a +/// shared shape means a reader who knows one knows the other. +pub trait IosHost: Send + Sync { + /// Wake the main run loop so it calls `drain_main_thread`. + /// + /// Called from GPUI's worker threads, so an implementation must post to the + /// main queue rather than assume it is already there. + fn wake_main_thread(&self); + + /// Schedule one `CADisplayLink` tick that calls `frame`. + /// + /// Must coalesce while a tick is pending: GPUI may ask several times within + /// one vsync, and each extra tick would queue a redundant frame. + fn request_frame(&self); + + /// Ask the shell to suspend the app. + /// + /// iOS apps do not exit on demand — `exit(0)` is grounds for App Store + /// rejection and looks like a crash to the user — so this is advisory and a + /// shell may reasonably do nothing. + fn finish_activity(&self); + + /// Open a URL through `UIApplication`. + fn open_url(&self, url: &str) -> Result<()>; + + /// Read plain text from `UIPasteboard`. + fn read_clipboard_text(&self) -> Result>; + + /// Write plain text to `UIPasteboard`. + fn write_clipboard_text(&self, text: &str) -> Result<()>; + + /// Store a secret in the iOS keychain. + fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Result<()>; + + /// Read a secret from the iOS keychain. + fn read_credentials(&self, url: &str) -> Result)>>; + + /// Delete a secret from the iOS keychain. + fn delete_credentials(&self, url: &str) -> Result<()>; + + /// Become first responder so the software keyboard appears. + fn show_soft_keyboard(&self); + + /// Resign first responder. + fn hide_soft_keyboard(&self); + + /// Report the caret rectangle so the IME can position its candidate bar. + fn update_ime_position(&self, bounds: Bounds); + + /// Current `UITraitCollection.userInterfaceStyle`. + fn window_appearance(&self) -> WindowAppearance; + + /// Current thermal pressure. + /// + /// iOS reports this through `ProcessInfo.thermalState`, which is a + /// notification rather than a poll, so a shell caches the last value. + fn thermal_state(&self) -> gpui::ThermalState; + + /// GPUI's focused text-input state changed. + fn text_input_state_changed(&self, change: gpui::TextInputStateChange); + + /// Enable or disable the shell's back gesture, if it has one. + /// + /// iOS has no system back button; this exists so the shared window code can + /// be identical on both backends, and a shell may ignore it. + fn set_back_enabled(&self, enabled: bool); +} diff --git a/crates/gpui-ios/src/platform.rs b/crates/gpui-ios/src/platform.rs new file mode 100644 index 00000000..89cac7f5 --- /dev/null +++ b/crates/gpui-ios/src/platform.rs @@ -0,0 +1,736 @@ +use std::{ + borrow::Cow, + cell::RefCell, + ops::Range, + path::{Path, PathBuf}, + rc::{Rc, Weak}, + sync::Arc, +}; + +use anyhow::{Context as _, Result, anyhow, bail}; +use futures::channel::oneshot; +use gpui::{ + Action, AnyWindowHandle, AppLifecyclePhase, BackgroundExecutor, ClipboardItem, CursorStyle, + DispatchEventResult, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, + OwnedMenu, PathPromptOptions, Platform, PlatformDispatcher, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, + ScreenCaptureSource, Task, ThermalState, TouchEvent, TouchId, TouchPhase, WindowAppearance, + WindowInsets, WindowKind, WindowParams, point, popup::PopupNotSupportedError, px, +}; +use gpui_wgpu::{CosmicTextSystem, GpuContext}; + +use crate::{ + IosDisplayMetrics, IosHost, IosSurface, + dispatcher::IosDispatcher, + display::IosDisplay, + keyboard::IosKeyboardLayout, + window::{IosWindow, IosWindowInner}, +}; + +static BUNDLED_FONTS: &[&[u8]] = &[ + include_bytes!("../../../assets/fonts/DMSans[wght].ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-Italic.ttf"), + include_bytes!("../../../assets/fonts/lilex/Lilex-BoldItalic.ttf"), +]; + +#[derive(Default)] +struct IosPlatformCallbacks { + open_urls: Option)>>, + quit: Option>, + reopen: Option>, + system_wake: Option>, + lifecycle: Option>, + memory_warning: Option>, + thermal_state_change: Option>, + keyboard_layout_change: Option>, +} + +struct IosPlatformState { + surface: Option, + active_window: Option, + window: Option>, + appearance: WindowAppearance, + thermal_state: ThermalState, + callbacks: IosPlatformCallbacks, +} + +/// GPUI's Android platform implementation. +pub struct IosPlatform { + host: Arc, + dispatcher: Arc, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + display: Rc, + state: Rc>, + gpu_context: GpuContext, +} + +impl IosPlatform { + /// Creates the backend on Android's UI thread with the current surface generation. + pub fn new(host: Arc, surface: IosSurface) -> Result { + surface.metrics.validate()?; + + let dispatcher = Arc::new(IosDispatcher::new()); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher.clone()); + + let text_system = Arc::new(CosmicTextSystem::new_without_system_fonts("DM Sans")); + text_system.add_fonts( + BUNDLED_FONTS + .iter() + .map(|bytes| Cow::Borrowed(*bytes)) + .collect(), + )?; + let text_system: Arc = text_system; + + let display = Rc::new(IosDisplay::new(surface.metrics)); + let appearance = host.window_appearance(); + let thermal_state = host.thermal_state(); + + Ok(Self { + host, + dispatcher, + background_executor, + foreground_executor, + text_system, + display, + state: Rc::new(RefCell::new(IosPlatformState { + surface: Some(surface), + active_window: None, + window: None, + appearance, + thermal_state, + callbacks: IosPlatformCallbacks::default(), + })), + gpu_context: Rc::new(RefCell::new(None)), + }) + } + + /// Returns the inbound JNI/NDK event boundary for the owning activity. + pub fn event_sink(&self) -> IosEventSink { + IosEventSink { + host: self.host.clone(), + dispatcher: self.dispatcher.clone(), + display: self.display.clone(), + state: self.state.clone(), + } + } +} + +/// Main-thread entry points called by the Android activity and Choreographer. +/// +/// This type is deliberately `!Send`: Android UI, lifecycle, input, and surface +/// callbacks must enter GPUI on the same thread that constructed the platform. +#[derive(Clone)] +pub struct IosEventSink { + host: Arc, + dispatcher: Arc, + display: Rc, + state: Rc>, +} + +impl IosEventSink { + /// Drains foreground tasks after `IosHost::wake_main_thread`. + pub fn drain_main_thread(&self) { + self.dispatcher.drain_main_thread(); + } + + /// Replaces a surface after Android creates a new `ANativeWindow`. + pub fn surface_created(&self, surface: IosSurface) -> Result<()> { + self.assert_main_thread(); + surface.metrics.validate()?; + + if let Some(window) = self.window() { + window.replace_surface(surface)?; + } + self.display.update(surface.metrics); + self.state.borrow_mut().surface = Some(surface); + + if self + .window() + .is_some_and(|window| window.should_schedule_frames()) + { + self.host.request_frame(); + } + Ok(()) + } + + /// Unconfigures wgpu before Android releases the current native window. + pub fn surface_destroyed(&self) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.detach_surface(); + } + self.state.borrow_mut().surface = None; + } + + /// Applies a native surface resize or density change. + pub fn resized(&self, metrics: IosDisplayMetrics) -> Result<()> { + self.assert_main_thread(); + metrics.validate()?; + if let Some(surface) = self.state.borrow_mut().surface.as_mut() { + surface.metrics = metrics; + } + self.display.update(metrics); + if let Some(window) = self.window() { + window.resize_from_native(metrics); + } + Ok(()) + } + + /// Delivers one Choreographer frame and schedules the next while visible. + pub fn frame(&self) { + self.assert_main_thread(); + let Some(window) = self.window() else { + return; + }; + window.frame(); + if window.should_schedule_frames() { + self.host.request_frame(); + } + } + + /// Delivers an already translated GPUI input event. + pub fn dispatch_input(&self, input: gpui::PlatformInput) -> Option { + self.assert_main_thread(); + self.window() + .and_then(|window| window.dispatch_input(input)) + } + + /// Converts a physical Android touch into logical coordinates and mouse input. + /// + /// GPUI core does not yet dispatch `TouchEvent` to elements, so the primary + /// contact is synthesized as left-button mouse input for the mobile MVP. + pub fn touch( + &self, + id: u64, + phase: TouchPhase, + x_px: f32, + y_px: f32, + force: Option, + ) -> Option { + self.assert_main_thread(); + let window = self.window()?; + let scale = window.scale_factor(); + window.dispatch_touch(TouchEvent { + id: TouchId(id), + phase, + position: point(px(x_px / scale), px(y_px / scale)), + force, + }) + } + + /// Updates focus from Android lifecycle or pointer-device callbacks. + pub fn active_changed(&self, active: bool) { + self.assert_main_thread(); + if let Some(window) = self.window() { + let changed = window.set_active(active); + if changed && active && window.should_schedule_frames() { + // A surface can survive pause/resume, so restart the frame chain explicitly. + self.host.request_frame(); + } + } + } + + /// Updates hover state for attached mouse or stylus devices. + pub fn hovered_changed(&self, hovered: bool) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.set_hovered(hovered); + } + } + + /// Notifies GPUI that Android moved the activity to another display. + pub fn moved(&self) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.moved(); + } + } + + /// Runs GPUI's registered window-control hit test, if any. + pub fn hit_test_window_control(&self) -> Option { + self.assert_main_thread(); + self.window() + .and_then(|window| window.hit_test_window_control()) + } + + /// Delivers Android's application lifecycle vocabulary. + pub fn lifecycle_changed(&self, phase: AppLifecyclePhase) { + self.assert_main_thread(); + if let Some(window) = self.window() { + let active = matches!(phase, AppLifecyclePhase::Active); + let changed = window.set_active(active); + if changed && active && window.should_schedule_frames() { + // Android may resume without recreating the surface. + self.host.request_frame(); + } + } + if let Some(callback) = self.state.borrow_mut().callbacks.lifecycle.as_mut() { + callback(phase); + } + } + + /// Delivers `onTrimMemory`/memory-pressure notification. + pub fn memory_warning(&self) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.memory_warning.as_mut() { + callback(); + } + } + + /// Delivers animated safe-area and IME inset updates in logical pixels. + pub fn insets_changed(&self, insets: WindowInsets) { + self.assert_main_thread(); + if let Some(window) = self.window() { + window.set_insets(insets); + } + } + + /// Runs GPUI's back callback. Returns false when Android should use its default. + pub fn back_pressed(&self) -> bool { + self.assert_main_thread(); + self.window().is_some_and(|window| window.back_pressed()) + } + + /// Consults GPUI's close veto, delivers close, and finishes the activity. + pub fn close_requested(&self) -> bool { + self.assert_main_thread(); + if let Some(window) = self.window() + && !window.close_requested() + { + return false; + } + + { + let mut state = self.state.borrow_mut(); + state.active_window = None; + state.window = None; + } + self.host.finish_activity(); + true + } + + /// Delivers Android UI-mode changes. + pub fn appearance_changed(&self, appearance: WindowAppearance) { + self.assert_main_thread(); + self.state.borrow_mut().appearance = appearance; + if let Some(window) = self.window() { + window.set_appearance(appearance); + } + } + + /// Delivers Android thermal-status changes. + pub fn thermal_state_changed(&self, thermal_state: ThermalState) { + self.assert_main_thread(); + let changed = self.state.borrow().thermal_state != thermal_state; + self.state.borrow_mut().thermal_state = thermal_state; + if changed + && let Some(callback) = self + .state + .borrow_mut() + .callbacks + .thermal_state_change + .as_mut() + { + callback(); + } + } + + /// Delivers Android keyboard-layout changes. + pub fn keyboard_layout_changed(&self) { + self.assert_main_thread(); + if let Some(callback) = self + .state + .borrow_mut() + .callbacks + .keyboard_layout_change + .as_mut() + { + callback(); + } + } + + /// Delivers VIEW intents registered by the Android manifest. + pub fn open_urls(&self, urls: Vec) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.open_urls.as_mut() { + callback(urls); + } + } + + /// Delivers a host-driven reopen request. + pub fn reopen(&self) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.reopen.as_mut() { + callback(); + } + } + + /// Delivers a system-wake notification. + pub fn system_wake(&self) { + self.assert_main_thread(); + if let Some(callback) = self.state.borrow_mut().callbacks.system_wake.as_mut() { + callback(); + } + } + + /// Commits text from Android's InputConnection. + pub fn ime_replace_text(&self, range: Option>, text: &str) -> bool { + self.assert_main_thread(); + self.window() + .is_some_and(|window| window.ime_replace_text(range, text)) + } + + /// Updates composing text from Android's InputConnection. + pub fn ime_set_composing_text( + &self, + range: Option>, + text: &str, + selected_range: Option>, + ) -> bool { + self.assert_main_thread(); + self.window() + .is_some_and(|window| window.ime_set_composing_text(range, text, selected_range)) + } + + /// Finishes the current IME composition. + pub fn ime_finish_composing(&self) -> bool { + self.assert_main_thread(); + self.window() + .is_some_and(|window| window.ime_finish_composing()) + } + + fn window(&self) -> Option> { + self.state.borrow().window.as_ref().and_then(Weak::upgrade) + } + + fn assert_main_thread(&self) { + assert!( + self.dispatcher.is_main_thread(), + "Android event delivered outside the main thread" + ); + } +} + +impl Platform for IosPlatform { + fn background_executor(&self) -> BackgroundExecutor { + self.background_executor.clone() + } + + fn foreground_executor(&self) -> ForegroundExecutor { + self.foreground_executor.clone() + } + + fn text_system(&self) -> Arc { + self.text_system.clone() + } + + fn run(&self, on_finish_launching: Box) { + assert!( + self.dispatcher.is_main_thread(), + "IosPlatform::run must be called on the activity UI thread" + ); + // Android's Looper already owns the event loop; GPUI launches inside it. + on_finish_launching(); + } + + fn quit(&self) { + if let Some(callback) = self.state.borrow_mut().callbacks.quit.as_mut() { + callback(); + } + self.host.finish_activity(); + } + + fn restart(&self, _binary_path: Option) { + // Android packages cannot restart by executing a replacement binary path. + log::warn!("Android does not support Platform::restart"); + } + + fn activate(&self, _ignoring_other_apps: bool) { + // Android owns activity activation through its lifecycle. + } + + fn hide(&self) { + // Android activities are backgrounded by the OS, not hidden as windows. + } + + fn hide_other_apps(&self) { + // Android apps cannot hide other applications. + } + + fn unhide_other_apps(&self) { + // Android apps cannot change other applications' visibility. + } + + fn displays(&self) -> Vec> { + vec![self.display.clone()] + } + + fn primary_display(&self) -> Option> { + Some(self.display.clone()) + } + + fn active_window(&self) -> Option { + self.state.borrow().active_window + } + + fn window_stack(&self) -> Option> { + self.state.borrow().active_window.map(|window| vec![window]) + } + + fn is_screen_capture_supported(&self) -> bool { + false + } + + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + // Android MediaProjection is consent/activity-result based and not this synchronous API. + unsupported_receiver("screen capture is not supported by gpui-android") + } + + fn open_window( + &self, + handle: AnyWindowHandle, + params: WindowParams, + ) -> Result> { + if matches!(¶ms.kind, WindowKind::AnchoredPopup(_)) { + return Err(PopupNotSupportedError.into()); + } + if !matches!(¶ms.kind, WindowKind::Normal) { + bail!("iOS supports only its single scene window"); + } + if self + .state + .borrow() + .window + .as_ref() + .and_then(Weak::upgrade) + .is_some() + { + bail!("Android currently supports only one GPUI platform window"); + } + + // Copy, not clone: IosSurface is a view pointer plus metrics, unlike + // Android's ref-counted ANativeWindow wrapper. + let surface = self + .state + .borrow() + .surface + .context("no CAMetalLayer is currently available")?; + let appearance = self.state.borrow().appearance; + let window = IosWindow::new( + self.host.clone(), + self.gpu_context.clone(), + self.display.clone(), + surface, + appearance, + )?; + + { + let mut state = self.state.borrow_mut(); + state.active_window = Some(handle); + state.window = Some(Rc::downgrade(&window.inner)); + } + Ok(Box::new(window)) + } + + fn window_appearance(&self) -> WindowAppearance { + self.state.borrow().appearance + } + + fn open_url(&self, url: &str) { + if let Err(error) = self.host.open_url(url) { + log::warn!("failed to open Android URI {url:?}: {error:#}"); + } + } + + fn on_open_urls(&self, callback: Box)>) { + self.state.borrow_mut().callbacks.open_urls = Some(callback); + } + + fn register_url_scheme(&self, _url: &str) -> Task> { + // Android URL schemes are declared statically as manifest intent filters. + Task::ready(Err(anyhow!( + "Android URL schemes must be registered in AndroidManifest.xml" + ))) + } + + fn prompt_for_paths( + &self, + _options: PathPromptOptions, + ) -> oneshot::Receiver>>> { + // Android's Storage Access Framework requires an Activity result contract. + unsupported_receiver("file selection is not supported by gpui-android") + } + + fn prompt_for_new_path( + &self, + _directory: &Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + // Android's Storage Access Framework returns content URIs, not writable Paths. + unsupported_receiver("new-path prompts are not supported by gpui-android") + } + + fn can_select_mixed_files_and_dirs(&self) -> bool { + false + } + + fn reveal_path(&self, _path: &Path) { + // Android has no system file-manager reveal operation for arbitrary Paths. + } + + fn open_with_system(&self, path: &Path) { + if let Err(error) = self.host.open_url(&format!("file://{}", path.display())) { + log::warn!("failed to open Android path {path:?}: {error:#}"); + } + } + + fn on_quit(&self, callback: Box) { + self.state.borrow_mut().callbacks.quit = Some(callback); + } + + fn on_reopen(&self, callback: Box) { + self.state.borrow_mut().callbacks.reopen = Some(callback); + } + + fn on_system_wake(&self, callback: Box) { + self.state.borrow_mut().callbacks.system_wake = Some(callback); + } + + fn on_app_lifecycle(&self, callback: Box) { + self.state.borrow_mut().callbacks.lifecycle = Some(callback); + } + + fn on_memory_warning(&self, callback: Box) { + self.state.borrow_mut().callbacks.memory_warning = Some(callback); + } + + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) { + // Android has no process-wide desktop menu bar. + } + + fn get_menus(&self) -> Option> { + // Android has no process-wide desktop menu bar to query. + None + } + + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) { + // Android has no desktop dock menu. + } + + fn perform_dock_menu_action(&self, _action: usize) { + // Android has no desktop dock menu action source. + } + + fn add_recent_document(&self, _path: &Path) { + // Android's recents screen tracks activities, not application documents. + } + + fn on_app_menu_action(&self, _callback: Box) { + // Android has no GPUI app-menu action source. + } + + fn on_will_open_app_menu(&self, _callback: Box) { + // Android has no process-wide application menu. + } + + fn on_validate_app_menu_command(&self, _callback: Box bool>) { + // Android has no process-wide application-menu commands to validate. + } + + fn thermal_state(&self) -> ThermalState { + self.state.borrow().thermal_state + } + + fn on_thermal_state_change(&self, callback: Box) { + self.state.borrow_mut().callbacks.thermal_state_change = Some(callback); + } + + fn compositor_name(&self) -> &'static str { + "Android SurfaceFlinger" + } + + fn app_path(&self) -> Result { + // Android applications are APKs, not executable filesystem bundles. + bail!("application paths are not available on Android") + } + + fn path_for_auxiliary_executable(&self, _name: &str) -> Result { + // Android packages cannot ship independently executed auxiliary binaries. + bail!("auxiliary executable paths are not available on Android") + } + + fn set_cursor_style(&self, _style: CursorStyle) { + // The mobile MVP has touch input and no application-controlled cursor. + } + + fn hide_cursor_until_mouse_moves(&self) { + // The mobile MVP has no visible mouse cursor to hide. + } + + fn is_cursor_visible(&self) -> bool { + false + } + + fn should_auto_hide_scrollbars(&self) -> bool { + true + } + + fn read_from_clipboard(&self) -> Option { + match self.host.read_clipboard_text() { + Ok(Some(text)) => Some(ClipboardItem::new_string(text)), + Ok(None) => None, + Err(error) => { + log::warn!("failed to read Android clipboard: {error:#}"); + None + } + } + } + + fn write_to_clipboard(&self, item: ClipboardItem) { + let Some(text) = item.text() else { + // Android host seam currently supports GPUI's plain-text clipboard entries only. + log::warn!("gpui-android cannot write a non-text clipboard item"); + return; + }; + if let Err(error) = self.host.write_clipboard_text(&text) { + log::warn!("failed to write Android clipboard: {error:#}"); + } + } + + fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { + Task::ready(self.host.write_credentials(url, username, password)) + } + + fn read_credentials(&self, url: &str) -> Task)>>> { + Task::ready(self.host.read_credentials(url)) + } + + fn delete_credentials(&self, url: &str) -> Task> { + Task::ready(self.host.delete_credentials(url)) + } + + fn keyboard_layout(&self) -> Box { + Box::new(IosKeyboardLayout) + } + + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + + fn on_keyboard_layout_change(&self, callback: Box) { + self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); + } +} + +fn unsupported_receiver(message: &'static str) -> oneshot::Receiver> { + let (sender, receiver) = oneshot::channel(); + sender.send(Err(anyhow!(message))).ok(); + receiver +} diff --git a/crates/gpui-ios/src/window.rs b/crates/gpui-ios/src/window.rs new file mode 100644 index 00000000..3a59b30b --- /dev/null +++ b/crates/gpui-ios/src/window.rs @@ -0,0 +1,667 @@ +use std::{cell::RefCell, ops::Range, rc::Rc, sync::Arc}; + +use anyhow::{Context as _, Result}; +use futures::channel::oneshot; +use gpui::{ + Bounds, Capslock, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, + PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, + RequestFrameOptions, Scene, Size, TextInputStateChange, TouchEvent, TouchId, TouchPhase, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, + WindowInsets, +}; +use gpui_wgpu::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use raw_window_handle::{ + DisplayHandle, HandleError, HasDisplayHandle, HasWindowHandle, WindowHandle, +}; + +use crate::{IosDisplayMetrics, IosHost, IosLayer, IosSurface, display::IosDisplay}; + +type InputCallback = Box DispatchEventResult>; +type ResizeCallback = Box, f32)>; +type HitTestCallback = Box Option>; + +#[derive(Default)] +struct IosWindowCallbacks { + request_frame: Option>, + input: Option, + active_status_change: Option>, + hover_status_change: Option>, + resize: Option, + moved: Option>, + should_close: Option bool>>, + hit_test_window_control: Option, + close: Option>, + appearance_changed: Option>, + insets_changed: Option>, + back: Option>, +} + +struct IosWindowState { + // Drop the renderer before either retained ANativeWindow lease. + renderer: WgpuRenderer, + native_window: Option, + retired_native_window: Option, + bounds: Bounds, + scale_factor: f32, + appearance: WindowAppearance, + background_appearance: WindowBackgroundAppearance, + title: String, + input_handler: Option, + active: bool, + hovered: bool, + mouse_position: Point, + modifiers: Modifiers, + capslock: Capslock, + pressed_button: Option, + primary_touch: Option, + insets: WindowInsets, + back_enabled: bool, +} + +pub(crate) struct IosWindowInner { + host: Arc, + gpu_context: GpuContext, + state: RefCell, + callbacks: RefCell, +} + +pub(crate) struct IosWindow { + pub(crate) inner: Rc, + display: Rc, +} + +impl IosWindow { + pub(crate) fn new( + host: Arc, + gpu_context: GpuContext, + display: Rc, + surface: IosSurface, + appearance: WindowAppearance, + ) -> Result { + let renderer = WgpuRenderer::new( + gpu_context.clone(), + &surface.window, + surface_config(surface.metrics, false), + None, + ) + .context("failed to create the iOS Metal renderer")?; + + let state = IosWindowState { + renderer, + native_window: Some(surface.window), + retired_native_window: None, + bounds: surface.metrics.logical_bounds(), + scale_factor: surface.metrics.scale_factor, + appearance, + background_appearance: WindowBackgroundAppearance::Opaque, + title: String::new(), + input_handler: None, + active: true, + hovered: false, + mouse_position: Point::default(), + modifiers: Modifiers::default(), + capslock: Capslock::default(), + pressed_button: None, + primary_touch: None, + insets: WindowInsets::default(), + back_enabled: false, + }; + + Ok(Self { + inner: Rc::new(IosWindowInner { + host, + gpu_context, + state: RefCell::new(state), + callbacks: RefCell::new(IosWindowCallbacks::default()), + }), + display, + }) + } +} + +impl IosWindowInner { + pub(crate) fn replace_surface(&self, surface: IosSurface) -> Result<()> { + let instance = self + .gpu_context + .borrow() + .as_ref() + .context("wgpu context missing while replacing iOS drawable")? + .instance + .clone(); + + { + let mut state = self.state.borrow_mut(); + let transparent = state.background_appearance != WindowBackgroundAppearance::Opaque; + state.renderer.replace_surface( + &surface.window, + surface_config(surface.metrics, transparent), + &instance, + )?; + state.native_window = Some(surface.window); + state.retired_native_window = None; + state.bounds = surface.metrics.logical_bounds(); + state.scale_factor = surface.metrics.scale_factor; + } + + self.notify_resize(surface.metrics); + Ok(()) + } + + pub(crate) fn detach_surface(&self) { + let mut state = self.state.borrow_mut(); + if state.native_window.is_some() { + state.renderer.unconfigure_surface(); + state.retired_native_window = state.native_window.take(); + } + } + + pub(crate) fn resize_from_native(&self, metrics: IosDisplayMetrics) { + { + let mut state = self.state.borrow_mut(); + state.bounds = metrics.logical_bounds(); + state.scale_factor = metrics.scale_factor; + if state.native_window.is_some() { + state.renderer.update_drawable_size(Size { + width: DevicePixels(metrics.width_px as i32), + height: DevicePixels(metrics.height_px as i32), + }); + } + } + self.notify_resize(metrics); + } + + fn notify_resize(&self, metrics: IosDisplayMetrics) { + if let Some(callback) = self.callbacks.borrow_mut().resize.as_mut() { + callback(metrics.logical_size(), metrics.scale_factor); + } + } + + pub(crate) fn frame(&self) { + if let Some(callback) = self.callbacks.borrow_mut().request_frame.as_mut() { + callback(RequestFrameOptions { + require_presentation: true, + force_render: false, + }); + } + } + + pub(crate) fn should_schedule_frames(&self) -> bool { + let state = self.state.borrow(); + state.active && state.native_window.is_some() + } + + pub(crate) fn dispatch_input(&self, input: PlatformInput) -> Option { + { + let mut state = self.state.borrow_mut(); + match &input { + PlatformInput::MouseDown(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + state.pressed_button = Some(event.button); + } + PlatformInput::MouseUp(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + state.pressed_button = None; + } + PlatformInput::MouseMove(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + state.pressed_button = event.pressed_button; + } + PlatformInput::ModifiersChanged(event) => { + state.modifiers = event.modifiers; + state.capslock = event.capslock; + } + PlatformInput::ScrollWheel(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + } + PlatformInput::Pinch(event) => { + state.mouse_position = event.position; + state.modifiers = event.modifiers; + } + PlatformInput::KeyDown(_) + | PlatformInput::KeyUp(_) + | PlatformInput::MousePressure(_) + | PlatformInput::MouseExited(_) + | PlatformInput::FileDrop(_) + | PlatformInput::Touch(_) => {} + } + } + + self.callbacks + .borrow_mut() + .input + .as_mut() + .map(|callback| callback(input)) + } + + pub(crate) fn dispatch_touch(&self, touch: TouchEvent) -> Option { + let input = { + let mut state = self.state.borrow_mut(); + match touch.phase { + TouchPhase::Started if state.primary_touch.is_none() => { + state.primary_touch = Some(touch.id); + Some(PlatformInput::MouseDown(MouseDownEvent { + button: MouseButton::Left, + position: touch.position, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + })) + } + TouchPhase::Moved if state.primary_touch == Some(touch.id) => { + Some(PlatformInput::MouseMove(MouseMoveEvent { + position: touch.position, + pressed_button: Some(MouseButton::Left), + modifiers: Modifiers::default(), + })) + } + TouchPhase::Ended | TouchPhase::Cancelled + if state.primary_touch == Some(touch.id) => + { + state.primary_touch = None; + // GPUI has no pointer-cancel mouse event yet; MouseUp is the + // only portable way to release captures for a cancelled touch. + Some(PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: touch.position, + modifiers: Modifiers::default(), + click_count: 1, + })) + } + TouchPhase::Started + | TouchPhase::Moved + | TouchPhase::Ended + | TouchPhase::Cancelled => None, + } + }; + input.and_then(|input| self.dispatch_input(input)) + } + + pub(crate) fn scale_factor(&self) -> f32 { + self.state.borrow().scale_factor + } + + pub(crate) fn set_active(&self, active: bool) -> bool { + let changed = { + let mut state = self.state.borrow_mut(); + if state.active == active { + false + } else { + state.active = active; + true + } + }; + if changed && let Some(callback) = self.callbacks.borrow_mut().active_status_change.as_mut() + { + callback(active); + } + changed + } + + pub(crate) fn set_hovered(&self, hovered: bool) { + let changed = { + let mut state = self.state.borrow_mut(); + if state.hovered == hovered { + false + } else { + state.hovered = hovered; + true + } + }; + if changed && let Some(callback) = self.callbacks.borrow_mut().hover_status_change.as_mut() + { + callback(hovered); + } + } + + pub(crate) fn moved(&self) { + if let Some(callback) = self.callbacks.borrow_mut().moved.as_mut() { + callback(); + } + } + + pub(crate) fn hit_test_window_control(&self) -> Option { + self.callbacks + .borrow_mut() + .hit_test_window_control + .as_mut() + .and_then(|callback| callback()) + } + + pub(crate) fn close_requested(&self) -> bool { + let should_close = self + .callbacks + .borrow_mut() + .should_close + .as_mut() + .is_none_or(|callback| callback()); + if !should_close { + return false; + } + + if let Some(callback) = self.callbacks.borrow_mut().close.take() { + callback(); + } + true + } + + pub(crate) fn set_appearance(&self, appearance: WindowAppearance) { + let changed = self.state.borrow().appearance != appearance; + self.state.borrow_mut().appearance = appearance; + if changed && let Some(callback) = self.callbacks.borrow_mut().appearance_changed.as_mut() { + callback(); + } + } + + pub(crate) fn set_insets(&self, insets: WindowInsets) { + let changed = self.state.borrow().insets != insets; + self.state.borrow_mut().insets = insets.clone(); + if changed && let Some(callback) = self.callbacks.borrow_mut().insets_changed.as_mut() { + callback(insets); + } + } + + pub(crate) fn back_pressed(&self) -> bool { + if !self.state.borrow().back_enabled { + return false; + } + if let Some(callback) = self.callbacks.borrow_mut().back.as_mut() { + callback(); + true + } else { + false + } + } + + pub(crate) fn ime_replace_text(&self, range: Option>, text: &str) -> bool { + self.with_input_handler(|handler| handler.replace_text_in_range(range, text)) + .is_some() + } + + pub(crate) fn ime_set_composing_text( + &self, + range: Option>, + text: &str, + selected_range: Option>, + ) -> bool { + self.with_input_handler(|handler| { + handler.replace_and_mark_text_in_range(range, text, selected_range); + }) + .is_some() + } + + pub(crate) fn ime_finish_composing(&self) -> bool { + self.with_input_handler(PlatformInputHandler::unmark_text) + .is_some() + } + + fn with_input_handler( + &self, + callback: impl FnOnce(&mut PlatformInputHandler) -> T, + ) -> Option { + let mut handler = self.state.borrow_mut().input_handler.take()?; + let result = callback(&mut handler); + self.state.borrow_mut().input_handler = Some(handler); + Some(result) + } +} + +impl HasWindowHandle for IosWindow { + fn window_handle(&self) -> Result, HandleError> { + let raw = { + let state = self.inner.state.borrow(); + let native_window = state + .native_window + .as_ref() + .ok_or(HandleError::Unavailable)?; + native_window.window_handle()?.as_raw() + }; + + // SAFETY: IosWindowInner retains the corresponding NativeWindow + // lease, and all lifecycle mutation is confined to the UI thread. + Ok(unsafe { WindowHandle::borrow_raw(raw) }) + } +} + +impl HasDisplayHandle for IosWindow { + fn display_handle(&self) -> Result, HandleError> { + Ok(DisplayHandle::android()) + } +} + +impl PlatformWindow for IosWindow { + fn bounds(&self) -> Bounds { + self.inner.state.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Fullscreen(self.bounds()) + } + + fn content_size(&self) -> Size { + self.bounds().size + } + + fn resize(&mut self, _size: Size) { + // UIKit owns the drawable's dimensions; layout callbacks update GPUI. + } + + fn scale_factor(&self) -> f32 { + self.inner.state.borrow().scale_factor + } + + fn appearance(&self) -> WindowAppearance { + self.inner.state.borrow().appearance + } + + fn display(&self) -> Option> { + Some(self.display.clone()) + } + + fn mouse_position(&self) -> Point { + self.inner.state.borrow().mouse_position + } + + fn modifiers(&self) -> Modifiers { + self.inner.state.borrow().modifiers + } + + fn capslock(&self) -> Capslock { + self.inner.state.borrow().capslock + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.inner.state.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.inner.state.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + // UIAlertController is presented asynchronously, which GPUI's synchronous prompt API cannot express. + None + } + + fn activate(&self) { + // iOS, not an application window, owns foreground activation. + } + + fn is_active(&self) -> bool { + self.inner.state.borrow().active + } + + fn is_hovered(&self) -> bool { + self.inner.state.borrow().hovered + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + self.inner.state.borrow().background_appearance + } + + fn set_title(&mut self, title: &str) { + // iOS takes its task label from Info.plist; the title is retained for GPUI's own queries. + self.inner.state.borrow_mut().title = title.to_owned(); + } + + fn set_background_appearance(&self, appearance: WindowBackgroundAppearance) { + let mut state = self.inner.state.borrow_mut(); + state.background_appearance = appearance; + if state.native_window.is_some() { + // Reconfiguring a retired CAMetalLayer after it is released can wedge the Metal driver. + state + .renderer + .update_transparency(appearance != WindowBackgroundAppearance::Opaque); + } + } + + fn minimize(&self) { + // iOS scene visibility is lifecycle-owned; there is no minimize. + } + + fn zoom(&self) { + // iOS has no desktop maximize or zoom operation. + } + + fn toggle_fullscreen(&self) { + // The activity surface is the backend's single full-screen GPUI window. + } + + fn is_fullscreen(&self) -> bool { + true + } + + fn on_request_frame(&self, callback: Box) { + self.inner.callbacks.borrow_mut().request_frame = Some(callback); + self.inner.host.request_frame(); + } + + fn on_input(&self, callback: Box DispatchEventResult>) { + self.inner.callbacks.borrow_mut().input = Some(callback); + } + + fn on_active_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().active_status_change = Some(callback); + } + + fn on_hover_status_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); + } + + fn on_resize(&self, callback: Box, f32)>) { + self.inner.callbacks.borrow_mut().resize = Some(callback); + } + + fn on_moved(&self, callback: Box) { + self.inner.callbacks.borrow_mut().moved = Some(callback); + } + + fn on_should_close(&self, callback: Box bool>) { + self.inner.callbacks.borrow_mut().should_close = Some(callback); + } + + fn on_hit_test_window_control(&self, callback: Box Option>) { + self.inner.callbacks.borrow_mut().hit_test_window_control = Some(callback); + } + + fn on_close(&self, callback: Box) { + self.inner.callbacks.borrow_mut().close = Some(callback); + } + + fn on_appearance_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().appearance_changed = Some(callback); + } + + fn draw(&self, scene: &Scene) { + self.inner.state.borrow_mut().renderer.draw(scene); + } + + fn completed_frame(&self) { + // WgpuRenderer presents the iOS drawable as part of draw. + } + + fn sprite_atlas(&self) -> Arc { + self.inner.state.borrow().renderer.sprite_atlas().clone() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + // iOS display rotation makes RGB stripe order unstable; use grayscale glyphs. + false + } + + fn get_title(&self) -> String { + self.inner.state.borrow().title.clone() + } + + fn window_controls(&self) -> WindowControls { + WindowControls { + fullscreen: false, + maximize: false, + minimize: false, + window_menu: false, + } + } + + fn gpu_specs(&self) -> Option { + Some(self.inner.state.borrow().renderer.gpu_specs()) + } + + fn update_ime_position(&self, bounds: Bounds) { + self.inner.host.update_ime_position(bounds); + } + + fn insets(&self) -> WindowInsets { + self.inner.state.borrow().insets.clone() + } + + fn on_insets_changed(&self, callback: Box) { + self.inner.callbacks.borrow_mut().insets_changed = Some(callback); + } + + fn set_back_handler(&self, callback: Box) { + self.inner.callbacks.borrow_mut().back = Some(callback); + } + + fn set_back_enabled(&self, enabled: bool) { + self.inner.state.borrow_mut().back_enabled = enabled; + self.inner.host.set_back_enabled(enabled); + } + + fn show_soft_keyboard(&self) { + self.inner.host.show_soft_keyboard(); + } + + fn hide_soft_keyboard(&self) { + self.inner.host.hide_soft_keyboard(); + } + + fn text_input_state_changed(&self, change: TextInputStateChange) { + self.inner.host.text_input_state_changed(change); + } +} + +fn surface_config(metrics: IosDisplayMetrics, transparent: bool) -> WgpuSurfaceConfig { + WgpuSurfaceConfig { + size: Size { + width: DevicePixels(metrics.width_px as i32), + height: DevicePixels(metrics.height_px as i32), + }, + transparent, + // FIFO is universally available and remains valid across native-window replacement. + preferred_present_mode: None, + } +} diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index e3f31e9f..b4faeba3 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -14,6 +14,7 @@ preview-mcp = { path = "../preview-mcp" } serde = { version = "1", features = ["derive"] } serde_json = "1" smol = "2" +sync-host = { path = "../sync-host" } tcode-core = { path = "../core" } tcode-services = { path = "../services" } term = { path = "../term" } diff --git a/crates/runtime/src/app.rs b/crates/runtime/src/app.rs index 2fe544b6..8814518c 100644 --- a/crates/runtime/src/app.rs +++ b/crates/runtime/src/app.rs @@ -3,6 +3,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::time::{Duration, Instant}; use agent::{ @@ -178,13 +179,19 @@ fn run_store_write( store: &SessionStore, settings_store: &SettingsStore, terminal_preferences_path: &Path, + sync_server: Option<&sync_host::SyncServer>, write: StoreWrite, ) -> Option { match write { - StoreWrite::AppendEvent { id, ts, event } => store - .append_event(&id, ts, &event) - .err() - .map(|err| StoreWriteFailure::PersistEvent(err.to_string())), + StoreWrite::AppendEvent { id, ts, event } => match store.append_event(&id, ts, &event) { + Ok(_) => { + if let Some(server) = sync_server { + server.notify_advanced(&id); + } + None + } + Err(err) => Some(StoreWriteFailure::PersistEvent(err.to_string())), + }, StoreWrite::UpsertMeta { meta, initial } => store.upsert_meta(&meta).err().map(|err| { if initial { StoreWriteFailure::PersistSession(err.to_string()) @@ -729,11 +736,16 @@ impl ActiveSession { &mut self, request_id: String, text: String, - attachments: Vec, + mut attachments: Vec, ) -> Result<(), ()> { let Runtime::Live(commands) = &self.runtime else { return Err(()); }; + if let Some(workspace_id) = self.meta.project_id.as_deref() { + for attachment in &mut attachments { + attachment.add_workspace_path(workspace_id, &self.meta.cwd); + } + } commands .try_send(SessionCommand::Steer { request_id, @@ -745,8 +757,13 @@ impl ActiveSession { /// Append a message to the queue, consuming the armed Ultrathink flag (it is /// per-send, so it belongs to this message, not to whatever is sent later). - fn push_queued(&mut self, text: String, attachments: Vec) -> u64 { + fn push_queued(&mut self, text: String, mut attachments: Vec) -> u64 { self.idle_since = None; + if let Some(workspace_id) = self.meta.project_id.as_deref() { + for attachment in &mut attachments { + attachment.add_workspace_path(workspace_id, &self.meta.cwd); + } + } let id = self.next_queue_id; self.next_queue_id += 1; let options = self.turn_options(); @@ -1003,6 +1020,9 @@ pub struct AppState { orchestrate_registrations: HashMap, /// Requests from the orchestrate MCP runtime, pumped on the gpui thread. pub orchestrate_requests: Option>, + sync_server: Option>, + sync_commands: Option>, + sync_live: Option, /// Process-wide computer-use MCP registration, supplied only to sessions /// while the global computer-use setting is enabled. computer_use_registration: Option, @@ -1158,6 +1178,9 @@ impl AppState { orchestrate_tokens: None, orchestrate_registrations: HashMap::new(), orchestrate_requests: None, + sync_server: None, + sync_commands: None, + sync_live: None, computer_use_registration: None, callback_last_turn: HashMap::new(), callback_approval_requests: HashSet::new(), @@ -1190,6 +1213,7 @@ impl AppState { let settings_store = self.settings_store.clone(); let terminal_preferences_path = self.terminal_preferences_path.clone(); let failures = self.store_write_failures.clone(); + let sync_server = self.sync_server.clone(); cx.background_executor() .spawn(async move { while let Ok(write) = writes.recv().await { @@ -1197,6 +1221,7 @@ impl AppState { &store, &settings_store, &terminal_preferences_path, + sync_server.as_deref(), write, ) { let _ = failures.send(failure).await; @@ -1317,6 +1342,12 @@ impl AppState { self.orchestrate_requests = Some(server.requests); } + pub fn attach_sync_host(&mut self, server: sync_host::SyncServer) { + self.sync_commands = Some(server.commands.clone()); + self.sync_live = Some(server.live.clone()); + self.sync_server = Some(Arc::new(server)); + } + pub fn attach_computer_use_mcp(&mut self, url: String, token: String) { self.computer_use_registration = Some(agent::McpRegistration { name: agent::McpRegistration::SERVER_NAME_COMPUTER_USE.into(), @@ -1346,6 +1377,72 @@ impl AppState { .detach(); } + /// Pump remote commands through the same provider channels as local UI actions. + pub fn pump_sync_commands(&mut self, cx: &mut Context) { + let Some(commands) = self.sync_commands.take() else { + return; + }; + cx.spawn(async move |this, cx| { + while let Ok(request) = commands.recv().await { + let Ok(()) = this.update(cx, |state, _| state.apply_sync_command(request)) else { + break; + }; + } + }) + .detach(); + } + + fn apply_sync_command(&mut self, request: sync_host::CommandRequest) { + let sync_host::CommandRequest { + session_id, + command, + } = request; + let commands = self + .active + .as_ref() + .filter(|session| session.meta.id == session_id) + .or_else(|| self.background.get(&session_id)) + .and_then(|session| match &session.runtime { + Runtime::Live(commands) => Some(commands.clone()), + Runtime::Idle | Runtime::Starting { .. } => None, + }); + let Some(commands) = commands else { + log::warn!("dropping sync command for session {session_id}: no live provider"); + return; + }; + if let Err(err) = commands.try_send(command) { + log::warn!("dropping sync command for session {session_id}: {err}"); + } + } + + fn publish_sync_liveness(&self, session_id: &str) { + let Some(live) = &self.sync_live else { + return; + }; + let working = self + .active + .as_ref() + .filter(|session| session.meta.id == session_id) + .or_else(|| self.background.get(session_id)) + .is_some_and(|session| session.turn_in_flight); + live.set( + session_id, + sync_host::LiveFlags { + working, + awaiting_approval: self + .sessions_awaiting_approval + .get(session_id) + .is_some_and(|requests| !requests.is_empty()), + }, + ); + } + + fn clear_sync_liveness(&self, session_id: &str) { + if let Some(live) = &self.sync_live { + live.clear(session_id); + } + } + /// Persistently opt a session into native orchestration. Callers restart a /// currently-live provider so its next spawn receives the MCP registration. pub fn enable_orchestrate( @@ -6874,6 +6971,7 @@ impl AppState { parked.background_task_count = 0; } state.pending_native_rewinds.remove(&session_id); + state.clear_sync_liveness(&session_id); let error_event = AgentEvent::ProviderStartFailed { error: err.to_string(), }; @@ -6924,6 +7022,9 @@ impl AppState { } if let AgentEvent::TurnAccepted { delivery_id } = &event { + // This control-plane acknowledgement must also be durable: sync + // clients use it after reconnecting to decide which turns to resend. + self.record_event(session_id, &event, cx); self.on_turn_accepted(session_id, *delivery_id, cx); return; } @@ -6955,6 +7056,7 @@ impl AppState { if let AgentEvent::SessionClosed { reason } = &event { self.pending_native_rewinds.remove(session_id); self.sessions_awaiting_approval.remove(session_id); + self.clear_sync_liveness(session_id); self.close_orchestrator_children(session_id); let is_active = self.active_session_id() == Some(session_id); if !is_active { @@ -7279,6 +7381,17 @@ impl AppState { self.on_background_turn_completed(session_id, cx); } + if matches!( + &event, + AgentEvent::SessionStarted { .. } + | AgentEvent::TurnStarted { .. } + | AgentEvent::TurnCompleted { .. } + | AgentEvent::ApprovalRequested(_) + | AgentEvent::ApprovalResolved { .. } + ) { + self.publish_sync_liveness(session_id); + } + // Smoke-mode automation. if let Some(smoke) = self.smoke { match &event { @@ -7362,6 +7475,27 @@ impl AppState { /// The same wall-clock timestamp is persisted and folded exactly once so /// the on-disk log and the loaded timeline agree. fn record_event(&mut self, session_id: &str, event: &AgentEvent, cx: &mut Context) { + let workspace = self + .active + .as_ref() + .filter(|active| active.meta.id == session_id) + .map(|active| &active.meta) + .or_else(|| { + self.background + .get(session_id) + .map(|background| &background.meta) + }) + .or_else(|| self.sessions.iter().find(|meta| meta.id == session_id)) + .and_then(|meta| { + meta.project_id + .as_ref() + .map(|project_id| (project_id.clone(), meta.cwd.clone())) + }); + let mut event = event.clone(); + if let Some((workspace_id, workspace_root)) = workspace { + event.add_workspace_paths(&workspace_id, &workspace_root); + } + self.store_append_generation += 1; let ts = now_millis(); self.enqueue_store_write( @@ -7375,9 +7509,9 @@ impl AppState { if let Some(active) = self.active.as_mut() && active.meta.id == session_id { - active.timeline.apply_at(Some(ts), event); + active.timeline.apply_at(Some(ts), &event); } else if let Some(background) = self.background.get_mut(session_id) { - background.timeline.apply_at(Some(ts), event); + background.timeline.apply_at(Some(ts), &event); } } @@ -7506,6 +7640,7 @@ impl AppState { self.sessions_awaiting_approval.remove(&session_id); self.pending_native_rewinds.remove(&session_id); self.native_rewind_prefills.remove(&session_id); + self.clear_sync_liveness(&session_id); } if let Some(active) = self.active.take() && let Runtime::Live(commands) = active.runtime @@ -7631,6 +7766,7 @@ impl AppState { self.sessions_awaiting_approval.remove(session_id); self.pending_native_rewinds.remove(session_id); self.native_rewind_prefills.remove(session_id); + self.clear_sync_liveness(session_id); if let Some(parked) = self.background.remove(session_id) && let Runtime::Live(commands) = parked.runtime { @@ -10117,6 +10253,7 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "touch blocked".into(), cwd: Some("/tmp/project".into()), + workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -10179,6 +10316,7 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "touch allowed".into(), cwd: None, + workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -10237,6 +10375,7 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "touch blocked".into(), cwd: None, + workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -10281,6 +10420,7 @@ mod tests { kind: agent::ApprovalKind::ExecCommand { command: "cargo test".into(), cwd: None, + workspace_cwd: None, reason: None, }, options: Vec::new(), @@ -11184,6 +11324,7 @@ mod tests { media_type: "image/png".into(), data_base64: "AAAA".into(), source_path: Some("/tmp/a.png".into()), + workspace_path: None, }; active.push_queued(String::new(), vec![attachment.clone()]); @@ -11278,6 +11419,45 @@ mod tests { assert!(!active.is_starting_generation(2)); } + #[gpui::test] + fn turn_acceptance_is_persisted_and_flushes_the_queued_message(cx: &mut gpui::TestAppContext) { + let root = + std::env::temp_dir().join(format!("tcode-turn-accepted-test-{}", uuid::Uuid::new_v4())); + let store = SessionStore::open_at(root.clone()).unwrap(); + let state = cx.new(|_| AppState::new(store.clone())); + let (commands, receiver) = async_channel::unbounded(); + let mut session = live_session(ProviderKind::Codex, commands); + session.meta.id = "accepted".into(); + + let mut delivery_id = 0; + state.update(cx, |state, cx| { + state.active = Some(session); + state.send_turn("persist this acceptance".into(), Vec::new(), cx); + delivery_id = match receiver.try_recv() { + Ok(SessionCommand::SendTurn { delivery_id, .. }) => delivery_id, + other => panic!("expected SendTurn, got {other:?}"), + }; + + state.on_event("accepted", AgentEvent::TurnAccepted { delivery_id }, cx); + + let active = state.active.as_ref().unwrap(); + assert!(active.queue.is_empty()); + assert_eq!(active.delivery_in_flight, None); + assert!(active.turn_in_flight); + }); + cx.run_until_parked(); + + assert!(store.read_events("accepted").iter().any(|stored| { + matches!( + stored.event, + AgentEvent::TurnAccepted { + delivery_id: stored_id + } if stored_id == delivery_id + ) + })); + let _ = std::fs::remove_dir_all(root); + } + #[gpui::test] fn unaccepted_send_survives_eof_and_is_delivered_once_after_resume( cx: &mut gpui::TestAppContext, @@ -11908,6 +12088,47 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[gpui::test] + fn sync_command_reaches_the_intended_live_session(cx: &mut gpui::TestAppContext) { + let root = + std::env::temp_dir().join(format!("tcode-sync-command-{}", uuid::Uuid::new_v4())); + let store = SessionStore::open_at(root.clone()).unwrap(); + let state = cx.new(|_| AppState::new(store)); + let (remote_commands, requests) = async_channel::unbounded(); + let (active_commands, active_receiver) = async_channel::unbounded(); + let (target_commands, target_receiver) = async_channel::unbounded(); + + state.update(cx, |state, cx| { + let mut active = live_session(ProviderKind::Codex, active_commands); + active.meta.id = "active".into(); + state.active = Some(active); + + let mut target = live_session(ProviderKind::ClaudeCode, target_commands); + target.meta.id = "target".into(); + state.background.insert(target.meta.id.clone(), target); + + state.sync_commands = Some(requests); + state.pump_sync_commands(cx); + }); + remote_commands + .try_send(sync_host::CommandRequest { + session_id: "target".into(), + command: SessionCommand::Interrupt, + }) + .unwrap(); + cx.run_until_parked(); + + assert!(matches!( + target_receiver.try_recv(), + Ok(SessionCommand::Interrupt) + )); + assert!( + active_receiver.try_recv().is_err(), + "the command reached the wrong session" + ); + let _ = std::fs::remove_dir_all(root); + } + #[gpui::test] fn store_writer_upsert_is_visible_to_fresh_store(cx: &mut gpui::TestAppContext) { let root = diff --git a/crates/runtime/src/blocking.rs b/crates/runtime/src/blocking.rs index 78eea3e9..db0c57bc 100644 --- a/crates/runtime/src/blocking.rs +++ b/crates/runtime/src/blocking.rs @@ -47,6 +47,11 @@ mod tests { let mut roots = Vec::new(); let mut discovered = Vec::new(); + // client-app is excluded from the workspace on purpose (see the root + // Cargo.toml): it builds only for the client targets, because it needs + // tcode-ui's `portable` feature and the desktop build needs `desktop`. + const NON_MEMBERS: &[&str] = &["client-app"]; + for entry in std::fs::read_dir(&crates_dir) .expect("workspace crates directory must be readable") .flatten() @@ -56,7 +61,11 @@ mod tests { if !crate_dir.is_dir() || !source_root.is_dir() { continue; } - discovered.push(entry.file_name().to_string_lossy().into_owned()); + let name = entry.file_name().to_string_lossy().into_owned(); + if NON_MEMBERS.contains(&name.as_str()) { + continue; + } + discovered.push(name); roots.push(source_root); } roots.sort(); @@ -66,11 +75,20 @@ mod tests { "app", "computer-use-mcp", "core", + "gpui-android", + "gpui-ios", "i18n", "orchestrate-mcp", "preview-mcp", "runtime", "services", + "sync-client", + "sync-host", + "sync-protocol", + "tcode-android", + "tcode-ios", + "tcode-server", + "tcode-web", "term", "ui", ] diff --git a/crates/services/Cargo.toml b/crates/services/Cargo.toml index 90431bca..62632bd4 100644 --- a/crates/services/Cargo.toml +++ b/crates/services/Cargo.toml @@ -3,21 +3,39 @@ name = "tcode-services" version = "0.1.0" edition = "2024" +[features] +default = ["desktop"] +desktop = [ + "agent/local", + "dep:bzip2", + "dep:chrono", + "dep:dirs", + "dep:flate2", + "dep:log", + "dep:sha2", + "dep:smol", + "dep:tar", + "dep:thiserror", + "dep:ureq", + "dep:uuid", + "dep:zip", +] + [dependencies] -agent = { path = "../agent" } +agent = { path = "../agent", default-features = false, optional = true } tcode-core = { path = "../core" } base64 = "0.22" -bzip2 = "0.6" -chrono = "0.4" -dirs = "6" -flate2 = "1" -log = "0.4" +bzip2 = { version = "0.6", optional = true } +chrono = { version = "0.4", optional = true } +dirs = { version = "6", optional = true } +flate2 = { version = "1", optional = true } +log = { version = "0.4", optional = true } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha2 = "0.11" -smol = "2" -tar = "0.4" -thiserror = "2" -ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } -uuid = { version = "1", features = ["v4"] } -zip = { version = "2", default-features = false, features = ["deflate-flate2", "flate2", "bzip2"] } +sha2 = { version = "0.11", optional = true } +smol = { version = "2", optional = true } +tar = { version = "0.4", optional = true } +thiserror = { version = "2", optional = true } +ureq = { version = "2", default-features = false, features = ["tls", "gzip"], optional = true } +uuid = { version = "1", features = ["v4"], optional = true } +zip = { version = "2", default-features = false, features = ["deflate-flate2", "flate2", "bzip2"], optional = true } diff --git a/crates/services/src/git.rs b/crates/services/src/git.rs index 4c3bf0b8..a770a36e 100644 --- a/crates/services/src/git.rs +++ b/crates/services/src/git.rs @@ -138,6 +138,7 @@ fn split_git_patch(raw: &str, cwd: &Path, repo_prefix: &str) -> Vec std::io::Result { + let mut all = self.load_secrets(); + if let Some(token) = all + .get(APP_SECRETS) + .and_then(|secrets| secrets.get(SYNC_TOKEN_SECRET)) + .filter(|token| !token.is_empty()) + { + return Ok(token.clone()); + } + + let token = uuid::Uuid::new_v4().to_string(); + all.entry(APP_SECRETS.into()) + .or_default() + .insert(SYNC_TOKEN_SECRET.into(), token.clone()); + self.write_secrets(&all)?; + Ok(token) + } + /// The sensitive env values for one provider (used only when spawning). pub fn provider_secrets(&self, provider: ProviderKind) -> BTreeMap { self.profile_secrets(provider_key(provider)) @@ -322,6 +347,30 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn sync_token_survives_reopening_the_same_data_directory() { + let root = + std::env::temp_dir().join(format!("tcode-sync-token-test-{}", uuid::Uuid::new_v4())); + fs::create_dir_all(&root).unwrap(); + + let token = SettingsStore::new(root.clone()) + .load_or_create_sync_token() + .unwrap(); + let reopened = SettingsStore::new(root.clone()); + + assert_eq!(reopened.load_or_create_sync_token().unwrap(), token); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + let mode = fs::metadata(root.join("secrets.json")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + let _ = fs::remove_dir_all(root); + } + #[test] fn profile_secrets_are_keyed_by_profile_id() { let root = diff --git a/crates/services/src/store.rs b/crates/services/src/store.rs index 6cf3d0b3..8e032974 100644 --- a/crates/services/src/store.rs +++ b/crates/services/src/store.rs @@ -8,9 +8,11 @@ //! [`AgentEvent`], and fold them with [`tcode_core::session::fold_events`] into a //! [`tcode_core::session::Timeline`]. +use std::collections::HashMap; use std::fs::{self, File, OpenOptions}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; #[cfg(test)] use agent::ApprovalMode; @@ -22,18 +24,35 @@ use tcode_core::project::WorktreeInfo; use tcode_core::project::{IndexFile, Project, SessionMeta, migrate_index}; use tcode_core::session::StoredEvent; -/// On-disk envelope wrapping each event with its record time. Kept private: -/// callers deal in [`StoredEvent`] (which tolerates the legacy bare form). +/// On-disk envelope wrapping each event with its record time and log position. +/// Kept private: callers deal in [`StoredEvent`] (which tolerates the legacy +/// bare form). #[derive(Serialize, Deserialize)] struct EventEnvelope { ts: u64, + /// 1-based position in this session's log. Absent on lines written before + /// the field existed; those are numbered by position on read, so a log may + /// mix the two forms and still present one contiguous sequence. + #[serde(default, skip_serializing_if = "Option::is_none")] + seq: Option, event: AgentEvent, } /// Cheap, cloneable handle to the on-disk data directory. +/// +/// Clones share the sequence counters: they are per *store*, not per handle, +/// so two clones appending to one session cannot hand out the same `seq`. #[derive(Debug, Clone)] pub struct SessionStore { root: PathBuf, + /// Next `seq` to assign, per session id. + /// + /// Normally primed by `read_events`, which has already parsed the log and + /// so knows the answer for free. `scan_next_seq` is the fallback for a + /// session appended to without being read first. Either way it is computed + /// once per session, never per append — that would make writing a log + /// quadratic in its length. + next_seq: Arc>>, } impl SessionStore { @@ -52,7 +71,10 @@ impl SessionStore { pub fn open_at(root: PathBuf) -> std::io::Result { fs::create_dir_all(&root)?; - Ok(Self { root }) + Ok(Self { + root, + next_seq: Arc::new(Mutex::new(HashMap::new())), + }) } pub fn root(&self) -> &PathBuf { @@ -252,11 +274,44 @@ impl SessionStore { fs::rename(&tmp, self.index_path()) } - /// Append one event to the session's JSONL log, wrapped in a timestamped - /// envelope (`{"ts": , "event": {…}}`). - pub fn append_event(&self, id: &str, ts: u64, event: &AgentEvent) -> std::io::Result<()> { + /// Next unused `seq` for a session, scanning its log once and caching the + /// result. The scan takes the larger of the line count and the highest + /// explicit `seq`, so a log that mixes legacy (position-numbered) lines + /// with newer explicit ones never reissues a number. + fn reserve_seq(&self, id: &str) -> u64 { + let mut counters = self + .next_seq + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let next = counters + .entry(id.to_owned()) + .or_insert_with(|| scan_next_seq(&self.events_path(id))); + let seq = *next; + *next += 1; + seq + } + + /// Forget a session's cached counter, so the next append rescans the log. + /// Needed wherever the file changes behind the cache's back. + fn forget_seq(&self, id: &str) { + self.next_seq + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(id); + } + + /// Append one event to the session's JSONL log, wrapped in an envelope + /// (`{"ts": , "seq": , "event": {…}}`), returning the `seq` it + /// was assigned. + /// + /// `seq` is assigned here rather than by the caller because this is the + /// point every write funnels through, so the numbers it hands out are + /// dense and gap-free by construction. + pub fn append_event(&self, id: &str, ts: u64, event: &AgentEvent) -> std::io::Result { + let seq = self.reserve_seq(id); let envelope = EventEnvelope { ts, + seq: Some(seq), event: event.clone(), }; let line = serde_json::to_string(&envelope) @@ -276,7 +331,8 @@ impl SessionStore { } } file.write_all(line.as_bytes())?; - file.write_all(b"\n") + file.write_all(b"\n")?; + Ok(seq) } /// Read and parse every persisted event for a session (skipping bad lines). @@ -284,6 +340,8 @@ impl SessionStore { /// Each line is tolerantly parsed as either a timestamped envelope /// (`{"ts":…,"event":…}`) or a legacy bare event (`{"type":…}`), so logs /// written before the envelope format still replay (with `ts == None`). + /// Every returned event carries `Some(seq)`: explicit where the line has + /// one, positional otherwise. pub fn read_events(&self, id: &str) -> Vec { let path = self.events_path(id); let Ok(file) = File::open(&path) else { @@ -297,10 +355,32 @@ impl SessionStore { continue; } match parse_stored_line(trimmed) { - Ok(stored) => events.push(stored), + // Legacy lines carry no `seq`; number them by position so the + // sequence a caller sees is contiguous from 1 regardless of + // when each line was written. `events.len()` counts only lines + // that parsed, which is what keeps it contiguous — a corrupt + // line is skipped rather than consuming a number. + Ok(mut stored) => { + stored.seq = stored.seq.or(Some(events.len() as u64 + 1)); + events.push(stored); + } Err(err) => log::warn!("skipping unparseable event in {id}.jsonl: {err}"), } } + // Replaying already parsed the whole log, so the highest seq is in + // hand. Priming the counter here means the common path — open a + // session, then append to it — never pays for `scan_next_seq`, which + // would re-read a log that can run to tens of megabytes. + // + // `or_insert` rather than overwrite: a counter that already exists has + // seen appends this read may predate, and must not be walked back. + if let Some(highest) = events.last().and_then(|stored| stored.seq) { + self.next_seq + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(id.to_owned()) + .or_insert(highest + 1); + } events } @@ -316,7 +396,11 @@ impl SessionStore { let dst = self.events_path(dst_id); let tmp = dst.with_extension("jsonl.tmp"); fs::write(&tmp, data)?; - fs::rename(tmp, dst) + fs::rename(tmp, dst)?; + // The destination's log just changed behind any cached counter (a fork + // reuses an id whose counter may still say 1). Rescan on next append. + self.forget_seq(dst_id); + Ok(()) } /// Remove a session from the index and delete its event log. @@ -324,6 +408,7 @@ impl SessionStore { let mut file = self.read_file(); file.sessions.retain(|meta| meta.id != id); self.write_file(&file)?; + self.forget_seq(id); match fs::remove_file(self.events_path(id)) { Ok(()) => Ok(()), Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -340,10 +425,15 @@ fn parse_stored_line(line: &str) -> Result { match serde_json::from_str::(line) { Ok(envelope) => Ok(StoredEvent { ts: Some(envelope.ts), + seq: envelope.seq, event: envelope.event, }), Err(_envelope_err) => match serde_json::from_str::(line) { - Ok(event) => Ok(StoredEvent { ts: None, event }), + Ok(event) => Ok(StoredEvent { + ts: None, + seq: None, + event, + }), // Both forms failed: the line is genuinely corrupt. The bare-event // error is the more informative one (the envelope attempt always // fails on a bare event merely because `ts` is missing). @@ -352,6 +442,51 @@ fn parse_stored_line(line: &str) -> Result { } } +/// The first `seq` that is safe to assign in an existing log. +/// +/// The cold path. Opening a session calls `read_events`, which primes the +/// counter from a parse it was doing anyway; this runs only when something +/// appends to a session that was never read in this process. It reads each +/// line's `seq` rather than parsing events, so it costs a file read rather +/// than a full deserialization of a log that can reach tens of megabytes. +/// +/// Takes the larger of the parsed-line count and the highest explicit `seq` +/// because a log may hold both forms — legacy lines get positional numbers on +/// read, and reusing one of those for a new line would break the order the +/// numbers exist to provide. A missing file is an empty log, so `seq` starts +/// at 1. +fn scan_next_seq(path: &PathBuf) -> u64 { + let Ok(file) = File::open(path) else { + return 1; + }; + let mut lines = 0_u64; + let mut highest = 0_u64; + for line in BufReader::new(file).lines() { + let Ok(line) = line else { break }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + // A corrupt line still occupies a position: `read_events` skips it and + // therefore never issues its number, so counting it here only leaves a + // gap. Overshooting is safe; colliding is not. + lines += 1; + if let Ok(seq) = serde_json::from_str::(trimmed) + && let Some(seq) = seq.seq + { + highest = highest.max(seq); + } + } + lines.max(highest) + 1 +} + +/// Just enough of an envelope to read its `seq` (see [`scan_next_seq`]). +#[derive(Deserialize)] +struct SeqOnly { + #[serde(default)] + seq: Option, +} + pub fn now_secs() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -807,4 +942,173 @@ mod tests { assert!(!store.events_path("empty-fork").exists()); let _ = fs::remove_dir_all(store.root()); } + + // -- event sequence numbers --------------------------------------------- + + fn assistant(text: &str) -> AgentEvent { + AgentEvent::Delta { + item_id: "item-1".into(), + kind: agent::DeltaKind::AssistantText, + text: text.into(), + } + } + + fn seqs(store: &SessionStore, id: &str) -> Vec { + store + .read_events(id) + .into_iter() + .map(|stored| stored.seq.expect("read_events always assigns a seq")) + .collect() + } + + #[test] + fn appended_events_are_numbered_from_one_and_the_number_is_returned() { + let store = SessionStore::open_at(temp_root()).unwrap(); + let assigned: Vec = ["a", "b", "c"] + .iter() + .map(|text| store.append_event("s1", 1, &assistant(text)).unwrap()) + .collect(); + + assert_eq!(assigned, vec![1, 2, 3], "append must hand back its own seq"); + assert_eq!(seqs(&store, "s1"), vec![1, 2, 3]); + let _ = fs::remove_dir_all(store.root()); + } + + #[test] + fn sequences_are_per_session_not_global() { + let store = SessionStore::open_at(temp_root()).unwrap(); + store.append_event("s1", 1, &assistant("a")).unwrap(); + store.append_event("s2", 1, &assistant("b")).unwrap(); + store.append_event("s1", 1, &assistant("c")).unwrap(); + + assert_eq!(seqs(&store, "s1"), vec![1, 2]); + assert_eq!(seqs(&store, "s2"), vec![1]); + let _ = fs::remove_dir_all(store.root()); + } + + /// Logs written before `seq` existed carry only `ts`. They must still + /// present a contiguous sequence, or a client resuming from a cursor would + /// silently skip the whole legacy prefix. + #[test] + fn legacy_lines_without_seq_are_numbered_by_position() { + let store = SessionStore::open_at(temp_root()).unwrap(); + let path = store.events_path("s1"); + fs::write( + &path, + "{\"ts\":10,\"event\":{\"type\":\"session_closed\"}}\n\ + {\"type\":\"session_closed\"}\n\ + {\"ts\":30,\"event\":{\"type\":\"session_closed\"}}\n", + ) + .unwrap(); + + assert_eq!(seqs(&store, "s1"), vec![1, 2, 3]); + let _ = fs::remove_dir_all(store.root()); + } + + /// The case the counter exists to survive: a log that already has legacy + /// lines, reopened by a fresh process, then appended to. The new lines must + /// continue past the positional numbers rather than restart and collide. + #[test] + fn appending_to_a_legacy_log_continues_past_the_positional_numbers() { + let root = temp_root(); + let store = SessionStore::open_at(root.clone()).unwrap(); + fs::write( + store.events_path("s1"), + "{\"ts\":10,\"event\":{\"type\":\"session_closed\"}}\n\ + {\"ts\":20,\"event\":{\"type\":\"session_closed\"}}\n", + ) + .unwrap(); + + // A separate handle: nothing is cached, exactly like a restart. + let reopened = SessionStore::open_at(root.clone()).unwrap(); + assert_eq!(reopened.append_event("s1", 30, &assistant("x")).unwrap(), 3); + + let observed = seqs(&reopened, "s1"); + assert_eq!(observed, vec![1, 2, 3]); + assert!( + observed.windows(2).all(|w| w[1] == w[0] + 1), + "sequence must stay contiguous across the format change: {observed:?}" + ); + let _ = fs::remove_dir_all(root); + } + + /// A fork copies the log verbatim, so the destination inherits its + /// sequence. Appending to the fork must not restart at 1 and overwrite the + /// inherited numbering. + #[test] + fn cloning_a_log_carries_its_sequence_into_the_fork() { + let store = SessionStore::open_at(temp_root()).unwrap(); + store.append_event("src", 1, &assistant("a")).unwrap(); + store.append_event("src", 2, &assistant("b")).unwrap(); + + // Touch the destination first so a stale counter would exist to catch. + store.append_event("dst", 1, &assistant("scratch")).unwrap(); + store.clone_events("src", "dst").unwrap(); + + assert_eq!(store.append_event("dst", 3, &assistant("c")).unwrap(), 3); + assert_eq!(seqs(&store, "dst"), vec![1, 2, 3]); + let _ = fs::remove_dir_all(store.root()); + } + + /// Deleting a session frees its id. If the counter outlived the file, the + /// next session reusing that id would start numbering mid-sequence. + #[test] + fn removing_a_session_resets_its_sequence() { + let store = SessionStore::open_at(temp_root()).unwrap(); + store.append_event("s1", 1, &assistant("a")).unwrap(); + store.append_event("s1", 2, &assistant("b")).unwrap(); + store.remove_session("s1").unwrap(); + + assert_eq!(store.append_event("s1", 3, &assistant("c")).unwrap(), 1); + let _ = fs::remove_dir_all(store.root()); + } + + /// Opening a session must leave the counter primed, so the append that + /// follows does not re-read a log the replay just parsed. Checked directly + /// rather than through behaviour, because a working-but-rescanning + /// implementation passes every black-box assertion here. + #[test] + fn reading_a_log_primes_the_counter_so_appends_skip_the_rescan() { + let root = temp_root(); + let store = SessionStore::open_at(root.clone()).unwrap(); + store.append_event("s1", 1, &assistant("a")).unwrap(); + store.append_event("s1", 2, &assistant("b")).unwrap(); + + // A fresh handle has no cached counters, exactly like a restart. + let reopened = SessionStore::open_at(root.clone()).unwrap(); + let primed = |store: &SessionStore| -> Option { + store.next_seq.lock().unwrap().get("s1").copied() + }; + assert_eq!(primed(&reopened), None, "counter must start cold"); + + assert_eq!(reopened.read_events("s1").len(), 2); + assert_eq!( + primed(&reopened), + Some(3), + "replay must hand the counter its answer" + ); + assert_eq!(reopened.append_event("s1", 3, &assistant("c")).unwrap(), 3); + let _ = fs::remove_dir_all(root); + } + + /// `seq` must not appear on the wire for events that never had one, so a + /// reader can tell "no seq recorded" from "seq zero". + #[test] + fn envelopes_omit_seq_when_absent_and_carry_it_when_present() { + let with = serde_json::to_string(&EventEnvelope { + ts: 5, + seq: Some(9), + event: assistant("a"), + }) + .unwrap(); + assert!(with.contains("\"seq\":9"), "{with}"); + + let without = serde_json::to_string(&EventEnvelope { + ts: 5, + seq: None, + event: assistant("a"), + }) + .unwrap(); + assert!(!without.contains("seq"), "{without}"); + } } diff --git a/crates/sync-client/Cargo.toml b/crates/sync-client/Cargo.toml new file mode 100644 index 00000000..9a2ec359 --- /dev/null +++ b/crates/sync-client/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "sync-client" +version = "0.1.0" +edition = "2024" + +[dependencies] +sync-protocol = { path = "../sync-protocol" } +# Types only. This crate runs on phones and in browsers, where there are no +# provider processes to spawn — that is the entire reason it exists. +agent = { path = "../agent", default-features = false } +tcode-core = { path = "../core" } +log = "0.4" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +futures-util = "0.3" +serde_json = "1" +tcode-services = { path = "../services" } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } +tokio-tungstenite = "0.28" diff --git a/crates/sync-client/src/bin/sync-probe.rs b/crates/sync-client/src/bin/sync-probe.rs new file mode 100644 index 00000000..0b15ee1a --- /dev/null +++ b/crates/sync-client/src/bin/sync-probe.rs @@ -0,0 +1,535 @@ +#[cfg(target_arch = "wasm32")] +fn main() { + eprintln!("sync-probe is only available on native targets"); +} + +#[cfg(not(target_arch = "wasm32"))] +mod native { + use std::error::Error; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use agent::{ApprovalDecision, ApprovalMode, SessionCommand}; + use futures_util::{SinkExt as _, StreamExt as _}; + use sync_client::{Client, ClientConfig}; + use sync_protocol::{ClientFrame, ClientInfo, HostFrame}; + use tcode_services::settings::SettingsStore; + use tcode_services::store::SessionStore; + use tokio_tungstenite::tungstenite::Message; + + const REPLY_TIMEOUT: Duration = Duration::from_secs(30); + + type Socket = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + enum Command { + /// Trade a pairing code for the durable token. + Pair { + code: String, + }, + List, + Watch { + session_id: String, + }, + Send { + session_id: String, + text: String, + }, + Mode { + session_id: String, + mode: ApprovalMode, + }, + Approve { + session_id: String, + request_id: String, + decision: ApprovalDecision, + }, + } + + struct Args { + url: String, + token: Option, + command: Command, + } + + pub async fn run() -> Result<(), Box> { + let args = parse_args()?; + // Pairing is the one command that runs without a token — that is what it + // exists to obtain — so it must not fall back to reading the host's + // secrets file, which a phone could never do either. + let pairing_code = match &args.command { + Command::Pair { code } => Some(code.clone()), + _ => None, + }; + let token = match (args.token, &pairing_code) { + (Some(token), _) => token, + (None, Some(_)) => String::new(), + (None, None) => default_token()?, + }; + let (mut socket, _) = tokio_tungstenite::connect_async(&args.url).await?; + let mut client = Client::new(ClientConfig { + client: ClientInfo { + client_id: format!("sync-probe-{}", std::process::id()), + display_name: "tcode sync probe".into(), + platform: std::env::consts::OS.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }, + token, + }); + if let Some(code) = pairing_code { + send_frame(&mut socket, client.pair(code)).await?; + let token = loop { + let frame = recv_frame(&mut socket).await?; + match &frame { + HostFrame::Paired { token, .. } => break token.clone(), + HostFrame::Refused { reason } => { + return Err(format!("pairing refused: {reason:?}").into()); + } + _ => { + client.handle(frame); + } + } + }; + println!("paired; token: {token}"); + return Ok(()); + } + + send_frame(&mut socket, client.connect()).await?; + handshake(&mut socket, &mut client).await?; + + match args.command { + // Handled before the handshake; unreachable here. + Command::Pair { .. } => Ok(()), + Command::List => list(&mut socket, &mut client).await, + Command::Watch { session_id } => watch(&mut socket, &mut client, session_id).await, + Command::Send { session_id, text } => { + send_turn(&mut socket, &mut client, session_id, text).await + } + Command::Mode { session_id, mode } => { + set_approval_mode(&mut socket, &mut client, session_id, mode).await + } + Command::Approve { + session_id, + request_id, + decision, + } => approve(&mut socket, &mut client, session_id, request_id, decision).await, + } + } + + fn parse_args() -> Result> { + let mut positional = Vec::new(); + let mut url = None; + let mut token = None; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--url" => url = Some(args.next().ok_or("--url requires a value")?), + "--token" => token = Some(args.next().ok_or("--token requires a value")?), + "-h" | "--help" => { + print_usage(); + std::process::exit(0); + } + _ if arg.starts_with('-') => return Err(format!("unknown flag: {arg}").into()), + _ => positional.push(arg), + } + } + + let url = url.ok_or("missing required --url")?; + let command = match positional.as_slice() { + [command, code] if command == "pair" => Command::Pair { code: code.clone() }, + [command] if command == "list" => Command::List, + [command, session_id] if command == "watch" => Command::Watch { + session_id: session_id.clone(), + }, + [command, session_id, text] if command == "send" => Command::Send { + session_id: session_id.clone(), + text: text.clone(), + }, + [command, session_id, mode] if command == "mode" => Command::Mode { + session_id: session_id.clone(), + mode: parse_approval_mode(mode)?, + }, + [command, session_id, request_id, decision] if command == "approve" => { + Command::Approve { + session_id: session_id.clone(), + request_id: request_id.clone(), + decision: parse_decision(decision)?, + } + } + _ => { + print_usage(); + return Err("invalid command arguments".into()); + } + }; + Ok(Args { + url, + token, + command, + }) + } + + fn parse_approval_mode(value: &str) -> Result> { + match value { + "supervised" => Ok(ApprovalMode::Supervised), + "read-only" => Ok(ApprovalMode::ReadOnly), + "auto-accept-edits" => Ok(ApprovalMode::AutoAcceptEdits), + "full-access" => Ok(ApprovalMode::FullAccess), + _ => Err(format!( + "invalid mode {value:?}; use supervised, read-only, auto-accept-edits, or full-access" + ) + .into()), + } + } + + fn parse_decision(value: &str) -> Result> { + match value { + "approve" => Ok(ApprovalDecision::Approve), + "approve-for-session" => Ok(ApprovalDecision::ApproveForSession), + "deny" => Ok(ApprovalDecision::Deny), + "cancel" => Ok(ApprovalDecision::Cancel), + value if value.starts_with("option:") => Ok(ApprovalDecision::Option( + value.trim_start_matches("option:").into(), + )), + _ => Err(format!( + "invalid decision {value:?}; use approve, approve-for-session, deny, cancel, or option:" + ) + .into()), + } + } + + fn print_usage() { + eprintln!( + "usage: sync-probe [arguments] --url [--token ]\n\ + commands:\n\ + \x20 list\n\ + \x20 watch \n\ + \x20 send \n\ + \x20 mode \n\ + \x20 approve " + ); + } + + fn default_token() -> Result> { + let root = SessionStore::open_default()?.root().clone(); + Ok(SettingsStore::new(root).load_or_create_sync_token()?) + } + + async fn send_frame(socket: &mut Socket, frame: ClientFrame) -> Result<(), Box> { + socket.send(Message::Text(frame.encode()?.into())).await?; + Ok(()) + } + + async fn recv_frame(socket: &mut Socket) -> Result> { + loop { + let message = tokio::time::timeout(REPLY_TIMEOUT, socket.next()) + .await + .map_err(|_| "timed out waiting for the host")? + .ok_or("host closed the connection")??; + match message { + Message::Text(text) => return Ok(HostFrame::decode(&text)?), + Message::Close(_) => return Err("host closed the connection".into()), + _ => {} + } + } + } + + async fn handle( + socket: &mut Socket, + client: &mut Client, + frame: HostFrame, + ) -> Result<(), Box> { + for outgoing in client.handle(frame) { + send_frame(socket, outgoing).await?; + } + Ok(()) + } + + async fn handshake(socket: &mut Socket, client: &mut Client) -> Result<(), Box> { + let frame = recv_frame(socket).await?; + handle(socket, client, frame).await?; + if let Some(reason) = client.refusal_reason() { + return Err(format!("host refused the connection: {reason:?}").into()); + } + if !client.is_ready() { + return Err("host did not complete the sync handshake".into()); + } + Ok(()) + } + + async fn list(socket: &mut Socket, client: &mut Client) -> Result<(), Box> { + send_frame(socket, client.request_sessions()).await?; + loop { + let frame = recv_frame(socket).await?; + let is_list = matches!(frame, HostFrame::SessionList { .. }); + handle(socket, client, frame).await?; + if is_list { + break; + } + } + + println!("sessions: {}", client.sessions().len()); + for session in client.sessions() { + println!( + "{}\t{}\t{}\tworking={}\tawaiting-approval={}", + session.session_id, + session.title, + session.provider.display_name(), + session.working, + session.awaiting_approval + ); + } + Ok(()) + } + + async fn subscribe_until_caught_up( + socket: &mut Socket, + client: &mut Client, + session_id: &str, + print_events: bool, + ) -> Result<(), Box> { + let frame = client.subscribe(session_id); + send_frame(socket, frame).await?; + loop { + let frame = recv_frame(socket).await?; + if print_events { + print_events_for(&frame, session_id); + } + handle(socket, client, frame).await?; + if client + .session(session_id) + .is_some_and(|session| session.is_caught_up()) + { + return Ok(()); + } + if let Some(rejection) = client.command_rejections().last() { + return Err(format!("host rejected command: {:?}", rejection.reason).into()); + } + } + } + + async fn watch( + socket: &mut Socket, + client: &mut Client, + session_id: String, + ) -> Result<(), Box> { + subscribe_until_caught_up(socket, client, &session_id, true).await?; + println!("watching {session_id}"); + loop { + let frame = recv_frame(socket).await?; + print_events_for(&frame, &session_id); + let ended = matches!( + &frame, + HostFrame::SessionEnded { + session_id: ended, + .. + } if ended == &session_id + ); + handle(socket, client, frame).await?; + if ended { + return Ok(()); + } + } + } + + async fn send_turn( + socket: &mut Socket, + client: &mut Client, + session_id: String, + text: String, + ) -> Result<(), Box> { + subscribe_until_caught_up(socket, client, &session_id, false).await?; + let delivery_id = SystemTime::now() + .duration_since(UNIX_EPOCH)? + .as_nanos() + .try_into() + .unwrap_or(u64::MAX); + let frame = client.command( + &session_id, + SessionCommand::SendTurn { + delivery_id, + text, + options: None, + attachments: Vec::new(), + }, + ); + send_frame(socket, frame).await?; + + loop { + let frame = match recv_frame(socket).await { + Ok(frame) => frame, + Err(err) if err.to_string().contains("timed out waiting for the host") => { + println!("delivery_id {delivery_id} echoed by TurnAccepted: no (timed out)"); + return Ok(()); + } + Err(err) => return Err(err), + }; + print_events_for(&frame, &session_id); + handle(socket, client, frame).await?; + let echoed = client.unacknowledged_turns(&session_id).next().is_none(); + if echoed { + println!("delivery_id {delivery_id} echoed by TurnAccepted: yes"); + return Ok(()); + } + if let Some(rejection) = client.command_rejections().last() { + println!( + "delivery_id {delivery_id} echoed by TurnAccepted: no ({:?})", + rejection.reason + ); + return Ok(()); + } + } + } + + async fn set_approval_mode( + socket: &mut Socket, + client: &mut Client, + session_id: String, + mode: ApprovalMode, + ) -> Result<(), Box> { + subscribe_until_caught_up(socket, client, &session_id, false).await?; + let frame = client.command(&session_id, SessionCommand::SetApprovalMode(mode)); + send_frame(socket, frame).await?; + println!("approval mode set to {mode:?} for session {session_id}"); + Ok(()) + } + + async fn approve( + socket: &mut Socket, + client: &mut Client, + session_id: String, + request_id: String, + decision: ApprovalDecision, + ) -> Result<(), Box> { + subscribe_until_caught_up(socket, client, &session_id, false).await?; + let pending = client.session(&session_id).is_some_and(|session| { + session + .timeline() + .pending_approvals + .iter() + .any(|request| request.id == request_id) + }); + if !pending { + return Err(format!("approval request {request_id:?} is not pending").into()); + } + let frame = client.command( + &session_id, + SessionCommand::RespondApproval { + request_id: request_id.clone(), + decision, + }, + ); + send_frame(socket, frame).await?; + println!("approval response sent for request {request_id}"); + Ok(()) + } + + fn print_events_for(frame: &HostFrame, session_id: &str) { + let HostFrame::Events { + session_id: received, + events, + .. + } = frame + else { + return; + }; + if received != session_id { + return; + } + for event in events { + println!("{}\t{}", event.seq, event_summary(&event.event)); + } + } + + fn event_summary(event: &agent::AgentEvent) -> String { + if let agent::AgentEvent::ApprovalRequested(request) = event { + let detail = + serde_json::to_string(request).unwrap_or_else(|_| "unserializable request".into()); + return format!( + "approval_requested request_id={} request={detail}", + request.id + ); + } + + let Ok(mut value) = serde_json::to_value(event) else { + return "unserializable event".into(); + }; + let event_type = value + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("event") + .to_string(); + if let Some(object) = value.as_object_mut() { + object.remove("type"); + } + let detail = serde_json::to_string(&value).unwrap_or_default(); + let mut summary = if detail == "{}" { + event_type + } else { + format!("{event_type} {detail}") + }; + const LIMIT: usize = 180; + if summary.chars().count() > LIMIT { + summary = summary.chars().take(LIMIT - 1).collect::() + "…"; + } + summary + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn parses_every_approval_mode() { + assert_eq!( + parse_approval_mode("supervised").unwrap(), + ApprovalMode::Supervised + ); + assert_eq!( + parse_approval_mode("read-only").unwrap(), + ApprovalMode::ReadOnly + ); + assert_eq!( + parse_approval_mode("auto-accept-edits").unwrap(), + ApprovalMode::AutoAcceptEdits + ); + assert_eq!( + parse_approval_mode("full-access").unwrap(), + ApprovalMode::FullAccess + ); + assert!(parse_approval_mode("default").is_err()); + } + + #[test] + fn approval_summary_keeps_request_id_and_full_request() { + let command = "echo ".to_owned() + &"x".repeat(200); + let event = agent::AgentEvent::ApprovalRequested(agent::ApprovalRequest { + id: "request-42".into(), + turn_id: Some("turn-7".into()), + kind: agent::ApprovalKind::ExecCommand { + command: command.clone(), + cwd: Some("/tmp/example".into()), + workspace_cwd: None, + reason: Some("exercise the remote approval path".into()), + }, + options: Vec::new(), + }); + + let summary = event_summary(&event); + + assert!(summary.starts_with( + "approval_requested request_id=request-42 request={\"id\":\"request-42\"" + )); + assert!(summary.contains(&command)); + assert!(summary.contains("\"cwd\":\"/tmp/example\"")); + } + } +} + +#[cfg(not(target_arch = "wasm32"))] +#[tokio::main] +async fn main() { + if let Err(err) = native::run().await { + eprintln!("sync-probe: {err}"); + std::process::exit(1); + } +} diff --git a/crates/sync-client/src/lib.rs b/crates/sync-client/src/lib.rs new file mode 100644 index 00000000..5f4bfce9 --- /dev/null +++ b/crates/sync-client/src/lib.rs @@ -0,0 +1,835 @@ +//! Client side of the tcode sync protocol: the connection and session state +//! machines, with no I/O in them. +//! +//! [`Client`] turns host frames into client frames and observable replicated +//! state. A transport opens a socket, sends [`Client::connect`], passes each +//! received frame to [`Client::handle`], and writes the returned frames. The +//! client itself never touches a socket, a clock, or a task executor, so the +//! same logic runs behind a native WebSocket and the browser's WebSocket API. + +use std::collections::{BTreeMap, HashMap}; + +use sync_protocol::{ + AgentEvent, ClientFrame, ClientInfo, CommandRejection, HostFrame, HostInfo, RefuseReason, + SessionCommand, SessionEndReason, SessionSummary, +}; +use tcode_core::session::Timeline; + +/// Identity and credential sent at the start of every connection. +pub struct ClientConfig { + pub client: ClientInfo, + pub token: String, +} + +enum ConnectionState { + Disconnected, + AwaitingWelcome, + Ready { version: u32, host: HostInfo }, + Refused(RefuseReason), +} + +/// A host frame that could not be reconciled with known-good client state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProtocolViolation { + /// The host selected a version outside the range this build advertised. + UnsupportedWelcomeVersion { version: u32 }, + /// No part of an invalid batch is applied. The returned resubscribe starts + /// after the cursor named here, so recovery cannot bless partial data. + EventGap { + session_id: String, + expected_seq: u64, + received_seq: u64, + cursor: Option, + }, +} + +/// One command rejection retained until the application has presented it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RejectedCommand { + pub session_id: String, + pub command: Option, + pub reason: CommandRejection, +} + +/// One terminal session notification retained for the application. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EndedSession { + pub session_id: String, + pub reason: SessionEndReason, +} + +/// The replicated state of one session. +#[derive(Debug, Clone, Default)] +pub struct SessionState { + cursor: Option, + timeline: Timeline, + subscribed: bool, + caught_up: bool, +} + +impl SessionState { + /// Highest contiguous sequence folded into this replica. + pub fn cursor(&self) -> Option { + self.cursor + } + + pub fn timeline(&self) -> &Timeline { + &self.timeline + } + + /// False during backlog replay and after requesting a resubscribe. + pub fn is_caught_up(&self) -> bool { + self.caught_up + } + + pub fn is_subscribed(&self) -> bool { + self.subscribed + } +} + +/// Client state that survives transport connections. +/// +/// In particular, session cursors and unacknowledged turns are intentionally +/// not connection state. A mobile transport can discard and recreate its +/// socket without forcing either a full replay or a blind resend. +pub struct Client { + config: ClientConfig, + connection: ConnectionState, + sessions: Vec, + replicas: HashMap, + pending_turns: HashMap>, + violations: Vec, + rejections: Vec, + ended_sessions: Vec, + pongs: Vec, + /// Token obtained by pairing, for the shell to persist. + paired_token: Option, +} + +impl Client { + pub fn new(config: ClientConfig) -> Self { + Self { + config, + connection: ConnectionState::Disconnected, + sessions: Vec::new(), + replicas: HashMap::new(), + pending_turns: HashMap::new(), + violations: Vec::new(), + rejections: Vec::new(), + ended_sessions: Vec::new(), + pongs: Vec::new(), + paired_token: None, + } + } + + /// Start a transport connection and produce its mandatory first frame. + /// + /// Replicas and delivery bookkeeping are left intact. After `Welcome`, + /// [`Self::handle`] automatically restores every requested subscription + /// from its known-good cursor. + pub fn connect(&mut self) -> ClientFrame { + self.connection = ConnectionState::AwaitingWelcome; + ClientFrame::Hello { + min_version: sync_protocol::PROTOCOL_MIN_VERSION, + max_version: sync_protocol::PROTOCOL_MAX_VERSION, + client: self.config.client.clone(), + token: self.config.token.clone(), + } + } + + pub fn is_ready(&self) -> bool { + matches!(self.connection, ConnectionState::Ready { .. }) + } + + pub fn negotiated_version(&self) -> Option { + match self.connection { + ConnectionState::Ready { version, .. } => Some(version), + _ => None, + } + } + + pub fn host(&self) -> Option<&HostInfo> { + match &self.connection { + ConnectionState::Ready { host, .. } => Some(host), + _ => None, + } + } + + pub fn refusal_reason(&self) -> Option<&RefuseReason> { + match &self.connection { + ConnectionState::Refused(reason) => Some(reason), + _ => None, + } + } + + pub fn sessions(&self) -> &[SessionSummary] { + &self.sessions + } + + pub fn session(&self, session_id: &str) -> Option<&SessionState> { + self.replicas.get(session_id) + } + + pub fn violations(&self) -> &[ProtocolViolation] { + &self.violations + } + + pub fn command_rejections(&self) -> &[RejectedCommand] { + &self.rejections + } + + pub fn ended_sessions(&self) -> &[EndedSession] { + &self.ended_sessions + } + + pub fn pongs(&self) -> &[u64] { + &self.pongs + } + + /// Offer a pairing code in exchange for the durable token. + /// + /// Used instead of `connect` when the client has no token yet. The reply is + /// a `Paired` frame carrying one; the client then handshakes normally, + /// which is why this does not change the connection's state. + pub fn pair(&self, code: impl Into) -> ClientFrame { + ClientFrame::Pair { + min_version: sync_protocol::PROTOCOL_MIN_VERSION, + max_version: sync_protocol::PROTOCOL_MAX_VERSION, + code: code.into(), + client: self.config.client.clone(), + } + } + + /// The token a completed pairing produced, if one has arrived. + /// + /// A shell reads this to persist the token so the code is never needed + /// again — which is the entire point of pairing rather than passing a code + /// on every connection. + pub fn paired_token(&self) -> Option<&str> { + self.paired_token.as_deref() + } + + pub fn request_sessions(&self) -> ClientFrame { + ClientFrame::ListSessions + } + + /// Subscribe from the replica's highest contiguous event, if any. + pub fn subscribe(&mut self, session_id: impl Into) -> ClientFrame { + let session_id = session_id.into(); + let state = self.replicas.entry(session_id.clone()).or_default(); + state.subscribed = true; + state.caught_up = false; + ClientFrame::Subscribe { + session_id, + from_seq: state.cursor, + } + } + + /// Stop live delivery without discarding the replica or its resume cursor. + pub fn unsubscribe(&mut self, session_id: impl Into) -> ClientFrame { + let session_id = session_id.into(); + if let Some(state) = self.replicas.get_mut(&session_id) { + state.subscribed = false; + state.caught_up = false; + } + ClientFrame::Unsubscribe { session_id } + } + + /// Produce a command frame, retaining `SendTurn` until its delivery id is + /// observed in a contiguous event batch. + pub fn command( + &mut self, + session_id: impl Into, + command: SessionCommand, + ) -> ClientFrame { + let session_id = session_id.into(); + if let SessionCommand::SendTurn { delivery_id, .. } = &command { + self.pending_turns + .entry(session_id.clone()) + .or_default() + .insert(*delivery_id, command.clone()); + } + ClientFrame::Command { + session_id, + command, + } + } + + /// Turns that have been sent but have no observed `TurnAccepted`. + pub fn unacknowledged_turns(&self, session_id: &str) -> impl Iterator { + self.pending_turns + .get(session_id) + .into_iter() + .flat_map(|turns| turns.values()) + } + + /// Resend only turns for which no acceptance event has been observed. + /// + /// A caller should wait until the resumed subscription is caught up before + /// using this: its backfill may contain the acknowledgement that was in + /// flight when the old socket disappeared. + pub fn resend_unacknowledged_turns(&self, session_id: &str) -> Vec { + self.unacknowledged_turns(session_id) + .cloned() + .map(|command| ClientFrame::Command { + session_id: session_id.to_owned(), + command, + }) + .collect() + } + + pub fn ping(&self, nonce: u64) -> ClientFrame { + ClientFrame::Ping { nonce } + } + + /// Handle one host frame, returning any frames the transport should send. + pub fn handle(&mut self, frame: HostFrame) -> Vec { + match &self.connection { + ConnectionState::AwaitingWelcome => self.handle_handshake(frame), + ConnectionState::Ready { .. } => self.handle_ready(frame), + ConnectionState::Disconnected | ConnectionState::Refused(_) => Vec::new(), + } + } + + fn handle_handshake(&mut self, frame: HostFrame) -> Vec { + match frame { + HostFrame::Welcome { version, host } + if (sync_protocol::PROTOCOL_MIN_VERSION..=sync_protocol::PROTOCOL_MAX_VERSION) + .contains(&version) => + { + self.connection = ConnectionState::Ready { version, host }; + self.replicas + .iter() + .filter(|(_, state)| state.subscribed) + .map(|(session_id, state)| ClientFrame::Subscribe { + session_id: session_id.clone(), + from_seq: state.cursor, + }) + .collect() + } + HostFrame::Welcome { version, .. } => { + self.violations + .push(ProtocolViolation::UnsupportedWelcomeVersion { version }); + Vec::new() + } + HostFrame::Refused { reason } => { + self.connection = ConnectionState::Refused(reason); + Vec::new() + } + _ => Vec::new(), + } + } + + fn handle_ready(&mut self, frame: HostFrame) -> Vec { + match frame { + HostFrame::Welcome { .. } | HostFrame::Refused { .. } => Vec::new(), + HostFrame::SessionList { sessions } => { + self.sessions = sessions; + Vec::new() + } + HostFrame::Events { + session_id, + events, + caught_up, + } => self.apply_events(session_id, events, caught_up), + HostFrame::CommandRejected { + session_id, + command, + reason, + } => { + if let Some(sync_protocol::RejectedCommand::Turn { delivery_id }) = &command + && let Some(turns) = self.pending_turns.get_mut(&session_id) + { + turns.remove(delivery_id); + } + self.rejections.push(RejectedCommand { + session_id, + command, + reason, + }); + Vec::new() + } + HostFrame::SessionEnded { session_id, reason } => { + if let Some(state) = self.replicas.get_mut(&session_id) { + state.caught_up = true; + } + self.ended_sessions + .push(EndedSession { session_id, reason }); + Vec::new() + } + HostFrame::Paired { token, .. } => { + // Retained, not applied: the connection is still + // unauthenticated, and the shell decides whether to persist the + // token before handshaking with it. + self.paired_token = Some(token); + Vec::new() + } + HostFrame::Pong { nonce } => { + self.pongs.push(nonce); + Vec::new() + } + } + } + + fn apply_events( + &mut self, + session_id: String, + events: Vec, + caught_up: bool, + ) -> Vec { + let state = self.replicas.entry(session_id.clone()).or_default(); + let mut expected = state.cursor.unwrap_or(0).saturating_add(1); + for event in &events { + if event.seq != expected { + let violation = ProtocolViolation::EventGap { + session_id: session_id.clone(), + expected_seq: expected, + received_seq: event.seq, + cursor: state.cursor, + }; + log::warn!("sync event sequence violation: {violation:?}"); + self.violations.push(violation); + state.caught_up = false; + return vec![ClientFrame::Subscribe { + session_id, + from_seq: state.cursor, + }]; + } + expected = expected.saturating_add(1); + } + + for event in &events { + if let AgentEvent::TurnAccepted { delivery_id } = &event.event + && let Some(turns) = self.pending_turns.get_mut(&session_id) + { + turns.remove(delivery_id); + } + } + + // The complete contiguity check above happens before any mutation, so + // applying only this batch is equivalent to replaying the whole log: + // no duplicate or out-of-order event can reach the reducer. + for event in &events { + state.timeline.apply_at(Some(event.ts), &event.event); + } + state.cursor = events.last().map(|event| event.seq).or(state.cursor); + state.caught_up = caught_up; + Vec::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::TurnStatus; + use sync_protocol::{PROTOCOL_MAX_VERSION, PROTOCOL_MIN_VERSION, SeqEvent}; + use tcode_core::session::StoredEvent; + + fn config() -> ClientConfig { + ClientConfig { + client: ClientInfo { + client_id: "client-1".into(), + display_name: "Pixel".into(), + platform: "android".into(), + app_version: "0.1.0".into(), + }, + token: "correct-horse".into(), + } + } + + fn host() -> HostInfo { + HostInfo { + host_id: "host-1".into(), + display_name: "workstation".into(), + platform: "macos".into(), + app_version: "0.1.0".into(), + } + } + + fn event(seq: u64, event: AgentEvent) -> SeqEvent { + SeqEvent { + seq, + ts: 1_000 + seq, + event, + } + } + + fn connect(client: &mut Client) { + assert!(matches!( + client.connect(), + ClientFrame::Hello { + min_version: PROTOCOL_MIN_VERSION, + max_version: PROTOCOL_MAX_VERSION, + .. + } + )); + assert!( + client + .handle(HostFrame::Welcome { + version: PROTOCOL_MAX_VERSION, + host: host(), + }) + .is_empty() + ); + } + + fn turn(delivery_id: u64) -> SessionCommand { + SessionCommand::SendTurn { + delivery_id, + text: format!("turn {delivery_id}"), + options: None, + attachments: Vec::new(), + } + } + + #[test] + fn a_successful_handshake_exposes_the_host_and_version() { + let mut client = Client::new(config()); + connect(&mut client); + + assert!(client.is_ready()); + assert_eq!(client.negotiated_version(), Some(PROTOCOL_MAX_VERSION)); + assert_eq!( + client.host().map(|host| host.host_id.as_str()), + Some("host-1") + ); + assert_eq!(client.refusal_reason(), None); + } + + #[test] + fn authentication_and_version_refusals_are_distinguishable() { + let mut unauthorized = Client::new(config()); + unauthorized.connect(); + unauthorized.handle(HostFrame::Refused { + reason: RefuseReason::Unauthorized, + }); + assert_eq!( + unauthorized.refusal_reason(), + Some(&RefuseReason::Unauthorized) + ); + + let mut old_app = Client::new(config()); + old_app.connect(); + old_app.handle(HostFrame::Refused { + reason: RefuseReason::UnsupportedVersion { + host_min: PROTOCOL_MAX_VERSION + 1, + host_max: PROTOCOL_MAX_VERSION + 2, + }, + }); + assert!(matches!( + old_app.refusal_reason(), + Some(RefuseReason::UnsupportedVersion { host_min, host_max }) + if *host_min > PROTOCOL_MAX_VERSION && host_max >= host_min + )); + } + + #[test] + fn a_backfill_is_folded_by_the_shared_timeline_reducer() { + let mut client = Client::new(config()); + connect(&mut client); + client.subscribe("s1"); + + client.handle(HostFrame::Events { + session_id: "s1".into(), + events: vec![event( + 1, + AgentEvent::TurnStarted { + turn_id: "turn-1".into(), + }, + )], + caught_up: true, + }); + + let replica = client.session("s1").expect("replica"); + let local = Timeline::fold_events([StoredEvent { + ts: Some(1_001), + seq: Some(1), + event: AgentEvent::TurnStarted { + turn_id: "turn-1".into(), + }, + }]); + assert_eq!(replica.cursor(), Some(1)); + assert!(replica.is_caught_up()); + assert_eq!(replica.timeline().turns.len(), local.turns.len()); + assert_eq!( + replica.timeline().turns[0].provider_turn_id, + local.turns[0].provider_turn_id + ); + assert_eq!(replica.timeline().turn_running, local.turn_running); + } + + #[test] + fn incremental_batches_equal_a_single_whole_history_fold() { + let mut events = Vec::new(); + for turn in 0..384 { + let turn_id = format!("turn-{turn}"); + let seq = events.len() as u64 + 1; + events.push(event( + seq, + AgentEvent::TurnStarted { + turn_id: turn_id.clone(), + }, + )); + events.push(event( + seq + 1, + AgentEvent::TurnCompleted { + turn_id, + status: TurnStatus::Completed, + usage: None, + }, + )); + } + let expected = Timeline::fold_events(events.iter().cloned().map(|event| StoredEvent { + ts: Some(event.ts), + seq: Some(event.seq), + event: event.event, + })); + + let mut client = Client::new(config()); + connect(&mut client); + client.subscribe("s1"); + for (index, batch) in events.chunks(256).enumerate() { + assert!( + client + .handle(HostFrame::Events { + session_id: "s1".into(), + events: batch.to_vec(), + caught_up: index == 2, + }) + .is_empty() + ); + } + + let actual = client.session("s1").expect("replica"); + assert_eq!(actual.cursor(), Some(768)); + assert!(actual.is_caught_up()); + // Timeline has no PartialEq implementation. Its complete Debug form + // includes private reducer bookkeeping, so this compares more than the + // public render fields while keeping that API decision in core. + assert_eq!( + format!("{:#?}", actual.timeline()), + format!("{expected:#?}") + ); + } + + #[test] + fn contiguous_batches_advance_the_cursor() { + let mut client = Client::new(config()); + connect(&mut client); + client.subscribe("s1"); + + for seq in 1..=3 { + assert!( + client + .handle(HostFrame::Events { + session_id: "s1".into(), + events: vec![event(seq, AgentEvent::TurnAccepted { delivery_id: seq },)], + caught_up: seq == 3, + }) + .is_empty() + ); + } + + assert_eq!(client.session("s1").expect("replica").cursor(), Some(3)); + } + + #[test] + fn the_latest_session_list_replaces_the_previous_one() { + let mut client = Client::new(config()); + connect(&mut client); + let summary = SessionSummary { + session_id: "s1".into(), + title: "Fix the parser".into(), + provider: sync_protocol::ProviderKind::Codex, + model: None, + project: Some("tcode".into()), + updated_at: 1_000, + latest_seq: Some(3), + working: true, + awaiting_approval: false, + }; + + client.handle(HostFrame::SessionList { + sessions: vec![summary.clone()], + }); + assert_eq!(client.sessions(), [summary]); + + client.handle(HostFrame::SessionList { + sessions: Vec::new(), + }); + assert!(client.sessions().is_empty()); + } + + #[test] + fn a_gap_is_rejected_and_resubscribed_from_the_known_good_cursor() { + let mut client = Client::new(config()); + connect(&mut client); + client.subscribe("s1"); + client.handle(HostFrame::Events { + session_id: "s1".into(), + events: vec![event( + 1, + AgentEvent::TurnStarted { + turn_id: "good".into(), + }, + )], + caught_up: false, + }); + + let response = client.handle(HostFrame::Events { + session_id: "s1".into(), + events: vec![ + event( + 3, + AgentEvent::TurnStarted { + turn_id: "not-folded".into(), + }, + ), + event(4, AgentEvent::TurnAccepted { delivery_id: 4 }), + ], + caught_up: true, + }); + + assert_eq!( + response, + vec![ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: Some(1), + }] + ); + assert_eq!(client.session("s1").expect("replica").cursor(), Some(1)); + assert_eq!( + client + .session("s1") + .expect("replica") + .timeline() + .turns + .len(), + 1 + ); + assert!(matches!( + client.violations(), + [ProtocolViolation::EventGap { + expected_seq: 2, + received_seq: 3, + cursor: Some(1), + .. + }] + )); + } + + #[test] + fn reconnecting_restores_a_subscription_from_its_cursor() { + let mut client = Client::new(config()); + connect(&mut client); + client.subscribe("s1"); + client.handle(HostFrame::Events { + session_id: "s1".into(), + events: vec![event(1, AgentEvent::TurnAccepted { delivery_id: 1 })], + caught_up: true, + }); + + client.connect(); + let restore = client.handle(HostFrame::Welcome { + version: PROTOCOL_MAX_VERSION, + host: host(), + }); + + assert_eq!( + restore, + vec![ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: Some(1), + }] + ); + } + + #[test] + fn reconnect_retains_only_turns_without_an_observed_acknowledgement() { + let mut client = Client::new(config()); + connect(&mut client); + client.subscribe("s1"); + client.command("s1", turn(41)); + client.command("s1", turn(42)); + client.handle(HostFrame::Events { + session_id: "s1".into(), + events: vec![event(1, AgentEvent::TurnAccepted { delivery_id: 41 })], + caught_up: true, + }); + + client.connect(); + client.handle(HostFrame::Welcome { + version: PROTOCOL_MAX_VERSION, + host: host(), + }); + + let pending: Vec<&SessionCommand> = client.unacknowledged_turns("s1").collect(); + assert_eq!(pending, vec![&turn(42)]); + assert_eq!( + client.resend_unacknowledged_turns("s1"), + vec![ClientFrame::Command { + session_id: "s1".into(), + command: turn(42), + }] + ); + } + + #[test] + fn command_rejections_session_endings_and_pongs_are_observable() { + let mut client = Client::new(config()); + connect(&mut client); + + client.handle(HostFrame::CommandRejected { + session_id: "s1".into(), + command: Some(sync_protocol::RejectedCommand::Approval { + request_id: "approval-1".into(), + }), + reason: CommandRejection::SessionNotLive, + }); + client.handle(HostFrame::SessionEnded { + session_id: "s1".into(), + reason: SessionEndReason::Archived, + }); + client.handle(HostFrame::Pong { nonce: 99 }); + + assert_eq!( + client.command_rejections(), + [RejectedCommand { + session_id: "s1".into(), + command: Some(sync_protocol::RejectedCommand::Approval { + request_id: "approval-1".into(), + }), + reason: CommandRejection::SessionNotLive, + }] + ); + assert_eq!( + client.ended_sessions(), + [EndedSession { + session_id: "s1".into(), + reason: SessionEndReason::Archived, + }] + ); + assert_eq!(client.pongs(), [99]); + } + + #[test] + fn correlated_turn_rejection_removes_only_that_pending_turn() { + let mut client = Client::new(config()); + connect(&mut client); + client.command("s1", turn(41)); + client.command("s1", turn(42)); + + client.handle(HostFrame::CommandRejected { + session_id: "s1".into(), + command: Some(sync_protocol::RejectedCommand::Turn { delivery_id: 41 }), + reason: CommandRejection::SessionNotLive, + }); + + assert_eq!( + client + .unacknowledged_turns("s1") + .collect::>(), + vec![&turn(42)] + ); + } +} diff --git a/crates/sync-host/Cargo.toml b/crates/sync-host/Cargo.toml new file mode 100644 index 00000000..1cddf090 --- /dev/null +++ b/crates/sync-host/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "sync-host" +version = "0.1.0" +edition = "2024" + +[dependencies] +sync-protocol = { path = "../sync-protocol" } +# Types only: this crate must not depend on GPUI or on the provider adapters. +# Phase 1.5 of docs/multiplatform-plan.md reuses it verbatim for a headless +# `tcode-server` by swapping the binary's `main`, which only works if nothing +# here reaches for a UI or a child process. +agent = { path = "../agent", default-features = false } +tcode-core = { path = "../core" } +tcode-services = { path = "../services" } +async-channel = "2" +log = "0.4" +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "sync", "macros"] } +futures-util = "0.3" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +serde_json = "1" +tokio-tungstenite = "0.28" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "net"] } +futures-util = "0.3" +agent = { path = "../agent", default-features = false } +uuid = { version = "1", features = ["v4"] } diff --git a/crates/sync-host/src/lib.rs b/crates/sync-host/src/lib.rs new file mode 100644 index 00000000..69ea7436 --- /dev/null +++ b/crates/sync-host/src/lib.rs @@ -0,0 +1,1011 @@ +//! Host side of the tcode sync protocol: the connection state machine, with no +//! I/O in it. +//! +//! [`Connection`] turns one client frame into the host frames it deserves. It +//! never touches a socket, a clock, or a task executor, so the rules that +//! actually matter — you cannot act before a handshake, a bad token gets a +//! refusal and not a session list, a cursor resumes exactly where it left off — +//! are testable without standing up a server. +//! +//! The transport is a thin shell around this: read a text frame, call +//! [`Connection::handle`], write what comes back. +//! +//! Session data arrives through [`SessionSource`], which keeps this crate free +//! of GPUI and of the provider adapters. That is not incidental: phase 1.5 of +//! `docs/multiplatform-plan.md` reuses this crate for a headless `tcode-server` +//! by swapping the binary's `main`, which only works if nothing here reaches +//! for a UI. + +pub mod pairing; +pub mod server; +pub mod store_source; + +pub use pairing::Pairing; +pub use server::{SyncServer, WakeSource, start, start_on}; +pub use store_source::{CommandRequest, CommandSink, LiveFlags, LiveSessions, StoreSource}; + +use std::collections::HashMap; + +use sync_protocol::{ + ClientFrame, ClientInfo, CommandRejection, HostFrame, HostInfo, RefuseReason, RejectedCommand, + SeqEvent, SessionCommand, SessionSummary, negotiate_version, +}; + +/// How many events one `Events` frame carries while replaying backlog. +/// +/// Bounded because a session log can hold tens of thousands of events and a +/// phone on a mobile connection should start rendering the first screenful +/// rather than wait for a single enormous frame. +pub const BACKLOG_BATCH: usize = 256; + +/// Whatever owns the sessions on this host. +/// +/// On the desktop that is the running app; for a headless server it is the +/// session store directly. The state machine cares about neither. +pub trait SessionSource { + /// Sessions this host is willing to serve, newest first. + fn list_sessions(&self) -> Vec; + + /// Events after `from_seq`, at most `limit` of them, ascending. + /// + /// `from_seq` is exclusive: `Some(4)` means "start at 5". `None` means from + /// the beginning. An unknown session yields an empty vector — the caller + /// distinguishes the two cases via [`SessionSource::session_exists`]. + fn read_events(&self, session_id: &str, from_seq: Option, limit: usize) -> Vec; + + fn session_exists(&self, session_id: &str) -> bool; + + /// Deliver a command to a live session. + fn send_command( + &self, + session_id: &str, + command: SessionCommand, + ) -> Result<(), CommandRejection>; +} + +/// Host identity and the credential clients must present. +pub struct HostConfig { + pub host: HostInfo, + /// Outstanding pairing code, if the host offers pairing. + /// + /// `None` means a client must already hold the token — which is right for a + /// headless server nobody is standing in front of to read a code off. + pub pairing: Option, + /// Compared in full. Pairing — how a client comes to hold this — is host UX + /// and deliberately outside the protocol for now. + pub token: String, +} + +/// Per-session streaming position: the highest `seq` this client has been sent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct Cursor(Option); + +/// One client connection. +/// +/// Lives from the socket opening to it closing. A reconnect is a new +/// `Connection` — resumption is carried by the client's cursor, not by host +/// memory, which is what makes it survive the host restarting too. +pub struct Connection { + source: S, + config: HostConfig, + state: State, + cursors: HashMap, +} + +enum State { + /// Nothing but `Hello` is accepted. + AwaitingHello, + /// Handshake done, carrying the version both peers agreed on. Client + /// identity is logged at handshake rather than retained: nothing here reads + /// it back, and storing it "for later" would be speculation. + Ready { version: u32 }, + /// A refusal was sent. The transport should close; anything further is + /// ignored rather than answered. + Refused, +} + +impl Connection { + pub fn new(source: S, config: HostConfig) -> Self { + Self { + source, + config, + state: State::AwaitingHello, + cursors: HashMap::new(), + } + } + + /// True once the handshake succeeded. + pub fn is_ready(&self) -> bool { + matches!(self.state, State::Ready { .. }) + } + + /// The protocol version both peers agreed on, once the handshake is done. + /// + /// The transport needs this to shape frames when more than one version + /// exists; today it is also what proves the negotiated value is retained + /// rather than computed and dropped. + pub fn negotiated_version(&self) -> Option { + match self.state { + State::Ready { version } => Some(version), + _ => None, + } + } + + /// True when the transport should close the socket. + pub fn is_refused(&self) -> bool { + matches!(self.state, State::Refused) + } + + /// Handle one client frame, returning the frames to send back. + pub fn handle(&mut self, frame: ClientFrame) -> Vec { + match &self.state { + State::Refused => Vec::new(), + State::AwaitingHello => self.handle_unauthenticated(frame), + State::Ready { .. } => self.handle_ready(frame), + } + } + + fn handle_unauthenticated(&mut self, frame: ClientFrame) -> Vec { + if let ClientFrame::Pair { + min_version, + max_version, + code, + client, + } = frame + { + return self.handle_pairing(min_version, max_version, &code, client); + } + let ClientFrame::Hello { + min_version, + max_version, + client, + token, + } = frame + else { + // Anything before a handshake is treated as unauthorized rather + // than as a protocol error: an unauthenticated peer must not be + // able to tell "wrong credential" from "wrong order", or the + // refusal becomes an oracle. + return self.refuse(RefuseReason::Unauthorized); + }; + + let Some(version) = negotiate_version(min_version, max_version) else { + // Version is checked before the token on purpose. A client too old + // to speak our protocol cannot be expected to have a valid token + // format either, and "upgrade" is the more useful thing to say. + return self.refuse(RefuseReason::UnsupportedVersion { + host_min: sync_protocol::PROTOCOL_MIN_VERSION, + host_max: sync_protocol::PROTOCOL_MAX_VERSION, + }); + }; + + if !constant_time_eq(token.as_bytes(), self.config.token.as_bytes()) { + return self.refuse(RefuseReason::Unauthorized); + } + + log::info!( + "sync client connected: {} ({}) protocol v{version}", + client.display_name, + client.platform + ); + self.state = State::Ready { version }; + vec![HostFrame::Welcome { + version, + host: self.config.host.clone(), + }] + } + + /// Trade a pairing code for the durable token. + /// + /// Deliberately does *not* authenticate the connection: the client sends a + /// normal `Hello` afterwards. Pairing then has exactly one job, and a + /// `Paired` frame is worth no more than the token inside it. + fn handle_pairing( + &mut self, + min_version: u32, + max_version: u32, + code: &str, + client: ClientInfo, + ) -> Vec { + let Some(version) = negotiate_version(min_version, max_version) else { + return self.refuse(RefuseReason::UnsupportedVersion { + host_min: sync_protocol::PROTOCOL_MIN_VERSION, + host_max: sync_protocol::PROTOCOL_MAX_VERSION, + }); + }; + let Some(pairing) = &self.config.pairing else { + // A host that offers no pairing says so as a rejection rather than + // as a distinct error: whether pairing is disabled or the code was + // wrong is not something an unauthenticated peer needs to learn. + return self.refuse(RefuseReason::PairingRejected); + }; + if !pairing.redeem(code, std::time::Instant::now()) { + return self.refuse(RefuseReason::PairingRejected); + } + log::info!( + "sync client paired: {} ({})", + client.display_name, + client.platform + ); + // The connection stays unauthenticated; the client reconnects, or sends + // Hello on this one, with the token it just received. + vec![HostFrame::Paired { + version, + token: self.config.token.clone(), + }] + } + + fn handle_ready(&mut self, frame: ClientFrame) -> Vec { + match frame { + // A second handshake is a confused client, not an attack. Refusing + // is still right: silently ignoring it would leave the two sides + // disagreeing about the negotiated version. + // Both are "you already did this": a second handshake, or pairing + // on a connection that is already authenticated. Silently ignoring + // either would leave the two sides disagreeing about state. + ClientFrame::Hello { .. } | ClientFrame::Pair { .. } => { + self.refuse(RefuseReason::Unauthorized) + } + ClientFrame::Ping { nonce } => vec![HostFrame::Pong { nonce }], + ClientFrame::ListSessions => vec![HostFrame::SessionList { + sessions: self.source.list_sessions(), + }], + ClientFrame::Subscribe { + session_id, + from_seq, + } => self.subscribe(session_id, from_seq), + ClientFrame::Unsubscribe { session_id } => { + self.cursors.remove(&session_id); + Vec::new() + } + ClientFrame::Command { + session_id, + command, + } => { + let rejected_command = rejected_command(&command); + match self.source.send_command(&session_id, command) { + Ok(()) => Vec::new(), + Err(reason) => vec![HostFrame::CommandRejected { + session_id, + command: rejected_command, + reason, + }], + } + } + } + } + + fn subscribe(&mut self, session_id: String, from_seq: Option) -> Vec { + if !self.source.session_exists(&session_id) { + return vec![HostFrame::CommandRejected { + session_id, + command: None, + reason: CommandRejection::UnknownSession, + }]; + } + self.cursors.insert(session_id.clone(), Cursor(from_seq)); + self.drain(&session_id) + } + + /// Send everything the host holds past this session's cursor. + /// + /// Also the live path: when new events land, the transport calls this and + /// the client receives whatever it has not seen. One code path for backlog + /// and live traffic means the catch-up boundary cannot drift between them. + pub fn drain(&mut self, session_id: &str) -> Vec { + let Some(Cursor(mut cursor)) = self.cursors.get(session_id).copied() else { + return Vec::new(); + }; + let mut frames = Vec::new(); + loop { + let events = self.source.read_events(session_id, cursor, BACKLOG_BATCH); + if events.is_empty() { + break; + } + let caught_up = events.len() < BACKLOG_BATCH; + cursor = Some(events.last().expect("non-empty").seq); + frames.push(HostFrame::Events { + session_id: session_id.to_owned(), + events, + caught_up, + }); + if caught_up { + break; + } + } + self.cursors.insert(session_id.to_owned(), Cursor(cursor)); + + // A subscribe that had nothing to send still owes the client a frame: + // otherwise it cannot tell "you are up to date" from "the host has not + // answered yet", and would sit showing a spinner forever. + if frames.is_empty() { + frames.push(HostFrame::Events { + session_id: session_id.to_owned(), + events: Vec::new(), + caught_up: true, + }); + } + frames + } + + /// Sessions this connection is streaming, for the transport to consult when + /// deciding whether a newly recorded event concerns this client. + pub fn subscribed_sessions(&self) -> impl Iterator { + self.cursors.keys().map(String::as_str) + } + + fn refuse(&mut self, reason: RefuseReason) -> Vec { + self.state = State::Refused; + vec![HostFrame::Refused { reason }] + } +} + +fn rejected_command(command: &SessionCommand) -> Option { + match command { + SessionCommand::SendTurn { delivery_id, .. } => Some(RejectedCommand::Turn { + delivery_id: *delivery_id, + }), + SessionCommand::RespondApproval { request_id, .. } => Some(RejectedCommand::Approval { + request_id: request_id.clone(), + }), + _ => None, + } +} + +/// Compare two secrets without leaking their common prefix length through +/// timing. Short and dependency-free; the alternative is pulling a crypto crate +/// into a type-level crate for sixteen lines. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b) + .fold(0_u8, |acc, (x, y)| acc | (x ^ y)) + .eq(&0) +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::AgentEvent; + use std::cell::RefCell; + use sync_protocol::ClientInfo; + + /// A source with a fixed set of sessions and logs. + struct FakeSource { + sessions: Vec, + logs: HashMap>, + sent: RefCell>, + reject: Option, + } + + impl FakeSource { + fn with_log(session_id: &str, len: u64) -> Self { + let events = (1..=len) + .map(|seq| SeqEvent { + seq, + ts: 1_000 + seq, + event: AgentEvent::TurnAccepted { delivery_id: seq }, + }) + .collect(); + Self { + sessions: vec![summary(session_id)], + logs: HashMap::from([(session_id.to_owned(), events)]), + sent: RefCell::new(Vec::new()), + reject: None, + } + } + } + + impl SessionSource for FakeSource { + fn list_sessions(&self) -> Vec { + self.sessions.clone() + } + + fn read_events( + &self, + session_id: &str, + from_seq: Option, + limit: usize, + ) -> Vec { + let after = from_seq.unwrap_or(0); + self.logs + .get(session_id) + .map(|events| { + events + .iter() + .filter(|event| event.seq > after) + .take(limit) + .cloned() + .collect() + }) + .unwrap_or_default() + } + + fn session_exists(&self, session_id: &str) -> bool { + self.logs.contains_key(session_id) + } + + fn send_command( + &self, + session_id: &str, + command: SessionCommand, + ) -> Result<(), CommandRejection> { + if let Some(reason) = self.reject.clone() { + return Err(reason); + } + self.sent + .borrow_mut() + .push((session_id.to_owned(), command)); + Ok(()) + } + } + + fn summary(session_id: &str) -> SessionSummary { + SessionSummary { + session_id: session_id.to_owned(), + title: "Fix the parser".into(), + provider: sync_protocol::ProviderKind::Codex, + model: None, + project: None, + updated_at: 0, + latest_seq: None, + working: false, + awaiting_approval: false, + } + } + + const TOKEN: &str = "correct-horse"; + + fn config() -> HostConfig { + HostConfig { + host: HostInfo { + host_id: "host-1".into(), + display_name: "workstation".into(), + platform: "macos".into(), + app_version: "0.1.0".into(), + }, + token: TOKEN.into(), + // Most tests exercise token auth; pairing gets its own fixtures. + pairing: None, + } + } + + fn hello(token: &str) -> ClientFrame { + ClientFrame::Hello { + min_version: sync_protocol::PROTOCOL_MIN_VERSION, + max_version: sync_protocol::PROTOCOL_MAX_VERSION, + client: ClientInfo { + client_id: "client-1".into(), + display_name: "Pixel".into(), + platform: "android".into(), + app_version: "0.1.0".into(), + }, + token: token.into(), + } + } + + fn connected(source: FakeSource) -> Connection { + let mut connection = Connection::new(source, config()); + let welcome = connection.handle(hello(TOKEN)); + assert!(matches!(welcome.as_slice(), [HostFrame::Welcome { .. }])); + connection + } + + fn events_of(frames: &[HostFrame]) -> Vec { + frames + .iter() + .flat_map(|frame| match frame { + HostFrame::Events { events, .. } => events.iter().map(|e| e.seq).collect(), + _ => Vec::new(), + }) + .collect() + } + + #[test] + fn a_valid_handshake_is_welcomed() { + let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); + let frames = connection.handle(hello(TOKEN)); + match frames.as_slice() { + [HostFrame::Welcome { version, host }] => { + assert_eq!(*version, sync_protocol::PROTOCOL_MAX_VERSION); + assert_eq!(host.host_id, "host-1"); + } + other => panic!("expected a welcome, got {other:?}"), + } + assert!(connection.is_ready()); + assert_eq!( + connection.negotiated_version(), + Some(sync_protocol::PROTOCOL_MAX_VERSION) + ); + } + + #[test] + fn a_wrong_token_is_refused_and_the_connection_is_done() { + let mut connection = Connection::new(FakeSource::with_log("s1", 3), config()); + let frames = connection.handle(hello("wrong")); + assert!(matches!( + frames.as_slice(), + [HostFrame::Refused { + reason: RefuseReason::Unauthorized + }] + )); + assert!(connection.is_refused()); + assert!(!connection.is_ready()); + assert_eq!(connection.negotiated_version(), None); + } + + /// The property that matters after a refusal: the connection goes mute. + /// Answering anything at all would let an unauthenticated peer keep probing + /// on a socket the host has already rejected. + #[test] + fn a_refused_connection_answers_nothing_further() { + let mut connection = Connection::new(FakeSource::with_log("s1", 3), config()); + connection.handle(hello("wrong")); + + assert!(connection.handle(ClientFrame::ListSessions).is_empty()); + assert!(connection.handle(ClientFrame::Ping { nonce: 1 }).is_empty()); + assert!( + connection + .handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + }) + .is_empty() + ); + assert!(connection.handle(hello(TOKEN)).is_empty()); + } + + /// Acting before the handshake must not be distinguishable from presenting + /// a bad credential, or the refusal tells an unauthenticated peer which of + /// the two it got wrong. + #[test] + fn frames_before_the_handshake_are_refused_as_unauthorized() { + for frame in [ + ClientFrame::ListSessions, + ClientFrame::Ping { nonce: 1 }, + ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + }, + ClientFrame::Command { + session_id: "s1".into(), + command: SessionCommand::Interrupt, + }, + ] { + let mut connection = Connection::new(FakeSource::with_log("s1", 3), config()); + let frames = connection.handle(frame); + assert!( + matches!( + frames.as_slice(), + [HostFrame::Refused { + reason: RefuseReason::Unauthorized + }] + ), + "got {frames:?}" + ); + } + } + + #[test] + fn an_unsupported_version_is_refused_with_the_hosts_range() { + let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); + let frames = connection.handle(ClientFrame::Hello { + min_version: sync_protocol::PROTOCOL_MAX_VERSION + 1, + max_version: sync_protocol::PROTOCOL_MAX_VERSION + 3, + client: ClientInfo { + client_id: "c".into(), + display_name: "c".into(), + platform: "web".into(), + app_version: "9.9.9".into(), + }, + token: TOKEN.into(), + }); + match frames.as_slice() { + [ + HostFrame::Refused { + reason: RefuseReason::UnsupportedVersion { host_min, host_max }, + }, + ] => { + assert_eq!(*host_min, sync_protocol::PROTOCOL_MIN_VERSION); + assert_eq!(*host_max, sync_protocol::PROTOCOL_MAX_VERSION); + } + other => panic!("expected a version refusal, got {other:?}"), + } + } + + /// A version mismatch must win over a bad token: a client too old to speak + /// the protocol cannot be expected to hold a well-formed credential, and + /// "you need to upgrade" is the actionable message. + #[test] + fn version_is_checked_before_the_token() { + let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); + let frames = connection.handle(ClientFrame::Hello { + min_version: sync_protocol::PROTOCOL_MAX_VERSION + 1, + max_version: sync_protocol::PROTOCOL_MAX_VERSION + 1, + client: ClientInfo { + client_id: "c".into(), + display_name: "c".into(), + platform: "web".into(), + app_version: "9.9.9".into(), + }, + token: "definitely wrong".into(), + }); + assert!( + matches!( + frames.as_slice(), + [HostFrame::Refused { + reason: RefuseReason::UnsupportedVersion { .. } + }] + ), + "got {frames:?}" + ); + } + + #[test] + fn subscribing_from_scratch_replays_the_whole_log() { + let mut connection = connected(FakeSource::with_log("s1", 5)); + let frames = connection.handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + }); + assert_eq!(events_of(&frames), vec![1, 2, 3, 4, 5]); + assert!(matches!( + frames.last(), + Some(HostFrame::Events { + caught_up: true, + .. + }) + )); + } + + /// The whole point of the cursor: a reconnecting client gets exactly what + /// it missed — no gap, no duplicate. + #[test] + fn subscribing_from_a_cursor_resumes_exactly_after_it() { + let mut connection = connected(FakeSource::with_log("s1", 5)); + let frames = connection.handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: Some(3), + }); + assert_eq!(events_of(&frames), vec![4, 5]); + } + + /// An up-to-date client still gets an answer. Silence is indistinguishable + /// from a host that has not replied, and the client would spin forever. + #[test] + fn subscribing_at_the_end_of_the_log_still_answers() { + let mut connection = connected(FakeSource::with_log("s1", 5)); + let frames = connection.handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: Some(5), + }); + match frames.as_slice() { + [ + HostFrame::Events { + events, caught_up, .. + }, + ] => { + assert!(events.is_empty()); + assert!(*caught_up); + } + other => panic!("expected an empty caught-up frame, got {other:?}"), + } + } + + /// Long logs arrive in bounded batches, with `caught_up` false until the + /// last one, so a client can render progressively instead of waiting. + #[test] + fn a_long_backlog_is_batched_and_only_the_last_frame_is_caught_up() { + let total = (BACKLOG_BATCH * 2 + 7) as u64; + let mut connection = connected(FakeSource::with_log("s1", total)); + let frames = connection.handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + }); + + assert_eq!(frames.len(), 3, "expected three batches"); + let flags: Vec = frames + .iter() + .map(|frame| match frame { + HostFrame::Events { caught_up, .. } => *caught_up, + other => panic!("expected events, got {other:?}"), + }) + .collect(); + assert_eq!(flags, vec![false, false, true]); + + // Contiguous, ascending, complete — the guarantee a cursor rests on. + let seqs = events_of(&frames); + assert_eq!(seqs.len(), total as usize); + assert_eq!(seqs.first(), Some(&1)); + assert_eq!(seqs.last(), Some(&total)); + assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1)); + } + + /// After a subscribe drains the log, new events reach the client through + /// the same path with no repeats — backlog and live traffic share one code + /// path precisely so the boundary between them cannot drift. + #[test] + fn draining_again_sends_only_what_arrived_since() { + let mut source = FakeSource::with_log("s1", 2); + source.logs.get_mut("s1").expect("seeded").push(SeqEvent { + seq: 3, + ts: 1_003, + event: AgentEvent::TurnAccepted { delivery_id: 3 }, + }); + let mut connection = connected(source); + + let first = connection.handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: Some(2), + }); + assert_eq!(events_of(&first), vec![3]); + + let again = connection.drain("s1"); + assert_eq!( + events_of(&again), + Vec::::new(), + "an event must not be delivered twice" + ); + } + + #[test] + fn draining_a_session_that_was_never_subscribed_sends_nothing() { + let mut connection = connected(FakeSource::with_log("s1", 3)); + assert!(connection.drain("s1").is_empty()); + } + + #[test] + fn unsubscribing_stops_the_stream() { + let mut connection = connected(FakeSource::with_log("s1", 3)); + connection.handle(ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + }); + assert_eq!(connection.subscribed_sessions().count(), 1); + + connection.handle(ClientFrame::Unsubscribe { + session_id: "s1".into(), + }); + assert_eq!(connection.subscribed_sessions().count(), 0); + assert!(connection.drain("s1").is_empty()); + } + + #[test] + fn subscribing_to_an_unknown_session_is_rejected() { + let mut connection = connected(FakeSource::with_log("s1", 3)); + let frames = connection.handle(ClientFrame::Subscribe { + session_id: "nope".into(), + from_seq: None, + }); + assert!( + matches!( + frames.as_slice(), + [HostFrame::CommandRejected { + reason: CommandRejection::UnknownSession, + .. + }] + ), + "got {frames:?}" + ); + } + + #[test] + fn an_accepted_command_reaches_the_source_and_is_answered_with_silence() { + let mut connection = connected(FakeSource::with_log("s1", 0)); + let frames = connection.handle(ClientFrame::Command { + session_id: "s1".into(), + command: SessionCommand::Interrupt, + }); + assert!(frames.is_empty(), "got {frames:?}"); + let sent = connection.source.sent.borrow(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].0, "s1"); + assert_eq!(sent[0].1, SessionCommand::Interrupt); + } + + /// A rejected command must be reported. Silence would leave the client + /// rendering a turn that never ran. + #[test] + fn a_rejected_command_is_reported_back() { + let mut source = FakeSource::with_log("s1", 0); + source.reject = Some(CommandRejection::SessionNotLive); + let mut connection = connected(source); + + let frames = connection.handle(ClientFrame::Command { + session_id: "s1".into(), + command: SessionCommand::Interrupt, + }); + match frames.as_slice() { + [ + HostFrame::CommandRejected { + session_id, + command, + reason, + }, + ] => { + assert_eq!(session_id, "s1"); + assert_eq!(*command, None); + assert_eq!(*reason, CommandRejection::SessionNotLive); + } + other => panic!("expected a rejection, got {other:?}"), + } + } + + #[test] + fn rejected_identifiable_commands_echo_their_identity() { + let commands = [ + ( + SessionCommand::SendTurn { + delivery_id: 42, + text: "continue".into(), + options: None, + attachments: Vec::new(), + }, + RejectedCommand::Turn { delivery_id: 42 }, + ), + ( + SessionCommand::RespondApproval { + request_id: "approval-1".into(), + decision: sync_protocol::ApprovalDecision::ApproveForSession, + }, + RejectedCommand::Approval { + request_id: "approval-1".into(), + }, + ), + ]; + + for (sent, expected) in commands { + let mut source = FakeSource::with_log("s1", 0); + source.reject = Some(CommandRejection::SessionNotLive); + let mut connection = connected(source); + let frames = connection.handle(ClientFrame::Command { + session_id: "s1".into(), + command: sent, + }); + + match frames.as_slice() { + [HostFrame::CommandRejected { command, .. }] => { + assert_eq!(command.as_ref(), Some(&expected)); + } + other => panic!("expected a rejection, got {other:?}"), + } + } + } + + #[test] + fn ping_is_answered_with_the_same_nonce() { + let mut connection = connected(FakeSource::with_log("s1", 0)); + let frames = connection.handle(ClientFrame::Ping { nonce: 99 }); + assert!(matches!(frames.as_slice(), [HostFrame::Pong { nonce: 99 }])); + } + + #[test] + fn a_second_handshake_is_refused() { + let mut connection = connected(FakeSource::with_log("s1", 0)); + let frames = connection.handle(hello(TOKEN)); + assert!( + matches!(frames.as_slice(), [HostFrame::Refused { .. }]), + "got {frames:?}" + ); + assert!(connection.is_refused()); + } + + #[test] + fn listing_sessions_returns_what_the_source_holds() { + let mut connection = connected(FakeSource::with_log("s1", 0)); + let frames = connection.handle(ClientFrame::ListSessions); + match frames.as_slice() { + [HostFrame::SessionList { sessions }] => { + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "s1"); + } + other => panic!("expected a session list, got {other:?}"), + } + } + + fn pair(code: &str) -> ClientFrame { + ClientFrame::Pair { + min_version: sync_protocol::PROTOCOL_MIN_VERSION, + max_version: sync_protocol::PROTOCOL_MAX_VERSION, + code: code.into(), + client: ClientInfo { + client_id: "client-1".into(), + display_name: "Pixel".into(), + platform: "android".into(), + app_version: "0.1.0".into(), + }, + } + } + + fn paired_config(pairing: crate::pairing::Pairing) -> HostConfig { + HostConfig { + pairing: Some(pairing), + ..config() + } + } + + /// The point of pairing: a client with no token can obtain one. + #[test] + fn a_valid_code_yields_the_token() { + let pairing = crate::pairing::Pairing::new(); + let code = pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); + let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); + + match connection.handle(pair(&code)).as_slice() { + [HostFrame::Paired { token, .. }] => assert_eq!(token, TOKEN), + other => panic!("expected a paired frame, got {other:?}"), + } + } + + /// Pairing must not authenticate the connection. Keeping the two steps + /// apart means a `Paired` frame is worth no more than the token in it, and + /// the client still proves possession by sending `Hello`. + #[test] + fn pairing_does_not_authenticate_the_connection() { + let pairing = crate::pairing::Pairing::new(); + let code = pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); + let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); + + connection.handle(pair(&code)); + assert!(!connection.is_ready(), "pairing is not a handshake"); + + // The token it just received is what actually authenticates. + assert!(matches!( + connection.handle(hello(TOKEN)).as_slice(), + [HostFrame::Welcome { .. }] + )); + } + + #[test] + fn a_wrong_code_is_rejected_and_the_connection_is_done() { + let pairing = crate::pairing::Pairing::new(); + pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); + let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); + + assert!(matches!( + connection.handle(pair("ZZZZZZ")).as_slice(), + [HostFrame::Refused { + reason: RefuseReason::PairingRejected + }] + )); + assert!(connection.is_refused()); + } + + /// A host with pairing disabled — a headless server nobody is standing in + /// front of — must answer the same way as a wrong code. Whether pairing is + /// off or the guess was bad is not something an unauthenticated peer needs. + #[test] + fn a_host_without_pairing_rejects_identically() { + let mut connection = Connection::new(FakeSource::with_log("s1", 0), config()); + assert!(matches!( + connection.handle(pair("ACDEFG")).as_slice(), + [HostFrame::Refused { + reason: RefuseReason::PairingRejected + }] + )); + } + + #[test] + fn pairing_after_a_handshake_is_refused() { + let pairing = crate::pairing::Pairing::new(); + let code = pairing.issue(std::time::Instant::now(), || [1, 2, 3, 4, 5, 6]); + let mut connection = Connection::new(FakeSource::with_log("s1", 0), paired_config(pairing)); + connection.handle(hello(TOKEN)); + + assert!(matches!( + connection.handle(pair(&code)).as_slice(), + [HostFrame::Refused { .. }] + )); + } + + #[test] + fn token_comparison_rejects_prefixes_and_accepts_the_exact_value() { + assert!(constant_time_eq(b"abc", b"abc")); + assert!(!constant_time_eq(b"abc", b"abd")); + assert!(!constant_time_eq(b"abc", b"ab")); + assert!(!constant_time_eq(b"ab", b"abc")); + assert!(constant_time_eq(b"", b"")); + } +} diff --git a/crates/sync-host/src/pairing.rs b/crates/sync-host/src/pairing.rs new file mode 100644 index 00000000..e140486e --- /dev/null +++ b/crates/sync-host/src/pairing.rs @@ -0,0 +1,237 @@ +//! Short-lived pairing codes. +//! +//! A phone cannot be handed a token the way a query string hands one to a +//! browser, and reading a UUID off another screen is not a flow anyone will +//! use. So the host mints a short code, shows it, and trades it once for the +//! durable token. +//! +//! Three properties make a six-character code defensible where a six-character +//! password would not be: +//! +//! * it expires in minutes, so an attacker has a bounded window rather than +//! forever; +//! * it is single-use, so a correct guess still races the real client; +//! * it is only worth anything to someone who can already reach the host's +//! socket, which on the default loopback binding means local access. +//! +//! Widen the bind address and those assumptions change — which is why `--bind` +//! says so, and why this file is the place to add rate limiting when it does. + +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// How long a code stays usable. +/// +/// Long enough to walk to another device, short enough that a code left on a +/// screen is not a standing invitation. +pub const CODE_LIFETIME: Duration = Duration::from_secs(180); + +/// Characters a code is drawn from. +/// +/// Digits and uppercase letters minus the pairs people misread aloud or +/// mistype: no O/0, no I/1/L, no S/5, no B/8. A code exists to be read off one +/// screen and typed into another, so ambiguity is a usability bug that reads as +/// "pairing is broken". +const ALPHABET: &[u8] = b"ACDEFGHJKMNPQRTUVWXY23467"; + +/// Length of a generated code. 25^6 is about 244 million, which against a +/// three-minute single-use window is far more than a guesser gets to try. +const CODE_LEN: usize = 6; + +#[derive(Clone)] +struct PendingCode { + code: String, + issued: Instant, +} + +/// The host's pairing state: at most one outstanding code. +/// +/// One, not a set: pairing is a deliberate act a person performs, and a host +/// holding several live codes is a host that cannot tell you which screen the +/// valid one is on. +#[derive(Clone, Default)] +pub struct Pairing { + pending: Arc>>, +} + +impl Pairing { + pub fn new() -> Self { + Self::default() + } + + /// Mint a code, replacing any outstanding one. + /// + /// Replacing rather than refusing: asking again is what a user does when + /// the first code scrolled away, and making them wait out an expiry would + /// be punishing them for the interface's shortcoming. + /// `entropy` supplies the raw bytes a code is drawn from — at least + /// [`CODE_LEN`] of them. Injected rather than sourced here so tests can be + /// deterministic, and so the caller owns the choice of RNG. + /// + /// It must be *random*. An earlier version called the system clock once per + /// character; on macOS the six reads landed in the same microsecond and + /// every code came out `AAAAAA`. A code's security rests on being + /// unguessable within its window, so a low-entropy source does not weaken + /// it — it removes it. + pub fn issue(&self, now: Instant, entropy: impl Fn() -> [u8; CODE_LEN]) -> String { + let bytes = entropy(); + let code: String = bytes + .iter() + .map(|byte| ALPHABET[*byte as usize % ALPHABET.len()] as char) + .collect(); + *self.lock() = Some(PendingCode { + code: code.clone(), + issued: now, + }); + code + } + + /// Consume `candidate` if it matches an unexpired code. + /// + /// Takes the code out of the slot whether or not it matched *when it + /// matched*, so a correct code cannot be replayed. A wrong guess leaves the + /// real code in place, so a wrong attempt cannot be used to cancel someone + /// else's pairing. + pub fn redeem(&self, candidate: &str, now: Instant) -> bool { + let mut slot = self.lock(); + let Some(pending) = slot.as_ref() else { + return false; + }; + if now.duration_since(pending.issued) > CODE_LIFETIME { + // Expired codes are cleared on contact rather than by a timer: there + // is no background task to run, and a stale entry is only reachable + // through this function anyway. + *slot = None; + return false; + } + if !constant_time_eq(candidate.as_bytes(), pending.code.as_bytes()) { + return false; + } + *slot = None; + true + } + + /// The outstanding code, if one is live. For the host to display. + pub fn current(&self, now: Instant) -> Option { + let slot = self.lock(); + slot.as_ref() + .filter(|pending| now.duration_since(pending.issued) <= CODE_LIFETIME) + .map(|pending| pending.code.clone()) + } + + fn lock(&self) -> std::sync::MutexGuard<'_, Option> { + self.pending + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// Compare without leaking the matching prefix length through timing. +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter() + .zip(b) + .fold(0_u8, |acc, (x, y)| acc | (x ^ y)) + .eq(&0) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Deterministic bytes so a test can predict the code. + fn fixed(seed: u8) -> impl Fn() -> [u8; CODE_LEN] { + move || std::array::from_fn(|index| seed.wrapping_add(index as u8)) + } + + #[test] + fn a_fresh_code_redeems_once() { + let pairing = Pairing::new(); + let now = Instant::now(); + let code = pairing.issue(now, fixed(0)); + + assert!(pairing.redeem(&code, now)); + // Single use is the property that makes a six-character code viable: a + // correct guess still has to beat the real client to it. + assert!(!pairing.redeem(&code, now), "a code must not redeem twice"); + } + + #[test] + fn an_expired_code_is_refused() { + let pairing = Pairing::new(); + let now = Instant::now(); + let code = pairing.issue(now, fixed(0)); + let later = now + CODE_LIFETIME + Duration::from_secs(1); + + assert!(!pairing.redeem(&code, later)); + assert_eq!(pairing.current(later), None, "expiry must also hide it"); + } + + /// A wrong guess must not clear the slot, or anyone able to reach the socket + /// could cancel a pairing in progress by typing nonsense. + #[test] + fn a_wrong_guess_leaves_the_real_code_usable() { + let pairing = Pairing::new(); + let now = Instant::now(); + let code = pairing.issue(now, fixed(0)); + + assert!(!pairing.redeem("ZZZZZZ", now)); + assert!(pairing.redeem(&code, now), "the real code must still work"); + } + + #[test] + fn issuing_again_replaces_the_previous_code() { + let pairing = Pairing::new(); + let now = Instant::now(); + let first = pairing.issue(now, fixed(0)); + let second = pairing.issue(now, fixed(9)); + + assert_ne!(first, second); + assert!(!pairing.redeem(&first, now), "the old code must be dead"); + assert!(pairing.redeem(&second, now)); + } + + #[test] + fn redeeming_without_an_outstanding_code_fails() { + let pairing = Pairing::new(); + assert!(!pairing.redeem("ACDEFG", Instant::now())); + } + + /// Codes are read aloud and typed by hand, so the alphabet excludes the + /// characters people confuse. A code containing them would surface as + /// "pairing is broken" rather than as a typo. + #[test] + fn the_alphabet_omits_ambiguous_characters() { + for ambiguous in *b"O0I1LS5B8" { + assert!( + !ALPHABET.contains(&ambiguous), + "{} is easily misread", + ambiguous as char + ); + } + } + + /// The defect this caught in practice: a low-entropy source produced + /// `AAAAAA`. A code whose characters are all equal has no security left, + /// so the generator must spread distinct input bytes across distinct + /// characters. + #[test] + fn distinct_entropy_produces_distinct_characters() { + let pairing = Pairing::new(); + let code = pairing.issue(Instant::now(), fixed(0)); + assert!( + code.chars().collect::>().len() > 1, + "every character identical means the entropy was not used: {code}" + ); + } + + #[test] + fn a_generated_code_uses_only_the_alphabet() { + let pairing = Pairing::new(); + let code = pairing.issue(Instant::now(), fixed(0)); + assert_eq!(code.len(), CODE_LEN); + assert!(code.bytes().all(|byte| ALPHABET.contains(&byte))); + } +} diff --git a/crates/sync-host/src/server.rs b/crates/sync-host/src/server.rs new file mode 100644 index 00000000..fa0a5f82 --- /dev/null +++ b/crates/sync-host/src/server.rs @@ -0,0 +1,460 @@ +//! WebSocket transport for the host. +//! +//! Deliberately thin: read a text frame, hand it to [`Connection`], write back +//! whatever it returns. All the rules live in the state machine, where they are +//! testable without a socket — the only judgement here is about sockets. +//! +//! Bind happens synchronously so the caller learns the port before [`start`] +//! returns, matching the in-process MCP servers this app already runs. + +use std::collections::HashMap; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::response::Response; +use axum::routing::any; +use futures_util::{SinkExt as _, StreamExt as _}; +use sync_protocol::{ClientFrame, HostFrame, HostInfo}; +use tcode_services::store::SessionStore; + +use crate::store_source::{CommandRequest, LiveSessions, StoreSource}; +use crate::{Connection, HostConfig}; + +/// How many session-advance notifications may queue before a slow connection +/// starts missing them. +/// +/// Missing one is survivable by design: a notification carries no data, only +/// "this session moved". A connection that lags simply drains from its cursor +/// on the next notification it does see, and catches up on everything at once. +const ADVANCE_BUFFER: usize = 256; + +/// How connections learn that a subscribed session may have advanced. +#[derive(Debug, Clone, Copy)] +pub enum WakeSource { + /// The embedding process calls [`SyncServer::notify_advanced`] after an + /// append, so clients wake immediately without touching the filesystem. + Broadcast, + /// Check event-log metadata periodically for changes. + /// + /// The headless host uses this because its process never appends to the log + /// and therefore has no in-process notification to subscribe to. + Polling { interval: Duration }, +} + +/// A running sync server. +pub struct SyncServer { + /// Issues the short codes a phone trades for the token. + pub pairing: crate::pairing::Pairing, + /// WebSocket endpoint, e.g. `ws://127.0.0.1:53213/sync`. + pub url: String, + /// The credential clients must present, supplied by the embedding app. + pub token: String, + /// Commands from remote clients, for the app to apply. Single consumer. + pub commands: async_channel::Receiver, + /// Per-session liveness for the app to publish into. + pub live: LiveSessions, + advance: Option>, +} + +impl SyncServer { + /// Tell connected clients that a session's log has grown. + /// + /// Carries no payload: subscribers re-read from their own cursor, so a + /// notification cannot deliver an event twice or out of order however many + /// times it fires. Cheap enough to call on every appended event. + pub fn notify_advanced(&self, session_id: &str) { + // An error means nobody is listening, which is the normal case. + if let Some(advance) = &self.advance { + let _ = advance.send(session_id.to_owned()); + } + } +} + +#[derive(Clone)] +struct ServerState { + store: SessionStore, + host: HostInfo, + token: String, + pairing: Option, + live: LiveSessions, + commands: async_channel::Sender, + wake: WakeFactory, +} + +#[derive(Clone)] +enum WakeFactory { + Broadcast(tokio::sync::broadcast::Sender), + Polling { root: PathBuf, interval: Duration }, +} + +enum ConnectionWake { + Broadcast(tokio::sync::broadcast::Receiver), + Polling(Box), +} + +struct PollWake { + root: PathBuf, + stamps: HashMap>, + interval: tokio::time::Interval, +} + +enum WakeEvent { + Session(String), + AllSubscriptions, + Poll, + Closed, +} + +enum ConnectionEvent { + Socket(Option>), + Wake(WakeEvent), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileStamp { + len: u64, + modified: Option, +} + +/// Bind the desktop's deliberate ephemeral-loopback default. +/// +/// Kept as the compact compatibility entry point used by socket-level tests. +/// Hosts that choose an address or wake policy use [`start_on`]. +pub fn start(store: SessionStore, host: HostInfo, token: String) -> std::io::Result { + start_on( + store, + host, + token, + SocketAddr::from(([127, 0, 0, 1], 0)), + WakeSource::Broadcast, + ) +} + +/// Bind `address` and serve using the selected connection wake source. +pub fn start_on( + store: SessionStore, + host: HostInfo, + token: String, + address: SocketAddr, + wake_source: WakeSource, +) -> std::io::Result { + let listener = TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + let local_addr = listener.local_addr()?; + let url = format!("ws://{local_addr}/sync"); + // Bounded: an unbounded queue would let a wedged app absorb commands + // forever while every client believes they were delivered. + let (command_tx, command_rx) = async_channel::bounded(256); + let live = LiveSessions::new(); + let pairing = crate::pairing::Pairing::new(); + let (wake, advance) = match wake_source { + WakeSource::Broadcast => { + let (advance, _) = tokio::sync::broadcast::channel(ADVANCE_BUFFER); + (WakeFactory::Broadcast(advance.clone()), Some(advance)) + } + WakeSource::Polling { interval } => ( + WakeFactory::Polling { + root: store.root().clone(), + interval, + }, + None, + ), + }; + + let state = ServerState { + store, + host, + token: token.clone(), + pairing: Some(pairing.clone()), + live: live.clone(), + commands: command_tx, + wake, + }; + + std::thread::Builder::new() + .name("sync-host".into()) + .spawn(move || { + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(err) => { + log::error!("sync-host: failed to build tokio runtime: {err}"); + return; + } + }; + runtime.block_on(async move { + let listener = match tokio::net::TcpListener::from_std(listener) { + Ok(listener) => listener, + Err(err) => { + log::error!("sync-host: failed to adopt listener: {err}"); + return; + } + }; + let app = Router::new() + .route("/sync", any(upgrade)) + .with_state(Arc::new(state)); + if let Err(err) = axum::serve(listener, app).await { + log::error!("sync-host: server exited with error: {err}"); + } + }); + })?; + + log::info!("sync-host: serving at {url}"); + Ok(SyncServer { + url, + token, + pairing, + commands: command_rx, + live, + advance, + }) +} + +async fn upgrade(upgrade: WebSocketUpgrade, State(state): State>) -> Response { + upgrade.on_upgrade(move |socket| serve_connection(socket, state)) +} + +async fn serve_connection(socket: WebSocket, state: Arc) { + let (mut sink, mut stream) = socket.split(); + let mut wake = state.wake.for_connection(); + let mut connection = Connection::new( + StoreSource::new( + state.store.clone(), + state.live.clone(), + state.commands.clone(), + ), + HostConfig { + host: state.host.clone(), + token: state.token.clone(), + pairing: state.pairing.clone(), + }, + ); + + loop { + let event = tokio::select! { + incoming = stream.next() => ConnectionEvent::Socket(incoming), + wake = wake.next() => ConnectionEvent::Wake(wake), + }; + let outgoing = match event { + ConnectionEvent::Socket(incoming) => { + match incoming { + Some(Ok(Message::Text(text))) => match ClientFrame::decode(&text) { + Ok(frame) => connection.handle(frame), + Err(err) => { + // Undecodable input is a broken or hostile peer. + // There is no frame that means "you sent gibberish" + // and inventing one would only help an attacker + // probe the parser. + log::warn!("sync-host: dropping undecodable frame: {err}"); + break; + } + }, + Some(Ok(Message::Close(_))) | None => break, + // Ping/Pong are handled by axum; binary frames are not part + // of this protocol. + Some(Ok(_)) => Vec::new(), + Some(Err(err)) => { + log::debug!("sync-host: connection closed: {err}"); + break; + } + } + } + ConnectionEvent::Wake(WakeEvent::Session(session_id)) => connection.drain(&session_id), + // A lagged broadcast identifies no particular missed session, so + // cursors are the authority and every subscription is re-drained. + ConnectionEvent::Wake(WakeEvent::AllSubscriptions) => { + drain_all_subscriptions(&mut connection) + } + ConnectionEvent::Wake(WakeEvent::Poll) => wake.drain_changed(&mut connection), + ConnectionEvent::Wake(WakeEvent::Closed) => break, + }; + + for frame in outgoing { + let Ok(text) = frame.encode() else { + log::error!("sync-host: failed to encode a host frame"); + continue; + }; + if sink.send(Message::Text(text.into())).await.is_err() { + return; + } + } + wake.sync_subscriptions(&connection); + + // A refusal is the last thing this connection ever says. + if connection.is_refused() { + let _ = sink.send(Message::Close(None)).await; + return; + } + } +} + +impl WakeFactory { + fn for_connection(&self) -> ConnectionWake { + match self { + Self::Broadcast(advance) => ConnectionWake::Broadcast(advance.subscribe()), + Self::Polling { root, interval } => ConnectionWake::Polling(Box::new(PollWake { + root: root.clone(), + stamps: HashMap::new(), + interval: tokio::time::interval(*interval), + })), + } + } +} + +impl ConnectionWake { + async fn next(&mut self) -> WakeEvent { + match self { + Self::Broadcast(advance) => match advance.recv().await { + Ok(session_id) => WakeEvent::Session(session_id), + Err(tokio::sync::broadcast::error::RecvError::Lagged(missed)) => { + log::debug!("sync-host: connection lagged {missed} notifications"); + WakeEvent::AllSubscriptions + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => WakeEvent::Closed, + }, + Self::Polling(poll) => { + poll.interval.tick().await; + WakeEvent::Poll + } + } + } + + fn drain_changed(&mut self, connection: &mut Connection) -> Vec { + let Self::Polling(poll) = self else { + return Vec::new(); + }; + let session_ids = connection + .subscribed_sessions() + .map(str::to_owned) + .collect::>(); + let mut outgoing = Vec::new(); + for session_id in session_ids { + let current = event_log_stamp(&poll.root, &session_id); + match poll.stamps.insert(session_id.clone(), current) { + Some(previous) if previous != current => { + outgoing.extend(connection.drain(&session_id)); + } + _ => {} + } + } + outgoing + } + + fn sync_subscriptions(&mut self, connection: &Connection) { + let Self::Polling(poll) = self else { + return; + }; + poll.stamps.retain(|session_id, _| { + connection + .subscribed_sessions() + .any(|subscribed| subscribed == session_id) + }); + for session_id in connection.subscribed_sessions() { + poll.stamps + .entry(session_id.to_owned()) + .or_insert_with(|| event_log_stamp(&poll.root, session_id)); + } + } +} + +fn drain_all_subscriptions(connection: &mut Connection) -> Vec { + let sessions = connection + .subscribed_sessions() + .map(str::to_owned) + .collect::>(); + sessions + .iter() + .flat_map(|session_id| connection.drain(session_id)) + .collect() +} + +fn event_log_stamp(root: &Path, session_id: &str) -> Option { + let metadata = std::fs::metadata(root.join(format!("{session_id}.jsonl"))).ok()?; + Some(FileStamp { + len: metadata.len(), + modified: metadata.modified().ok(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn host_info() -> HostInfo { + HostInfo { + host_id: "host-1".into(), + display_name: "workstation".into(), + platform: "test".into(), + app_version: "0.1.0".into(), + } + } + + fn temp_store() -> SessionStore { + let mut root = std::env::temp_dir(); + root.push(format!("tcode-sync-server-test-{}", uuid::Uuid::new_v4())); + SessionStore::open_at(root).expect("temp store") + } + + #[test] + fn start_binds_a_loopback_port_and_uses_the_supplied_token() { + let store = temp_store(); + let server = + start(store.clone(), host_info(), "stable-token".into()).expect("server starts"); + + assert!( + server.url.starts_with("ws://127.0.0.1:"), + "must not bind beyond loopback: {}", + server.url + ); + assert!(server.url.ends_with("/sync")); + assert_eq!(server.token, "stable-token"); + let _ = std::fs::remove_dir_all(store.root()); + } + + #[test] + fn separate_servers_can_share_a_persisted_token() { + let store = temp_store(); + let a = start(store.clone(), host_info(), "stable-token".into()).expect("first"); + let b = start(store.clone(), host_info(), "stable-token".into()).expect("second"); + assert_eq!(a.token, b.token); + assert_ne!(a.url, b.url); + let _ = std::fs::remove_dir_all(store.root()); + } + + #[test] + fn start_on_uses_the_supplied_bind_address() { + let store = temp_store(); + let server = start_on( + store.clone(), + host_info(), + "stable-token".into(), + SocketAddr::from(([127, 0, 0, 1], 0)), + WakeSource::Polling { + interval: Duration::from_millis(250), + }, + ) + .expect("server starts"); + + assert!(server.url.starts_with("ws://127.0.0.1:")); + assert!(server.url.ends_with("/sync")); + let _ = std::fs::remove_dir_all(store.root()); + } + + /// Notifying with nobody connected must not fail or block: the app calls + /// this on every appended event, whether or not a client exists. + #[test] + fn notifying_with_no_subscribers_is_harmless() { + let store = temp_store(); + let server = start(store.clone(), host_info(), "test-token".into()).expect("server starts"); + server.notify_advanced("s1"); + server.notify_advanced("s1"); + let _ = std::fs::remove_dir_all(store.root()); + } +} diff --git a/crates/sync-host/src/store_source.rs b/crates/sync-host/src/store_source.rs new file mode 100644 index 00000000..2449e2ab --- /dev/null +++ b/crates/sync-host/src/store_source.rs @@ -0,0 +1,366 @@ +//! A [`SessionSource`] backed by the on-disk session store. +//! +//! Reads do not go through the running app. `SessionStore` is cheap to clone +//! and safe to share, and the event log is the authoritative record anyway, so +//! the server thread reads it directly — no hop onto the UI thread, no request +//! broker, and nothing that stops working when the UI is busy. It is also what +//! lets a headless server reuse this unchanged: there is no app to ask. +//! +//! Only two things cannot come from disk, and each has its own channel: +//! +//! * **Commands** must reach the live provider actor, which the app owns. They +//! go out on [`CommandSink`]. +//! * **Liveness** — is a turn running, is someone waiting on an approval — is +//! in-memory state the log does not describe. The app publishes it into +//! [`LiveSessions`], which reads see immediately. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; + +use sync_protocol::{CommandRejection, SeqEvent, SessionCommand, SessionSummary}; +use tcode_services::store::SessionStore; + +use crate::SessionSource; + +/// A command on its way from a remote client to the app that owns the session. +#[derive(Debug, Clone)] +pub struct CommandRequest { + pub session_id: String, + pub command: SessionCommand, +} + +/// Where commands go. The app drains the receiving end. +pub type CommandSink = async_channel::Sender; + +/// Per-session state that exists only in the running app. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct LiveFlags { + /// A turn is in flight. + pub working: bool, + /// The session is blocked on a human. The state a phone exists to serve. + pub awaiting_approval: bool, +} + +/// Liveness published by the app and read by the server thread. +/// +/// Absent entries are not an error: a session with no live provider is simply +/// idle, which is exactly what `LiveFlags::default()` says. +#[derive(Clone, Default)] +pub struct LiveSessions(Arc>>); + +impl LiveSessions { + pub fn new() -> Self { + Self::default() + } + + pub fn get(&self, session_id: &str) -> LiveFlags { + self.0 + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(session_id) + .copied() + .unwrap_or_default() + } + + pub fn set(&self, session_id: &str, flags: LiveFlags) { + let mut map = self + .0 + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if flags == LiveFlags::default() { + // Idle is the default, so storing it would only grow the map for + // every session ever opened. + map.remove(session_id); + } else { + map.insert(session_id.to_owned(), flags); + } + } + + /// Forget a session — it ended, was archived, or was deleted. + pub fn clear(&self, session_id: &str) { + self.0 + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .remove(session_id); + } +} + +pub struct StoreSource { + store: SessionStore, + live: LiveSessions, + commands: CommandSink, +} + +impl StoreSource { + pub fn new(store: SessionStore, live: LiveSessions, commands: CommandSink) -> Self { + Self { + store, + live, + commands, + } + } +} + +impl SessionSource for StoreSource { + fn list_sessions(&self) -> Vec { + self.store + .load_index() + .into_iter() + // Archived threads are hidden in the desktop sidebar; a remote + // client should not be shown what the local UI has put away. + .filter(|meta| meta.archived_at.is_none()) + .map(|meta| { + let live = self.live.get(&meta.id); + SessionSummary { + // `latest_seq` is deliberately not computed here. Answering + // it means reading a log, and a host can hold thousands — + // this machine has 1084. The client learns the real head + // when it subscribes. + latest_seq: None, + session_id: meta.id, + title: meta.title, + provider: meta.provider, + model: meta.model, + // The directory name, not the path: a host-absolute path + // means nothing on a phone, and leaks the host's layout to + // every client that can list sessions. + project: meta + .cwd + .file_name() + .map(|name| name.to_string_lossy().into_owned()), + updated_at: meta.updated_at, + working: live.working, + awaiting_approval: live.awaiting_approval, + } + }) + .collect() + } + + fn read_events(&self, session_id: &str, from_seq: Option, limit: usize) -> Vec { + let after = from_seq.unwrap_or(0); + self.store + .read_events(session_id) + .into_iter() + .filter_map(|stored| { + // `read_events` assigns a seq to every line it returns, so the + // filter_map drops nothing in practice. Skipping rather than + // defaulting keeps that an invariant instead of a silent + // renumbering if it ever stops holding. + let seq = stored.seq?; + (seq > after).then(|| SeqEvent { + seq, + ts: stored.ts.unwrap_or_default(), + event: stored.event, + }) + }) + .take(limit) + .collect() + } + + fn session_exists(&self, session_id: &str) -> bool { + self.store + .load_index() + .iter() + .any(|meta| meta.id == session_id) + } + + fn send_command( + &self, + session_id: &str, + command: SessionCommand, + ) -> Result<(), CommandRejection> { + // Non-blocking on purpose: this runs on the server's thread, and + // blocking it on the app would let one busy session stall every other + // client. Whether the session is actually live is the app's answer to + // give, and it arrives out of band. + self.commands + .try_send(CommandRequest { + session_id: session_id.to_owned(), + command, + }) + .map_err(|err| { + log::warn!("dropping command for {session_id}: {err}"); + CommandRejection::SessionNotLive + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agent::{AgentEvent, ProviderKind}; + use std::path::PathBuf; + use tcode_core::project::SessionMeta; + + fn temp_store() -> SessionStore { + let mut root = std::env::temp_dir(); + root.push(format!("tcode-sync-host-test-{}", uuid_like())); + SessionStore::open_at(root).expect("temp store") + } + + /// The crate has no uuid dependency and does not need one for a directory + /// name; process id plus a counter is unique enough within one test run. + fn uuid_like() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + format!( + "{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ) + } + + fn event(n: u64) -> AgentEvent { + AgentEvent::TurnAccepted { delivery_id: n } + } + + fn seeded(store: &SessionStore, cwd: &str, len: u64) -> String { + let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from(cwd), None); + store.upsert_meta(&meta).expect("meta"); + for n in 1..=len { + store + .append_event(&meta.id, 1_000 + n, &event(n)) + .expect("append"); + } + meta.id + } + + fn source(store: SessionStore) -> (StoreSource, async_channel::Receiver) { + let (tx, rx) = async_channel::bounded(8); + (StoreSource::new(store, LiveSessions::new(), tx), rx) + } + + #[test] + fn events_are_read_from_the_cursor_with_their_sequence() { + let store = temp_store(); + let id = seeded(&store, "/work/alpha", 5); + let (source, _rx) = source(store.clone()); + + let all = source.read_events(&id, None, 100); + assert_eq!( + all.iter().map(|e| e.seq).collect::>(), + vec![1, 2, 3, 4, 5] + ); + assert_eq!(all[0].ts, 1_001); + + let tail = source.read_events(&id, Some(3), 100); + assert_eq!(tail.iter().map(|e| e.seq).collect::>(), vec![4, 5]); + + let _ = std::fs::remove_dir_all(store.root()); + } + + #[test] + fn the_limit_bounds_a_batch() { + let store = temp_store(); + let id = seeded(&store, "/work/alpha", 10); + let (source, _rx) = source(store.clone()); + + let batch = source.read_events(&id, None, 4); + assert_eq!( + batch.iter().map(|e| e.seq).collect::>(), + vec![1, 2, 3, 4] + ); + let _ = std::fs::remove_dir_all(store.root()); + } + + /// A host-absolute working directory must not reach a client: it is + /// meaningless on a phone and describes the host's filesystem layout. + #[test] + fn a_summary_carries_the_project_name_not_the_host_path() { + let store = temp_store(); + seeded(&store, "/Users/someone/work/alpha", 1); + let (source, _rx) = source(store.clone()); + + let sessions = source.list_sessions(); + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].project.as_deref(), Some("alpha")); + assert_eq!(sessions[0].latest_seq, None); + + let encoded = serde_json::to_string(&sessions[0]).expect("summary encodes"); + assert!( + !encoded.contains("/Users/someone"), + "host path leaked into a session summary: {encoded}" + ); + let _ = std::fs::remove_dir_all(store.root()); + } + + #[test] + fn archived_sessions_are_not_offered_to_clients() { + let store = temp_store(); + let id = seeded(&store, "/work/alpha", 1); + let mut meta = store + .load_index() + .into_iter() + .find(|m| m.id == id) + .expect("seeded"); + meta.archived_at = Some(42); + store.upsert_meta(&meta).expect("archive"); + + let (source, _rx) = source(store.clone()); + assert!(source.list_sessions().is_empty()); + // Still readable by id: archiving hides a thread, it does not revoke a + // client already streaming it. + assert!(source.session_exists(&id)); + let _ = std::fs::remove_dir_all(store.root()); + } + + #[test] + fn liveness_comes_from_the_app_not_the_log() { + let store = temp_store(); + let id = seeded(&store, "/work/alpha", 1); + let (tx, _rx) = async_channel::bounded(8); + let live = LiveSessions::new(); + let source = StoreSource::new(store.clone(), live.clone(), tx); + + assert!(!source.list_sessions()[0].working); + + live.set( + &id, + LiveFlags { + working: true, + awaiting_approval: true, + }, + ); + let summary = &source.list_sessions()[0]; + assert!(summary.working); + assert!(summary.awaiting_approval); + + live.clear(&id); + assert!(!source.list_sessions()[0].awaiting_approval); + let _ = std::fs::remove_dir_all(store.root()); + } + + #[test] + fn a_command_is_handed_to_the_app() { + let store = temp_store(); + let id = seeded(&store, "/work/alpha", 1); + let (source, rx) = source(store.clone()); + + source + .send_command(&id, SessionCommand::Interrupt) + .expect("queued"); + let request = rx.try_recv().expect("the app receives it"); + assert_eq!(request.session_id, id); + assert_eq!(request.command, SessionCommand::Interrupt); + let _ = std::fs::remove_dir_all(store.root()); + } + + /// Sending must never block the server thread: one wedged consumer would + /// otherwise stall every connected client. + #[test] + fn a_full_command_channel_is_rejected_rather_than_waited_on() { + let store = temp_store(); + let id = seeded(&store, "/work/alpha", 1); + let (tx, _rx) = async_channel::bounded(1); + let source = StoreSource::new(store.clone(), LiveSessions::new(), tx); + + source + .send_command(&id, SessionCommand::Interrupt) + .expect("first fits"); + assert_eq!( + source.send_command(&id, SessionCommand::Interrupt), + Err(CommandRejection::SessionNotLive) + ); + let _ = std::fs::remove_dir_all(store.root()); + } +} diff --git a/crates/sync-host/tests/websocket_end_to_end.rs b/crates/sync-host/tests/websocket_end_to_end.rs new file mode 100644 index 00000000..ca30fc3f --- /dev/null +++ b/crates/sync-host/tests/websocket_end_to_end.rs @@ -0,0 +1,295 @@ +//! End-to-end over a real WebSocket. +//! +//! The unit tests cover the state machine without a socket, which is where the +//! protocol rules belong. This file covers the part they cannot: that a client +//! can actually reach the host, that the token is enforced on the wire, and +//! that an event appended after a client subscribed reaches it live. +//! +//! That last property is the one worth a real socket. It spans the store, the +//! sequence numbers, the advance broadcast, and the cursor — every piece has to +//! agree, and a fake transport would let them disagree quietly. + +use std::path::PathBuf; +use std::time::Duration; + +use agent::{AgentEvent, ProviderKind}; +use futures_util::{SinkExt as _, StreamExt as _}; +use sync_host::{SyncServer, start}; +use sync_protocol::{ClientFrame, ClientInfo, HostFrame, HostInfo, RefuseReason}; +use tcode_core::project::SessionMeta; +use tcode_services::store::SessionStore; +use tokio_tungstenite::tungstenite::Message; + +/// Long enough that a loaded machine does not fail the test, short enough that +/// a genuine hang is still a fast failure rather than a stuck suite. +const REPLY_TIMEOUT: Duration = Duration::from_secs(5); + +type Socket = + tokio_tungstenite::WebSocketStream>; + +fn temp_store() -> SessionStore { + let mut root = std::env::temp_dir(); + root.push(format!("tcode-sync-e2e-{}", uuid::Uuid::new_v4())); + SessionStore::open_at(root).expect("temp store") +} + +fn host_info() -> HostInfo { + HostInfo { + host_id: "host-1".into(), + display_name: "workstation".into(), + platform: "test".into(), + app_version: "0.1.0".into(), + } +} + +fn client_info() -> ClientInfo { + ClientInfo { + client_id: "client-1".into(), + display_name: "Pixel".into(), + platform: "android".into(), + app_version: "0.1.0".into(), + } +} + +fn event(n: u64) -> AgentEvent { + AgentEvent::TurnAccepted { delivery_id: n } +} + +fn start_server(store: SessionStore) -> SyncServer { + start(store, host_info(), "correct-horse".into()).expect("server starts") +} + +/// A session with `len` events already logged. +fn seed(store: &SessionStore, len: u64) -> String { + let meta = SessionMeta::new(ProviderKind::Codex, PathBuf::from("/work/alpha"), None); + store.upsert_meta(&meta).expect("meta"); + for n in 1..=len { + store + .append_event(&meta.id, 1_000 + n, &event(n)) + .expect("append"); + } + meta.id +} + +async fn connect(server: &SyncServer) -> Socket { + let (socket, _) = tokio_tungstenite::connect_async(&server.url) + .await + .expect("client connects"); + socket +} + +async fn send(socket: &mut Socket, frame: ClientFrame) { + socket + .send(Message::Text(frame.encode().expect("encodes").into())) + .await + .expect("client sends"); +} + +/// Next host frame, or a failure naming what we were waiting for. +async fn recv(socket: &mut Socket, waiting_for: &str) -> HostFrame { + let message = tokio::time::timeout(REPLY_TIMEOUT, socket.next()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {waiting_for}")) + .unwrap_or_else(|| panic!("socket closed while waiting for {waiting_for}")) + .expect("socket error"); + match message { + Message::Text(text) => HostFrame::decode(&text).expect("host frame decodes"), + other => panic!("expected a text frame while waiting for {waiting_for}, got {other:?}"), + } +} + +fn hello(token: &str) -> ClientFrame { + ClientFrame::Hello { + min_version: sync_protocol::PROTOCOL_MIN_VERSION, + max_version: sync_protocol::PROTOCOL_MAX_VERSION, + client: client_info(), + token: token.into(), + } +} + +fn seqs(frame: &HostFrame) -> Vec { + match frame { + HostFrame::Events { events, .. } => events.iter().map(|event| event.seq).collect(), + other => panic!("expected events, got {other:?}"), + } +} + +#[tokio::test] +async fn a_client_handshakes_backfills_and_receives_a_live_event() { + let store = temp_store(); + let session_id = seed(&store, 3); + let server = start_server(store.clone()); + + let mut socket = connect(&server).await; + + send(&mut socket, hello(&server.token)).await; + match recv(&mut socket, "welcome").await { + HostFrame::Welcome { version, host } => { + assert_eq!(version, sync_protocol::PROTOCOL_MAX_VERSION); + assert_eq!(host.host_id, "host-1"); + } + other => panic!("expected a welcome, got {other:?}"), + } + + send(&mut socket, ClientFrame::ListSessions).await; + match recv(&mut socket, "session list").await { + HostFrame::SessionList { sessions } => { + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, session_id); + // The host's working directory must not travel. + assert_eq!(sessions[0].project.as_deref(), Some("alpha")); + } + other => panic!("expected a session list, got {other:?}"), + } + + // Backfill from scratch. + send( + &mut socket, + ClientFrame::Subscribe { + session_id: session_id.clone(), + from_seq: None, + }, + ) + .await; + let backfill = recv(&mut socket, "backfill").await; + assert_eq!(seqs(&backfill), vec![1, 2, 3]); + assert!(matches!( + backfill, + HostFrame::Events { + caught_up: true, + .. + } + )); + + // The live path: append, notify, and the subscriber gets only what is new. + let seq = store + .append_event(&session_id, 2_000, &event(4)) + .expect("append"); + assert_eq!(seq, 4, "the store continues the sequence"); + server.notify_advanced(&session_id); + + let live = recv(&mut socket, "the live event").await; + assert_eq!( + seqs(&live), + vec![4], + "a live event must not repeat backfill" + ); + + let _ = std::fs::remove_dir_all(store.root()); +} + +/// The cursor is what makes a dropped mobile connection cheap: reconnecting +/// costs exactly the events that were missed, not the whole log. +#[tokio::test] +async fn reconnecting_with_a_cursor_replays_only_the_gap() { + let store = temp_store(); + let session_id = seed(&store, 5); + let server = start_server(store.clone()); + + let mut socket = connect(&server).await; + send(&mut socket, hello(&server.token)).await; + recv(&mut socket, "welcome").await; + drop(socket); + + let mut socket = connect(&server).await; + send(&mut socket, hello(&server.token)).await; + recv(&mut socket, "welcome").await; + send( + &mut socket, + ClientFrame::Subscribe { + session_id: session_id.clone(), + from_seq: Some(3), + }, + ) + .await; + assert_eq!(seqs(&recv(&mut socket, "the gap").await), vec![4, 5]); + + let _ = std::fs::remove_dir_all(store.root()); +} + +/// The token must be enforced by the server that actually runs, not only by the +/// state machine in isolation. +#[tokio::test] +async fn a_wrong_token_is_refused_and_the_socket_closes() { + let store = temp_store(); + seed(&store, 1); + let server = start_server(store.clone()); + + let mut socket = connect(&server).await; + send(&mut socket, hello("not-the-token")).await; + + match recv(&mut socket, "refusal").await { + HostFrame::Refused { + reason: RefuseReason::Unauthorized, + } => {} + other => panic!("expected an unauthorized refusal, got {other:?}"), + } + + // The host hangs up rather than leaving a rejected peer holding a socket. + let closed = tokio::time::timeout(REPLY_TIMEOUT, socket.next()) + .await + .expect("host must close the socket"); + assert!( + matches!(closed, None | Some(Ok(Message::Close(_))) | Some(Err(_))), + "expected a close, got {closed:?}" + ); + + let _ = std::fs::remove_dir_all(store.root()); +} + +/// A client that never authenticates must not be able to read anything. +#[tokio::test] +async fn subscribing_without_a_handshake_is_refused() { + let store = temp_store(); + let session_id = seed(&store, 3); + let server = start_server(store.clone()); + + let mut socket = connect(&server).await; + send( + &mut socket, + ClientFrame::Subscribe { + session_id, + from_seq: None, + }, + ) + .await; + + match recv(&mut socket, "refusal").await { + HostFrame::Refused { + reason: RefuseReason::Unauthorized, + } => {} + other => panic!("expected a refusal, got {other:?}"), + } + + let _ = std::fs::remove_dir_all(store.root()); +} + +/// A command from a client must arrive on the channel the app drains, with its +/// session id intact. +#[tokio::test] +async fn a_command_reaches_the_apps_channel() { + let store = temp_store(); + let session_id = seed(&store, 1); + let server = start_server(store.clone()); + + let mut socket = connect(&server).await; + send(&mut socket, hello(&server.token)).await; + recv(&mut socket, "welcome").await; + send( + &mut socket, + ClientFrame::Command { + session_id: session_id.clone(), + command: sync_protocol::SessionCommand::Interrupt, + }, + ) + .await; + + let request = tokio::time::timeout(REPLY_TIMEOUT, server.commands.recv()) + .await + .expect("the app must receive the command") + .expect("channel open"); + assert_eq!(request.session_id, session_id); + assert_eq!(request.command, sync_protocol::SessionCommand::Interrupt); + + let _ = std::fs::remove_dir_all(store.root()); +} diff --git a/crates/sync-protocol/Cargo.toml b/crates/sync-protocol/Cargo.toml new file mode 100644 index 00000000..bc89a0ae --- /dev/null +++ b/crates/sync-protocol/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "sync-protocol" +version = "0.1.0" +edition = "2024" + +[dependencies] +# Types only. The adapters that spawn provider processes live behind agent's +# `local` feature, which this crate must not enable: it has to build for +# wasm32-unknown-unknown, where there are no processes to spawn. +agent = { path = "../agent", default-features = false } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/sync-protocol/src/lib.rs b/crates/sync-protocol/src/lib.rs new file mode 100644 index 00000000..540f392c --- /dev/null +++ b/crates/sync-protocol/src/lib.rs @@ -0,0 +1,525 @@ +//! Wire types for the tcode host/client sync protocol. +//! +//! A **host** is a machine that can actually run agents: it spawns provider +//! CLIs, owns the repository, and holds the session log. A **client** is a +//! phone, browser, tablet, or another desktop that cannot — iOS forbids +//! fork/exec and wasm has no process model at all — and therefore drives a +//! host's sessions remotely. +//! +//! See `docs/sync-protocol.md` for the reasoning; this crate is normative. +//! +//! Types and serde only: no I/O, no transport, no async. That keeps the crate +//! buildable for `wasm32-unknown-unknown` and lets the same frames travel over +//! a WebSocket, a unix socket, or a test channel. + +use serde::{Deserialize, Serialize}; + +// `ApprovalDecision` is protocol vocabulary, not an implementation detail: +// `SessionCommand::RespondApproval` carries it, so a client cannot answer an +// approval without naming it. +pub use agent::{AgentEvent, ApprovalDecision, ProviderKind, SessionCommand, WorkspacePath}; + +/// Oldest protocol version this build can speak. +pub const PROTOCOL_MIN_VERSION: u32 = 1; +/// Newest protocol version this build can speak. +pub const PROTOCOL_MAX_VERSION: u32 = 1; + +/// Highest version both peers support, or `None` when the ranges are disjoint. +/// +/// Peers exchange *ranges* rather than a single version because host and client +/// ship separately — an App Store release cannot be upgraded in lockstep with +/// the desktop app, so a protocol that demands equality is a protocol that +/// breaks on every release. +pub fn negotiate_version(client_min: u32, client_max: u32) -> Option { + let low = client_min.max(PROTOCOL_MIN_VERSION); + let high = client_max.min(PROTOCOL_MAX_VERSION); + (low <= high).then_some(high) +} + +/// One event as it crosses the wire: the session's canonical event plus the +/// position that makes resumption exact. +/// +/// Not `PartialEq`: [`AgentEvent`] deliberately is not either, because it +/// carries `ResumeCursor` — opaque provider-owned JSON for which structural +/// equality would be a claim we cannot honour. Compare encoded forms instead; +/// for a wire type that is the equality that actually matters. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SeqEvent { + /// Position in the session's log: 1-based, contiguous, strictly increasing. + /// + /// This — never `ts` — is what a cursor tracks. `ts` is a wall clock the + /// writer tolerates repeating or going backwards, so a `ts` cursor would + /// skip or duplicate events at exactly the moments that matter. + pub seq: u64, + /// Unix milliseconds, for display only. + pub ts: u64, + pub event: AgentEvent, +} + +/// What a client tells a host about itself. Diagnostics and compatibility +/// warnings; the host must not make authorization decisions on it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ClientInfo { + /// Stable per-installation id, so a host can recognise a returning client + /// across reconnects. + pub client_id: String, + /// Human-readable, for the host's "connected devices" list. + pub display_name: String, + /// e.g. `ios`, `android`, `web`, `macos`. + pub platform: String, + /// Client build version, for support and for telling a user which side is + /// too old. + pub app_version: String, +} + +/// What a host tells a client about itself, once the handshake is accepted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct HostInfo { + pub host_id: String, + pub display_name: String, + pub platform: String, + pub app_version: String, +} + +/// Enough of a session for a client to render a list and pick one. +/// +/// Deliberately its own type rather than the stored `SessionMeta`: the wire +/// format and the on-disk format have different reasons to change, and a client +/// has no use for host-local fields such as absolute working directories. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionSummary { + pub session_id: String, + pub title: String, + pub provider: ProviderKind, + pub model: Option, + /// Project display name. Not a path: a host-absolute path means nothing on + /// a phone (`docs/multiplatform-plan.md` §3.4). + pub project: Option, + /// Unix milliseconds of the most recent activity, for sorting. + pub updated_at: u64, + /// Highest `seq` the host currently holds, when it happens to know. + /// + /// Best-effort and therefore optional: answering it for every session + /// would mean scanning every log on every list, and a host can hold + /// thousands. `None` means "not computed", not "empty" — a client uses it + /// to size a backfill or badge unread counts, and simply omits the badge + /// when it is absent. + pub latest_seq: Option, + /// True while a turn is in flight. + pub working: bool, + /// True when the session is waiting on a human — the state a phone exists + /// to serve, and the one worth a push notification. + pub awaiting_approval: bool, +} + +/// Why a handshake was refused. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RefuseReason { + /// No overlap between the peers' supported ranges. Carries the host's range + /// so the client can say which side needs upgrading instead of "failed". + UnsupportedVersion { host_min: u32, host_max: u32 }, + /// Missing, malformed, expired, or revoked token. + Unauthorized, + /// The host is shutting down or already serving its connection limit. + Unavailable { detail: String }, + /// The pairing code was wrong, already used, or has expired. + /// + /// Deliberately one reason for all three: distinguishing them would let an + /// attacker learn that a guessed code was *once* valid, which is most of + /// what they need to know. + PairingRejected, +} + +/// Why the host could not apply a command. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CommandRejection { + UnknownSession, + /// The session exists but has no running provider, so there is nothing to + /// send the command to. + SessionNotLive, + /// The provider does not support this command (steering, rewind, and live + /// mode switches are not universal). + Unsupported { + detail: String, + }, + /// The token does not grant this session. + Forbidden, +} + +/// The command identity needed to reconcile a rejection with optimistic state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum RejectedCommand { + Turn { delivery_id: u64 }, + Approval { request_id: String }, +} + +/// Why a session stopped producing events. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SessionEndReason { + /// The provider process exited. The session's log remains readable. + ProviderExited { + detail: String, + }, + Archived, + Deleted, +} + +/// Client to host. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum ClientFrame { + /// Exchange a short-lived pairing code for a durable token. + /// + /// The one frame a client may send before `Hello`, because it is how a + /// client that has no token gets one. A phone cannot be handed a + /// credential the way a query string hands one to a browser, and asking + /// someone to type a UUID off another screen is not a pairing flow. + Pair { + min_version: u32, + max_version: u32, + code: String, + client: ClientInfo, + }, + /// Must be the first frame; a host rejects anything else before it. + Hello { + min_version: u32, + max_version: u32, + client: ClientInfo, + token: String, + }, + ListSessions, + /// Stream a session from `from_seq` exclusive — `Some(n)` means "I hold + /// through n, send n+1 onward". `None` means from the beginning. + Subscribe { + session_id: String, + from_seq: Option, + }, + /// Stop streaming. The session keeps running on the host. + Unsubscribe { + session_id: String, + }, + Command { + session_id: String, + command: SessionCommand, + }, + /// Liveness. Mobile networks drop idle connections without closing them. + Ping { + nonce: u64, + }, +} + +/// Host to client. +/// +/// Not `PartialEq` — see [`SeqEvent`], which it carries. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +pub enum HostFrame { + /// Handshake accepted. `version` is the agreed one, which both peers use + /// from here on. + Welcome { + version: u32, + host: HostInfo, + }, + Refused { + reason: RefuseReason, + }, + /// A pairing code was accepted. The token is durable: a client stores it and + /// never needs the code again. + /// + /// The connection is *not* authenticated by this — the client still sends + /// `Hello` with the new token. Keeping the two steps separate means pairing + /// has exactly one job, and a stolen `Paired` frame is worth no more than + /// the token it carries. + Paired { + version: u32, + token: String, + }, + SessionList { + sessions: Vec, + }, + /// A batch of events, ascending and contiguous by `seq`. + /// + /// `caught_up` is false while the host is replaying backlog and true once + /// the client is live. Clients use it to hold off rendering "waiting for + /// the agent" states during a long replay. + Events { + session_id: String, + events: Vec, + caught_up: bool, + }, + /// A command could not be applied. Sent even though the client cannot + /// usually recover, because silence would leave it rendering a turn that + /// never ran. + CommandRejected { + session_id: String, + /// Older hosts omit this, so clients must retain a send-order fallback. + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, + reason: CommandRejection, + }, + SessionEnded { + session_id: String, + reason: SessionEndReason, + }, + Pong { + nonce: u64, + }, +} + +macro_rules! json_codec { + ($ty:ty) => { + impl $ty { + /// Encode for a WebSocket text frame. + pub fn encode(&self) -> Result { + serde_json::to_string(self) + } + + /// Decode one received frame. + pub fn decode(text: &str) -> Result { + serde_json::from_str(text) + } + } + }; +} + +json_codec!(ClientFrame); +json_codec!(HostFrame); + +#[cfg(test)] +mod tests { + use super::*; + + fn client_info() -> ClientInfo { + ClientInfo { + client_id: "client-1".into(), + display_name: "Pixel".into(), + platform: "android".into(), + app_version: "0.1.0".into(), + } + } + + fn every_client_frame() -> Vec { + vec![ + ClientFrame::Hello { + min_version: PROTOCOL_MIN_VERSION, + max_version: PROTOCOL_MAX_VERSION, + client: client_info(), + token: "secret".into(), + }, + ClientFrame::Pair { + min_version: PROTOCOL_MIN_VERSION, + max_version: PROTOCOL_MAX_VERSION, + code: "K7M2QX".into(), + client: client_info(), + }, + ClientFrame::ListSessions, + ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: Some(42), + }, + ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + }, + ClientFrame::Unsubscribe { + session_id: "s1".into(), + }, + ClientFrame::Command { + session_id: "s1".into(), + command: SessionCommand::Interrupt, + }, + ClientFrame::Ping { nonce: 7 }, + ] + } + + fn every_host_frame() -> Vec { + vec![ + HostFrame::Welcome { + version: PROTOCOL_MAX_VERSION, + host: HostInfo { + host_id: "host-1".into(), + display_name: "workstation".into(), + platform: "macos".into(), + app_version: "0.1.0".into(), + }, + }, + HostFrame::Refused { + reason: RefuseReason::UnsupportedVersion { + host_min: 2, + host_max: 3, + }, + }, + HostFrame::Refused { + reason: RefuseReason::Unauthorized, + }, + HostFrame::Refused { + reason: RefuseReason::PairingRejected, + }, + HostFrame::Paired { + version: PROTOCOL_MAX_VERSION, + token: "durable-token".into(), + }, + HostFrame::SessionList { + sessions: vec![SessionSummary { + session_id: "s1".into(), + title: "Fix the parser".into(), + provider: ProviderKind::Codex, + model: Some("gpt-5".into()), + project: Some("tcode".into()), + updated_at: 1_700_000_000_000, + latest_seq: Some(128), + working: true, + awaiting_approval: false, + }], + }, + HostFrame::Events { + session_id: "s1".into(), + events: vec![SeqEvent { + seq: 1, + ts: 1_700_000_000_000, + event: AgentEvent::TurnAccepted { delivery_id: 9 }, + }], + caught_up: true, + }, + HostFrame::CommandRejected { + session_id: "s1".into(), + command: Some(RejectedCommand::Approval { + request_id: "approval-1".into(), + }), + reason: CommandRejection::SessionNotLive, + }, + HostFrame::SessionEnded { + session_id: "s1".into(), + reason: SessionEndReason::Archived, + }, + HostFrame::Pong { nonce: 7 }, + ] + } + + #[test] + fn client_frames_round_trip() { + for frame in every_client_frame() { + let text = frame.encode().expect("frame must encode"); + let back = ClientFrame::decode(&text) + .unwrap_or_else(|err| panic!("{frame:?} must decode from {text}: {err}")); + assert_eq!(back, frame); + } + } + + /// Compares canonical encodings because `HostFrame` is not `PartialEq` + /// (see its doc comment). Re-encoding what we decoded proves the decode + /// lost nothing, which is the property a wire type owes its peer. + #[test] + fn host_frames_round_trip() { + for frame in every_host_frame() { + let text = frame.encode().expect("frame must encode"); + let reencoded = HostFrame::decode(&text) + .unwrap_or_else(|err| panic!("{frame:?} must decode from {text}: {err}")) + .encode() + .expect("a decoded frame must re-encode"); + assert_eq!(reencoded, text, "round trip changed {frame:?}"); + } + } + + #[test] + fn command_rejection_correlation_is_backward_compatible() { + for command in [ + Some(RejectedCommand::Turn { delivery_id: 9 }), + Some(RejectedCommand::Approval { + request_id: "approval-1".into(), + }), + None, + ] { + let frame = HostFrame::CommandRejected { + session_id: "s1".into(), + command, + reason: CommandRejection::SessionNotLive, + }; + let text = frame.encode().expect("rejection must encode"); + let reencoded = HostFrame::decode(&text) + .expect("rejection must decode") + .encode() + .expect("decoded rejection must encode"); + assert_eq!(reencoded, text); + } + + let older_host = r#"{"type":"command_rejected","data":{"session_id":"s1","reason":{"kind":"session_not_live"}}}"#; + match HostFrame::decode(older_host).expect("older-host rejection must decode") { + HostFrame::CommandRejected { command, .. } => assert_eq!(command, None), + other => panic!("decoded as {other:?}"), + } + } + + /// `from_seq: None` (start from the beginning) and `from_seq: Some(0)` are + /// different requests, and JSON null must not collapse them — a client + /// resuming from nothing would otherwise be indistinguishable from one + /// asking for a full backfill. + #[test] + fn absent_cursor_survives_encoding() { + let text = ClientFrame::Subscribe { + session_id: "s1".into(), + from_seq: None, + } + .encode() + .unwrap(); + match ClientFrame::decode(&text).unwrap() { + ClientFrame::Subscribe { from_seq, .. } => assert_eq!(from_seq, None), + other => panic!("decoded as {other:?}"), + } + } + + #[test] + fn version_negotiation_picks_the_highest_shared_version() { + // Exactly our range. + assert_eq!( + negotiate_version(PROTOCOL_MIN_VERSION, PROTOCOL_MAX_VERSION), + Some(PROTOCOL_MAX_VERSION) + ); + // A newer client that still speaks our version: meet at ours. + assert_eq!( + negotiate_version(PROTOCOL_MIN_VERSION, PROTOCOL_MAX_VERSION + 5), + Some(PROTOCOL_MAX_VERSION) + ); + // A future-only client. + assert_eq!( + negotiate_version(PROTOCOL_MAX_VERSION + 1, PROTOCOL_MAX_VERSION + 5), + None + ); + // A client too old to speak anything we still support. + assert_eq!(negotiate_version(0, PROTOCOL_MIN_VERSION - 1), None); + } + + /// A refusal must be actionable: the host's range travels with it so the + /// client can tell the user which side is out of date. + #[test] + fn version_refusal_carries_the_hosts_range() { + let text = HostFrame::Refused { + reason: RefuseReason::UnsupportedVersion { + host_min: 4, + host_max: 7, + }, + } + .encode() + .unwrap(); + assert!(text.contains("\"host_min\":4"), "{text}"); + assert!(text.contains("\"host_max\":7"), "{text}"); + } + + /// The frame carrying a `SessionCommand` must survive the enum-in-enum + /// nesting. `SessionCommand` is adjacently tagged for its own reasons + /// (newtype variants wrapping string-serializing enums), and nesting two + /// tagged enums is exactly where serde representations tend to break. + #[test] + fn commands_survive_nesting_inside_a_frame() { + let frame = ClientFrame::Command { + session_id: "s1".into(), + command: SessionCommand::SetInteractionMode(agent::InteractionMode::Plan), + }; + let text = frame.encode().unwrap(); + assert_eq!(ClientFrame::decode(&text).unwrap(), frame); + } +} diff --git a/crates/tcode-android/Cargo.toml b/crates/tcode-android/Cargo.toml new file mode 100644 index 00000000..7b9df824 --- /dev/null +++ b/crates/tcode-android/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "tcode-android" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +# `cdylib` is what Gradle packages into the APK and what `System.loadLibrary` +# opens. `rlib` keeps the crate testable from the host workspace. +crate-type = ["cdylib", "rlib"] + +# Android-only: the JNI bridge and the platform backend it drives cannot exist +# on a host target, and the workspace build must stay green on desktop. +[target.'cfg(target_os = "android")'.dependencies] +gpui = { git = "https://github.com/zed-industries/zed" } +gpui-android = { path = "../gpui-android" } +anyhow = "1" +client-app = { path = "../client-app" } +jni = "0.21" +log = "0.4" +android_logger = "0.15" +ndk = "0.9" +raw-window-handle = "0.6" diff --git a/crates/tcode-android/README.md b/crates/tcode-android/README.md new file mode 100644 index 00000000..a976e1ad --- /dev/null +++ b/crates/tcode-android/README.md @@ -0,0 +1,64 @@ +# tcode-android + +The Android app shell: a JNI bridge that drives the `gpui-android` platform +backend, plus the minimal Activity needed to package it. + +The split from `gpui-android` is deliberate. That crate owns the GPUI `Platform` +implementation and knows nothing about Java; this one owns Java and knows +nothing about GPUI's internals. Keeping them apart is what lets the backend be +offered upstream on its own. + +## Building + +An Android C toolchain is required even for `cargo check`, because +`gpui → stacksafe → psm` assembles AArch64 while dependencies are checked. +Verified with Homebrew's NDK 29.0.14206865 and API 26: + +```sh +export ANDROID_NDK_HOME=/opt/homebrew/share/android-ndk +cargo ndk -t arm64-v8a --platform 26 build -p tcode-android +``` + +Note `cargo ndk`'s `-p` is `--package`, not the platform level; the API level is +`--platform`. + +The produced `libtcode_android.so` is what Gradle packages and what +`System.loadLibrary("tcode_android")` opens. + +## What is verified + +The shared library **links** for `aarch64-linux-android`, which a `cargo check` +never establishes — it means the Android linker resolved every symbol, including +those `gpui_wgpu`, `wgpu` and `psm` require. All ten JNI entry points are +exported and confirmed present in the ELF dynamic symbol table. + +## What is not verified + +Nothing has run on a device. There is no emulator or phone in the environment +this was built in, so the first execution is unexercised. The most likely first +failure is `WgpuRenderer::new`: raw-handle validity, Vulkan adapter and device +selection, and surface capabilities have never met real hardware. + +## Constants shared with Kotlin + +`touch_phase` and `lifecycle_phase` in `src/entry.rs` mirror the integer +constants in `TcodeActivity`. They are translated at the boundary on purpose — +an unrecognised value is rejected there rather than becoming some other phase +deeper in, where a dropped `Ended` would leave a pointer stuck down forever. +The two lists must be edited together. + +## Deliberately unimplemented + +These return honest errors rather than pretending to succeed: + +- **Credential storage** needs an Android Keystore-backed host service. A store + that silently discards secrets is worse than one that reports itself absent, + because the caller would believe the secret was saved. +- **`open_path`** needs a FileProvider `content://` URI; Android cannot hand a + raw filesystem path to another app. +- **IME positioning and text-input state** need an InputConnection reporting + caret bounds through `CursorAnchorInfo`. Until then the platform IME places + itself. +- **Thermal state** reports nominal. Android exposes this as a PowerManager + subscription rather than a query, and guessing would make GPUI throttle for + no reason. diff --git a/crates/tcode-android/android/app/src/main/AndroidManifest.xml b/crates/tcode-android/android/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..566144d9 --- /dev/null +++ b/crates/tcode-android/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + diff --git a/crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt b/crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt new file mode 100644 index 00000000..9242009a --- /dev/null +++ b/crates/tcode-android/android/app/src/main/java/com/tryanks/tcode/TcodeActivity.kt @@ -0,0 +1,212 @@ +package com.tryanks.tcode + +import android.app.Activity +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.graphics.PixelFormat +import android.net.Uri +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.view.Choreographer +import android.view.MotionEvent +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.inputmethod.InputMethodManager + +/** + * Host side of the JNI boundary described in `crates/gpui-android/README.md`. + * + * Every native call below happens on the main Looper thread. That is not + * incidental: the Rust event sink is `!Send`, and the surface lifecycle contract + * requires create/destroy/frame to be serialized against each other. + */ +class TcodeActivity : Activity(), SurfaceHolder.Callback2 { + + companion object { + init { + System.loadLibrary("tcode_android") + } + + // Must match `touch_phase` in crates/tcode-android/src/entry.rs. The + // Rust side rejects unknown values rather than guessing, so these two + // lists have to be kept together. + private const val TOUCH_STARTED = 0 + private const val TOUCH_MOVED = 1 + private const val TOUCH_ENDED = 2 + private const val TOUCH_CANCELLED = 3 + + // Must match `lifecycle_phase` in the same file. + private const val LIFECYCLE_ACTIVE = 0 + private const val LIFECYCLE_INACTIVE = 1 + private const val LIFECYCLE_BACKGROUND = 2 + private const val LIFECYCLE_FOREGROUND = 3 + } + + private lateinit var surfaceView: SurfaceView + private val handler = Handler(Looper.getMainLooper()) + private var started = false + private var backEnabled = false + + private external fun nativeStart(surface: Surface, width: Int, height: Int, density: Float) + private external fun nativeSurfaceCreated(surface: Surface, width: Int, height: Int, density: Float) + private external fun nativeSurfaceDestroyed() + private external fun nativeResized(width: Int, height: Int, density: Float) + private external fun nativeFrame() + private external fun nativeDrainMainThread() + private external fun nativeTouch(pointerId: Long, phase: Int, x: Float, y: Float, pressure: Float) + private external fun nativeLifecycle(phase: Int) + private external fun nativeBackPressed() + private external fun nativeMemoryWarning() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + surfaceView = SurfaceView(this) + surfaceView.holder.setFormat(PixelFormat.RGBA_8888) + surfaceView.holder.addCallback(this) + setContentView(surfaceView) + } + + // -- surface lifecycle --------------------------------------------------- + + override fun surfaceCreated(holder: SurfaceHolder) { + // Dimensions arrive with surfaceChanged; nothing to report yet. + } + + override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) { + val density = resources.displayMetrics.density + if (!started) { + // The first surface must stay valid across GPUI's launch. + nativeStart(holder.surface, width, height, density) + started = true + requestFrame() + } else { + nativeSurfaceCreated(holder.surface, width, height, density) + nativeResized(width, height, density) + } + } + + override fun surfaceDestroyed(holder: SurfaceHolder) { + // Must precede Android releasing the surface: the Rust side unconfigures + // rendering and holds the retired ANativeWindow lease until a + // replacement succeeds, so wgpu cannot outlive the window. + nativeSurfaceDestroyed() + } + + override fun surfaceRedrawNeeded(holder: SurfaceHolder) { + nativeFrame() + } + + // -- frame and main-thread pumping -------------------------------------- + + /** Called from Rust (`AndroidHost::request_frame`), already coalesced there. */ + fun requestFrame() { + Choreographer.getInstance().postFrameCallback { + nativeFrame() + } + } + + /** Called from Rust worker threads (`AndroidHost::wake_main_thread`). */ + fun wakeMainThread() { + handler.post { nativeDrainMainThread() } + } + + // -- input -------------------------------------------------------------- + + override fun onTouchEvent(event: MotionEvent): Boolean { + val phase = when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> TOUCH_STARTED + MotionEvent.ACTION_MOVE -> TOUCH_MOVED + MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> TOUCH_ENDED + MotionEvent.ACTION_CANCEL -> TOUCH_CANCELLED + else -> return false + } + // ACTION_MOVE batches every active pointer into one event, so all of + // them must be forwarded or a multi-touch gesture loses fingers. + for (index in 0 until event.pointerCount) { + nativeTouch( + event.getPointerId(index).toLong(), + phase, + event.getX(index), + event.getY(index), + event.getPressure(index), + ) + } + return true + } + + override fun onBackPressed() { + if (backEnabled) { + nativeBackPressed() + } else { + super.onBackPressed() + } + } + + // -- lifecycle ---------------------------------------------------------- + + override fun onResume() { + super.onResume() + nativeLifecycle(LIFECYCLE_ACTIVE) + } + + override fun onPause() { + super.onPause() + nativeLifecycle(LIFECYCLE_INACTIVE) + } + + override fun onStart() { + super.onStart() + nativeLifecycle(LIFECYCLE_FOREGROUND) + } + + override fun onStop() { + super.onStop() + nativeLifecycle(LIFECYCLE_BACKGROUND) + } + + override fun onTrimMemory(level: Int) { + super.onTrimMemory(level) + nativeMemoryWarning() + } + + // -- AndroidHost operations, called from Rust --------------------------- + + fun finishActivity() = runOnUiThread { finish() } + + fun openUri(uri: String) { + startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(uri))) + } + + fun readClipboardText(): String? { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + return clipboard.primaryClip?.takeIf { it.itemCount > 0 }?.getItemAt(0)?.text?.toString() + } + + fun writeClipboardText(text: String) { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(null, text)) + } + + fun showSoftKeyboard() = runOnUiThread { + val ime = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + ime.showSoftInput(surfaceView, 0) + } + + fun hideSoftKeyboard() = runOnUiThread { + val ime = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + ime.hideSoftInputFromWindow(surfaceView.windowToken, 0) + } + + fun setBackEnabled(enabled: Boolean) { + backEnabled = enabled + } + + fun isDarkMode(): Boolean = + (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == + Configuration.UI_MODE_NIGHT_YES +} diff --git a/crates/tcode-android/src/bridge.rs b/crates/tcode-android/src/bridge.rs new file mode 100644 index 00000000..076d2495 --- /dev/null +++ b/crates/tcode-android/src/bridge.rs @@ -0,0 +1,258 @@ +//! `AndroidHost` implemented over JNI. +//! +//! The one structural rule, from `crates/gpui-android/README.md`: hold a +//! `JavaVM` and global references, never a thread-local `JNIEnv`. GPUI calls +//! `wake_main_thread` from Rust worker threads, and a `JNIEnv` is only valid on +//! the thread that obtained it — so every method here attaches to the VM itself +//! rather than capturing an environment. + +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +use anyhow::{Context as _, Result, anyhow}; +use gpui::{Bounds, Pixels, TextInputStateChange, ThermalState, WindowAppearance}; +use gpui_android::AndroidHost; +use jni::objects::{GlobalRef, JObject, JValue}; +use jni::{JNIEnv, JavaVM}; + +/// Java-side methods this bridge calls. Kept in one place so the Kotlin/Java +/// side has a single list to satisfy, and so a rename shows up as one diff +/// rather than scattered string literals. +mod method { + pub const WAKE: &str = "wakeMainThread"; + pub const REQUEST_FRAME: &str = "requestFrame"; + pub const FINISH: &str = "finishActivity"; + pub const OPEN_URI: &str = "openUri"; + pub const READ_CLIPBOARD: &str = "readClipboardText"; + pub const WRITE_CLIPBOARD: &str = "writeClipboardText"; + pub const SHOW_KEYBOARD: &str = "showSoftKeyboard"; + pub const HIDE_KEYBOARD: &str = "hideSoftKeyboard"; + pub const SET_BACK_ENABLED: &str = "setBackEnabled"; + pub const IS_DARK_MODE: &str = "isDarkMode"; +} + +pub struct JniHost { + vm: JavaVM, + /// The `TcodeActivity` (or any object implementing the methods above). + activity: GlobalRef, + /// True while a Choreographer callback is already scheduled. + /// + /// The contract requires `request_frame` to coalesce: GPUI may ask for a + /// frame many times within one vsync interval, and posting a callback per + /// request would queue redundant frames behind each other. `AndroidEventSink` + /// rearms after it fires, so this only has to suppress duplicates in + /// between. + frame_pending: AtomicBool, +} + +impl JniHost { + pub fn new(env: &JNIEnv<'_>, activity: &JObject<'_>) -> Result { + Ok(Self { + vm: env.get_java_vm().context("retaining the JavaVM")?, + activity: env + .new_global_ref(activity) + .context("retaining a global ref to the activity")?, + frame_pending: AtomicBool::new(false), + }) + } + + /// Attach to the VM and run `body` with a usable environment. + /// + /// `attach_current_thread` is the reason the VM is stored rather than an + /// env: it works from any thread, including the Rust worker threads GPUI's + /// executors run on. + fn with_env( + &self, + body: impl FnOnce(&mut JNIEnv<'_>, &JObject<'_>) -> Result, + ) -> Result { + let mut guard = self + .vm + .attach_current_thread() + .context("attaching the current thread to the JavaVM")?; + let activity = self.activity.as_obj(); + let result = body(&mut guard, activity); + // A pending Java exception would poison every later call on this + // thread, so clear it here and fold it into the error instead. + if guard.exception_check().unwrap_or(false) { + let _ = guard.exception_clear(); + return Err(anyhow!("the Java side threw while handling a host call")); + } + result + } + + fn call_void(&self, name: &'static str) { + let outcome = self.with_env(|env, activity| { + env.call_method(activity, name, "()V", &[]) + .with_context(|| format!("calling {name}()"))?; + Ok(()) + }); + // Void notifications have no caller who could act on a failure, so log + // rather than propagate. Silence here would make a dead Java side look + // like a hung Rust side. + if let Err(error) = outcome { + log::error!("tcode-android: {name} failed: {error:#}"); + } + } + + fn call_bool_arg(&self, name: &'static str, value: bool) { + let outcome = self.with_env(|env, activity| { + env.call_method(activity, name, "(Z)V", &[JValue::Bool(u8::from(value))]) + .with_context(|| format!("calling {name}({value})"))?; + Ok(()) + }); + if let Err(error) = outcome { + log::error!("tcode-android: {name} failed: {error:#}"); + } + } + + fn call_string_arg(&self, name: &'static str, value: &str) -> Result<()> { + self.with_env(|env, activity| { + let argument = env + .new_string(value) + .with_context(|| format!("allocating the {name} argument"))?; + env.call_method( + activity, + name, + "(Ljava/lang/String;)V", + &[JValue::Object(&argument)], + ) + .with_context(|| format!("calling {name}(..)"))?; + Ok(()) + }) + } + + /// Called by the JNI frame entry point once Choreographer has fired, so the + /// next `request_frame` posts a fresh callback. + pub(crate) fn clear_frame_pending(&self) { + self.frame_pending.store(false, Ordering::Release); + } +} + +impl AndroidHost for JniHost { + fn wake_main_thread(&self) { + self.call_void(method::WAKE); + } + + fn request_frame(&self) { + // Only the transition from not-pending to pending posts a callback. + if self + .frame_pending + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.call_void(method::REQUEST_FRAME); + } + } + + fn finish_activity(&self) { + self.call_void(method::FINISH); + } + + fn open_uri(&self, uri: &str) -> Result<()> { + self.call_string_arg(method::OPEN_URI, uri) + } + + fn open_path(&self, path: &Path) -> Result<()> { + // Android cannot hand a raw filesystem path to another app; sharing a + // file needs a FileProvider content:// URI, which is app-configuration + // rather than something this bridge can synthesize. Failing honestly is + // better than opening the wrong thing. + Err(anyhow!( + "opening a filesystem path is unsupported on Android without a FileProvider: {}", + path.display() + )) + } + + fn read_clipboard_text(&self) -> Result> { + self.with_env(|env, activity| { + let value = env + .call_method( + activity, + method::READ_CLIPBOARD, + "()Ljava/lang/String;", + &[], + ) + .context("calling readClipboardText()")? + .l() + .context("readClipboardText must return a String or null")?; + if value.is_null() { + return Ok(None); + } + let text: String = env + .get_string(&value.into()) + .context("decoding the clipboard string")? + .into(); + Ok(Some(text)) + }) + } + + fn write_clipboard_text(&self, text: &str) -> Result<()> { + self.call_string_arg(method::WRITE_CLIPBOARD, text) + } + + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Result<()> { + // Deliberately unimplemented rather than stubbed to success: a + // credential store that silently discards secrets is worse than one + // that says it is absent, because the caller would believe the secret + // was saved. Needs an Android Keystore-backed host service. + Err(anyhow!("credential storage is not wired up on Android yet")) + } + + fn read_credentials(&self, _url: &str) -> Result)>> { + // `None` is honest here: nothing was ever stored, so nothing is found. + Ok(None) + } + + fn delete_credentials(&self, _url: &str) -> Result<()> { + Err(anyhow!("credential storage is not wired up on Android yet")) + } + + fn show_soft_keyboard(&self) { + self.call_void(method::SHOW_KEYBOARD); + } + + fn hide_soft_keyboard(&self) { + self.call_void(method::HIDE_KEYBOARD); + } + + fn update_ime_position(&self, _bounds: Bounds) { + // Positioning the IME needs an InputConnection reporting caret bounds + // through CursorAnchorInfo. Until that exists the platform IME places + // itself, which is wrong but not broken. + } + + fn text_input_state_changed(&self, _change: TextInputStateChange) { + // Same dependency as `update_ime_position`: without an InputConnection + // there is nothing on the Java side to tell. + } + + fn set_back_enabled(&self, enabled: bool) { + self.call_bool_arg(method::SET_BACK_ENABLED, enabled); + } + + fn window_appearance(&self) -> WindowAppearance { + let dark = self + .with_env(|env, activity| { + let value = env + .call_method(activity, method::IS_DARK_MODE, "()Z", &[]) + .context("calling isDarkMode()")? + .z() + .context("isDarkMode must return a boolean")?; + Ok(value) + }) + .unwrap_or(false); + if dark { + WindowAppearance::Dark + } else { + WindowAppearance::Light + } + } + + fn thermal_state(&self) -> ThermalState { + // Android exposes this through PowerManager's thermal status listener, + // which is a Java-side subscription rather than a query. Reporting + // nominal is the honest default until that listener exists; guessing + // would make GPUI throttle for no reason. + ThermalState::Nominal + } +} diff --git a/crates/tcode-android/src/entry.rs b/crates/tcode-android/src/entry.rs new file mode 100644 index 00000000..5dd44089 --- /dev/null +++ b/crates/tcode-android/src/entry.rs @@ -0,0 +1,358 @@ +//! The native entry points `TcodeActivity` calls. +//! +//! Every function here runs on the Activity's Looper thread. That is not a +//! convenience — `AndroidEventSink` is `!Send` precisely so this invariant is +//! enforced by the type system rather than by hope, and the surface-lifecycle +//! contract in `crates/gpui-android/README.md` depends on these calls being +//! serialized against each other. + +use std::cell::RefCell; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context as _, Result, anyhow}; +use std::rc::Rc; + +use client_app::native_transport::NativeTransportFactory; +use client_app::{ + ClientApp, ClientConnection, ClientIdentity, ConnectionStore, FileStore, TransportFactory, +}; +use gpui::{App, AppContext as _, AppLifecyclePhase, ApplicationHandle, TouchPhase, WindowOptions}; +use gpui_android::{ + AndroidDisplayMetrics, AndroidEventSink, AndroidNativeWindow, AndroidPlatform, AndroidSurface, +}; +use jni::JNIEnv; +use jni::objects::JObject; +use jni::sys::{jfloat, jint, jlong}; +use ndk::native_window::NativeWindow; + +use crate::bridge::JniHost; + +// GPUI and its event sink, owned for the Activity's lifetime. +// +// Thread-local rather than a global: the sink is `!Send`, and putting it here +// means a call arriving on the wrong thread finds nothing instead of corrupting +// state that only the Looper thread may touch. +// +// The `allow` is for a confirmed clippy bug, not a real finding: this lint +// demands a `const` initializer and then reports the identical error when one is +// present. Verified by testing both forms against clippy 1.95 for +// aarch64-linux-android — `= const { RefCell::new(None) }` and +// `= RefCell::new(None)` produce the same message, so there is nothing to fix. +thread_local! { + #[allow(clippy::missing_const_for_thread_local)] + static RUNTIME: RefCell> = const { RefCell::new(None) }; +} + +struct AndroidRuntime { + host: Arc, + sink: AndroidEventSink, + /// Keeps GPUI alive. `run_embedded` returns once callbacks are installed, + /// because Android — not GPUI — owns the main loop. + _application: ApplicationHandle, +} + +/// Convert a Java `Surface` into the retained native window the backend needs. +/// +/// # Safety +/// `surface` must be a live `android.view.Surface`. `NativeWindow::from_surface` +/// takes ownership of a new ANativeWindow reference, which `AndroidSurface` then +/// holds for the generation's lifetime — the backend releases it only after +/// `replace_surface` succeeds, so wgpu can never outlive the window it renders +/// into. +unsafe fn native_window( + env: &mut JNIEnv<'_>, + surface: &JObject<'_>, +) -> Result { + let window = unsafe { NativeWindow::from_surface(env.get_raw(), surface.as_raw()) } + .ok_or_else(|| anyhow!("android.view.Surface did not yield an ANativeWindow"))?; + Ok(AndroidNativeWindow::new(window)) +} + +/// Java's touch-phase constants, mirrored from `TcodeActivity`. +/// +/// Translated at the boundary on purpose: an unrecognised value from Java must +/// be rejected here rather than silently becoming some other phase deeper in, +/// where a dropped `Ended` would leave a pointer stuck down forever. +fn touch_phase(value: jint) -> Result { + match value { + 0 => Ok(TouchPhase::Started), + 1 => Ok(TouchPhase::Moved), + 2 => Ok(TouchPhase::Ended), + 3 => Ok(TouchPhase::Cancelled), + other => Err(anyhow!("unknown touch phase {other} from the Java side")), + } +} + +/// Java's lifecycle constants, mirrored from `TcodeActivity`. +fn lifecycle_phase(value: jint) -> Result { + match value { + 0 => Ok(AppLifecyclePhase::Active), + 1 => Ok(AppLifecyclePhase::Inactive), + 2 => Ok(AppLifecyclePhase::Background), + 3 => Ok(AppLifecyclePhase::Foreground), + other => Err(anyhow!( + "unknown lifecycle phase {other} from the Java side" + )), + } +} + +fn start( + env: &mut JNIEnv<'_>, + activity: &JObject<'_>, + surface: &JObject<'_>, + width: jint, + height: jint, + density: jfloat, +) -> Result<()> { + let host = Arc::new(JniHost::new(env, activity).context("building the JNI host")?); + let metrics = AndroidDisplayMetrics::new(width.max(0) as u32, height.max(0) as u32, density) + .context("validating display metrics")?; + let window = unsafe { native_window(env, surface) }?; + let surface = AndroidSurface::new(window, metrics).context("building the initial surface")?; + + let platform = AndroidPlatform::new(host.clone(), surface) + .context("constructing the Android GPUI platform")?; + let sink = platform.event_sink(); + // One runtime for the process; the client reconnects through this factory. + let transport: Arc = + Arc::new(NativeTransportFactory::new().context("starting the client's network runtime")?); + // The paired token lands in app-private storage, which the connect screen + // fills by pairing — the phone equivalent of the browser's localStorage. + let files_dir = app_files_dir(env, activity).context("locating app-private storage")?; + let store: Arc = Arc::new(FileStore::new(files_dir)); + + // The initial surface must stay valid across launch, per the contract. + let application = + gpui::Application::with_platform(Rc::new(platform)).run_embedded(move |cx: &mut App| { + cx.open_window(WindowOptions::default(), |window, cx| { + let connection = client_connection(store.clone()); + cx.new(|cx| ClientApp::new(connection, transport.clone(), window, cx)) + }) + .expect("the Android GPUI window must open"); + cx.activate(true); + }); + + RUNTIME.with(|slot| { + *slot.borrow_mut() = Some(AndroidRuntime { + host, + sink, + _application: application, + }); + }); + Ok(()) +} + +/// Run `body` against the live runtime, logging rather than unwinding. +/// +/// A panic crossing the JNI boundary aborts the process with no useful Java +/// stack, so every entry point funnels through here. +/// The app-private files directory, queried from the Activity. +/// +/// Android exposes no environment variable for this; `getFilesDir()` is the +/// source, and it names storage the OS isolates per app — which is where a +/// durable token belongs. +fn app_files_dir(env: &mut JNIEnv<'_>, activity: &JObject<'_>) -> Result { + let dir = env + .call_method(activity, "getFilesDir", "()Ljava/io/File;", &[]) + .context("calling getFilesDir()")? + .l() + .context("getFilesDir must return a File")?; + let path = env + .call_method(&dir, "getAbsolutePath", "()Ljava/lang/String;", &[]) + .context("calling getAbsolutePath()")? + .l() + .context("getAbsolutePath must return a String")?; + let path: String = env + .get_string(&path.into()) + .context("decoding the files-dir path")? + .into(); + Ok(PathBuf::from(path)) +} + +/// Where this client attaches, and what it calls itself. +/// +/// The token comes from pairing on the connect screen and is kept in `store`; +/// the code is typed once. `TCODE_SYNC_URL` survives only as a convenience that +/// prefills the host address — never a secret, exactly like the browser's +/// `?url=`. +fn client_connection(store: Arc) -> ClientConnection { + ClientConnection { + identity: ClientIdentity { + client_id: format!("tcode-android-{}", std::process::id()), + display_name: "tcode android".into(), + platform: "android".into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }, + url: std::env::var("TCODE_SYNC_URL") + .ok() + .filter(|v| !v.is_empty()), + store, + } +} + +fn with_runtime(name: &'static str, body: impl FnOnce(&mut AndroidRuntime) -> Result<()>) { + RUNTIME.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(runtime) = slot.as_mut() else { + log::warn!("tcode-android: {name} arrived before the runtime was started"); + return; + }; + if let Err(error) = body(runtime) { + log::error!("tcode-android: {name} failed: {error:#}"); + } + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeStart( + mut env: JNIEnv<'_>, + activity: JObject<'_>, + surface: JObject<'_>, + width: jint, + height: jint, + density: jfloat, +) { + android_logger::init_once( + android_logger::Config::default() + .with_max_level(log::LevelFilter::Info) + .with_tag("tcode"), + ); + if let Err(error) = start(&mut env, &activity, &surface, width, height, density) { + log::error!("tcode-android: failed to start: {error:#}"); + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeSurfaceCreated( + mut env: JNIEnv<'_>, + _activity: JObject<'_>, + surface: JObject<'_>, + width: jint, + height: jint, + density: jfloat, +) { + let acquired = unsafe { native_window(&mut env, &surface) }; + with_runtime("surfaceCreated", |runtime| { + let metrics = + AndroidDisplayMetrics::new(width.max(0) as u32, height.max(0) as u32, density) + .context("validating display metrics")?; + let surface = AndroidSurface::new(acquired?, metrics)?; + runtime.sink.surface_created(surface)?; + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeSurfaceDestroyed( + _env: JNIEnv<'_>, + _activity: JObject<'_>, +) { + // Must run before Android releases the surface: the backend unconfigures + // rendering and keeps the retired lease until a replacement succeeds. + with_runtime("surfaceDestroyed", |runtime| { + runtime.sink.surface_destroyed(); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeResized( + _env: JNIEnv<'_>, + _activity: JObject<'_>, + width: jint, + height: jint, + density: jfloat, +) { + with_runtime("resized", |runtime| { + let metrics = + AndroidDisplayMetrics::new(width.max(0) as u32, height.max(0) as u32, density) + .context("validating display metrics")?; + runtime.sink.resized(metrics)?; + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeFrame( + _env: JNIEnv<'_>, + _activity: JObject<'_>, +) { + with_runtime("frame", |runtime| { + // Clear before invoking: the sink rearms while the window is active, and + // that rearm must be able to post a new callback. + runtime.host.clear_frame_pending(); + runtime.sink.frame(); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeDrainMainThread( + _env: JNIEnv<'_>, + _activity: JObject<'_>, +) { + with_runtime("drainMainThread", |runtime| { + runtime.sink.drain_main_thread(); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeTouch( + _env: JNIEnv<'_>, + _activity: JObject<'_>, + pointer_id: jlong, + phase: jint, + x: jfloat, + y: jfloat, + pressure: jfloat, +) { + with_runtime("touch", |runtime| { + // A zero pressure is Android's "no force information", not a real zero + // force reading, so it is reported as absent rather than as 0.0. + let force = (pressure > 0.0).then_some(pressure); + runtime + .sink + .touch(pointer_id as u64, touch_phase(phase)?, x, y, force); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeLifecycle( + _env: JNIEnv<'_>, + _activity: JObject<'_>, + phase: jint, +) { + with_runtime("lifecycle", |runtime| { + runtime.sink.lifecycle_changed(lifecycle_phase(phase)?); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeBackPressed( + _env: JNIEnv<'_>, + _activity: JObject<'_>, +) { + with_runtime("backPressed", |runtime| { + // The bool says whether GPUI consumed it. Android needs that answer to + // decide whether to pop its own back stack, which is a later wiring + // step; logging keeps the information visible until then. + let handled = runtime.sink.back_pressed(); + log::debug!("tcode-android: back press handled by gpui: {handled}"); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_com_tryanks_tcode_TcodeActivity_nativeMemoryWarning( + _env: JNIEnv<'_>, + _activity: JObject<'_>, +) { + with_runtime("memoryWarning", |runtime| { + runtime.sink.memory_warning(); + Ok(()) + }); +} diff --git a/crates/tcode-android/src/lib.rs b/crates/tcode-android/src/lib.rs new file mode 100644 index 00000000..11189bf9 --- /dev/null +++ b/crates/tcode-android/src/lib.rs @@ -0,0 +1,20 @@ +//! Android app shell for tcode: the JNI bridge that drives `gpui-android`. +//! +//! The host build exports nothing. Everything here needs a `JavaVM`, an +//! `ANativeWindow` and a Looper, none of which exist off Android, so the whole +//! implementation stays behind `target_os = "android"` and ordinary desktop +//! workspace builds are unaffected. +//! +//! See `crates/gpui-android/README.md` for the contract this implements. The +//! division of labour there is deliberate: `gpui-android` owns the GPUI +//! `Platform` implementation and knows nothing about Java, while this crate owns +//! Java and knows nothing about GPUI's internals. Keeping them separate is what +//! lets the backend be offered upstream on its own. + +#[cfg(target_os = "android")] +mod bridge; +#[cfg(target_os = "android")] +mod entry; + +#[cfg(target_os = "android")] +pub use bridge::JniHost; diff --git a/crates/tcode-android/tools/verify-link.sh b/crates/tcode-android/tools/verify-link.sh new file mode 100755 index 00000000..bccdc617 --- /dev/null +++ b/crates/tcode-android/tools/verify-link.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Prove the Android shared library links. +# +# The counterpart to crates/tcode-ios/tools/verify-link.sh. A cdylib is linked +# by construction, so unlike iOS this needs no separate harness — but it does +# need the NDK, and the toolchain variables are easy to get wrong. Capturing the +# invocation means the next person does not rediscover them. +# +# `cargo check` is not enough on its own: it never runs the linker, so it cannot +# tell you whether the symbols gpui_wgpu, wgpu and psm need from the NDK's libc +# resolve. +set -euo pipefail + +cd "$(dirname "$0")/../../.." +NDK=${ANDROID_NDK_HOME:-/opt/homebrew/share/android-ndk} +API=${ANDROID_API_LEVEL:-26} +PROFILE=${1:-debug} + +[ -d "$NDK" ] || { echo "no NDK at $NDK; set ANDROID_NDK_HOME"; exit 1; } + +# The prebuilt directory is darwin-x86_64 even on Apple silicon. +BIN=$(echo "$NDK"/toolchains/llvm/prebuilt/*/bin) +export ANDROID_NDK_HOME="$NDK" +export CC_aarch64_linux_android="$BIN/aarch64-linux-android$API-clang" +export AR_aarch64_linux_android="$BIN/llvm-ar" + +ARGS=(-t arm64-v8a --platform "$API" build -p tcode-android) +[ "$PROFILE" = release ] && ARGS+=(--release) +cargo ndk "${ARGS[@]}" 2>&1 | tail -1 + +SO="target/aarch64-linux-android/$PROFILE/libtcode_android.so" +[ -f "$SO" ] || { echo "no shared object at $SO"; exit 1; } + +file "$SO" +echo "JNI entry points exported:" +"$BIN/llvm-nm" -D --defined-only "$SO" \ + | grep -oE "Java_com_tryanks_tcode_TcodeActivity_native[A-Za-z]+" \ + | sort -u | sed 's/^/ /' diff --git a/crates/tcode-ios/Cargo.toml b/crates/tcode-ios/Cargo.toml new file mode 100644 index 00000000..6da3d14d --- /dev/null +++ b/crates/tcode-ios/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "tcode-ios" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +# `staticlib` is what Xcode links into the app binary; `rlib` keeps the crate +# usable from the host workspace so `cargo test` still sees it. +crate-type = ["staticlib", "rlib"] + +[target.'cfg(target_os = "ios")'.dependencies] +gpui = { git = "https://github.com/zed-industries/zed" } +gpui-ios = { path = "../gpui-ios" } +anyhow = "1" +client-app = { path = "../client-app" } +log = "0.4" +objc2 = "0.6" +objc2-foundation = { version = "0.3", features = ["NSString", "NSURL", "NSThread", "NSDictionary", "NSArray", "NSData", "NSValue", "NSObject"] } +objc2-ui-kit = { version = "0.3", features = ["UIApplication", "UIPasteboard", "UIResponder", "UITraitCollection", "UIView", "UIScreen"] } diff --git a/crates/tcode-ios/README.md b/crates/tcode-ios/README.md new file mode 100644 index 00000000..3693542a --- /dev/null +++ b/crates/tcode-ios/README.md @@ -0,0 +1,81 @@ +# tcode-ios + +The iOS app shell: a C-ABI bridge that drives the `gpui-ios` platform backend. + +Mirrors `tcode-android`. That crate owns Java and knows nothing about GPUI's +internals; this one owns Objective-C and likewise. The backends stay plain Rust +crates that could be offered upstream on their own. + +## Building + +```sh +cargo build -p tcode-ios --target aarch64-apple-ios --release +``` + +Produces a `staticlib` for Xcode to link into the app binary. No NDK-style +toolchain variables are needed — the Apple toolchain ships with Xcode. + +## The seam + +The shell registers four function pointers at launch and calls the +`tcode_ios_*` entry points. A function-pointer table rather than an +Objective-C protocol object, because these are invoked from arbitrary Rust +threads and a plain `extern "C"` pointer has no retain/release or +thread-affinity semantics to get wrong. + +The shell must: + +1. Own a `UIView` whose `layer` is a `CAMetalLayer`, and keep it alive for as + long as any surface built from it. +2. Call `tcode_ios_start` once with that view, its pixel size, and + `contentScaleFactor`. +3. Hop to the main queue inside `wake_main_thread` and call + `tcode_ios_drain_main_thread`. +4. Schedule **one** `CADisplayLink` tick per `request_frame` and call + `tcode_ios_frame`; the Rust side coalesces, so a tick per request is wrong. +5. Call `tcode_ios_surface_destroyed` **before** UIKit releases the drawable, + and `tcode_ios_surface_created` when a new one arrives. + +Touch and lifecycle constants are translated at the boundary +(`entry.rs`), so an unrecognised value is rejected where it appears rather than +becoming some other phase deeper in — a dropped `Ended` would leave a touch +stuck down forever. The Swift/Objective-C constants must match that list. + +## What is verified + +Type-check and clippy for `aarch64-apple-ios`, and — the part that matters — +**the archive links**: + +```sh +./crates/tcode-ios/tools/verify-link.sh +``` + +`cargo build` for a staticlib only archives object files; it never runs the +linker, so it cannot tell you whether every symbol gpui, gpui_wgpu, Metal and +cosmic-text need resolves. That script links the archive into a real arm64 +Mach-O executable against the iOS SDK, referencing all nine entry points so the +linker must resolve the whole graph rather than proving most of it dead. Android +gets this for free — a cdylib is linked by construction — so on iOS it is done +deliberately. + +One benign warning remains: `psm`'s assembly object carries the installed SDK's +minimum (26.5) while the probe links at 17.0. It is a metadata mismatch, not an +incompatibility; the link succeeds either way. + +## What is not + +No device, no simulator, and no Xcode project. The first unexercised step is +`metal_context`: Metal adapter selection and surface creation have never met +real hardware. + +## Deliberately unimplemented + +- **Keychain credentials.** A store that silently discards secrets is worse than + one reporting itself absent, because the caller would believe the secret was + saved. Needs a Security.framework wrapper. +- **IME positioning and text-input state.** Need a `UITextInput` conformance on + the hosting view reporting caret rects. +- **Thermal state** reports nominal. `ProcessInfo.thermalState` arrives as a + notification rather than a poll; guessing would make GPUI throttle for nothing. +- **`finish_activity` does nothing.** iOS apps do not exit on demand — `exit(0)` + is grounds for App Store rejection and reads as a crash. diff --git a/crates/tcode-ios/src/bridge.rs b/crates/tcode-ios/src/bridge.rs new file mode 100644 index 00000000..a09eae31 --- /dev/null +++ b/crates/tcode-ios/src/bridge.rs @@ -0,0 +1,183 @@ +//! `IosHost` implemented over UIKit. +//! +//! The structural rule mirrors the Android bridge's: nothing here may assume it +//! is on the main thread. GPUI calls `wake_main_thread` and `request_frame` from +//! worker threads, while UIKit and Core Animation are main-thread-only, so every +//! such call is marshalled rather than performed in place. + +use std::ffi::c_void; +use std::sync::atomic::{AtomicBool, Ordering}; + +use anyhow::{Result, anyhow}; +use gpui::{Bounds, Pixels, WindowAppearance}; +use gpui_ios::IosHost; +use objc2_foundation::{MainThreadMarker, NSDictionary, NSString, NSURL}; +use objc2_ui_kit::{UIApplication, UIPasteboard, UIUserInterfaceStyle}; + +/// Callbacks the Swift/Objective-C shell registers at launch. +/// +/// A function-pointer table rather than a protocol object: these are called from +/// arbitrary Rust threads, and a plain `extern "C"` pointer has no retain/release +/// or thread-affinity semantics to get wrong. The shell is responsible for +/// hopping to the main queue inside them. +#[derive(Clone, Copy)] +pub struct HostCallbacks { + /// Post to the main queue so it calls `tcode_ios_drain_main_thread`. + pub wake_main_thread: extern "C" fn(*mut c_void), + /// Schedule one `CADisplayLink` tick that calls `tcode_ios_frame`. + pub request_frame: extern "C" fn(*mut c_void), + /// Make the hosting view first responder, showing the keyboard. + pub show_keyboard: extern "C" fn(*mut c_void), + /// Resign first responder. + pub hide_keyboard: extern "C" fn(*mut c_void), + /// Opaque shell pointer handed back to every callback. + pub context: *mut c_void, +} + +// SAFETY: the shell must keep `context` alive for the process lifetime and make +// each callback safe to invoke from any thread — which is the documented +// contract for registering them. Without these impls the host could not be +// shared with GPUI's executors, which is the entire point of the type. +unsafe impl Send for HostCallbacks {} +unsafe impl Sync for HostCallbacks {} + +pub struct UiKitHost { + callbacks: HostCallbacks, + /// True while a display-link tick is already scheduled. + /// + /// The trait requires coalescing: GPUI may request a frame several times + /// within one vsync, and each extra tick would queue a redundant frame + /// behind the last. Cleared by the frame entry point before the sink runs, + /// so a rearm during that frame can schedule the next one. + frame_pending: AtomicBool, +} + +impl UiKitHost { + pub fn new(callbacks: HostCallbacks) -> Self { + Self { + callbacks, + frame_pending: AtomicBool::new(false), + } + } + + pub(crate) fn clear_frame_pending(&self) { + self.frame_pending.store(false, Ordering::Release); + } +} + +impl IosHost for UiKitHost { + fn wake_main_thread(&self) { + (self.callbacks.wake_main_thread)(self.callbacks.context); + } + + fn request_frame(&self) { + if self + .frame_pending + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + (self.callbacks.request_frame)(self.callbacks.context); + } + } + + fn finish_activity(&self) { + // iOS apps do not exit on demand: exit(0) is grounds for App Store + // rejection and reads as a crash to the user. The system decides when a + // suspended app dies, so the honest implementation is to do nothing. + } + + fn open_url(&self, url: &str) -> Result<()> { + let string = NSString::from_str(url); + let url = NSURL::URLWithString(&string) + .ok_or_else(|| anyhow!("{url} is not a URL UIKit can open"))?; + // `openURL:` is main-thread-only, and objc2 makes that a type + // requirement rather than a comment. Refusing off-thread is right: + // opening a URL is always user-initiated, so a background caller is a + // bug worth reporting instead of a race worth hiding. + let marker = MainThreadMarker::new() + .ok_or_else(|| anyhow!("openURL must be called on the main thread"))?; + let application = UIApplication::sharedApplication(marker); + // The options/completionHandler form, not the deprecated one-argument + // `openURL:`. Empty options and no handler mean "open with the default + // behaviour and do not tell me whether it worked" — which matches what + // GPUI's `open_url` can express, since its signature returns nothing. + // SAFETY: a main-thread-checked UIApplication with a valid URL and an + // empty options dictionary. + unsafe { application.openURL_options_completionHandler(&url, &NSDictionary::new(), None) }; + Ok(()) + } + + fn read_clipboard_text(&self) -> Result> { + let pasteboard = UIPasteboard::generalPasteboard(); + // SAFETY: UIPasteboard's accessors are main-thread-only; GPUI reads the + // clipboard from its main-thread paste handling. + Ok(unsafe { pasteboard.string() }.map(|value| value.to_string())) + } + + fn write_clipboard_text(&self, text: &str) -> Result<()> { + let pasteboard = UIPasteboard::generalPasteboard(); + // SAFETY: as above. + unsafe { pasteboard.setString(Some(&NSString::from_str(text))) }; + Ok(()) + } + + fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Result<()> { + // Honest failure, not a silent success. A credential store that discards + // secrets is worse than one reporting itself absent, because the caller + // would believe the secret was saved. Needs a Security.framework keychain + // wrapper. + Err(anyhow!("keychain storage is not wired up on iOS yet")) + } + + fn read_credentials(&self, _url: &str) -> Result)>> { + // `None` is truthful: nothing was ever stored, so nothing is found. + Ok(None) + } + + fn delete_credentials(&self, _url: &str) -> Result<()> { + Err(anyhow!("keychain storage is not wired up on iOS yet")) + } + + fn show_soft_keyboard(&self) { + (self.callbacks.show_keyboard)(self.callbacks.context); + } + + fn hide_soft_keyboard(&self) { + (self.callbacks.hide_keyboard)(self.callbacks.context); + } + + fn update_ime_position(&self, _bounds: Bounds) { + // Positioning the candidate bar needs a `UITextInput` conformance + // reporting caret rects. Until the shell has one, iOS places the IME + // itself — wrong, but not broken. + } + + fn thermal_state(&self) -> gpui::ThermalState { + // ProcessInfo.thermalState is delivered as a notification rather than + // polled, so a shell caches the last value. Until it does, nominal is + // the honest answer — guessing would make GPUI throttle for nothing. + gpui::ThermalState::Nominal + } + + fn text_input_state_changed(&self, _change: gpui::TextInputStateChange) { + // Needs a UITextInput conformance on the hosting view; without one there + // is nothing on the UIKit side to tell. + } + + fn set_back_enabled(&self, _enabled: bool) { + // iOS has no system back button. The method exists so the shared window + // code is identical on both backends. + } + + fn window_appearance(&self) -> WindowAppearance { + // `UITraitCollection.current` is main-thread-only; GPUI queries this + // during layout, which is already on the main thread. + // SAFETY: `UITraitCollection.current` is main-thread-only, and GPUI reads + // appearance during layout, which is already on the main thread. + let style = unsafe { objc2_ui_kit::UITraitCollection::currentTraitCollection() }; + match unsafe { style.userInterfaceStyle() } { + UIUserInterfaceStyle::Dark => WindowAppearance::Dark, + _ => WindowAppearance::Light, + } + } +} diff --git a/crates/tcode-ios/src/entry.rs b/crates/tcode-ios/src/entry.rs new file mode 100644 index 00000000..99073a7e --- /dev/null +++ b/crates/tcode-ios/src/entry.rs @@ -0,0 +1,276 @@ +//! The C entry points the Swift/Objective-C shell calls. +//! +//! Every function here must run on the main thread, for the same reason as the +//! Android side: `IosEventSink` is `!Send`, and the surface lifecycle depends on +//! create/destroy/frame being serialized against each other. Storing the runtime +//! thread-locally means a call arriving elsewhere finds nothing rather than +//! corrupting state. + +use std::cell::RefCell; +use std::ffi::{CStr, c_char, c_void}; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context as _, Result}; +use client_app::native_transport::NativeTransportFactory; +use client_app::{ + ClientApp, ClientConnection, ClientIdentity, ConnectionStore, FileStore, TransportFactory, +}; +use gpui::{App, AppContext as _, AppLifecyclePhase, ApplicationHandle, TouchPhase, WindowOptions}; +use gpui_ios::{IosDisplayMetrics, IosEventSink, IosLayer, IosPlatform, IosSurface}; + +use crate::bridge::{HostCallbacks, UiKitHost}; + +thread_local! { + #[allow(clippy::missing_const_for_thread_local)] + static RUNTIME: RefCell> = const { RefCell::new(None) }; +} + +struct IosRuntime { + host: Arc, + sink: IosEventSink, + /// Keeps GPUI alive: `run_embedded` returns once callbacks are installed, + /// because UIKit — not GPUI — owns the run loop. + _application: ApplicationHandle, +} + +/// Java's phases have integer constants; UIKit's do too, and both are translated +/// at the boundary so an unrecognised value is rejected where it appears rather +/// than becoming some other phase deeper in. A dropped `Ended` would leave a +/// touch stuck down forever. +fn touch_phase(value: i32) -> Result { + match value { + 0 => Ok(TouchPhase::Started), + 1 => Ok(TouchPhase::Moved), + 2 => Ok(TouchPhase::Ended), + 3 => Ok(TouchPhase::Cancelled), + other => Err(anyhow::anyhow!("unknown touch phase {other} from UIKit")), + } +} + +fn lifecycle_phase(value: i32) -> Result { + match value { + 0 => Ok(AppLifecyclePhase::Active), + 1 => Ok(AppLifecyclePhase::Inactive), + 2 => Ok(AppLifecyclePhase::Background), + 3 => Ok(AppLifecyclePhase::Foreground), + other => Err(anyhow::anyhow!( + "unknown lifecycle phase {other} from UIKit" + )), + } +} + +/// The app container's Documents directory. +/// +/// On iOS the process `HOME` is the app sandbox, so `$HOME/Documents` is the +/// per-app store the OS isolates — where a durable token belongs. Falls back to +/// the temp dir only if `HOME` is somehow unset, which keeps the app running +/// rather than refusing to start over persistence. +fn documents_dir() -> PathBuf { + std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("Documents") +} + +/// Where this client attaches, and what it calls itself. +/// +/// The token comes from pairing on the connect screen and is kept in `store`; +/// the code is typed once. `TCODE_SYNC_URL` survives only as a convenience that +/// prefills the host address — never a secret, exactly like the browser's +/// `?url=`. +fn client_connection(store: Arc) -> ClientConnection { + ClientConnection { + identity: ClientIdentity { + client_id: format!("tcode-ios-{}", std::process::id()), + display_name: "tcode iOS".into(), + platform: "ios".into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }, + url: std::env::var("TCODE_SYNC_URL") + .ok() + .filter(|v| !v.is_empty()), + store, + } +} + +fn with_runtime(name: &'static str, body: impl FnOnce(&mut IosRuntime) -> Result<()>) { + RUNTIME.with(|slot| { + let mut slot = slot.borrow_mut(); + let Some(runtime) = slot.as_mut() else { + log::warn!("tcode-ios: {name} arrived before the runtime was started"); + return; + }; + if let Err(error) = body(runtime) { + log::error!("tcode-ios: {name} failed: {error:#}"); + } + }); +} + +fn start( + view: *mut c_void, + width: u32, + height: u32, + scale: f32, + callbacks: HostCallbacks, +) -> Result<()> { + // SAFETY: the shell guarantees `view` is a live UIView whose layer is a + // CAMetalLayer, and that it outlives every surface built from it. + let layer = unsafe { IosLayer::from_raw(view) }.context("wrapping the UIKit view")?; + let metrics = + IosDisplayMetrics::new(width, height, scale).context("validating drawable metrics")?; + let surface = IosSurface::new(layer, metrics).context("validating the initial surface")?; + + let host = Arc::new(UiKitHost::new(callbacks)); + // The platform owns its scheduler, exactly as the Android backend does. + let platform = + IosPlatform::new(host.clone(), surface).context("constructing the iOS GPUI platform")?; + let sink = platform.event_sink(); + // One runtime for the process; the client reconnects through this factory. + let transport: Arc = + Arc::new(NativeTransportFactory::new().context("starting the client's network runtime")?); + // The paired token lands in the app's Documents dir, which the connect + // screen fills by pairing — the phone equivalent of the browser's + // localStorage. + let store: Arc = Arc::new(FileStore::new(documents_dir())); + + let handle = gpui::Application::with_platform(std::rc::Rc::new(platform)).run_embedded( + move |cx: &mut App| { + cx.open_window(WindowOptions::default(), |window, cx| { + let connection = client_connection(store.clone()); + cx.new(|cx| ClientApp::new(connection, transport.clone(), window, cx)) + }) + .expect("the iOS GPUI window must open"); + cx.activate(true); + }, + ); + + RUNTIME.with(|slot| { + *slot.borrow_mut() = Some(IosRuntime { + host, + sink, + _application: handle, + }); + }); + Ok(()) +} + +/// # Safety +/// `view` must be a live `UIView*` with a `CAMetalLayer`, and every callback in +/// `callbacks` must be safe to call from any thread for the process lifetime. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tcode_ios_start( + view: *mut c_void, + width: u32, + height: u32, + scale: f32, + wake_main_thread: extern "C" fn(*mut c_void), + request_frame: extern "C" fn(*mut c_void), + show_keyboard: extern "C" fn(*mut c_void), + hide_keyboard: extern "C" fn(*mut c_void), + context: *mut c_void, +) { + let callbacks = HostCallbacks { + wake_main_thread, + request_frame, + show_keyboard, + hide_keyboard, + context, + }; + if let Err(error) = start(view, width, height, scale, callbacks) { + log::error!("tcode-ios: failed to start: {error:#}"); + } +} + +/// # Safety +/// `view` must satisfy the same contract as [`tcode_ios_start`]. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tcode_ios_surface_created( + view: *mut c_void, + width: u32, + height: u32, + scale: f32, +) { + // SAFETY: delegated to the caller by this function's own contract. + let layer = unsafe { IosLayer::from_raw(view) }; + with_runtime("surfaceCreated", |runtime| { + let metrics = IosDisplayMetrics::new(width, height, scale)?; + runtime + .sink + .surface_created(IosSurface::new(layer?, metrics)?) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_surface_destroyed() { + // Must precede UIKit releasing the drawable: the backend drops its renderer + // so wgpu cannot present into a layer iOS has reclaimed. + with_runtime("surfaceDestroyed", |runtime| { + runtime.sink.surface_destroyed(); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_resized(width: u32, height: u32, scale: f32) { + with_runtime("resized", |runtime| { + runtime + .sink + .resized(IosDisplayMetrics::new(width, height, scale)?) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_frame() { + with_runtime("frame", |runtime| { + // Cleared before the sink runs so a rearm during this frame can schedule + // the next tick. + runtime.host.clear_frame_pending(); + runtime.sink.frame(); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_drain_main_thread() { + with_runtime("drainMainThread", |runtime| { + runtime.sink.drain_main_thread(); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_touch(id: u64, phase: i32, x: f32, y: f32, force: f32) { + with_runtime("touch", |runtime| { + // A non-positive force is UIKit's "this device reports no force", not a + // real zero reading. + let force = (force > 0.0).then_some(force); + runtime.sink.touch(id, touch_phase(phase)?, x, y, force); + Ok(()) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn tcode_ios_lifecycle(phase: i32) { + with_runtime("lifecycle", |runtime| { + runtime.sink.lifecycle_changed(lifecycle_phase(phase)?); + Ok(()) + }); +} + +/// # Safety +/// `tag` must be a valid NUL-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn tcode_ios_init_logging(tag: *const c_char) { + let _ = tag; + // SAFETY: the caller guarantees a valid C string; the value is only read for + // diagnostics and never retained. + let name = if tag.is_null() { + "tcode".to_owned() + } else { + unsafe { CStr::from_ptr(tag) } + .to_string_lossy() + .into_owned() + }; + log::info!("tcode-ios: logging initialised for {name}"); +} diff --git a/crates/tcode-ios/src/lib.rs b/crates/tcode-ios/src/lib.rs new file mode 100644 index 00000000..45ae4cfa --- /dev/null +++ b/crates/tcode-ios/src/lib.rs @@ -0,0 +1,17 @@ +//! iOS app shell for tcode: the UIKit bridge that drives `gpui-ios`. +//! +//! Mirrors `tcode-android`. That crate owns Java and knows nothing about GPUI's +//! internals; this one owns Objective-C and likewise. The platform backends stay +//! plain Rust crates that could be offered upstream on their own. +//! +//! Everything is behind `target_os = "ios"`: a `UIApplication`, a `CAMetalLayer` +//! and a main run loop do not exist on a host, and the desktop workspace build +//! must stay green. + +#[cfg(target_os = "ios")] +mod bridge; +#[cfg(target_os = "ios")] +mod entry; + +#[cfg(target_os = "ios")] +pub use bridge::UiKitHost; diff --git a/crates/tcode-ios/tools/verify-link.sh b/crates/tcode-ios/tools/verify-link.sh new file mode 100755 index 00000000..0d3bad14 --- /dev/null +++ b/crates/tcode-ios/tools/verify-link.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Prove the iOS staticlib links. +# +# `cargo build` for a staticlib only archives object files — it never runs the +# linker, so it cannot tell you whether every symbol gpui, gpui_wgpu, Metal and +# cosmic-text need actually resolves. This links the archive into a real +# executable against the iOS SDK, which does. +# +# The Android side gets this for free: a cdylib is linked by construction. iOS +# needs it done deliberately, which is why this script exists. +set -euo pipefail + +cd "$(dirname "$0")/../../.." +TARGET=aarch64-apple-ios +PROFILE=${1:-release} +DEPLOY=${IOS_DEPLOYMENT_TARGET:-17.0} + +cargo build -p tcode-ios --target "$TARGET" --profile "$PROFILE" 2>&1 | tail -1 +ARCHIVE="target/$TARGET/$PROFILE/libtcode_ios.a" +[ -f "$ARCHIVE" ] || { echo "no archive at $ARCHIVE"; exit 1; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# Every entry point is referenced so the linker must resolve the whole graph +# rather than proving most of it dead. +cat > "$WORK/main.m" <<'OBJC' +#import +extern void tcode_ios_start(void *, uint32_t, uint32_t, float, + void (*)(void *), void (*)(void *), + void (*)(void *), void (*)(void *), void *); +extern void tcode_ios_surface_created(void *, uint32_t, uint32_t, float); +extern void tcode_ios_surface_destroyed(void); +extern void tcode_ios_resized(uint32_t, uint32_t, float); +extern void tcode_ios_frame(void); +extern void tcode_ios_drain_main_thread(void); +extern void tcode_ios_touch(uint64_t, int32_t, float, float, float); +extern void tcode_ios_lifecycle(int32_t); +extern void tcode_ios_init_logging(const char *); +static void noop(void *ctx) { (void)ctx; } +int main(int argc, char **argv) { + if (argc > 99) { + tcode_ios_start(NULL, 0, 0, 0.0f, noop, noop, noop, noop, NULL); + tcode_ios_surface_created(NULL, 0, 0, 0.0f); + tcode_ios_surface_destroyed(); + tcode_ios_resized(0, 0, 0.0f); + tcode_ios_frame(); + tcode_ios_drain_main_thread(); + tcode_ios_touch(0, 0, 0, 0, 0); + tcode_ios_lifecycle(0); + tcode_ios_init_logging("tcode"); + } + return 0; +} +OBJC + +SDK=$(xcrun --sdk iphoneos --show-sdk-path) +xcrun --sdk iphoneos clang \ + -target "arm64-apple-ios$DEPLOY" -isysroot "$SDK" \ + "$WORK/main.m" "$ARCHIVE" \ + -framework Foundation -framework UIKit -framework Metal \ + -framework QuartzCore -framework CoreGraphics -framework CoreText \ + -framework Security \ + -o "$WORK/probe" + +file "$WORK/probe" +echo "entry points linked:" +nm -gU "$WORK/probe" | grep -oE "_tcode_ios_[a-z_]+" | sort -u | sed 's/^/ /' diff --git a/crates/tcode-server/Cargo.toml b/crates/tcode-server/Cargo.toml new file mode 100644 index 00000000..710e0ec9 --- /dev/null +++ b/crates/tcode-server/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "tcode-server" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "tcode-server" +path = "src/main.rs" + +[dependencies] +sync-host = { path = "../sync-host" } +sync-protocol = { path = "../sync-protocol" } +tcode-services = { path = "../services" } +log = "0.4" +env_logger = "0.11" +async-channel = "2" +axum = { version = "0.8", features = ["ws"] } +futures-util = "0.3" +tokio = { version = "1", features = [ + "macros", + "net", + "rt-multi-thread", + "signal", + "sync", + "time", +] } diff --git a/crates/tcode-server/src/main.rs b/crates/tcode-server/src/main.rs new file mode 100644 index 00000000..18097341 --- /dev/null +++ b/crates/tcode-server/src/main.rs @@ -0,0 +1,208 @@ +//! Headless, read-only tcode sync host. + +use std::io; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use sync_host::{CommandRequest, WakeSource}; +use sync_protocol::{HostInfo, SessionCommand}; +use tcode_services::settings::SettingsStore; +use tcode_services::store::SessionStore; + +const HELP: &str = "\ +tcode-server — serve tcode session history over WebSocket + +Usage: tcode-server [OPTIONS] + +Options: + --bind Address to listen on [default: 127.0.0.1:0] + Port 0 selects an available port. Widening the IP exposes + unencrypted traffic; the sync token is the only protection. + --print-token Print the sync token to stdout and exit + -h, --help Print help +"; + +const LIVE_POLL_INTERVAL: Duration = Duration::from_millis(250); + +#[derive(Debug, PartialEq, Eq)] +struct Options { + bind: SocketAddr, + print_token: bool, + help: bool, +} + +impl Default for Options { + fn default() -> Self { + Self { + bind: SocketAddr::from(([127, 0, 0, 1], 0)), + print_token: false, + help: false, + } + } +} + +impl Options { + fn parse(args: impl IntoIterator) -> Result { + let mut options = Self::default(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "-h" | "--help" => options.help = true, + "--print-token" => options.print_token = true, + "--bind" => { + let value = args + .next() + .ok_or_else(|| "--bind requires an IP address".to_owned())?; + options.bind = parse_bind(&value)?; + } + _ => { + if let Some(value) = arg.strip_prefix("--bind=") { + options.bind = parse_bind(value)?; + } else { + return Err(format!("unknown option: {arg}")); + } + } + } + } + Ok(options) + } +} + +fn parse_bind(value: &str) -> Result { + if let Ok(address) = value.parse::() { + return Ok(address); + } + value + .parse::() + .map(|ip| SocketAddr::new(ip, 0)) + .map_err(|_| format!("invalid bind address {value:?}; expected IP[:PORT]")) +} + +#[tokio::main] +async fn main() { + env_logger::init(); + + let options = match Options::parse(std::env::args().skip(1)) { + Ok(options) => options, + Err(err) => { + eprintln!("error: {err}\n\n{HELP}"); + std::process::exit(2); + } + }; + if options.help { + print!("{HELP}"); + return; + } + if let Err(err) = run(options).await { + log::error!("tcode-server: {err}"); + std::process::exit(1); + } +} + +async fn run(options: Options) -> io::Result<()> { + let store = SessionStore::open_default()?; + let settings = SettingsStore::new(store.root().clone()); + let token = settings.load_or_create_sync_token()?; + + if options.print_token { + println!("{token}"); + return Ok(()); + } + + let display_name = std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "tcode".into()); + let host = HostInfo { + host_id: format!("{display_name}:{}", store.root().display()), + display_name, + platform: std::env::consts::OS.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }; + + let data_dir = store.root().clone(); + let server = sync_host::start_on( + store, + host, + token, + options.bind, + WakeSource::Polling { + interval: LIVE_POLL_INTERVAL, + }, + )?; + + log::info!("tcode-server: serving at {}", server.url); + log::info!("tcode-server: data directory {}", data_dir.display()); + log::warn!("tcode-server: read-only host; serves history and live events but cannot run turns"); + + let command_drain = tokio::spawn(drain_commands(server.commands.clone())); + shutdown_signal().await; + command_drain.abort(); + Ok(()) +} + +async fn shutdown_signal() { + if let Err(err) = tokio::signal::ctrl_c().await { + log::error!("tcode-server: failed to listen for Ctrl-C: {err}"); + } + log::info!("tcode-server: stopping"); +} + +async fn drain_commands(commands: async_channel::Receiver) { + while let Ok(request) = commands.recv().await { + log::warn!( + "tcode-server: dropped {} command for session {}: no provider process", + command_kind(&request.command), + request.session_id + ); + } +} + +fn command_kind(command: &SessionCommand) -> &'static str { + match command { + SessionCommand::SendTurn { .. } => "send-turn", + SessionCommand::Interrupt => "interrupt", + SessionCommand::RespondApproval { .. } => "approval-response", + SessionCommand::RespondUserInput { .. } => "user-input-response", + SessionCommand::SetApprovalMode(_) => "set-approval-mode", + SessionCommand::Steer { .. } => "steer", + SessionCommand::SetInteractionMode(_) => "set-interaction-mode", + SessionCommand::SetOption { .. } => "set-option", + SessionCommand::Rewind { .. } => "rewind", + SessionCommand::Shutdown => "shutdown", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_an_ephemeral_loopback_port() { + assert_eq!( + Options::parse(Vec::::new()).unwrap(), + Options::default() + ); + } + + #[test] + fn bind_accepts_an_ip_with_or_without_a_port() { + assert_eq!( + Options::parse(["--bind".into(), "0.0.0.0".into()]) + .unwrap() + .bind, + "0.0.0.0:0".parse().unwrap() + ); + assert_eq!( + Options::parse(["--bind=[::1]:9876".into()]).unwrap().bind, + "[::1]:9876".parse().unwrap() + ); + } + + #[test] + fn unknown_options_are_rejected() { + assert_eq!( + Options::parse(["--listen".into()]).unwrap_err(), + "unknown option: --listen" + ); + } +} diff --git a/crates/tcode-web/.gitignore b/crates/tcode-web/.gitignore new file mode 100644 index 00000000..178135c2 --- /dev/null +++ b/crates/tcode-web/.gitignore @@ -0,0 +1 @@ +/dist/ diff --git a/crates/tcode-web/Cargo.toml b/crates/tcode-web/Cargo.toml new file mode 100644 index 00000000..1c69200e --- /dev/null +++ b/crates/tcode-web/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "tcode-web" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[target.'cfg(target_arch = "wasm32")'.dependencies] +gpui = { git = "https://github.com/zed-industries/zed" } +gpui_platform = { git = "https://github.com/zed-industries/zed" } +gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } +gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } +client-app = { path = "../client-app" } +js-sys = "0.3" +sync-client = { path = "../sync-client" } +sync-protocol = { path = "../sync-protocol" } +tcode-ui = { path = "../ui", default-features = false, features = ["portable"] } +wasm-bindgen = "0.2" + +[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys] +version = "0.3" +features = [ + "CloseEvent", + "Event", + "Location", + "MessageEvent", + "Storage", + "UrlSearchParams", + "WebSocket", + "Window", +] diff --git a/crates/tcode-web/README.md b/crates/tcode-web/README.md new file mode 100644 index 00000000..ca9223f0 --- /dev/null +++ b/crates/tcode-web/README.md @@ -0,0 +1,73 @@ +# tcode web + +This is the browser shell for the full tcode sync client: it can follow a +session, send turns, and answer approval requests. It uses +`gpui_platform::application()`, which selects GPUI's multithreaded WebPlatform +for wasm. + +## Toolchain and serving requirements + +The pinned GPUI revision needs nightly, a locally rebuilt standard library, and +the wasm atomics, bulk-memory, and mutable-globals target features: + +```sh +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" \ +RUSTC_BOOTSTRAP=1 \ +cargo +nightly -Zbuild-std=std,panic_abort check \ + -p tcode-web --target wasm32-unknown-unknown +``` + +Linking an actual browser module additionally needs shared memory and its +thread-local-storage exports. Trunk does not forward Cargo's `-Z` option, so +the corresponding unstable setting is supplied through Cargo's environment: + +```sh +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals -C link-arg=--shared-memory -C link-arg=--max-memory=1073741824 -C link-arg=--import-memory -C link-arg=--export=__heap_base -C link-arg=--export=__wasm_init_tls -C link-arg=--export=__tls_size -C link-arg=--export=__tls_align -C link-arg=--export=__tls_base" \ +CARGO_UNSTABLE_BUILD_STD=std,panic_abort \ +RUSTC_BOOTSTRAP=1 \ +RUSTUP_TOOLCHAIN=nightly \ +trunk build --release --locked +``` + +WebAssembly threads require `SharedArrayBuffer`. Browsers expose it only when +the page is cross-origin isolated, so `Trunk.toml` makes `trunk serve` send: + +```text +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin +``` + +Production hosting must send the same headers. Without them the dispatcher +cannot start its worker threads. + +Serve with the same environment and `trunk serve --release --locked`, then open +the page. It shows a connect screen: enter the host address and the short-lived +pairing code the desktop host prints at startup. Pairing trades the code for a +durable token, which is kept in `localStorage`, so a reload reconnects without +retyping anything; **Sign out** clears it and returns to the connect screen. + +`?url=` is honoured as a convenience — it only prefills the address field, which +is not a secret: + +```text +http://127.0.0.1:8080/?url=ws%3A%2F%2F127.0.0.1%3APORT%2Fsync +``` + +There is deliberately no `?token=`. Pairing is the only credential bootstrap +path, and a durable token has no business in the address bar, in browser +history, or in a referrer header — which is the whole reason pairing exists. + +## Upstream contribution candidate + +At Zed revision `1a246efd`, `crates/gpui_web/src/dispatcher.rs` defines +`shared_memory_supported()` only under `#[cfg(feature = "multithreaded")]` but +calls it unconditionally from `MainThreadMailbox::run_waker_loop`. +Consequently, `gpui_web` does not compile with `default-features = false`: + +```text +error[E0425]: cannot find function `shared_memory_supported` in this scope +``` + +That prevents `gpui_platform::single_threaded_web()` from being built without +also unifying in the default multithreaded feature. The fix belongs upstream; +tcode intentionally carries no GPUI fork or local patch. diff --git a/crates/tcode-web/Trunk.toml b/crates/tcode-web/Trunk.toml new file mode 100644 index 00000000..fd90ce32 --- /dev/null +++ b/crates/tcode-web/Trunk.toml @@ -0,0 +1,11 @@ +[build] +dist = "dist" + +[serve] +addresses = ["127.0.0.1"] +port = 8080 +open = false + +# WebAssembly threads require SharedArrayBuffer, which browsers expose only in +# a cross-origin-isolated page. +headers = { "Cross-Origin-Embedder-Policy" = "require-corp", "Cross-Origin-Opener-Policy" = "same-origin" } diff --git a/crates/tcode-web/index.html b/crates/tcode-web/index.html new file mode 100644 index 00000000..d209dacb --- /dev/null +++ b/crates/tcode-web/index.html @@ -0,0 +1,36 @@ + + + + + + tcode web + + + + + diff --git a/crates/tcode-web/src/lib.rs b/crates/tcode-web/src/lib.rs new file mode 100644 index 00000000..e048dee6 --- /dev/null +++ b/crates/tcode-web/src/lib.rs @@ -0,0 +1,2 @@ +#[cfg(target_arch = "wasm32")] +mod wasm; diff --git a/crates/tcode-web/src/wasm.rs b/crates/tcode-web/src/wasm.rs new file mode 100644 index 00000000..192c276b --- /dev/null +++ b/crates/tcode-web/src/wasm.rs @@ -0,0 +1,213 @@ +//! Browser shell for the shared tcode client. +//! +//! Everything the user sees lives in `client-app`; this file is the part a +//! browser cannot share — a `web-sys` WebSocket, the page's query string, and +//! the wasm-bindgen entry point. + +use std::{borrow::Cow, cell::RefCell, sync::Arc}; + +use client_app::{ + ClientApp, ClientConnection, ClientIdentity, ConnectionStore, Inbox, StoredConnection, + Transport, TransportEvent, TransportFactory, +}; +use gpui::{App, AppContext as _, ApplicationHandle, WindowOptions}; +use gpui_component::Root; +use wasm_bindgen::{JsCast as _, closure::Closure, prelude::*}; +use web_sys::{CloseEvent, Event, MessageEvent, UrlSearchParams, WebSocket}; + +thread_local! { + // WebPlatform::run returns after installing browser callbacks. This handle + // deliberately owns GPUI for the rest of the page lifetime. + static APPLICATION: RefCell> = const { RefCell::new(None) }; +} + +/// A browser WebSocket. +struct WebTransport { + socket: WebSocket, +} + +impl Transport for WebTransport { + fn send(&self, text: &str) -> Result<(), String> { + self.socket.send_with_str(text).map_err(js_error) + } +} + +struct WebTransportFactory; + +// SAFETY: wasm is single-threaded here, and the factory holds no state. The +// bounds exist because the shared client is written for platforms that do have +// threads. +unsafe impl Send for WebTransportFactory {} +unsafe impl Sync for WebTransportFactory {} + +impl TransportFactory for WebTransportFactory { + fn connect(&self, url: &str, hello: &str, inbox: Inbox) -> Result, String> { + let socket = connect(url, hello, inbox).map_err(js_error)?; + Ok(Box::new(WebTransport { socket })) + } +} + +/// Push one event onto the shared inbox. +/// +/// The inbox is an `Arc>` because native transports push from a socket +/// thread; the browser is single-threaded so the lock is never contended, but +/// the type is shared with those platforms and so is used the same way here. +fn push(inbox: &Inbox, event: TransportEvent) { + inbox + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .push_back(event); +} + +fn connect(url: &str, hello: &str, incoming: Inbox) -> Result { + let socket = WebSocket::new(url)?; + + let on_open = Closure::::new({ + let socket = socket.clone(); + let hello = hello.to_owned(); + let incoming = incoming.clone(); + move |_| { + if socket.send_with_str(&hello).is_err() { + push(&incoming, TransportEvent::Error); + } else { + push(&incoming, TransportEvent::Open); + } + } + }); + socket.set_onopen(Some(on_open.as_ref().unchecked_ref())); + on_open.forget(); + + let on_message = Closure::::new({ + let incoming = incoming.clone(); + move |event: MessageEvent| { + if let Some(text) = event.data().as_string() { + push(&incoming, TransportEvent::Text(text)); + } + } + }); + socket.set_onmessage(Some(on_message.as_ref().unchecked_ref())); + on_message.forget(); + + let on_error = Closure::::new({ + let incoming = incoming.clone(); + move |_| push(&incoming, TransportEvent::Error) + }); + socket.set_onerror(Some(on_error.as_ref().unchecked_ref())); + on_error.forget(); + + let on_close = Closure::::new(move |event: CloseEvent| { + push(&incoming, TransportEvent::Closed(event.reason())); + }); + socket.set_onclose(Some(on_close.as_ref().unchecked_ref())); + on_close.forget(); + + Ok(socket) +} + +/// Persists the paired connection in the browser's `localStorage`. +/// +/// Stateless: each call re-reads `window.localStorage`, so the handle is +/// trivially `Send + Sync` for the shared client's bounds even though `Storage` +/// is neither. The token is the secret this exists to keep out of the address +/// bar; the URL rides along only so a reload need not re-type it. +struct LocalStorageStore; + +const URL_KEY: &str = "tcode.host_url"; +const TOKEN_KEY: &str = "tcode.sync_token"; + +impl LocalStorageStore { + fn storage() -> Option { + web_sys::window()?.local_storage().ok().flatten() + } +} + +impl ConnectionStore for LocalStorageStore { + fn load(&self) -> Option { + let storage = Self::storage()?; + // No token means no pairing; the URL alone is just a prefill and is not + // worth restoring a session for. + let token = storage.get_item(TOKEN_KEY).ok().flatten()?; + let url = storage.get_item(URL_KEY).ok().flatten().unwrap_or_default(); + Some(StoredConnection { url, token }) + } + + fn save(&self, connection: &StoredConnection) { + let Some(storage) = Self::storage() else { + return; + }; + // Best-effort: storage can be disabled or full. Failing to persist is + // not worth interrupting a working session — the user simply pairs + // again next load. + let _ = storage.set_item(URL_KEY, &connection.url); + let _ = storage.set_item(TOKEN_KEY, &connection.token); + } + + fn clear(&self) { + let Some(storage) = Self::storage() else { + return; + }; + let _ = storage.remove_item(URL_KEY); + let _ = storage.remove_item(TOKEN_KEY); + } +} + +fn page_config() -> Result { + let window = web_sys::window().ok_or_else(|| JsValue::from_str("window is unavailable"))?; + let search = window.location().search()?; + let params = UrlSearchParams::new_with_str(&search)?; + Ok(ClientConnection { + identity: ClientIdentity { + // Per page load, not per installation: a browser has no stable + // handle to offer, and claiming one would let a host think two tabs + // are the same device. + client_id: format!("tcode-web-{}", js_sys::Date::now() as u64), + display_name: "tcode web".into(), + platform: "web".into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }, + // `?url=` prefills the address only — a URL is not a secret. The token + // is never read from the page: it is earned by pairing and kept in + // localStorage, deliberately never `?token=`, which would leave a + // durable credential in the address bar, in history, and in referrers. + url: params.get("url").filter(|value| !value.is_empty()), + store: Arc::new(LocalStorageStore), + }) +} + +fn js_error(value: JsValue) -> String { + value.as_string().unwrap_or_else(|| format!("{value:?}")) +} + +#[wasm_bindgen(start)] +pub fn start() -> Result<(), JsValue> { + gpui_platform::web_init(); + let config = page_config()?; + let app = gpui_platform::application().with_assets(gpui_component_assets::Assets::new( + "https://longbridge.github.io/gpui-component/gallery/", + )); + let handle = app.run_embedded(move |cx: &mut App| { + gpui_component::init(cx); + tcode_ui::markdown::init(cx); + let fonts: Vec> = vec![ + Cow::Borrowed(tcode_ui::assets::DM_SANS), + Cow::Borrowed(tcode_ui::assets::LILEX_REGULAR), + Cow::Borrowed(tcode_ui::assets::LILEX_BOLD), + Cow::Borrowed(tcode_ui::assets::LILEX_ITALIC), + Cow::Borrowed(tcode_ui::assets::LILEX_BOLD_ITALIC), + ]; + cx.text_system() + .add_fonts(fonts) + .expect("bundled web fonts must load"); + cx.open_window(WindowOptions::default(), |window, cx| { + let app = + cx.new(|cx| ClientApp::new(config, Arc::new(WebTransportFactory), window, cx)); + cx.new(|cx| Root::new(app, window, cx).bordered(false)) + }) + .expect("the GPUI web window must open"); + cx.activate(true); + }); + APPLICATION.with(|slot| { + *slot.borrow_mut() = Some(handle); + }); + Ok(()) +} diff --git a/crates/ui/Cargo.toml b/crates/ui/Cargo.toml index a00a8dd6..987627d2 100644 --- a/crates/ui/Cargo.toml +++ b/crates/ui/Cargo.toml @@ -3,30 +3,48 @@ name = "tcode-ui" version = "0.1.0" edition = "2024" +[features] +default = ["desktop"] +# Native application shell and controllers. `portable` intentionally does not +# imply this feature: remote clients only need read-model-driven presentation. +desktop = [ + "agent/local", + "dep:async-channel", + "dep:computer-use-mcp", + "dep:preview-mcp", + "dep:smol", + "dep:tcode-runtime", + "dep:term", +] +portable = [] + [dependencies] -agent = { path = "../agent" } +agent = { path = "../agent", default-features = false } tcode-core = { path = "../core" } tcode-i18n = { path = "../i18n" } -tcode-runtime = { path = "../runtime" } -term = { path = "../term" } -preview-mcp = { path = "../preview-mcp" } -computer-use-mcp = { path = "../computer-use-mcp" } +tcode-runtime = { path = "../runtime", optional = true } +term = { path = "../term", optional = true } +preview-mcp = { path = "../preview-mcp", optional = true } +computer-use-mcp = { path = "../computer-use-mcp", optional = true } gpui = { git = "https://github.com/zed-industries/zed" } gpui-component = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } gpui-component-assets = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "tiff"] } -async-channel = "2" +async-channel = { version = "2", optional = true } base64 = "0.22" chrono = "0.4" log = "0.4" serde = { version = "1", features = ["derive"] } serde_json = "1" -smol = "2" +smol = { version = "2", optional = true } rushdown = "0.18.0" syntect = { version = "5.3.0", default-features = false, features = ["parsing", "regex-fancy", "dump-load"] } imara-diff = "0.2" -[target.'cfg(not(target_os = "linux"))'.dependencies] +# Platforms with an embeddable system WebView. Spelled out rather than written +# as `not(target_os = "linux")`: that form silently classifies Android, iOS and +# wasm as desktop, pulling native `wry` into targets that cannot use it. +[target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] gpui-wry = { git = "https://github.com/longbridge/gpui-component", rev = "031555662e99a1b5a549990b47f246d475b8288a" } wry = { version = "0.53.3", package = "lb-wry" } raw-window-handle = { version = "0.6", features = ["std"] } diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs index 2fc913c0..1f9bb2c8 100644 --- a/crates/ui/src/assets.rs +++ b/crates/ui/src/assets.rs @@ -2,6 +2,11 @@ use std::borrow::Cow; use gpui::{AssetSource, Result, SharedString}; use gpui_component_assets::Assets as ComponentAssets; +#[cfg(target_family = "wasm")] +use std::sync::LazyLock; + +#[cfg(target_family = "wasm")] +static COMPONENT_ASSETS: LazyLock = LazyLock::new(ComponentAssets::default); pub const DM_SANS: &[u8] = include_bytes!("../../../assets/fonts/DMSans[wght].ttf"); #[cfg(not(any(target_os = "macos", target_os = "windows")))] @@ -86,11 +91,17 @@ impl AssetSource for Assets { if let Some((_, bytes)) = EXTRA_ICONS.iter().find(|(name, _)| *name == path) { return Ok(Some(Cow::Borrowed(bytes))); } - ComponentAssets.load(path) + #[cfg(not(target_family = "wasm"))] + return ComponentAssets.load(path); + #[cfg(target_family = "wasm")] + COMPONENT_ASSETS.load(path) } fn list(&self, path: &str) -> Result> { + #[cfg(not(target_family = "wasm"))] let mut paths = ComponentAssets.list(path)?; + #[cfg(target_family = "wasm")] + let mut paths = COMPONENT_ASSETS.list(path)?; if DM_SANS_PATH.starts_with(path) { paths.push(DM_SANS_PATH.into()); } diff --git a/crates/ui/src/chat.rs b/crates/ui/src/chat.rs index 9e520eb4..d5f5b01f 100644 --- a/crates/ui/src/chat.rs +++ b/crates/ui/src/chat.rs @@ -7,7 +7,9 @@ use std::time::Duration; use std::path::{Path, PathBuf}; -use agent::{ChangeCompleteness, FileChange, ItemStatus, RewindMode}; +#[cfg(feature = "desktop")] +use agent::RewindMode; +use agent::{ChangeCompleteness, FileChange, ItemStatus}; use gpui::{ Anchor, AnyElement, App, AppContext as _, ClipboardItem, Context, Div, Entity, FollowMode, Hsla, InteractiveElement as _, IntoElement, ListAlignment, ListState, ObjectFit, @@ -25,20 +27,29 @@ use gpui_component::{ v_flex, }; +#[cfg(feature = "desktop")] use tcode_core::git::GitAction; +use tcode_core::git::{MenuItem, QuickAction}; use tcode_core::session::{ - EntryContent, OrchestrateCallback, SteeringStatus, TimelineEntry, TurnMeta, TurnTiming, - parse_orchestrate_callback, + EntryContent, OrchestrateCallback, SteeringStatus, Timeline, TimelineEntry, TurnMeta, + TurnTiming, parse_orchestrate_callback, }; +#[cfg(feature = "desktop")] use tcode_runtime::app::{AppState, RightTab}; +#[cfg(feature = "desktop")] use crate::commit_dialog::CommitDialog; +#[cfg(feature = "desktop")] use crate::composer::{Composer, ComposerEvent}; +#[cfg(feature = "desktop")] use crate::git::{git_action_label_key, git_hint_key}; use crate::markdown::{MarkdownState, MarkdownView}; +#[cfg(feature = "desktop")] use crate::terminal_drawer::TerminalDrawer; use crate::time::now_millis; +#[cfg(feature = "desktop")] use crate::window_caption; +#[cfg(feature = "desktop")] use crate::window_drag_area; /// Content-column max width (T3 centers the timeline at ~760px). Shared with @@ -638,9 +649,114 @@ impl MdState { } } +/// Owned data needed to draw the chat surface without borrowing desktop runtime state. +/// +/// Remote clients can construct this from their synced [`Timeline`]. The desktop +/// adapter below also captures its surrounding chrome state so the existing +/// `ChatView` keeps rendering exactly the same controls. +#[derive(Clone, Default)] +pub struct ChatReadModel { + pub active: Option, + pub sidebar_collapsed: bool, + pub diff_showing: bool, + pub plan_showing: bool, + pub preview_showing: bool, + pub terminal_open: bool, + pub git_quick_action: Option, + pub git_menu_items: Vec, + chat_hosts_caption: bool, +} + +impl ChatReadModel { + /// Wrap one synced session with neutral client-chrome defaults. + pub fn new(active: ChatSessionReadModel) -> Self { + Self { + active: Some(active), + ..Self::default() + } + } +} + +/// Domain snapshot for the active chat session. +#[derive(Clone)] +pub struct ChatSessionReadModel { + pub session_id: String, + pub title: String, + pub cwd: PathBuf, + pub is_draft: bool, + pub provider: agent::ProviderKind, + pub timeline: Timeline, + /// Live-only delivery state used to disable native rewind while work is queued. + pub turn_in_flight: bool, + pub has_queued_messages: bool, + pub native_rewind_pending: bool, + /// Desktop-only drawer preference; remote clients can retain the default. + pub terminal_height: f32, +} + +impl ChatSessionReadModel { + /// Construct the portable timeline read path from sync-owned domain data. + pub fn new( + session_id: impl Into, + title: impl Into, + cwd: PathBuf, + provider: agent::ProviderKind, + timeline: Timeline, + ) -> Self { + Self { + session_id: session_id.into(), + title: title.into(), + cwd, + is_draft: false, + provider, + timeline, + turn_in_flight: false, + has_queued_messages: false, + native_rewind_pending: false, + terminal_height: 240., + } + } +} + +#[cfg(feature = "desktop")] +impl From<&AppState> for ChatReadModel { + fn from(state: &AppState) -> Self { + let active = state.active.as_ref().map(|active| ChatSessionReadModel { + session_id: active.meta.id.clone(), + title: active.meta.title.clone(), + cwd: active.meta.cwd.clone(), + is_draft: active.draft, + provider: active.meta.provider, + timeline: active.timeline.clone(), + turn_in_flight: active.is_turn_running(), + has_queued_messages: !active.queued().is_empty(), + native_rewind_pending: state.native_rewind_pending(), + terminal_height: active.terminal_workspace.height, + }); + Self { + active, + sidebar_collapsed: state.sidebar_collapsed, + diff_showing: state.diff_panel_open() && state.right_tab() == RightTab::Diff, + plan_showing: state.plan_panel_showing(), + preview_showing: state.preview_panel_showing(), + terminal_open: state.terminal_panel_open(), + git_quick_action: state.git_quick_action(), + git_menu_items: state.git_menu_items(), + chat_hosts_caption: window_caption::hosts_caption( + window_caption::CaptionSurface::Chat, + state, + ), + } + } +} + pub struct ChatView { + #[cfg(feature = "desktop")] app_state: Entity, + read_model: Arc, + #[cfg(feature = "desktop")] composer: Entity, + #[cfg(feature = "desktop")] terminal_drawer: Entity, list_state: ListState, turn_items: Vec, @@ -655,11 +771,13 @@ pub struct ChatView { copied: Option, _copied_task: Option>, /// The live commit dialog entity while it is open (kept alive across frames). + #[cfg(feature = "desktop")] commit_dialog: Option>, _subscriptions: Vec, } impl ChatView { + #[cfg(feature = "desktop")] pub fn new(app_state: Entity, window: &mut Window, cx: &mut Context) -> Self { let composer = cx.new(|cx| Composer::new(app_state.clone(), window, cx)); let overdraw = timeline_overdraw(f32::from(window.bounds().size.height)); @@ -675,7 +793,7 @@ impl ChatView { cx.notify(); }), cx.observe(&app_state, |this, _, cx| { - this.sync_markdown_states(cx); + this.refresh_read_model(cx); cx.notify(); }), ]; @@ -683,6 +801,7 @@ impl ChatView { let mut this = Self { app_state, + read_model: Arc::default(), composer, terminal_drawer, list_state, @@ -696,19 +815,68 @@ impl ChatView { commit_dialog: None, _subscriptions: subscriptions, }; + this.refresh_read_model(cx); + this + } + + /// Construct the timeline-only surface from state already owned by a remote + /// client. + /// + /// Gated on `not(desktop)` as well: with the desktop feature on, `ChatView` + /// carries an `AppState` and a composer that a remote client has no way to + /// supply. The two modes are alternatives, not layers. + #[cfg(all(feature = "portable", not(feature = "desktop")))] + pub fn from_read_model( + read_model: ChatReadModel, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let overdraw = timeline_overdraw(f32::from(window.bounds().size.height)); + let list_state = ListState::new(0, ListAlignment::Bottom, px(overdraw)); + list_state.set_follow_mode(FollowMode::Tail); + let mut this = Self { + read_model: Arc::new(read_model), + list_state, + turn_items: Vec::new(), + md_states: HashMap::new(), + expanded: HashSet::new(), + session_key: None, + _tick: None, + copied: None, + _copied_task: None, + _subscriptions: Vec::new(), + }; this.sync_markdown_states(cx); this } + /// Replace the remote snapshot without coupling the view to its sync store. + #[cfg(all(feature = "portable", not(feature = "desktop")))] + pub fn set_read_model(&mut self, read_model: ChatReadModel, cx: &mut Context) { + self.read_model = Arc::new(read_model); + self.sync_markdown_states(cx); + cx.notify(); + } + + /// Refresh the desktop adapter. Timeline rendering below consumes only this snapshot. + #[cfg(feature = "desktop")] + fn refresh_read_model(&mut self, cx: &mut Context) { + self.read_model = Arc::new(ChatReadModel::from(self.app_state.read(cx))); + self.sync_markdown_states(cx); + } + /// Mirror timeline markdown text into synchronous [`MarkdownState`] entities. fn sync_markdown_states(&mut self, cx: &mut Context) { let (session_key, texts, running, turn_items) = { - let state = self.app_state.read(cx); - let session_key = state.active_session_id().map(str::to_string); + let session_key = self + .read_model + .active + .as_ref() + .map(|active| active.session_id.clone()); let mut texts: Vec<(String, String)> = Vec::new(); let mut running = false; let mut turn_items = Vec::new(); - if let Some(active) = &state.active { + if let Some(active) = &self.read_model.active { let timeline = &active.timeline; running = timeline.turn_running; turn_items = index_turns( @@ -787,7 +955,10 @@ impl ChatView { if running && self._tick.is_none() { self._tick = Some(cx.spawn(async move |this, cx| { loop { + #[cfg(feature = "desktop")] smol::Timer::after(Duration::from_secs(1)).await; + #[cfg(not(feature = "desktop"))] + cx.background_executor().timer(Duration::from_secs(1)).await; if this.update(cx, |_, cx| cx.notify()).is_err() { break; } @@ -822,6 +993,7 @@ impl ChatView { &self, index: usize, turn: &TurnMeta, + session: &ChatSessionReadModel, cwd: &Path, entries: &[Arc], pinned: (Option<&str>, Option<&str>), @@ -871,6 +1043,7 @@ impl ChatView { index, segment_id, turn, + &session.timeline, cwd, activities, &turn_counts, @@ -903,6 +1076,7 @@ impl ChatView { index, &entry.id, text, + session, cwd, *context_len, attachments, @@ -944,6 +1118,7 @@ impl ChatView { index, &segment_id, turn, + &session.timeline, cwd, &[], &turn_counts, @@ -953,15 +1128,13 @@ impl ChatView { } // Proposed-plan card (the captured plan for this turn). - if let Some((item_id, markdown)) = { - let state = self.app_state.read(cx); - state - .active - .as_ref() - .and_then(|a| a.timeline.proposed_plan.as_ref()) - .filter(|plan| plan.turn == index) - .map(|plan| (plan.item_id.clone(), plan.markdown.clone())) - } { + if let Some((item_id, markdown)) = session + .timeline + .proposed_plan + .as_ref() + .filter(|plan| plan.turn == index) + .map(|plan| (plan.item_id.clone(), plan.markdown.clone())) + { column = column.child(self.render_proposed_plan_card(index, &item_id, &markdown, cwd, cx)); } @@ -972,15 +1145,10 @@ impl ChatView { // silently. Replay marks turns idle (mark_idle), so finished turns from // stored sessions still render the card. if !turn.running { - let (changes, completeness) = { - let state = self.app_state.read(cx); - ( - state.turn_file_changes(index).unwrap_or_default(), - state - .turn_change_completeness(index) - .unwrap_or(ChangeCompleteness::Partial), - ) - }; + let (changes, completeness) = turn.changes.as_ref().map_or_else( + || (Vec::new(), ChangeCompleteness::Partial), + |changes| (changes.changes.clone(), changes.completeness), + ); if !changes.is_empty() { column = column.child(self.render_changed_files(index, cwd, &changes, completeness, cx)); @@ -1011,6 +1179,7 @@ impl ChatView { index, &entry.id, text, + session, cwd, *context_len, attachments, @@ -1059,28 +1228,23 @@ impl ChatView { .into_any_element() } + #[cfg(feature = "desktop")] fn render_native_rewind_button( &self, turn: usize, - cx: &mut Context, + session: &ChatSessionReadModel, ) -> Option { - let (available, disabled) = { - let state = self.app_state.read(cx); - let active = state.active.as_ref()?; - ( - active.meta.provider == agent::ProviderKind::ClaudeCode - && active - .timeline - .turns - .get(turn) - .and_then(|turn| turn.provider_checkpoint_id.as_ref()) - .is_some(), - active.is_turn_running() - || active.timeline.turn_running - || !active.queued().is_empty() - || state.native_rewind_pending(), - ) - }; + let available = session.provider == agent::ProviderKind::ClaudeCode + && session + .timeline + .turns + .get(turn) + .and_then(|turn| turn.provider_checkpoint_id.as_ref()) + .is_some(); + let disabled = session.turn_in_flight + || session.timeline.turn_running + || session.has_queued_messages + || session.native_rewind_pending; if !available { return None; } @@ -1163,17 +1327,27 @@ impl ChatView { ) } + // Native rewind mutates host-owned checkpoints, so remote clients omit it. + #[cfg(not(feature = "desktop"))] + fn render_native_rewind_button( + &self, + _turn: usize, + _session: &ChatSessionReadModel, + ) -> Option { + None + } + /// A user message: the right-aligned bubble plus its action row. The /// row's height is always reserved, so revealing it on hover never shifts /// the timeline; it is revealed for `pinned` (the last user message) so the /// action is reachable without hovering. #[allow(clippy::too_many_arguments)] - #[allow(clippy::too_many_arguments)] fn render_user( &self, turn: usize, entry_id: &str, text: &str, + session: &ChatSessionReadModel, cwd: &Path, context_len: Option, attachments: &[String], @@ -1211,7 +1385,7 @@ impl ChatView { )); } if steering.is_none() - && let Some(rewind) = self.render_native_rewind_button(turn, cx) + && let Some(rewind) = self.render_native_rewind_button(turn, session) { actions = actions.child(rewind); } @@ -1641,6 +1815,7 @@ impl ChatView { index: usize, segment_id: &str, turn: &TurnMeta, + timeline: &Timeline, cwd: &Path, activities: &[&TimelineEntry], turn_counts: &WorkLogCounts, @@ -1706,7 +1881,7 @@ impl ChatView { section = section.child(self.render_file_edit_row(&row, cx)); } } else { - section = section.child(self.render_activity_row(entry, false, cx)); + section = section.child(self.render_activity_row(entry, timeline, false, cx)); } } @@ -1810,11 +1985,12 @@ impl ChatView { fn render_activity_row( &self, entry: &TimelineEntry, + timeline: &Timeline, compact: bool, cx: &mut Context, ) -> AnyElement { if matches!(&entry.content, EntryContent::Subagent { .. }) { - return self.render_subagent_row(entry, cx); + return self.render_subagent_row(entry, timeline, cx); } let muted = cx.theme().muted_foreground; let (icon, summary): (IconName, AnyElement) = match &entry.content { @@ -1920,7 +2096,12 @@ impl ChatView { .into_any_element() } - fn render_subagent_row(&self, entry: &TimelineEntry, cx: &mut Context) -> AnyElement { + fn render_subagent_row( + &self, + entry: &TimelineEntry, + timeline: &Timeline, + cx: &mut Context, + ) -> AnyElement { let EntryContent::Subagent { agent_type, description, @@ -1933,19 +2114,8 @@ impl ChatView { let key = format!("subagent-{}", entry.id); let expanded = self.expanded.contains(&key); let parent_id = entry.id.clone(); - let (children, truncated) = { - let state = self.app_state.read(cx); - state - .active - .as_ref() - .map(|active| { - ( - active.timeline.children(&parent_id).to_vec(), - active.timeline.children_truncated(&parent_id), - ) - }) - .unwrap_or_default() - }; + let children = timeline.children(&parent_id).to_vec(); + let truncated = timeline.children_truncated(&parent_id); let muted = cx.theme().muted_foreground; let finished = !matches!(status, ItemStatus::InProgress); let turn = entry.turn; @@ -2018,14 +2188,19 @@ impl ChatView { ); } for child in &children { - nested = nested.child(self.render_subagent_child(child, cx)); + nested = nested.child(self.render_subagent_child(child, timeline, cx)); } block = block.child(nested); } block.into_any_element() } - fn render_subagent_child(&self, entry: &TimelineEntry, cx: &mut Context) -> AnyElement { + fn render_subagent_child( + &self, + entry: &TimelineEntry, + timeline: &Timeline, + cx: &mut Context, + ) -> AnyElement { let muted = cx.theme().muted_foreground; match &entry.content { EntryContent::User { text, .. } => h_flex() @@ -2061,7 +2236,7 @@ impl ChatView { .text_color(muted) .child(tcode_i18n::tr!("chat.changed_files", count = changes.len())) .into_any_element(), - _ => self.render_activity_row(entry, true, cx), + _ => self.render_activity_row(entry, timeline, true, cx), } } @@ -2083,7 +2258,7 @@ impl ChatView { .map(|c| diff_stats(c.diff.as_deref())) .fold((0, 0), |(a, d), (ca, cd)| (a + ca, d + cd)); - let header = h_flex() + let mut header = h_flex() .w_full() .px_1() .py_1() @@ -2126,18 +2301,22 @@ impl ChatView { .on_click(cx.listener(move |this, _, _, cx| { this.toggle_expanded(index, &card_key, cx); })), - ) - .child( + ); + // Opening the native diff panel mutates AppState. Portable clients get + // the complete changed-files summary but no inert host-only control. + #[cfg(feature = "desktop")] + { + header = header.child( Button::new(("view-diff", index)) .outline() .xsmall() .label(tcode_i18n::tr!("chat.view_diff")) .tooltip(tcode_i18n::tr!("chat.view_diff_tooltip")) .on_click(cx.listener(move |this, _, _, cx| { - this.app_state - .update(cx, |state, cx| state.open_diff_for_turn(index, cx)); + this.open_diff_for_turn(index, cx); })), ); + } let mut content = v_flex().w_full().child(header); @@ -2172,42 +2351,45 @@ impl ChatView { ); } for file in files { + #[cfg(feature = "desktop")] let path = file.path.clone(); + #[cfg(feature = "desktop")] let row_label = format!("{}: {}", tcode_i18n::tr!("chat.view_diff"), file.path); + #[cfg(feature = "desktop")] + let row = crate::material::accessible_clickable( + h_flex(), + SharedString::from(format!("changed-file-{index}-{}", file.path)), + Role::Button, + row_label, + cx, + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.open_diff_for_file(index, path.clone(), cx); + })); + #[cfg(not(feature = "desktop"))] + let row = h_flex(); body = body.child( - crate::material::accessible_clickable( - h_flex(), - SharedString::from(format!("changed-file-{index}-{}", file.path)), - Role::Button, - row_label, - cx, - ) - .w_full() - .pl(px(if dir.is_empty() { 8. } else { 28. })) - .pr_2() - .py_1() - .gap_1p5() - .items_center() - .text_size(px(13.)) - .rounded(px(6.)) - .cursor_pointer() - .hover(|s| s.bg(cx.theme().list_hover)) - .on_click(cx.listener(move |this, _, _, cx| { - this.app_state.update(cx, |state, cx| { - state.open_diff_for_file(index, path.clone(), cx) - }); - })) - .child(Icon::new(IconName::File).xsmall().text_color(muted)) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .text_ellipsis() - .font_family(cx.theme().mono_font_family.clone()) - .child(file.name), - ) - .child(diff_counts(file.added, file.deleted, cx)), + row.w_full() + .pl(px(if dir.is_empty() { 8. } else { 28. })) + .pr_2() + .py_1() + .gap_1p5() + .items_center() + .text_size(px(13.)) + .rounded(px(6.)) + .hover(|s| s.bg(cx.theme().list_hover)) + .when(cfg!(feature = "desktop"), |row| row.cursor_pointer()) + .child(Icon::new(IconName::File).xsmall().text_color(muted)) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_ellipsis() + .font_family(cx.theme().mono_font_family.clone()) + .child(file.name), + ) + .child(diff_counts(file.added, file.deleted, cx)), ); } } @@ -2252,10 +2434,57 @@ impl ChatView { }; let md_copy = markdown.to_string(); + #[cfg(feature = "desktop")] let md_download = markdown.to_string(); + #[cfg(feature = "desktop")] let md_save = markdown.to_string(); let copied = self.copied.as_deref() == Some("plan"); + let mut plan_actions = h_flex().w_full().gap_1().flex_wrap().child( + Button::new(("plan-copy", turn)) + .ghost() + .xsmall() + .icon(IconName::Copy) + .label(if copied { + tcode_i18n::tr!("plan.copied") + } else { + tcode_i18n::tr!("plan.copy") + }) + .on_click(cx.listener(move |this, _, _, cx| { + let md = md_copy.clone(); + this.copy_plan(md, cx); + })), + ); + // Download and workspace save are filesystem operations owned by the + // desktop host; the portable card intentionally exposes Copy only. + #[cfg(feature = "desktop")] + { + plan_actions = plan_actions + .child( + Button::new(("plan-download", turn)) + .ghost() + .xsmall() + .icon(Icon::empty().path("icons/download.svg")) + .label(tcode_i18n::tr!("plan.download")) + .on_click(cx.listener(move |this, _, _, cx| { + let md = md_download.clone(); + let fallback = tcode_i18n::tr!("plan.proposed_plan").into_owned(); + this.download_plan(md, fallback, cx); + })), + ) + .child( + Button::new(("plan-save", turn)) + .ghost() + .xsmall() + .icon(IconName::HardDrive) + .label(tcode_i18n::tr!("plan.save_workspace")) + .on_click(cx.listener(move |this, _, _, cx| { + let md = md_save.clone(); + this.save_plan_to_workspace(md, cx); + })), + ); + } + let content = v_flex() .flex_1() .min_w_0() @@ -2305,53 +2534,7 @@ impl ChatView { }), ) .child(body) - .child( - h_flex() - .w_full() - .gap_1() - .flex_wrap() - .child( - Button::new(("plan-copy", turn)) - .ghost() - .xsmall() - .icon(IconName::Copy) - .label(if copied { - tcode_i18n::tr!("plan.copied") - } else { - tcode_i18n::tr!("plan.copy") - }) - .on_click(cx.listener(move |this, _, _, cx| { - let md = md_copy.clone(); - this.app_state.update(cx, |s, cx| s.copy_plan(md, cx)); - this.mark_copied("plan".into(), cx); - })), - ) - .child( - Button::new(("plan-download", turn)) - .ghost() - .xsmall() - .icon(Icon::empty().path("icons/download.svg")) - .label(tcode_i18n::tr!("plan.download")) - .on_click(cx.listener(move |this, _, _, cx| { - let md = md_download.clone(); - let fallback = tcode_i18n::tr!("plan.proposed_plan").into_owned(); - this.app_state - .update(cx, |s, cx| s.download_plan(md, fallback, cx)); - })), - ) - .child( - Button::new(("plan-save", turn)) - .ghost() - .xsmall() - .icon(IconName::HardDrive) - .label(tcode_i18n::tr!("plan.save_workspace")) - .on_click(cx.listener(move |this, _, _, cx| { - let md = md_save.clone(); - this.app_state - .update(cx, |s, cx| s.save_plan_to_workspace(md, cx)); - })), - ), - ); + .child(plan_actions); h_flex() .w_full() @@ -2377,7 +2560,10 @@ impl ChatView { fn mark_copied(&mut self, key: String, cx: &mut Context) { self.copied = Some(key.clone()); self._copied_task = Some(cx.spawn(async move |this, cx| { + #[cfg(feature = "desktop")] smol::Timer::after(Duration::from_secs(2)).await; + #[cfg(not(feature = "desktop"))] + cx.background_executor().timer(Duration::from_secs(2)).await; let _ = this.update(cx, |this, cx| { if this.copied.as_deref() == Some(key.as_str()) { this.copied = None; @@ -2388,6 +2574,39 @@ impl ChatView { cx.notify(); } + #[cfg(feature = "desktop")] + fn open_diff_for_turn(&mut self, index: usize, cx: &mut Context) { + self.app_state + .update(cx, |state, cx| state.open_diff_for_turn(index, cx)); + } + + #[cfg(feature = "desktop")] + fn open_diff_for_file(&mut self, index: usize, path: String, cx: &mut Context) { + self.app_state + .update(cx, |state, cx| state.open_diff_for_file(index, path, cx)); + } + + fn copy_plan(&mut self, markdown: String, cx: &mut Context) { + #[cfg(feature = "desktop")] + self.app_state + .update(cx, |state, cx| state.copy_plan(markdown, cx)); + #[cfg(not(feature = "desktop"))] + cx.write_to_clipboard(copy_payload(&markdown)); + self.mark_copied("plan".into(), cx); + } + + #[cfg(feature = "desktop")] + fn download_plan(&mut self, markdown: String, fallback: String, cx: &mut Context) { + self.app_state + .update(cx, |state, cx| state.download_plan(markdown, fallback, cx)); + } + + #[cfg(feature = "desktop")] + fn save_plan_to_workspace(&mut self, markdown: String, cx: &mut Context) { + self.app_state + .update(cx, |state, cx| state.save_plan_to_workspace(markdown, cx)); + } + /// The finished turn's bottom row: the local completion clock, followed by /// the wall-clock breakdown when the turn's events supported deriving one. fn render_timestamp( @@ -2405,8 +2624,10 @@ impl ChatView { // -- top-level surfaces ------------------------------------------------- + #[cfg(feature = "desktop")] fn render_header( &self, + read_model: &ChatReadModel, title: Option, is_draft: bool, cwd: Option, @@ -2418,16 +2639,13 @@ impl ChatView { // the row's leading content (the sidebar toggle) is inset past them — // but only when the platform actually draws them: they are hidden in // fullscreen, and other platforms never had them. - let collapsed = self.app_state.read(cx).sidebar_collapsed; + let collapsed = read_model.sidebar_collapsed; let clears_traffic_lights = cfg!(target_os = "macos") && collapsed && !window.is_fullscreen(); // Windows: with no right panel open this header is the window's // top-right corner, so it hosts the caption buttons — flush to the // right edge, past the header's usual inset. - let hosts_caption = window_caption::hosts_caption( - window_caption::CaptionSurface::Chat, - self.app_state.read(cx), - ); + let hosts_caption = read_model.chat_hosts_caption; let base = h_flex() .flex_shrink_0() .h(px(52.)) @@ -2500,18 +2718,15 @@ impl ChatView { // The right-side cluster (Open split-button + panel toggles) shows for // any active thread, including a draft. let show_actions = is_draft || title.is_some(); - let diff_showing = { - let state = self.app_state.read(cx); - state.diff_panel_open() && state.right_tab() == RightTab::Diff - }; - let plan_showing = self.app_state.read(cx).plan_panel_showing(); - let preview_showing = self.app_state.read(cx).preview_panel_showing(); - let terminal_open = self.app_state.read(cx).terminal_panel_open(); + let diff_showing = read_model.diff_showing; + let plan_showing = read_model.plan_showing; + let preview_showing = read_model.preview_showing; + let terminal_open = read_model.terminal_open; window_drag_area("chat-header-drag", base, window, cx) .child(sidebar_toggle) .child(window_caption::drag_region(title_el)) .when(show_actions, |this| { - this.children(self.render_git_button(cx)) + this.children(self.render_git_button(read_model, cx)) .children(cwd.clone().map(|cwd| self.render_open_button(cwd, cx))) .child( h_flex() @@ -2582,10 +2797,15 @@ impl ChatView { /// action follows the background git status (Commit / Commit & push / Push / /// Pull / Publish branch / Initialize Git, or a disabled status hint); the /// chevron lists the applicable subset. Ported from T3's `GitActionsControl`. - fn render_git_button(&self, cx: &mut Context) -> Option { - let quick = self.app_state.read(cx).git_quick_action()?; + #[cfg(feature = "desktop")] + fn render_git_button( + &self, + read_model: &ChatReadModel, + cx: &mut Context, + ) -> Option { + let quick = read_model.git_quick_action.clone()?; let border = cx.theme().border; - let items = self.app_state.read(cx).git_menu_items(); + let items = read_model.git_menu_items.clone(); // Main action segment. let label: SharedString = tcode_i18n::tr!(git_action_label_key(quick.label)) @@ -2717,6 +2937,7 @@ impl ChatView { /// Dispatch a git quick-action: commit-style actions open the commit dialog; /// everything else runs in the background with a progress toast. + #[cfg(feature = "desktop")] fn trigger_git_action( &mut self, action: GitAction, @@ -2733,6 +2954,7 @@ impl ChatView { } /// Open the commit dialog for `action` (Commit or Commit & push). + #[cfg(feature = "desktop")] fn open_commit_dialog( &mut self, action: GitAction, @@ -2757,6 +2979,7 @@ impl ChatView { /// The bordered "Open" split-button: main click opens the session cwd in /// Zed; the chevron opens a menu (Zed / Finder / Copy path). Matches T3's /// header control. + #[cfg(feature = "desktop")] fn render_open_button(&self, cwd: PathBuf, cx: &mut Context) -> AnyElement { let border = cx.theme().border; let main_cwd = cwd.clone(); @@ -2880,6 +3103,7 @@ impl ChatView { .into_any_element() } + #[cfg(feature = "desktop")] fn render_empty_state(&self, cx: &mut Context) -> AnyElement { v_flex() .flex_1() @@ -2924,6 +3148,7 @@ impl ChatView { } } +#[cfg(feature = "desktop")] impl Render for ChatView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { // Screenshot-only: `--debug-git-dialog` opens the commit dialog once the @@ -2940,35 +3165,24 @@ impl Render for ChatView { self.open_commit_dialog(GitAction::Commit, window, cx); } - let active = { - let state = self.app_state.read(cx); - state.active.as_ref().map(|active| { - ( - active.meta.title.clone(), - active.meta.cwd.clone(), - active.draft, - ) - }) - }; + let read_model = self.read_model.clone(); let root = v_flex().size_full().min_w_0().bg(cx.theme().background); - let Some((title, cwd, is_draft)) = active else { + let Some(active) = read_model.active.as_ref() else { return root - .child(self.render_header(None, false, None, window, cx)) + .child(self.render_header(&read_model, None, false, None, window, cx)) .child(self.render_empty_state(cx)); }; + let cwd = active.cwd.clone(); + let is_draft = active.is_draft; + let title = active.title.clone(); let title = if is_draft { None } else { Some(title) }; - let header = self.render_header(title, is_draft, Some(cwd.clone()), window, cx); - let terminal_open = self.app_state.read(cx).terminal_panel_open(); - let terminal_height = self - .app_state - .read(cx) - .active - .as_ref() - .map(|a| a.terminal_workspace.height) - .unwrap_or(240.); + let header = + self.render_header(&read_model, title, is_draft, Some(cwd.clone()), window, cx); + let terminal_open = read_model.terminal_open; + let terminal_height = active.terminal_height; // Group entries by turn and render each turn section into the centered // content column. The column fills the available width up to @@ -2978,13 +3192,7 @@ impl Render for ChatView { // The newest user / assistant message: their action rows stay visible // (hover is not the only way to reach Copy / native rewind). let (last_user_id, last_assistant_id) = { - let state = self.app_state.read(cx); - let entries = &state - .active - .as_ref() - .expect("active session") - .timeline - .entries; + let entries = &active.timeline.entries; ( entries .iter() @@ -3001,6 +3209,7 @@ impl Render for ChatView { let item_count = self.turn_items.len(); let item_cwd = cwd.clone(); + let item_read_model = read_model.clone(); let timeline = list( self.list_state.clone(), cx.processor(move |this, index: usize, window, cx| { @@ -3008,24 +3217,21 @@ impl Render for ChatView { return div().into_any_element(); }; // Clone only the handful of entries in this visible/overdrawn - // turn. The full history remains in AppState and is never - // cloned by the render path. - let Some((turn, entries)) = this.app_state.read(cx).active.as_ref().map(|active| { - ( - active - .timeline - .turns - .get(index) - .cloned() - .unwrap_or_default(), - active.timeline.entries[item.entry_range.clone()].to_vec(), - ) - }) else { + // turn. The full read snapshot remains shared by the render path. + let Some(active) = item_read_model.active.as_ref() else { return div().into_any_element(); }; + let turn = active + .timeline + .turns + .get(index) + .cloned() + .unwrap_or_default(); + let entries = active.timeline.entries[item.entry_range.clone()].to_vec(); let rendered = this.render_turn( index, &turn, + active, &item_cwd, &entries, (last_user_id.as_deref(), last_assistant_id.as_deref()), @@ -3093,6 +3299,76 @@ impl Render for ChatView { } } +#[cfg(all(feature = "portable", not(feature = "desktop")))] +impl Render for ChatView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let read_model = self.read_model.clone(); + let root = v_flex().size_full().min_w_0().bg(cx.theme().background); + let Some(active) = read_model.active.as_ref() else { + return root.into_any_element(); + }; + let cwd = active.cwd.clone(); + let (last_user_id, last_assistant_id) = { + let entries = &active.timeline.entries; + ( + entries + .iter() + .rev() + .find(|entry| matches!(entry.content, EntryContent::User { .. })) + .map(|entry| entry.id.clone()), + entries + .iter() + .rev() + .find(|entry| matches!(entry.content, EntryContent::Assistant { .. })) + .map(|entry| entry.id.clone()), + ) + }; + let item_count = self.turn_items.len(); + let item_cwd = cwd; + let item_read_model = read_model; + let timeline = list( + self.list_state.clone(), + cx.processor(move |this, index: usize, window, cx| { + let Some(item) = this.turn_items.get(index) else { + return div().into_any_element(); + }; + let Some(active) = item_read_model.active.as_ref() else { + return div().into_any_element(); + }; + let turn = active + .timeline + .turns + .get(index) + .cloned() + .unwrap_or_default(); + let entries = active.timeline.entries[item.entry_range.clone()].to_vec(); + let rendered = this.render_turn( + index, + &turn, + active, + &item_cwd, + &entries, + (last_user_id.as_deref(), last_assistant_id.as_deref()), + window, + cx, + ); + h_flex() + .w_full() + .justify_center() + .px(px(CONTENT_MIN_PADDING)) + .when(index + 1 < item_count, |item| item.pb(px(TURN_GAP))) + .child(div().w_full().max_w(px(CONTENT_MAX_WIDTH)).child(rendered)) + .into_any_element() + }), + ) + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_1() + .min_h_0() + .py_6(); + root.child(timeline).into_any_element() + } +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -3135,6 +3411,7 @@ fn chevron(open: bool) -> IconName { /// Launch `zed ` detached; surface a notification if the CLI is missing. /// The leading icon for a git quick-action. +#[cfg(feature = "desktop")] fn git_action_icon(action: GitAction) -> Icon { match action { GitAction::Push => Icon::new(IconName::ArrowUp), @@ -3145,6 +3422,7 @@ fn git_action_icon(action: GitAction) -> Icon { /// The commit dialog's footer action row (Cancel / Commit[& push]). Built inside /// the `open_dialog` builder so the buttons can close the dialog on click. +#[cfg(feature = "desktop")] fn render_commit_footer( dialog: &Entity, _window: &mut Window, @@ -3180,6 +3458,7 @@ fn render_commit_footer( .into_any_element() } +#[cfg(feature = "desktop")] fn open_in_zed(cwd: &Path, window: &mut Window, cx: &mut App) { if tcode_runtime::ui_facade::open_in_zed(cwd).is_err() { window.push_notification( @@ -3191,6 +3470,7 @@ fn open_in_zed(cwd: &Path, window: &mut Window, cx: &mut App) { /// Reveal `cwd` in the platform's file manager (Finder / Explorer / the XDG /// file manager). gpui does the platform dispatch, so no shell-out is needed. +#[cfg(feature = "desktop")] fn reveal_in_file_manager(cwd: &Path, cx: &mut App) { cx.reveal_path(cwd); } @@ -3393,7 +3673,7 @@ fn live_edit_rows(changes: &[FileChange], cwd: &Path) -> Vec { changes .iter() .map(|change| LiveEditRow { - path: tcode_runtime::ui_facade::relativize_to_workspace(&change.path, cwd), + path: relativize_to_workspace(&change.path, cwd), counts: live_edit_counts(change.diff.as_deref()), }) .collect() @@ -3437,7 +3717,7 @@ fn work_log_row_entries<'a>( fn group_by_dir(changes: &[FileChange], cwd: &Path) -> Vec<(String, Vec)> { let mut groups: Vec<(String, Vec)> = Vec::new(); for change in changes { - let display = tcode_runtime::ui_facade::relativize_to_workspace(&change.path, cwd); + let display = relativize_to_workspace(&change.path, cwd); let (dir, name) = match display.rsplit_once('/') { Some((dir, name)) => (dir.to_string(), name.to_string()), None => (String::new(), display.clone()), @@ -3458,6 +3738,16 @@ fn group_by_dir(changes: &[FileChange], cwd: &Path) -> Vec<(String, Vec groups } +fn relativize_to_workspace(path: &str, cwd: &Path) -> String { + let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); + let path_buf = Path::new(path); + path_buf + .strip_prefix(cwd) + .or_else(|_| path_buf.strip_prefix(&canonical_cwd)) + .map(|relative| relative.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()) +} + fn diff_counts(added: u32, deleted: u32, cx: &Context) -> AnyElement { diff_counts_colored(added, deleted, cx.theme().success, cx.theme().danger).into_any_element() } @@ -4248,6 +4538,7 @@ This begins after the hard break."#; .iter() .map(|path| FileChange { path: (*path).to_string(), + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }) @@ -4385,16 +4676,19 @@ This begins after the hard break."#; let changes = vec![ FileChange { path: "/work/repo/src/foo.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }, FileChange { path: "/work/repo/crates/ui/src/chat.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: None, }, FileChange { path: "/elsewhere/vendor/bar.rs".into(), + workspace_path: None, kind: FileChangeKind::Create, diff: None, }, @@ -4453,11 +4747,13 @@ This begins after the hard break."#; let changes = vec![ FileChange { path: "/work/repo/src/foo.rs".into(), + workspace_path: None, kind: FileChangeKind::Modify, diff: Some(REAL_DIFF.into()), }, FileChange { path: "/work/repo/src/bar.rs".into(), + workspace_path: None, kind: FileChangeKind::Create, diff: Some(String::new()), }, diff --git a/crates/ui/src/composer.rs b/crates/ui/src/composer.rs index a17a8384..c28eb353 100644 --- a/crates/ui/src/composer.rs +++ b/crates/ui/src/composer.rs @@ -1314,6 +1314,7 @@ impl Composer { media_type: mime_from_path(&image.path), data_base64: base64::engine::general_purpose::STANDARD.encode(&bytes), source_path: Some(image.path.to_string_lossy().into_owned()), + workspace_path: None, }) }) .collect() diff --git a/crates/ui/src/diff/mod.rs b/crates/ui/src/diff/mod.rs index 111fa469..39fb413a 100644 --- a/crates/ui/src/diff/mod.rs +++ b/crates/ui/src/diff/mod.rs @@ -1,6 +1,9 @@ pub mod algorithm; pub mod model; pub mod parse; +// The panel owns desktop AppState; portable clients consume the diff model. +#[cfg(feature = "desktop")] mod view; +#[cfg(feature = "desktop")] pub use view::DiffPanel; diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs index 92809d0e..4f57bf8e 100644 --- a/crates/ui/src/lib.rs +++ b/crates/ui/src/lib.rs @@ -1,38 +1,68 @@ +#[cfg(feature = "desktop")] mod acp_panel; +#[cfg(feature = "desktop")] mod add_project_dialog; pub mod assets; mod attachments; mod chat; +#[cfg(feature = "desktop")] mod commit_dialog; +#[cfg(feature = "desktop")] mod composer; +#[cfg(feature = "desktop")] mod composer_trigger; +#[cfg(feature = "desktop")] mod context_meter; pub mod diff; +#[cfg(feature = "desktop")] pub mod git; mod highlight; pub mod markdown; pub mod material; +#[cfg(feature = "desktop")] mod orchestrate_settings; +#[cfg(feature = "desktop")] mod palette; +#[cfg(feature = "desktop")] mod pasteboard; +#[cfg(feature = "desktop")] mod plan_panel; +#[cfg(feature = "desktop")] mod preview_panel; +#[cfg(feature = "desktop")] pub mod provider_card; +#[cfg(feature = "desktop")] mod provider_dialog; +#[cfg(feature = "desktop")] mod provider_model_picker; +#[cfg(feature = "desktop")] pub mod provider_models; +#[cfg(feature = "desktop")] pub mod provider_status; +#[cfg(feature = "desktop")] pub mod runtime_event; +#[cfg(feature = "desktop")] pub mod settings; +#[cfg(feature = "desktop")] mod settings_page; +#[cfg(feature = "desktop")] mod shell; +#[cfg(feature = "desktop")] mod shortcut; +#[cfg(feature = "desktop")] mod sidebar; +#[cfg(feature = "desktop")] mod terminal_drawer; pub mod time; +#[cfg(feature = "desktop")] pub(crate) mod toast; +#[cfg(feature = "desktop")] mod window_caption; +#[cfg(feature = "desktop")] mod workspace_walk; +pub use chat::{ChatReadModel, ChatSessionReadModel, ChatView}; +#[cfg(feature = "desktop")] pub(crate) use shell::window_drag_area; +#[cfg(feature = "desktop")] pub use shell::{AppShell, Quit, TogglePalette}; diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs index 30c2d9ad..cb97d8a2 100644 --- a/crates/ui/src/markdown/view.rs +++ b/crates/ui/src/markdown/view.rs @@ -206,9 +206,9 @@ impl Element for MarkdownView { ), LinkTarget::Local(path) => { let path = path.to_string_lossy().into_owned(); - let relative_path = markdown.base_dir().map(|base_dir| { - tcode_runtime::ui_facade::relativize_to_workspace(&path, base_dir) - }); + let relative_path = markdown + .base_dir() + .map(|base_dir| relativize_to_workspace(&path, base_dir)); menu.menu( tcode_i18n::tr!("chat.open").into_owned(), Box::new(OpenPath(path.clone())), @@ -289,6 +289,7 @@ impl Element for MarkdownView { } } +#[cfg(feature = "desktop")] fn open_in_zed(path: &Path, window: &mut Window, cx: &mut App) { if tcode_runtime::ui_facade::open_in_zed(path).is_err() { window.push_notification( @@ -298,6 +299,21 @@ fn open_in_zed(path: &Path, window: &mut Window, cx: &mut App) { } } +// Local-path launching is host-owned; portable builds keep link rendering and +// copying but cannot dispatch a desktop editor process. +#[cfg(not(feature = "desktop"))] +fn open_in_zed(_path: &Path, _window: &mut Window, _cx: &mut App) {} + +fn relativize_to_workspace(path: &str, cwd: &Path) -> String { + let canonical_cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf()); + let path_buf = Path::new(path); + path_buf + .strip_prefix(cwd) + .or_else(|_| path_buf.strip_prefix(&canonical_cwd)) + .map(|relative| relative.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()) +} + #[cfg(test)] mod tests { use gpui::{ diff --git a/crates/ui/src/preview_panel.rs b/crates/ui/src/preview_panel.rs index 65c89c8b..2fec3503 100644 --- a/crates/ui/src/preview_panel.rs +++ b/crates/ui/src/preview_panel.rs @@ -10,14 +10,23 @@ //! //! ## Platform support //! -//! macOS + Windows get the real WebView. **Linux does not**: lb-wry's -//! `build_as_child` is X11-only there *and* requires a GTK main loop (`gtk::init` -//! plus `gtk::main_iteration_do` pumped on the UI thread), while gpui's Linux -//! backend runs calloop/xcb and never pumps GTK — the webview would panic at -//! construction and could never be driven. So `wry`/`gpui-wry` are not even -//! dependencies on Linux (see the `[target.'cfg(not(target_os = "linux"))']` -//! table in Cargo.toml); the tab renders a placeholder and every `preview_*` MCP -//! tool answers with an error. The MCP server itself still starts, harmlessly. +//! **macOS + Windows get the real WebView; every other target gets the +//! placeholder.** The gate is spelled `any(target_os = "macos", target_os = +//! "windows")` rather than `not(target_os = "linux")` because the latter also +//! matches Android, iOS and wasm — which have no `target_os = "linux"` and +//! would therefore have pulled in native `wry` as "desktop". +//! +//! Linux is excluded because lb-wry's `build_as_child` is X11-only there *and* +//! requires a GTK main loop (`gtk::init` plus `gtk::main_iteration_do` pumped on +//! the UI thread), while gpui's Linux backend runs calloop/xcb and never pumps +//! GTK — the webview would panic at construction and could never be driven. +//! Mobile and wasm are excluded because a native child view drawn over the gpui +//! window is not a concept those platforms offer at all. +//! +//! So `wry`/`gpui-wry` are not even dependencies off macOS/Windows (see the +//! matching target table in Cargo.toml); the tab renders a placeholder and every +//! `preview_*` MCP tool answers with an error. The MCP server itself still +//! starts, harmlessly. //! //! ## Known caveat — native overlay //! @@ -31,10 +40,10 @@ //! known limitation (documented, not fixed). use preview_mcp::PreviewReply; -#[cfg(any(not(target_os = "linux"), test))] +#[cfg(any(target_os = "macos", target_os = "windows", test))] use tcode_runtime::app::Route; -#[cfg(any(not(target_os = "linux"), test))] +#[cfg(any(target_os = "macos", target_os = "windows", test))] fn visible_preview_key( active_key: Option<&str>, route: Route, @@ -49,7 +58,7 @@ fn visible_preview_key( /// Resolve an MCP request's physical session id to the stable WebView key. /// Only the active surface can be an unsent project draft; every background /// request therefore keys directly by its stored session id. -#[cfg(any(not(target_os = "linux"), test))] +#[cfg(any(target_os = "macos", target_os = "windows", test))] fn preview_key_for_session( requested_session_id: &str, active_session_id: Option<&str>, @@ -65,13 +74,13 @@ fn preview_key_for_session( /// The reply channel a broker request is answered on. type ReplyTx = async_channel::Sender>; -#[cfg(not(target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "windows"))] pub use native::PreviewPanel; -#[cfg(target_os = "linux")] +#[cfg(not(any(target_os = "macos", target_os = "windows")))] pub use placeholder::PreviewPanel; -#[cfg(not(target_os = "linux"))] +#[cfg(any(target_os = "macos", target_os = "windows"))] mod native { use std::collections::{HashMap, HashSet}; use std::time::Duration; @@ -820,10 +829,11 @@ mod native { } } -/// Linux: no WebView (see the module docs). The tab still exists — it renders a -/// muted placeholder — and the preview MCP server still starts, but every tool -/// call answers with an error instead of driving a browser that cannot exist. -#[cfg(target_os = "linux")] +/// Linux, Android, iOS and wasm: no WebView (see the module docs). The tab still +/// exists — it renders a muted placeholder — and the preview MCP server still +/// starts, but every tool call answers with an error instead of driving a +/// browser that cannot exist. +#[cfg(not(any(target_os = "macos", target_os = "windows")))] mod placeholder { use gpui::{Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Window}; use gpui_component::{ActiveTheme as _, v_flex}; @@ -877,8 +887,8 @@ mod placeholder { } /// The error `preview_screenshot` reports where native-webview snapshots have no -/// implementation. Linux has no webview at all, so it never gets this far. -#[cfg(not(target_os = "linux"))] +/// implementation. Targets without a webview at all never get this far. +#[cfg(any(target_os = "macos", target_os = "windows"))] #[cfg_attr(target_os = "macos", allow(dead_code))] const SCREENSHOT_UNSUPPORTED: &str = "preview_screenshot is only supported on macOS"; @@ -911,7 +921,7 @@ fn snapshot_reply(image: &objc2_app_kit::NSImage) -> Result String { format!( "the preview browser is unavailable on this machine \ @@ -920,7 +930,7 @@ fn unavailable_message(err: &str) -> String { } /// Add a scheme to a bare host/port (so `localhost:5173` becomes a real URL). -#[cfg_attr(target_os = "linux", allow(dead_code))] +#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))] fn normalize_url(input: &str) -> String { let trimmed = input.trim(); if trimmed.contains("://") || trimmed.starts_with("about:") { @@ -996,7 +1006,7 @@ mod tests { /// Off macOS (but where a webview exists — i.e. Windows) `preview_screenshot` /// surfaces a plain tool error instead of a broken capture. On Linux there is /// no webview at all and the whole panel is a placeholder. - #[cfg(all(not(target_os = "macos"), not(target_os = "linux")))] + #[cfg(target_os = "windows")] #[test] fn screenshot_is_unsupported_off_macos() { assert!(SCREENSHOT_UNSUPPORTED.contains("only supported on macOS")); diff --git a/docs/multiplatform-plan.md b/docs/multiplatform-plan.md new file mode 100644 index 00000000..535c3088 --- /dev/null +++ b/docs/multiplatform-plan.md @@ -0,0 +1,697 @@ +# tcode 多端预案:移动端、Web 端与远程协同 + +本文是移动 app(Android/iOS)、wasm 网页版、多端同步三项工作的**总预案**。 +它记录已验证的事实、架构决策、分期与闸门、风险与退出条件。 + +结论先行:**这三件事不是三个并列的功能,而是一条强制串行的依赖链。** +同步层是另外两者的地基,不是收尾。 + +侦察基准:zed rev `1a246efd7e1b83ab568ec5e3e6c1a43a42e1abba`(2026-07-16,zed +v1.13.0,gpui 0.2.2),gpui-component rev `0315556`。本文所有事实性断言均在 +该基准上实测得出;与代码不符时,**明确地**修正其中之一。 + +--- + +## 1. 决定一切的那条事实 + +**手机和浏览器上永远跑不了 agent。** + +tcode 的核心机制是 spawn 本地 CLI 子进程并用 stdio 管道对话。五个 provider +全部如此: + +| provider | 机制 | 位置 | +| --- | --- | --- | +| Claude | `claude --print --input-format stream-json` + 三条管道 | `crates/agent/src/claude.rs:119` | +| Codex | `codex app-server` + NDJSON 管道 | `crates/agent/src/codex.rs:642` | +| pi | `pi --mode rpc` + LF 分帧 JSON | `crates/agent/src/pi.rs:224` | +| OpenCode | spawn `opencode serve` + 回环 TCP + SSE | `crates/agent/src/opencode.rs:1247` | +| ACP | 子进程 stdio 上的 JSON-RPC,且**代其执行命令** | `crates/agent/src/acp.rs:137`、`:2560` | + +iOS 禁止 `fork`/`exec` 且无第三方 CLI 安装模型;wasm 根本没有进程概念。 +`crates/term` 的 PTY + 登录 shell + `tcgetpgrp`(`term/src/lib.rs:322`)、 +`services/git.rs:63` 的 shell-out git、`acp_registry.rs:291` 的 +下载→解压→chmod→执行,同理全部不可移植。 + +所以移动端与 Web 端**只能是远程客户端**,桌面机(或用户自己的服务器)当 host。 + +``` +多端同步(host/client 拆分) + ├──► 移动端 app(Android / iOS)──► 移动端 UI 完善 + └──► wasm 网页版 +``` + +任何"先做移动端、同步以后再补"的排期都会在第一周撞墙:没有 host,移动端 +app 打开后是一个什么都做不了的空壳。 + +--- + +## 2. 好消息:三个前提条件都已经成立 + +侦察推翻了三个原本以为要从零做的假设。 + +### 2.1 上游已有 wasm 后端 + +`crates/gpui_web/` 是一个 3010 行的可运行 `WebPlatform`:异步初始化 WebGPU、 +创建 canvas 与隐藏 input、跟踪 DPR/resize、`requestAnimationFrame` 驱动帧、 +翻译键鼠/滚轮/拖放事件、经 `gpui_wgpu` 渲染 +(`gpui_web/src/platform.rs:60`、`window.rs:73`)。基于隐藏 `` + +DOM composition 事件的 **IME 是真的**(`window.rs:119`、`events.rs:465`)。 +`gpui_platform` 已在 `cfg(target_family = "wasm")` 下分发到它。 +上游 CI 会对 wasm 做编译检查(`.github/workflows/run_tests.yml:663`)。 + +**但它是一个有分量的 spike,不是生产级。** 已确认的缺口: + +- `Platform` 桩:`quit`/`restart`/`activate`/`hide`/`reveal_path`/`set_menus` + 等为空操作;文件对话框与可执行路径返回错误(`platform.rs:138`、`:209`)。 +- 剪贴板读取恒为 `None`;凭据读 `None`、写/删返回错误(`platform.rs:341`、`:355`)。 +- URL/reopen/menu/thermal/layout 回调**存了但无人触发**(`platform.rs:48`)。 +- `PlatformWindow` 桩:`prompt`、最小化、缩放、IME 位置、窗口装饰(`window.rs:566`)。 +- **触摸被无条件转成鼠标事件**,不检查 `pointerType`(`events.rs:133`)。 +- dispatcher 有 5 处未决的 `TODO-Wasm`,含邮箱同步与优先级调度(`dispatcher.rs:31`)。 +- **`crates/gpui_web` 下没有任何 `#[test]` 或浏览器测试。** + +### 2.2 上游已有可移植渲染器与文字系统 + +`crates/gpui_wgpu/`(4081 行)提供 `WgpuContext`/`WgpuRenderer`/`WgpuAtlas` 与 +`CosmicTextSystem`(cosmic-text 分词整形 + swash 度量光栅)。Linux 的 X11/Wayland +后端已经在用它(`gpui_linux/src/linux/x11/window.rs:1703`)。 + +GPUI **没有渲染器 trait**;抽象边界就是 `PlatformWindow::draw(&Scene)` 加 +`sprite_atlas()`(`gpui/src/platform.rs:750`)。原生构造函数接受任意 +`HasWindowHandle + HasDisplayHandle`(`wgpu_renderer.rs:176`)。 + +**关键信号**:`WgpuRenderer` 里已经有 `unconfigure_surface`/`replace_surface`, +注释明写是为 Android 原生窗口销毁/重建准备的(`wgpu_renderer.rs:1690`)。 + +也就是说 **Android 后端不需要重写渲染,也不需要重写文字排版**。这正是 +`gpui-mobile` 当年手搓的那套(wgpu + cosmic-text + swash)——上游现在自带了。 + +### 2.3 上游正在主动为移动端做准备 + +`gpui::Platform` / `PlatformWindow` 里已有成体系的移动端词汇: + +- `AppLifecyclePhase { Active, Inactive, Background, Foreground }`,文档里带 + **iOS/Android 回调对照表**(`platform.rs:647`)。 +- `WindowInsets { safe_area, ime }`,注释直接点名 iOS `safeAreaInsets` 与 + Android `WindowInsets.Type.ime()`(`platform.rs:672`)。 +- `on_memory_warning`,点名 `didReceiveMemoryWarning` / `onTrimMemory`(`platform.rs:211`)。 +- `TouchEvent { id, phase, position, force }`、`TouchId`、`TouchPhase`、 + `PlatformGestures`(`interactive.rs:85`)。 +- Android 返回键、软键盘显隐、文本状态通知(`platform.rs:822`)。 + +**这些绝大多数带默认实现,且核心尚未接线**:`App::new_app` 注册了键盘布局、 +热状态、退出回调,但**没有注册生命周期与内存回调**(`app.rs:858`); +`TouchEvent` 自己的文档写着分发"实现待定",窗口层记录了触摸模态却不派发 +(`window.rs:4721`);`insets`/`on_insets_changed` 在声明之外找不到消费者。 + +**判读**:Zed 正在把 GPUI 往移动端推,词汇先落地、接线在后。这对我们是**顺风**, +但也意味着**移动端 trait 表面是移动靶**。战略含义见 §7。 + +### 2.4 gpui-component 官方支持 WASM + +就在 tcode 当前 pin 的 rev 上:`crates/story-web` 存在,README 支持矩阵写 +`Web | Yes (WASM)`,非 wasm 依赖已做 target 门控。代价:**tree-sitter 在 wasm +下被整体去掉**——但 tcode 用的是 syntect,不受影响。 + +--- + +## 3. 目标架构 + +### 3.1 拆分接缝 + +侦察确认接缝**已经天然存在**,不需要发明: + +```rust +// crates/agent/src/lib.rs:615 +pub struct SessionHandle { + pub provider: ProviderKind, + pub commands: async_channel::Sender, + pub events: async_channel::Receiver, +} +``` + +五个 provider 全部归一化进同一个 `AgentEvent` 流(32 个变体, +`lib.rs:652`),且**已经是 `Serialize + Deserialize`、`#[serde(tag="type")]`**。 +`SessionCommand`(`lib.rs:548`)只差两个 derive,payload 类型均可序列化。 +`Timeline::fold_events`(`core/src/session.rs:558`)是纯 reducer,客户端可以 +原样重放出与本地完全一致的 Timeline。 + +**线协议基本是白送的。** + +### 3.2 crate 布局 + +区分两层,这一点不能混:**GPUI 后端是通用的、可上游化的;app 外壳是 tcode 专属的。** + +``` +crates/ + # ── 现有,需要拆分 ── + agent/ → agent-protocol(纯类型,全平台) + agent-local(spawn,桌面) + services/ → services-core(纯逻辑) + services-desktop(fs/git/进程) + runtime/ → runtime-core(状态与 fold) + runtime-host(进程/PTY/存储属主) + ui/ → ui-core(领域渲染,复用) + ui-desktop + ui-mobile + term/ → 不动,永远桌面专属 + {preview,orchestrate,computer-use}-mcp/ → 逻辑与 HTTP 服务器分离 + + # ── 新增:同步层 ── + sync-protocol/ 纯类型 + 编解码,全平台 + sync-host/ 桌面侧服务端 + sync-client/ 客户端,全平台(含 wasm) + + # ── 新增:GPUI 后端(上游候选,与 tcode 无关)── + gpui-android/ Platform 实现 · NDK/NativeActivity · JNI + gpui-ios/ Platform 实现 · UIKit · Metal + + # ── 新增:三端共用的客户端 UI ── + client-app/ ClientApp + Transport trait,全平台 + + # ── 新增:app 外壳 ── + tcode-android/ cdylib + Gradle 工程 + tcode-ios/ staticlib + Xcode 工程 + tcode-web/ cdylib + Trunk +``` + +**`client-app` 刻意不是工作区成员。** 它依赖 `tcode-ui` 的 `portable` feature, +而那与 `desktop` 是**互斥模式**而非可叠加的层:开了 `desktop` 的 `ChatView` 持有 +`AppState` 和 composer,远程客户端无从提供。Cargo 会在工作区构建内统一 feature, +所以把它列为成员会让 `tcode-ui` 同时开启两者,两个构造函数都无法满足。 + +三个 shell 各自在 **target 表内**按路径依赖它,因此它只为 wasm/Android/iOS 构建, +从不为宿主构建。单独检查:`cargo check -p client-app --target wasm32-unknown-unknown`。 +两个工作区清单守卫为它加了显式豁免——一个刻意的非成员应当被写明,而不是靠沉默。 + +把 `gpui-android`/`gpui-ios` 和 `tcode-android`/`tcode-ios` 分开,是为了让后端 +能独立于 tcode 演进、独立于 tcode 被上游接纳。合在一起会永久绑死。 + +### 3.3 同步模型:单写者,不做 CRDT + +**host 是唯一权威。客户端是瘦的。** + +- host → client:带**单调 `seq`** 的 `AgentEvent` 流(游标续传)。 +- client → host:`SessionCommand` + 新增的 `RuntimeCommand`(app 级操作:开线程、 + 归档、git、设置……候选面在 `AppState` 的 209 个 public 方法里,需筛选)。 +- client 本地只保留纯展示态(滚动位置、面板开合、草稿文本)。 + +**明确不做 CRDT、不做离线编辑合并。** 客户端离线时排队用户输入并在重连后重放, +冲突就报错让用户重试。做 CRDT 会让工程量翻倍且收益存疑——agent 会话本质上是 +单写者的。 + +### 3.4 必须补的两个洞 + +1. **没有序列号。** 事件信封只有 Unix 毫秒 `ts`,且代码明确容忍时间戳相等或 + 回退(`services/src/store.rs:25`)——**时间戳不构成严格序**。必须加持久化的 + per-session 单调 `seq`,否则断线续传无从谈起。这是对现有 JSONL 格式的 + 向后兼容扩展(旧行 `seq` 缺省按文件位置补齐)。 + +2. **机器局部状态泄漏。** 原先以为问题在持久化索引结构里,**实测推翻了这个判断**。 + + 对本机 120 个真实会话日志按事件类型统计"含主机绝对路径"的比例: + + | 事件类型 | 含绝对路径 | 总数 | + | --- | ---: | ---: | + | `approval_requested` | 288 | 300(96%) | + | `item_completed` | 8047 | 27344(29%) | + | `steer_requested` | 31 | 52(60%) | + | `item_started` | 1284 | 15463(8%) | + | `delta` | 955 | 260584 | + | `turn_started` / `turn_completed` / `session_started` / `token_usage` | 0 | — | + + 下表那些字段**从不过线**:`SessionSummary` 已经只传项目目录名(Gate 1 实测断言过 host 路径不出现在编码结果里)。真正的泄漏全部在 **`AgentEvent` 的载荷内部**,而且最集中的是 `approval_requested`——恰好是手机客户端最需要看的那个事件。 + + **因此目标要重述。** "把主机路径藏起来"既做不到也不是重点:助手会在散文里直接写路径,工具调用的 JSON 是自由格式,强行改写会破坏语义。而且能驱动 agent 的客户端本来就已经被授权到那个程度。 + + 真正要解决的是**路径在客户端上没有意义**:手机应该显示 `src/main.rs` 而不是 `/Users/tryanks/proj/src/main.rs`,并且能把路径关联到项目。所以: + + - **类型化字段**(`ApprovalKind::ExecCommand.cwd`、`FileChange.path`)→ 规范化为 host 相对路径 + workspace 标识。可做且正确。 + - **自由格式内容**(工具 JSON、助手散文)→ 原样透传,客户端按原样渲染。这是被接受的现状,不是待办。 + + 下面这些字段仍需处理,但因为**第二台设备打开会话时它们失效**,而不是因为泄漏: + + | 字段 | 位置 | + | --- | --- | + | `Project.root` | `core/src/project.rs:14` | + | `SessionMeta.cwd` | `core/src/project.rs:47` | + | `WorktreeInfo.root_project_path` | `core/src/project.rs:70` | + | provider `binary_path` / `home_path` | `core/src/settings.rs:84` | + | `AcpLaunch::Binary.command` | `agent/src/lib.rs:80` | + | `Attachment.source_path` | `agent/src/lib.rs:435` | + | `ApprovalKind::ExecCommand.cwd` | `agent/src/lib.rs:965` | + | `ResumeCursor`(provider 私有 JSON,绑定 host 安装/账号) | `agent/src/lib.rs:110` | + +--- + +## 4. 明确的非目标(范围围栏) + +写下来是为了以后有人想加时,必须显式推翻它。 + +- **移动端/Web 端不跑本地 agent。** 永远。 +- **手机上没有可交互终端。** 最多只读回滚屏。原因:无键盘时的原始按键透传 + + h/v 分屏是死路(`terminal_drawer.rs:402`、`:1503`)。 +- **手机上没有内嵌 WebView 预览。** 它是 GPUI 不合成的原生 WKWebView,Linux 上 + 本来就已经是占位符;手机上交给系统浏览器。 +- **不做离线编辑与冲突合并。** +- **移动端不做 computer-use。** 后端只有 macOS 有实现,其余平台是恒返回 + unsupported 的桩(`computer-use-mcp/src/backend.rs:9`)。 +- **不 vendor / fork GPUI。** 见 §7。 +- **手机不做代码编辑器。** tcode 是 agent 客户端,不是移动 IDE。 + +--- + +## 5. 分期与闸门 + +每一期都以**可运行的东西**结束,而不是以"重构完成"结束。每期入口有 gate, +不过 gate 不进下一期。 + +### 期 0 — 地基与止血(无用户可见变化) + +1. **锁死 gpui rev。** 现在 `crates/{app,runtime,ui}/Cargo.toml` 的 gpui git + 依赖**没有任何 `rev`**,只有 Cargo.lock 兜着——一次 `cargo update` 就会把 + GPUI 静默跳到 master。移植期间这是不可接受的。显式 pin,并定一个升级节奏 + (建议双周,单独 PR,单独跑全量 gate)。 +2. **修 `cfg(not(target_os = "linux"))`。** `crates/ui/Cargo.toml:29` 用它门控 + `gpui-wry`/`wry`,这个条件把 **Android/iOS/wasm 全算成了桌面**。改成显式 + 列举 macos/windows。移植第一天就会被它绊倒。 +3. 建立 `cargo check --target wasm32-unknown-unknown` 与 + `aarch64-linux-android` 的 CI 任务(**允许失败**,只作为进度计分板)。 +4. **注意 wasm 工具链约束**:上游 CI 用的是 + `cargo -Zbuild-std=std,panic_abort check --target wasm32-unknown-unknown` + 加 `-C target-feature=+atomics,+bulk-memory,+mutable-globals`,即 + **需要 nightly + build-std**,且多线程模式依赖 SharedArrayBuffer + → **部署页面必须带 COOP/COEP 响应头**。这条要在期 0 就确认能接受。 + +**Gate 0**:pin 生效;wasm/android 的 check 任务能跑起来并输出一份错误清单 +(此时必然大量失败,这是基线)。 + +### 期 1 — 协议与 host(最大的一块) + +1. `sync-protocol`:给 `SessionCommand` 加 derive;定义带 `seq` 的事件信封; + 定义 `RuntimeCommand`;定义握手/鉴权/游标续传。 +2. 存储加 `seq`(向后兼容)。 +3. 拆 `agent` → protocol / local。`start_session` 增加 `Remote` 分支。 +4. `sync-host`:**独立 crate,不依赖 GPUI**(这样期 1.5 的无头 `tcode-server` + 只需换一个 `main` 即可复用)。桌面进程内起服务端,鉴权(配对码 + 长期 + token),WebSocket 传输(axum 0.8 自带 `ws`,无新增重依赖)。 +5. 路径与机器身份的规范化(§3.4 第 2 条)。 +6. **协议版本号 + 握手兼容性协商**——这是多端版本漂移的正解(见 §8.1)。 + +**Gate 1**:两个**桌面** tcode 实例,一个当 host、一个当 client, +client 能实时看到 host 上跑的会话并成功发送一个 turn、批准一次权限请求。 +这一步完全不碰 GPUI,用桌面验证协议——**这是整个计划里最重要的一个 gate**。 + +### 期 1.5 — 无头 tcode-server(可与期 2 并行) + +复用期 1 的 `sync-host` crate,只换一个 `main` + 配置/日志/进程管理。 +需要额外解决的:provider CLI 在服务器上的安装与鉴权路径、无 GUI 环境下的 +`shell_env::import_login_shell_environment` 等价物、升级策略。 + +**Gate 1.5**:`tcode-server` 跑在一台远程机器上,桌面 client 连上去完成 +读/发/批准。用户笔记本可以合盖。 + +### 期 2 — Web 客户端(第一个真正的新端) + +1. `services`/`runtime` 拆分,把 fs/进程/git 关到 `-desktop` 后面。 +2. `tcode-web` + Trunk 构建。 +3. `ui` 拆出 `ui-core`。**注意**:`chat.rs`(5156 行) 与 `composer.rs`(5139 行) + 把领域渲染和外壳布局**交织在同一批 `render_*` 函数里**——`markdown/*` 和 + `diff/model.rs` 已经分得很干净,这两个没有。**拆这两个文件是整个 UI 移植的 + 前置闸门**,也是"40% 可复用"是真数字还是纸面数字的分水岭。 +4. 补 `gpui_web` 的缺口(剪贴板、文件、触摸类型判别),能上游的上游。 + +**Gate 2**:浏览器里打开一个 host 上的活跃会话,能读、能发 turn、能批准。 +桌面布局,不做响应式。 + +**读的那一半已达成(2026-07-25,目视确认)。** 浏览器加载 `tcode-web`, +经 WebSocket 连上真实 host,渲染出真实会话的时间线:用户消息气泡、 +助手回复、Copy 按钮、回合计时页脚(`Total 5s · AI thinking & response 5s`), +与桌面端一致。 + +关键在于**那是桌面端的同一个 `ChatView`**,编译成 wasm 后由同步来的 `Timeline` +驱动——浏览器侧没有 `AppState`、没有 runtime、没有任何进程。这正是 §5 期 2 +第 3 项那个"拆分重构"要买的东西,现在兑现了。 + +产物体积(多线程 atomics 构建,即今天真能发布的那个配置): + +| | 体积 | +| --- | ---: | +| 裸 wasm | 15.6 MB | +| `wasm-opt -Oz` | 14.3 MB | +| gzip -9 | 6.0 MB | +| **brotli -q11** | **4.1 MB** | + +`wasm-opt` 只砍 8% 裸体积、brotli 后不变——它去掉的冗余 brotli 本来就压掉了。 +**4.1 MB 是终值**,远在"10MB 以内可接受嵌入"的门槛内,且这是悲观值 +(单线程构建会更小,但见下)。 + +**一个上游 bug 决定了配置。** `gpui_web` 的单线程配置在本文 pin 的 rev 上 +编不过:`shared_memory_supported` 定义在 `#[cfg(feature = "multithreaded")]` +下(`dispatcher.rs:130`)却被无条件调用(`:82`)。已在独立 crate 复现。 +根因是上游 CI 只跑 atomics 那条路径——又一条 §2.1「spike 而非生产级」的证据。 +按 §7 不 fork:记为上游贡献候选,本地走多线程。 + +代价是一条真实部署约束:atomics 需要 SharedArrayBuffer, +**页面必须以 COOP/COEP 跨源隔离方式提供**。 + +**实测的起点(2026-07-25)。** `cargo check --target wasm32-unknown-unknown` 错误数: + +| crate | 起始 | 现在 | +| --- | ---: | ---: | +| `tcode-i18n` | 0 | 0 | +| `sync-protocol` / `sync-client` | — | 0 | +| `tcode-core` | 2 | 0 | +| `agent`(`--no-default-features`) | 7 | 0 | +| `tcode-services`(同上) | 多 | 0 | +| `tcode-ui` | — | **51** | + +`tcode-ui` 那 51 个**全部落在 4 个依赖 crate 里**,没有一个来自 `crates/ui/src/`: +`mio` 48(经 tokio 的 `net` feature,由 `preview-mcp` / `computer-use-mcp` 引入)、 +`polling` 与 `errno`(经 `smol`)、`getrandom`(缺 wasm 的 `js`)。 + +**但不能据此断言 UI 自身可移植。** cargo 先编依赖,`mio` 失败后 `tcode-ui` 本体根本没被检查到。 +门控这四条依赖是必要的下一步,之后才谈得上测量 UI 自身的真实代价—— +而那个数字才是 §6 风险台账里"`chat.rs`/`composer.rs` 拆不开"那一条的真正判据。 + + +### 期 3 — Android + +1. `gpui-android`:`Platform`/`PlatformWindow`/`PlatformDispatcher`/`PlatformDisplay` + 实现,复用 `gpui_wgpu` 的 `WgpuRenderer` + `CosmicTextSystem`。 + 参考骨架用 `gpui_web`(架构最接近),不用 Linux headless。 +2. Activity 生命周期 ↔ `AppLifecyclePhase`;surface 销毁/重建 ↔ + `unconfigure_surface`/`replace_surface`。 +3. 触摸合成为鼠标/滚轮/pinch(因为 `TouchEvent` 的核心分发上游尚未实现)。 +4. 软键盘 + `WindowInsets`;IME 桥接 `PlatformInputHandler`。 +5. `tcode-android` cdylib + Gradle。 + +**期 3a 实测进展(2026-07-25)。** `crates/gpui-android` 已落地 2083 行, +`cargo check -p gpui-android --target aarch64-linux-android` **0 错误**—— +且这次编译顺带证明了 **`gpui` 与 `gpui_wgpu` 本身能为 Android 目标通过类型检查**, +这是 §2.2 那个"Android 只缺平台胶水、不缺渲染器"判断的第一份硬证据。 +`Platform` 覆盖 56/59,`PlatformWindow` 覆盖 49/81,零 `todo!()`。 + +**两条侦察修正:** + +1. **`cargo check` 确实需要 Android C 工具链**(本文档此前假设不需要)。 + `gpui → stacksafe → psm` 在依赖检查阶段就要汇编 AArch64。已装 NDK + 29.0.14206865,API 26,env 变量记在 `crates/gpui-android/README.md`。 +2. GPUI 可复用的优先队列模块被 cfg 门控到 Windows/Linux/wasm/test, + **Android 拿不到**,所以 dispatcher 自带了一份。这是上游贡献的候选项。 + +**距离 Gate 3a 还差什么(子线程的诚实评估,未软化):** 明天把它放上真机, +第一个失败发生在**渲染之前**——没有 Activity/SurfaceView、没有 JNI 实现、 +没有原生入口、没有 cdylib/APK 打包。接上之后,第一个未验证的后端失败点是 +`WgpuRenderer::new`(raw handle 有效性、Vulkan 适配器选择、surface 能力)。 +`draw` 本身已接通到 present。 + +**Gate 3a(渲染证明)**:Android 设备上开出窗口并渲染一帧。 +最小可行后端所需的非桩方法:执行器、text system、`run`、display 发现、 +`open_window`、raw handle、bounds/scale、input handler 存储、 +`request_frame`/input/resize/close 回调存储、`draw`、`sprite_atlas`、 +以及**由原生 vsync 回调驱动 `on_request_frame`**——最后这条漏了会开出窗口但 +永不绘制。 + +**Gate 3b**:手机上跑通期 2 的同一条端到端路径(读/发/批准)。 + +### 期 4 — iOS + +同期 3。**此前记录的"额外阻塞"是错的,已实测推翻。** + +原判断:`gpui_wgpu` 的原生 instance 只启用 `Backends::VULKAN | GL` +(`wgpu_context.rs:184`),所以 iOS 需要上游加 Metal 支持。 + +实测结果:那个 `instance()` 只是**便利函数,不是唯一入口**。 +`WgpuContext::new(instance, surface, compositor_gpu)`(`wgpu_context.rs:27`) +接受调用方自己构造的 instance,而 `WgpuRenderer::new` 接受 +`GpuContext = Rc>>`(`wgpu_renderer.rs:105`)—— +一个共享槽位,只在为空时才自建。**预先填入 Metal 的 `WgpuContext` 就绕开了默认值。** + +在一个 scratch crate 里对 `aarch64-apple-ios` 实测,**0 错误**:`gpui` 编译通过、 +`gpui_wgpu` 编译通过、`Backends::METAL` 的 instance 可构造。 + +**所以 iOS 不需要任何上游改动**,形状与 Android 相同:写 `Platform` 胶水, +复用上游的渲染器与文字系统。§7 的"不 fork"策略在这里不需要任何例外。 + +**Gate 4**:iOS 设备上跑通同一条端到端路径。 + +### 期 5 — 移动端 UI + +现在才动 IA。已量化的现状: + +- **161 处 hover/tooltip**(59 + 102),集中在 `composer.rs`(18+21) 与 + `chat.rs`(11+10)。触摸端全部失效。 +- **⌘K 命令面板在手机上完全无法触达**:全 crate 只有 8 个键绑定,其余动作都有 + 指针入口,唯独 palette 只能靠 `on_action`(`shell.rs:318`、`main.rs:296`), + 没有任何按钮能打开它。它还兼任线程搜索/切换——这是导航黑洞。 +- **侧栏折叠态是 hover 触发的浮层**(`shell.rs:533`,12px 左边缘条),触摸无对应手势。 +- **`main.rs:442` 写死 `window_min_size 900×600`**。 +- 大量写死宽度:`palette.rs:492` 640px、`add_project_dialog.rs:48` 680px、 + `settings_page.rs:341` 620px、`shell.rs:126` 右栏 560px、侧栏 255px + (单它就吃掉 390pt 手机宽度的 65%)、`diff/view.rs:981` 分栏最小 190+180px。 + +**IA 决策:** + +- 手机:**底部 tab(Threads · Chat · Changes · More)+ 每 tab 内导航栈 + + bottom sheet 承载决策**。理由:本 app 恰好只有约 3 个同级目的地和 1 个主导 + 目的地(chat),tab 让主导面永远一次拇指点击可达,抽屉做不到; + 批准/提交/选 provider 这类瞬时决策形状天然适配 bottom sheet。 +- chat + composer 是首屏,app 启动即进。 +- diff 改成**全屏 unified**,杀掉 2-up(380pt 分栏塞不进 390pt)。 +- 命令面板 → **Search tab**,从隐藏模态升为一等导航。 +- 批准面板 → bottom sheet,Approve/Reject 拇指区大按钮。这是最时间敏感的交互。 +- hover 类可供性 → 每行常驻 `⋯` 溢出按钮 + 长按菜单。**不允许任何 hover-only。** +- **平板:适配桌面布局,不是放大手机布局。** 1024pt 放得下 sidebar+chat, + diff 并排在平板上是真有用。三栏保留,可拖拽把手换成 2–3 个断点。 + **已定为手机之后**(§8)——它是这三条里最便宜的一条,留作手机 IA 定型后的 + 快速追加。 + +**手机独有的新能力**(桌面不需要):需批准/turn 完成的**推送通知**(桌面假设 +你在看,手机假设你不在)、后台挂起与重连、拇指可达的发送/批准、语音输入、 +连接状态横幅。 + +**复用估算**(基于实测 `crates/ui/src` = **36,929** 行): + +| 桶 | 约 LOC | 占比 | +| --- | ---: | ---: | +| 可直接复用(markdown 引擎 6153 + diff model/parse/algorithm + 高亮/格式化等纯逻辑) | ~15,000 | ~40% | +| 外壳需重写(shell/sidebar/settings/各类 dialog + chat/composer/diff-view 的布局半边) | ~17,000 | ~46% | +| 直接砍掉(terminal_drawer 2239 + preview_panel 1004 + window_caption 283 + 胶水) | ~4,000 | ~11% | + +> 此表按模块行数分桶得出,`chat.rs`/`composer.rs`/`diff/view.rs` 三个混合文件 +> 按 50/50 拆分——这是全表**最不可靠的一个数字**,且它成立的前提正是期 2 第 3 项 +> 的拆分重构。 + +**实测修正(2026-07-25)。** 上面那个"最不可靠的数字"现在有实测了。按模块统计 +对 `AppState` 的引用密度: + +| 模块 | 行数 | `app_state` 引用 | 密度(次/千行) | +| --- | ---: | ---: | ---: | +| `markdown/*`、`diff/model`、`diff/parse` | 10,145 | **0** | 已可移植 | +| `chat.rs` | 5,162 | 42 | 8.1 | +| `diff/view.rs` | 2,153 | 27 | 12.5 | +| `composer.rs` | 5,140 | 93 | 18.1 | +| `settings_page.rs` | 1,792 | 46 | 25.7 | +| `sidebar.rs` | 1,710 | 67 | 39.2 | + +而深层渲染函数**本来就是数据驱动的**: + +```rust +// crates/ui/src/chat.rs:821 +fn render_turn(&self, index: usize, turn: &TurnMeta, cwd: &Path, + entries: &[Arc], ...) -> AnyElement +``` + +取的是 `tcode-core` 的 `TurnMeta` / `TimelineEntry`,不是 `AppState`。 + +**所以 §6 风险台账里"`chat.rs`/`composer.rs` 拆不开"那一条应当降级。** 审计报告 +描述的"渲染与布局交织在同一批 `render_*` 函数里"在 `chat.rs` 上不成立:8 次 +引用/千行不是交织,是顶层的一道薄接缝——`ChatView` 持 `Entity` 取数据、 +触发动作,往下传的全是领域数据。40% 的复用估计**偏保守**。 + +耦合真正重的是导航与设置外壳(`sidebar` 39.2、`settings_page` 25.7),而那两块 +按 §5 期 5 的 IA 决策本来就要重写。 + +**Gate 5**:手机上完成一次真实工作流:收到批准推送 → 打开 → 读 diff → 批准 → +发下一个 turn。 + +--- + +## 6. 风险台账与退出条件 + +| 风险 | 判据 | 预案 | +| --- | --- | --- | +| **GPUI 移动端表面漂移**(上游正在活跃改动) | 双周升级 PR 的修复成本 | 若单次升级修复 > 2 天,改为季度升级并接受落后 | +| **`gpui_web` 不够生产级**(0 测试、5 个 TODO-Wasm、回调空转) | 期 2 中缺陷密度 | 补齐并上游;上游不接则维护薄补丁层(**不 fork 整个 GPUI**) | +| **iOS Metal 未启用**(已确认) | 上游 PR 是否被接纳 | 本地补丁 `gpui_wgpu`,成本可控 | +| **`AppState` 上帝对象**(69 字段、impl 块 7,416 行、209 个 public 方法) | 期 1 中 `RuntimeCommand` 面的抽取难度 | 只抽取移动端**真正需要**的子集,桌面路径保持直调不变 | +| **`chat.rs`/`composer.rs` 拆不开** | 期 2 第 3 项耗时 | **这是硬闸门。** 若拆分失败,手机端 UI 只能整体重写,复用率从 40% 掉到 ~15%,期 5 工作量翻倍——此时应重新评估是否继续 | +| **wasm 需 nightly + build-std + COOP/COEP** | 期 0 确认 | 若部署环境给不了 COOP/COEP,退到 `single_threaded_web()` 单线程模式,接受性能损失 | +| **wasm 包体积** | **已实测,见 §6.1** | **已排除"降级为只读"这条退路**(§8 定为完整客户端)。预案:`wasm-opt -Oz` + brotli 预压缩 + 剥离 syntect 语法集与 `image` 多余解码器 + 代码分割/懒加载。若仍不可接受,退到"首屏骨架 + 渐进加载",而非砍功能 | +| **触摸分发上游未实现** | 期 3 手感 | 合成鼠标事件(`gpui_web` 已是此路线);滚动手感不达标则自研惯性层 | +| **移动端 IME/软键盘不可用** | 期 3a 之后立刻验证 | **这是移动端最大的未知。**必须在期 3 早期用一个"能打字的输入框"独立验证,不要等到期 5 | + +### 6.1 wasm 体积:已实测 + +在本 rev 上实际构建两个代理产物测得(非估算)。tcode 自身尚不能编译到 wasm, +故取上下界:`hello_web` 是 GPUI + `gpui_wgpu` + cosmic-text 的**地板**, +`story-web` 是**最接近 tcode 依赖面的代理**。 + +| 产物 | 裸 wasm | `wasm-opt -Oz` | gzip -9 | **brotli -q 11** | 首屏总载荷(含 JS 胶水与资源) | +| --- | ---: | ---: | ---: | ---: | ---: | +| `hello_web`(地板) | 9.60 MB | 7.24 MB | 2.84 MB | **1.93 MB** | 1.94 MB (br) | +| `story-web`(代理) | 31.55 MB | 20.97 MB | 7.55 MB | **5.18 MB** | 5.22 MB (br) | + +工具链:rustc 1.99.0-nightly、trunk 0.21.14、wasm-bindgen 0.2.126/0.2.121、 +binaryen 131、brotli 1.2.0。两个 `-Oz` 产物均通过 `wasm-dis` 复核可解析。 + +**对"能否嵌入桌面 app"的结论:取决于嵌未压缩还是嵌预压缩。** + +- 嵌 `-Oz` 未压缩:**21 MB 起,超出 10MB 预算**,且 tcode 只会更大。 +- 嵌 **brotli 预压缩产物并以 `Content-Encoding: br` 直接吐出**:**5.2 MB**, + tcode 预计落在 6–8 MB,**在 10MB 预算之内**。浏览器本来就要解压,服务端 + 不需要持有原始字节。 + +tcode 相对 `story-web` 的增量:syntect 语法/主题 dump、`image` 的六个解码器 +(`ui/Cargo.toml:17`)、markdown 引擎、打包字体与主题;同时会去掉 story 的 +演示内容。**前两项是最肥也最容易砍的**——wasm 构建只需保留实际用到的语法集 +与 png/jpeg。 + +两点必须记住的前提: + +1. 以上均为**多线程构建**(`+atomics` + `--shared-memory`),部署需 COOP/COEP + 响应头。切到 `single_threaded_web()` 会更小且无需特殊响应头,代价是性能—— + **卡在预算边缘时这是第一个该拉的杠杆**。 +2. **上游两个 wasm 完整构建目前都不是开箱即用的**,实测均需绕过:`hello_web` + 缺 `--export=__heap_base` 导致 Trunk 线程注入失败;`story-web` 需要 + `getrandom` 的 `js` feature(经 `rand 0.8` 传入),外加一个 `psm` 的 git + patch 不可达问题。**注意 `getrandom` 这一类错误在本仓库的 wasm 基线检查中 + 同样出现**(见 §6.2)——这是期 2 会反复遇到的一类工作,不是个例。 + +### 6.2 移植基线:已实测 + +期 0 就地测得的 `wasm32-unknown-unknown` 首个基线(`cargo check`,非 build-std): + +| crate | 错误数 | 备注 | +| --- | ---: | --- | +| `tcode-i18n` | **0** | 干净通过,验证了"直接可移植"的判定 | +| `tcode-core` | 2 | 经 `agent` 传递 | +| `agent` | 7 | `ring` 构建脚本、`getrandom` 缺 `js`、`errno`/`polling` 不支持该 OS | +| `tcode-services` | 7 | 同上量级 | + +错误性质集中在**传递依赖的 target 支持**,而非 tcode 自身逻辑——这与 §2 的 +判断一致,也说明这些数字会随依赖门控快速下降。 +`.github/workflows/port-scoreboard.yml` 会持续跟踪这个数列。 + +**整体退出条件(要不要继续做):** + +- **Gate 1 拿不下**(两个桌面实例互联失败)→ 停。协议都跑不通,端就别谈了。 +- **Gate 3a 拿不下**(Android 开不出窗口渲染不出帧)→ 移动端搁置, + 只交付 Web 端(Web 已被上游 CI 覆盖,风险低一个量级)。 +- **两次分期计划都失败** → 停下来重新评估整体可行性,而不是加人。 + +--- + +## 7. 上游策略:跟随,不分叉 + +参考项目的教训: + +- **`gpui-mobile`**(itsbalamurali):pin 在另一个 zed rev,自己搓 + wgpu + cosmic-text + swash——**正是上游 `gpui_wgpu` 现在提供的东西**。 + 它的参考价值在**平台胶水层**(NDK/UIKit 桥接、生命周期、触摸), + **不在渲染层**。 +- **`gpui-toolkit`**(pierreaubert):pin 在 **zed v1.9.0**,且 + **vendor 了 19 个 zed crate(含 gpui 本体)**。整体采用等于把 tcode 从 + v1.13.0 倒退回 v1.9.0 并接管一个 fork。**代价不可接受。** + 只作为 `gpui-ios`/`gpui-android` 的实现参考。 + +**我们的策略**:显式 pin rev + 定期升级;把 `gpui-android`/`gpui-ios` 写成 +**干净的、与 tcode 无关的**后端 crate,主动向上游投递。上游正在为移动端铺路 +(§2.3),这是投递窗口期。被接纳则维护成本归零,不被接纳我们也只维护两个 +独立 crate,而不是一个 GPUI 分叉。 + +依赖可达性已确认:`gpui_web`/`gpui_wgpu` 都是 workspace 包,有公开库根与公开 +入口类型,下游可按 git 依赖。但两者继承 `publish = false`,**不会上 crates.io**; +且 `gpui_platform::current_platform` **没有 Android/iOS 分支** +(`gpui_platform.rs:35`),移动端 crate 应直接依赖 `gpui` + `gpui_wgpu`, +绕开该 facade。 + +--- + +## 8. 已定决策 + +| 议题 | 决定 | 影响 | +| --- | --- | --- | +| **host 形态** | **两者都做,内嵌优先。** 期 1 用桌面内嵌 host 拿下 Gate 1,但 `sync-host` 从第一天就写成**不依赖 GPUI 的独立 crate**;无头 `tcode-server` 作为期 1.5 复用同一套代码,只换一个 `main`。 | 内嵌的增量体积近乎为零——app 已内嵌三个 MCP 服务器,`axum` + `tokio` 早已链入(当前 release 二进制 30.3 MB),axum 0.8 自带 `ws` feature,无新增重依赖。 | +| **Web 端定位** | **完整客户端**(读 / 发 turn / 批准 / 看 diff),不是只读查看器。 | 包体积风险**不能**再靠"降级成只读"化解。期 2 必须实测首屏体积并准备代码分割/懒加载预案。见 §6。 | +| **手机 vs 平板** | **手机优先**,平板靠后。 | 与"先拿便宜胜利"的建议相反,但有真实好处:**把 `chat.rs`/`composer.rs` 的拆分硬闸门与触摸/IME 的真实手感提前引爆**。坏消息早来好过晚来。代价是更长时间没有可展示成果。 | +| **wasm 资产分发** | **独立静态托管**(GitHub Pages / CDN),桌面二进制不托管 wasm。 | 桌面二进制不受影响。纯内网场景需自行搭静态服务。 | +| **桌面 UI 是否也由 wasm 驱动** | **否。** 见 §8.1。 | UI 一致性改由源码共享 + 协议版本协商保证。 | + +### 8.1 被否决的方案:桌面 UI 也加载同一份 wasm + +提案:桌面 app 内嵌 wasm 运行时(不用 webview)执行与浏览器端相同的 UI wasm, +实现"一份 UI 产物跑所有端"。 + +**否决,理由如下(均已实测核实):** + +1. **浏览器那份 wasm 不可移植。** `gpui_web` 深度绑定 wasm-bindgen / js-sys / + web-sys(仅 `window.rs` + `platform.rs` 两文件即 42 处引用),示例经 Trunk + 以 `data-bindgen-target="web"` 构建。该模块导入的是 wasm-bindgen 生成的 + JS shim 函数面。在 wasmtime 中运行它 = 用 Rust 实现一个无头浏览器 + (canvas / WebGPU / DOM 事件 / ResizeObserver / 剪贴板)。且 wasm-bindgen + ABI 跨版本不稳定,生成的 JS 胶水不可省略。 +2. **改走"自定义 UI 协议 + GPUI 留在宿主侧"也不成立。** 唯一抽象边界是 + `PlatformWindow::draw(&Scene)`,而 `Scene`(`gpui/src/scene.rs:41`)的 + `paint_operations` / `primitive_bounds` / `layer_stack` 为私有或 `pub(crate)`, + 整个类型**无 `Serialize`**,且 `monochrome_sprites` / `polychrome_sprites` + 引用的是**图集贴片句柄(GPU 常驻状态)**。跨边界传 Scene 需连纹理图集共享 + 一并解决。该类型不是为跨进程设计的。 +3. **反向放大体积问题。** 该方案要求桌面内嵌 wasm 运行时(wasmtime 数 MB), + 比托管一个 wasm blob 更重——而提案的出发点正是控制桌面二进制体积。 +4. **iOS 大概率过不了审。** App Store 审核指南 2.5.2 限制下载并执行可执行代码; + 自带运行时执行下载来的模块历史上属高风险被拒区。 + +**它识别的真问题是有效的**:多端 UI 版本漂移。改用便宜解法—— +**协议版本号 + 握手时兼容性协商**,不兼容即明确拒绝并提示升级(成本约几百行)。 +而"一份 UI 代码跑所有端"**已经免费拥有**:tcode 全栈 Rust,`ui-core` 的**源码** +同时编译到 macOS / Windows / Linux / Android / iOS / wasm,源码级复用已提供 +100% 的代码共享收益。二进制级共享额外只买到 UI 热更新,而那在 iOS 上本就是禁区。 + +若日后仍要推进,作为**独立探索性专题**排期,不进主线关键路径,且须先完成 +最小可行性验证。 + +### 8.2 被否决的方案:用 OAuth2 登录身份提供方来准入设备 + +提案:设备不再读配对码,改为登录一个 OAuth2 身份提供方(当时具体到自建 +Gitea),host 验证 token、查白名单,然后签发它自己那枚长期凭证。 + +**已实现到可用程度后整体回退。** 回退理由不是技术缺陷——它是能跑的, +接缝也留得干净:协议里传的是不透明的 `provider_token`,host 侧是 +`IdentityVerifier` trait,配对码始终是默认路径。**理由是产品定位**: +仓库里唯一的实现绑着某一个人的自建实例,端点路径、userinfo 的字段名、 +设置项的形状全都是那一家的形状。这会把一个通用开源工具变成一份私人定制品—— +任何拿到源码的人,看到的是"配置你的 Gitea",而不是"配置你的身份提供方"。 + +**通用化本来是可行的**(改成 OIDC discovery + `preferred_username`, +把 Gitea/GitHub/GitLab 降级成预设数据),但那意味着主线要开始维护一套 +身份提供方兼容性矩阵,而这不是本项目的问题域。 + +**它识别的真问题依然存在**:每台新设备都要人工读一次六位码。 +但这在同类工具里是常态而非缺陷——Syncthing 的设备 ID、Tailscale 的 +pre-auth key、SSH 的 `authorized_keys` 都是一次性的手工准入动作, +且都刻意不依赖第三方在线。配对码的成本是"每台设备一次", +不是"每次连接一次",这个量级不值得引入一个外部依赖来消除。 + +若日后要重开,正确形态是:**只发布 `IdentityVerifier` 这个接缝**, +具体 provider 作为第三方 crate 或插件由使用者自己提供,主线一个都不带。 + +--- + +## 9. 仍待拍板 + +1. **网络模型?** 鉴权已定:配对码,且只有配对码(§8.2 记录了被否决的 + OAuth2 方案)。剩下的是可达性——局域网直连最简单,广域网需要中继或打洞。 + 先做局域网、后加中继,还是一开始就上中继?注意 host 默认只绑 loopback, + `pairing.rs` 里"六位码够用"那套论证正建立在这个前提上;一旦放开绑定地址, + 限流必须同时补上。 +2. **iOS 分发?** App Store 会不会因"远程执行代码"条款有麻烦(注意:即使按 + §8.1 否决了 wasm 方案,"手机遥控桌面执行命令"本身也可能触碰审核红线), + 还是只走 TestFlight/企业签名?**这个法务问题应在期 4 之前问清楚**, + 别等做完才发现。 diff --git a/docs/remote-and-mobile.md b/docs/remote-and-mobile.md new file mode 100644 index 00000000..2e65a6d1 --- /dev/null +++ b/docs/remote-and-mobile.md @@ -0,0 +1,212 @@ +# Working remotely: the browser and phone clients + +Tcode runs agents on a computer. A phone cannot run Claude Code, and neither can +a browser tab — there is no process to spawn, no filesystem to edit, no PTY. + +So the other clients are not smaller copies of the desktop app. They are +**remote controls for a machine that is already running one**. The desktop app +(or a headless server) keeps being the place where work happens; the browser and +the phone watch it, send turns to it, and answer its approval prompts. + +Everything on this page follows from that. If the host is asleep, there is +nothing to connect to. + +--- + +## What actually works today + +Be precise about this, because "it compiles" and "somebody used it" are very +different claims. + +| | Status | +| --- | --- | +| Desktop host, embedded | Working. Exercised end to end: a remote client sent a turn and approved a command, and the agent ran it. | +| Headless host (`tcode-server`) | Working. Same, driven from the CLI probe. | +| Browser client | Builds and runs. Follows a session, sends turns, answers approvals. | +| Android client | **Links** for `aarch64-linux-android`, all JNI entry points exported. Has never run on a device. | +| iOS client | **Links** for `aarch64-apple-ios` against the iOS SDK. Has never run on a device or simulator. | + +The two mobile rows are the honest ones. Linking is a real result — it proves +every symbol that GPUI, `gpui_wgpu`, `wgpu`, Metal, Vulkan and `psm` need +resolves, which a `cargo check` never establishes. It is not the same as having +seen a frame. The most likely first failure on both is renderer creation meeting +real hardware for the first time. See +[`crates/tcode-android/README.md`](../crates/tcode-android/README.md) and +[`crates/tcode-ios/README.md`](../crates/tcode-ios/README.md) for exactly what +each one does and does not implement. + +--- + +## Turning on the host + +### From the desktop app + +Nothing to enable. The app starts a sync host on loopback at launch and writes +two things to its log: + +```text +sync host listening at ws://127.0.0.1:54321/sync +pair a device within 180s with code K7M2QX +``` + +The address is what a client connects to. The code is what it connects *with* — +see [Pairing](#pairing) below. + +### Headless + +`tcode-server` is the same host with a different `main`. Use it when the machine +doing the work has no display, or when you want the agent to keep running after +you close the laptop lid on the app. + +```sh +cargo run -p tcode-server +cargo run -p tcode-server -- --bind 0.0.0.0:7777 +cargo run -p tcode-server -- --print-token +``` + +It has no screen to print a pairing code onto, so it hands out the durable token +directly with `--print-token`. + +### A word about `--bind` + +The default is `127.0.0.1`, and that default is load-bearing. + +The sync protocol runs over plain WebSocket. There is no TLS in it, and the +token is the only thing protecting the connection. On loopback that is fine: to +reach the socket you already have local access, and if you have local access you +could have run the agent yourself. + +Widen the bind address and both assumptions break at once. The token now crosses +a network in the clear, and the six-character pairing code — which is defensible +precisely because an attacker must first reach the socket — becomes something +anyone on the network can start guessing at. + +If you need this across machines, put it behind something that provides +transport security and access control: a WireGuard or Tailscale network, an SSH +tunnel, or a reverse proxy terminating TLS. Do not simply bind `0.0.0.0` on a +network you do not control. + +--- + +## Pairing + +A new device gets its credential by trading a short code for a durable token, +once. After that it reconnects with the token and never sees a code again. + +1. Read the six-character code off the host. +2. Enter the host address and the code on the client's connect screen. +3. The client stores the token it gets back and goes straight to the session + list from then on. **Sign out** discards it. + +The code is deliberately small and deliberately weak on its own: + +- it expires in **three minutes**; +- it is **single use**, so even a correct guess still has to beat the real + client to it; +- it is worth nothing to anyone who cannot already reach the host's socket. + +Its alphabet omits the characters people misread aloud or mistype — no `O`/`0`, +no `I`/`1`/`L`, no `S`/`5`, no `B`/`8` — because a code exists to be read off one +screen and typed into another. Case and separators do not matter: `k7 m2 qx` is +the same code as `K7M2QX`. + +There is no account, no identity provider, and no sign-in. That was tried and +removed on purpose; the reasoning is in +[`docs/multiplatform-plan.md`](multiplatform-plan.md) §8.2. The short version: +device pairing is the same shape as Syncthing's device IDs, Tailscale's +pre-auth keys, or `authorized_keys` — a one-time manual admission per device, +which is a cost you pay once and which never depends on a third party being +reachable. + +--- + +## The browser client + +The web client is a **full** client, not a viewer: it follows a session, sends +turns, and answers approval requests. + +It is not a web page wrapping the desktop UI. It is the same Rust UI code +compiled to WebAssembly and drawn with WebGPU, which is why it looks and behaves +like the app rather than like a website. + +### Building and serving it + +This one has real prerequisites. The pinned GPUI revision needs nightly Rust, a +locally rebuilt standard library, and the wasm atomics features: + +```sh +CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS="-C target-feature=+atomics,+bulk-memory,+mutable-globals" \ +RUSTC_BOOTSTRAP=1 \ +cargo +nightly -Zbuild-std=std,panic_abort check \ + -p tcode-web --target wasm32-unknown-unknown +``` + +The full link and serve invocations are in +[`crates/tcode-web/README.md`](../crates/tcode-web/README.md); they are long +enough that copying them from one place is better than maintaining two. + +### The header requirement that will bite you + +WebAssembly threads need `SharedArrayBuffer`, and browsers only expose it to a +cross-origin isolated page. Whatever serves the built assets **must** send: + +```text +Cross-Origin-Embedder-Policy: require-corp +Cross-Origin-Opener-Policy: same-origin +``` + +Without them the dispatcher cannot start its worker threads and the client will +not run. `trunk serve` is already configured to send them; a plain static file +server is not. This is the single most likely reason a working build fails to +start in a browser. + +### Credentials + +The token lives in `localStorage`, so a reload reconnects without retyping. + +`?url=` prefills the host address as a convenience — an address is not a secret. +There is deliberately no `?token=`: a durable credential has no business in an +address bar, in browser history, or in a referrer header. + +--- + +## The phone clients + +Android and iOS mount the same shared client the browser does. Each platform +crate is small on purpose: it owns the window, the touch events and the +lifecycle, and nothing else. + +The split is two layers: + +- **`gpui-android` / `gpui-ios`** implement GPUI's `Platform` trait for those + targets — window, display, dispatcher, text system, input. They know nothing + about tcode and could be offered upstream on their own. +- **`tcode-android` / `tcode-ios`** are the app shells. One owns Java and JNI, + the other Objective-C and UIKit. Neither knows GPUI's internals. + +Build instructions and prerequisites — including the Android NDK revision, and +why an NDK is required even for a `cargo check` — are in each crate's README. + +### What they do not do yet + +Both are honest about this rather than pretending: credential storage returns an +error instead of silently discarding secrets (it needs Keystore on Android and +Keychain on iOS), IME caret positioning is left to the platform, and thermal +state reports nominal rather than guessing. Neither has run on hardware. + +--- + +## Under the hood + +A tcode session is already an event log — an append-only sequence of +`AgentEvent`s that a pure reducer folds into the visible timeline. That is what +makes remote work cheap: a client does not need a parallel implementation of the +app's state, it needs the same events and the same reducer. + +So the wire protocol is thin. Clients send commands, the host sends events, and +each event carries a per-session sequence number that a reconnecting client uses +as a cursor. Frames, version negotiation and delivery guarantees are specified +in [`docs/sync-protocol.md`](sync-protocol.md). + +The architecture, the decisions taken, and the approaches rejected along the way +are recorded in [`docs/multiplatform-plan.md`](multiplatform-plan.md). diff --git a/docs/sync-protocol.md b/docs/sync-protocol.md new file mode 100644 index 00000000..32b4db23 --- /dev/null +++ b/docs/sync-protocol.md @@ -0,0 +1,142 @@ +# tcode sync protocol + +The wire contract between a **host** (a machine that can actually run agents: +spawn provider CLIs, own the repo, hold the session log) and a **client** (a +phone, browser, tablet, or another desktop that cannot). + +`crates/sync-protocol` is normative. This document explains the shape and the +reasoning; when the two disagree, fix one of them — deliberately. + +Background and the decision to make sync a prerequisite rather than a third +feature: `docs/multiplatform-plan.md` §1, §3. + +--- + +## 1. Shape + +One host, many clients, **single writer**. The host owns all state; clients +hold only presentation state (scroll offset, which panel is open, unsent draft +text). There is no offline editing and no merge: a client that cannot reach its +host can queue user input and replay it on reconnect, and a conflict is an +error the user retries. Agent sessions are inherently single-writer, so a CRDT +would double the work to model a problem we do not have. + +Transport is WebSocket with JSON text frames. Nothing in the frame definitions +depends on that choice; the crate is transport-agnostic types plus serde, with +no I/O, so it builds for wasm. + +## 2. Why these types already exist + +The protocol did not need a new vocabulary. All five providers — Codex, Claude, +pi, OpenCode, ACP — already normalize into one canonical stream, and the +existing seam is literally commands-in / events-out: + +```rust +// crates/agent/src/lib.rs +pub struct SessionHandle { + pub commands: async_channel::Sender, + pub events: async_channel::Receiver, +} +``` + +So the wire is `SessionCommand` one way and `AgentEvent` the other. Both are +serde types. A remote session becomes another `start_session` backend rather +than a parallel universe, and `Timeline::fold_events` — a pure reducer — folds +replicated events into exactly the timeline a local replay would produce. + +## 3. Ordering and cursors + +Every persisted event carries a **per-session `seq`**: 1-based, contiguous, +strictly increasing (`crates/services/src/store.rs`). + +`seq` exists because `ts` cannot do this job. `ts` is a wall clock that the +writer explicitly tolerates repeating or going backwards, so two events can +share a millisecond and a clock adjustment can invert a pair. A cursor built on +`ts` would silently skip or duplicate events at exactly the moments that matter. + +A cursor is therefore just "the last `seq` I have". Resume is exact, not +best-effort: the log is append-only and the sequence is dense, so `from_seq: N` +means "send `N+1` onward" with no ambiguity about what is missing. + +## 4. Frames + +### Client → host + +| frame | meaning | +| --- | --- | +| `Hello { min_version, max_version, client, token }` | First frame on every connection. Nothing else is accepted before it. | +| `ListSessions` | Enumerate sessions this token may see. | +| `Subscribe { session_id, from_seq }` | Stream a session. `from_seq: None` means from the beginning. | +| `Unsubscribe { session_id }` | Stop streaming; the session keeps running on the host. | +| `Command { session_id, command }` | A `SessionCommand` — send a turn, approve, interrupt, steer, rewind. | +| `Ping { nonce }` | Liveness. Mobile networks drop idle connections silently. | + +### Host → client + +| frame | meaning | +| --- | --- | +| `Welcome { version, host }` | Handshake accepted; `version` is the agreed one. | +| `Refused { reason }` | Handshake rejected. Carries the host's supported range on a version mismatch, so the client can say something better than "failed". | +| `SessionList { sessions }` | Answer to `ListSessions`. | +| `Events { session_id, events, caught_up }` | A batch. `caught_up: false` while replaying backlog, `true` once live. | +| `CommandRejected { session_id, command_ref, reason }` | The host could not apply a command. Silence would leave the client showing a turn that never ran. | +| `SessionEnded { session_id, reason }` | The provider exited or the session was archived/deleted. | +| `Pong { nonce }` | | + +## 5. Version negotiation + +The client sends a **range**, not a version, and the host picks the highest +value both support. A single version forces host and client to upgrade in +lockstep, which is not a thing you can ask of an App Store release. + +On no overlap the host answers `Refused { reason: UnsupportedVersion { +host_min, host_max } }` — enough for the client to tell the user which side is +too old. + +This is the answer to multi-device version drift, and it is why the +"ship one wasm UI artifact to every client" idea was rejected rather than +adopted (`docs/multiplatform-plan.md` §8.1): drift is a compatibility problem, +and it is far cheaper to negotiate compatibility than to make every client +execute the same binary. + +## 6. Delivery guarantees + +**Host → client is exactly-once, by construction.** Events are numbered and the +client tracks its cursor, so a duplicate batch is discarded by `seq` and a gap +is impossible without the client noticing. + +**Client → host is at-least-once, made idempotent by the existing +`delivery_id`.** `SessionCommand::SendTurn` already carries one, and +`AgentEvent::TurnAccepted` echoes it back — a mechanism that predates this +protocol and exists because the local UI has the same question after a provider +restart. A client that reconnects mid-turn compares the `delivery_id`s it sent +against the `TurnAccepted` events it can now see, and resends only what is +genuinely missing. No new dedup layer. + +For that to work the acknowledgement has to be **durable**, and originally it +was not: the runtime consumed `TurnAccepted` as control-plane state and returned +before persisting it, so it never entered the log the host streams from and no +remote client could ever observe one. Found by running the real app rather than +by any test — 770 of them passed over the broken design. + +The fix is to persist it, not to invent an out-of-band acknowledgement frame. +The question this answers is asked *after a disconnect*, so an ack that lives +only on the connection is lost at exactly the moment it is needed; one in the +log is visible on reconnect with no extra machinery and survives the host +restarting too. `Timeline` already folds `TurnAccepted` to nothing, so the extra +line costs a log entry and changes no rendering. + +Commands without a natural idempotency token are either harmless to repeat or +scoped by a `request_id` the host matches against state it already holds. + +## 7. Deliberately not in v1 + +- **Pairing.** v1 carries a pre-shared token. The pairing-code exchange is host + UX, and putting it in the wire format before that UX exists would be guessing. +- **A general app-level command surface.** `AppState` exposes 209 public + methods; the protocol should carry the ones a remote client is shown to need, + discovered by building the client, not by translating the whole surface up + front. +- **Binary framing.** JSON is legible and debuggable. Revisit when a measurement + says to, not before. +- **Multi-host federation.** One client talks to one host at a time.