diff --git a/.cargo/config.toml b/.cargo/config.toml index ce4f8c60d..f6000e3d5 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -7,7 +7,7 @@ linker = "rust-lld" rustflags = ["-C", "target-feature=+crt-static"] [alias] -workshop = "run -p build-workshop --" +installer = "run -p build-installer --" xtask = "run -p build-xtask --" # Outlines the workspace with the vibelight checkout next to this repo. Cargo # builds it in release mode on first use and rebuilds it when its source @@ -24,3 +24,8 @@ outline = "run --release --bin vibelight --manifest-path ../vibelight/Cargo.toml # identifies it. [env] CLIPPY_DISABLE_DOCS_LINKS = "1" +# The runvil-workshop checkout `cargo installer` builds from and its tests +# read: the one next to this repo, unless the variable is already set. The +# path is relative to this file's parent's parent, and Cargo passes it on +# as an absolute path. +RUNVIL_WORKSHOP = { value = "../runvil-workshop", relative = true } diff --git a/.cursor/rules/workshop-architecture.mdc b/.cursor/rules/workshop-architecture.mdc deleted file mode 100644 index 21bb7ec9b..000000000 --- a/.cursor/rules/workshop-architecture.mdc +++ /dev/null @@ -1,21 +0,0 @@ ---- -description: Workshop server crate architecture invariants - one-way dependency tiers, registry self-registration -globs: crates/workshop*/** -alwaysApply: false ---- - -# Workshop Architecture Invariants - -## One-way dependency graph - -- Dependencies flow one way: server -> features -> services -> vocabulary. Never add a dependency from a lower tier to a higher one. If Cargo rejects a cycle, the design is wrong, not the graph. -- Crates in the same tier never depend on each other, with one exception: `workshop-registry` depends on `workshop-protocol`, because the status push-channel slot holds `workshop-protocol`'s `StatusBarUpdate`. Otherwise same-tier crates meet through `workshop-protocol` wire types and `workshop-registry` proxy slots. -- The desktop app (package `workshop`) sits outside the tiers and depends on `workshop-server-api`, never on `workshop-server`; the facade is its entire view of the server. `cargo test -p build-xtask` enforces this. -- Every `workshop-*` crate's `lib.rs` opens with a `//!` doc carrying a `## Invariants` marker that lists what the crate may depend on and what it may not. Read it before adding an import. -- `lib.rs` is a facade only: crate docs, crate-level attributes, `mod` declarations, and `pub use` re-exports. No logic. - -## Registry pattern - -- Subsystems self-register into `workshop-registry` proxy slots: routes, state handles, background tasks, push channels, and shutdown handles. The composition root (`crates/workshop/server/src/app/compose.rs`) calls each subsystem's `register` by name and requires its concrete handle, so a missing registration fails boot naming the type. Subsystems reach each other only through the registry. -- Unregistered slots are graceful no-ops. Never add a hard dependency on an optional subsystem. -- Registration handles are `#[must_use]`; registry traits are sealed so only workshop crates implement them. diff --git a/.cursor/rules/workshop-spa.mdc b/.cursor/rules/workshop-spa.mdc deleted file mode 100644 index c722f2170..000000000 --- a/.cursor/rules/workshop-spa.mdc +++ /dev/null @@ -1,22 +0,0 @@ ---- -description: Workshop SPA conventions - feature directories, barrel exports, lazy loading, CSS colocation, token-only values -globs: crates/workshop/ui/**,crates/workshop/look/**,crates/workshop/platform/** -alwaysApply: false ---- - -# Workshop SPA Conventions - -## Feature directories and lazy loading - -- The package at `crates/workshop/ui/` is a sibling of the `crates/workshop/server/` crate that builds and serves it. `src/` has three layers: `base/` (paths), `services/` (the app's services and their tokens; services own no views), and `parts/` (feature directories; every panel extends `WorkshopPart` from `@workshop/platform/workshop-part`). All three layers may import `@workshop/platform`, the family's UI mechanics (lifecycle, events, the registries, the context-key and text-control services, the `WorkshopPart` base class), which never imports the app. -- Feature-based directories under `parts/` (`parts/agent/`, `parts/chatbox/`, `parts/chrome/`, `parts/editor/`, `parts/gateway/`, `parts/layout/`, `parts/menu/`, `parts/quickinput/`, `parts/run/`, `parts/status/`, `parts/stt/`, `parts/take/`, `parts/workspace/`, `parts/workspace-document/`). Shared code is defined with the entry bundle below; the panel dialog helper lives in `parts/shared/`, and the boot-loaded icons come from `@workshop/look/icons`; per-component tokens live in `tokens/component.css`. The workspace tree panel, its `tree` factory, and its Explorer and Primary Side Bar actions live in `parts/workspace/`; `parts/layout/` holds the layout machinery (zones, boot, persistence, and the keybinding dispatcher), and no `parts/layout` module imports `parts/workspace`. -- A part that the panel registry loads lazily has an `index.ts` as its chunk entry (`crates/workshop/platform/panel-registry.ts`), and other parts need none. Lazy directories export `register()` installing panel factories. -- The entry bundle is the eagerly loaded composition: `crates/workshop/ui/src/main.ts` and the `*.contribution.ts` modules it imports. Lazy panels never import a module inside it, directly or through another import. Everything else that eager and lazy code both import, such as `@workshop/platform`, `services/`, `base/`, and shared parts modules like `parts/layout/zones.ts`, is shared code. Lazy panels load via dynamic `import()` on first activation. -- Registration, not central wiring: panel types, menu items, services, socket handlers, keyboard shortcuts, and lifecycle disposal self-register through the panel, menu/command, and service registries. The menu, command, keybinding, and service registries live in `@workshop/platform`. - -## CSS colocation and tokens - -- CSS lives beside its TypeScript, imported as a side-effect. Never a separate `styles/` tree. Every feature directory is self-contained: its `.ts` and `.css` sit together, plus an `index.ts` when the panel registry loads it lazily. A designer finds the styles for the agent chat at `parts/agent/agent-session.css`, not by grepping a flat directory. -- No raw color, size, or spacing values in Workshop UI component CSS. The sizes and semantic tiers live in `@workshop/look`: primitives in `look/sizes.css`, intent aliases in `look/semantic.css`. Per-component overrides live in `ui/src/tokens/component.css`. Sizes and elevation are themed in `look/semantic.css`, and colors in `look/tokens.css`, which `look`'s own component sheets read directly. -- CSS classes use the `.ws-` project prefix (`.ws-agent-toolbar`). -- Directories and files are kebab-case (`agent-session-view.ts`, `agent-session.css`). diff --git a/.gitattributes b/.gitattributes index 2175e3a17..d9e86b829 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,19 +6,3 @@ *.icns binary *.gguf binary *.bin binary -# The UI sources are bundled by esbuild; CRLF conversion on Windows -# checkouts would change bundle bytes between platforms. -crates/gateway/config-ui/ui/** text eol=lf -crates/workshop/ui/** text eol=lf -crates/workshop/look/** text eol=lf -crates/workshop/platform/** text eol=lf -# Images are not text; the blanket rules above must not convert them. -crates/gateway/config-ui/ui/**/*.png binary -crates/workshop/ui/**/*.png binary -crates/workshop/look/**/*.png binary -# The event-log schema canary pins the version-1 file exactly as the log -# writer emits it (LF); autocrlf must not rewrite it on Windows checkouts. -crates/workshop/server/tests/it/observer/*.jsonl text eol=lf -# The Gateway discovery contract's golden fixtures are compared byte for -# byte against what the writer emits (LF); a checkout must not rewrite them. -crates/workshop/gateway-discovery/fixtures/*.json text eol=lf diff --git a/.githooks/pre-push b/.githooks/pre-push index 3d194b94f..75507d3b9 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -3,10 +3,10 @@ set -e echo "==> Running docs-claims wording scan..." -node --test crates/workshop/ui/test/docs-claims.mjs +node --test tools/docs-claims.mjs echo "==> Running Clippy on workspace..." -CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features +CARGO_BUILD_WARNINGS=deny cargo clippy --workspace --all-targets --all-features if command -v cargo-deny >/dev/null 2>&1; then echo "==> Running cargo deny check..." diff --git a/.github/actions/workshop-checkout/action.yml b/.github/actions/workshop-checkout/action.yml new file mode 100644 index 000000000..84821dc9c --- /dev/null +++ b/.github/actions/workshop-checkout/action.yml @@ -0,0 +1,63 @@ +name: Check out Workshop +description: >- + Checks out cppalliance/runvil-workshop into runvil-workshop/, at the commit + the root Cargo.toml pins or at the ref given, and sets RUNVIL_WORKSHOP to + the checkout. Run it after the checkout of this repository. + +inputs: + ref: + description: >- + The runvil-workshop commit, branch or tag to check out. Empty takes the + commit [workspace.metadata.workshop] pins in the root Cargo.toml. + required: false + default: "" + +outputs: + sha: + description: The runvil-workshop commit that was checked out. + value: ${{ steps.report.outputs.sha }} + +runs: + using: composite + steps: + # awk, not a TOML parser, because the action runs on every runner image + # and the Python that Ubuntu 22.04 ships has no tomllib. The value must + # then be a full commit id, so a pin the scan misread, a branch name or + # a short id stops the job here. + - name: Read the Workshop pin + id: pin + shell: bash + env: + REF: ${{ inputs.ref }} + run: | + set -euo pipefail + if [ -n "$REF" ]; then + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "Workshop ref (given): $REF" + exit 0 + fi + rev=$(awk ' + /^\[workspace\.metadata\.workshop\]/ { table = 1; next } + /^\[/ { table = 0 } + table && /^rev[ \t]*=/ { gsub(/[ \t"\r]/, ""); sub(/^rev=/, ""); print } + ' Cargo.toml) + [[ "$rev" =~ ^[0-9a-f]{40}$ ]] || + { echo "[workspace.metadata.workshop] in Cargo.toml must give rev as a 40-digit commit id, got '$rev'"; exit 1; } + echo "ref=$rev" >> "$GITHUB_OUTPUT" + echo "Workshop ref (pinned): $rev" + + - uses: actions/checkout@v4 + with: + repository: cppalliance/runvil-workshop + ref: ${{ steps.pin.outputs.ref }} + path: runvil-workshop + + - name: Report the Workshop checkout + id: report + shell: bash + run: | + set -euo pipefail + sha=$(git -C runvil-workshop rev-parse HEAD) + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "RUNVIL_WORKSHOP=$GITHUB_WORKSPACE/runvil-workshop" >> "$GITHUB_ENV" + echo "Workshop SHA: $sha" diff --git a/.github/fixtures/workshop-package-smoke/gateway.state.toml b/.github/fixtures/workshop-package-smoke/gateway.state.toml deleted file mode 100644 index b7c227214..000000000 --- a/.github/fixtures/workshop-package-smoke/gateway.state.toml +++ /dev/null @@ -1 +0,0 @@ -active_profile = "main" diff --git a/.github/fixtures/workshop-package-smoke/gateway.toml b/.github/fixtures/workshop-package-smoke/gateway.toml deleted file mode 100644 index 15c6eaa33..000000000 --- a/.github/fixtures/workshop-package-smoke/gateway.toml +++ /dev/null @@ -1,9 +0,0 @@ -config-version = 0 - -[server] -bind = "127.0.0.1:0" -api_key = "release-smoke" - -[[profile]] -name = "main" -models = [] diff --git a/.github/installers/cell.ps1 b/.github/installers/cell.ps1 index 982717121..7dd864032 100644 --- a/.github/installers/cell.ps1 +++ b/.github/installers/cell.ps1 @@ -3,18 +3,20 @@ # are present and that each present one runs, then runs the selection's # Windows-only check (update, relaunch, rerun, or rejections). # -# Usage: cell.ps1 -# the NSIS -setup.exe -# all, workshop, gateway, gateway-stt, or rejections -# the version Workshop must print; the Gateway must print the -# version gateway-pin/pin.json names +# Usage: cell.ps1 +# the NSIS -setup.exe +# all, workshop, gateway, gateway-stt, or rejections +# the version Workshop must print +# the version the Gateway must print: the release the +# Workshop checkout the setup was built from pins # # Environment: # CELL_LOGS write the logs here (default: a new temporary directory) param( [Parameter(Mandatory = $true, Position = 0)][string]$Setup, [Parameter(Mandatory = $true, Position = 1)][string]$Selection, - [Parameter(Mandatory = $true, Position = 2)][string]$Version + [Parameter(Mandatory = $true, Position = 2)][string]$Version, + [Parameter(Mandatory = $true, Position = 3)][string]$GatewayVersion ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' @@ -36,10 +38,6 @@ if (-not $selections.ContainsKey($Selection)) { Fail "selection must be all, workshop, gateway, gateway-stt, or rejections; got '$Selection'" } $want = $selections[$Selection] -# The setup carries the Gateway release the repository pins. -$pin = Join-Path $PSScriptRoot '..\..\gateway-pin\pin.json' -$gatewayVersion = (Get-Content -LiteralPath $pin -Raw | ConvertFrom-Json).version -if (-not $gatewayVersion) { Fail "$pin names no Gateway version" } $root = Join-Path $env:LOCALAPPDATA 'Runvil' $profileDir = Join-Path $env:USERPROFILE '.runvil' @@ -278,7 +276,7 @@ function Test-Selection($Want) { # 3. Every present executable prints the version. foreach ($check in @( @{ Wanted = $Want.Workshop; Exe = $workshopExe; Expected = "runvil-workshop $Version" }, - @{ Wanted = $Want.Gateway; Exe = $gatewayExe; Expected = "runvil-gateway $gatewayVersion" })) { + @{ Wanted = $Want.Gateway; Exe = $gatewayExe; Expected = "runvil-gateway $GatewayVersion" })) { if (-not $check.Wanted) { continue } $output = (& $check.Exe --version | Out-String).Trim() if ($output -ne $check.Expected) { Fail "$($check.Exe) --version printed '$output', required '$($check.Expected)'" } diff --git a/.github/installers/cell.sh b/.github/installers/cell.sh index 2f0d8f1e2..4fae2cfae 100644 --- a/.github/installers/cell.sh +++ b/.github/installers/cell.sh @@ -3,11 +3,12 @@ # selection with the Qt IFW installer, then checks that exactly the selected # components are present and that each present one runs. # -# Usage: cell.sh -# the .dmg on macOS, the .run on Linux -# all, workshop, or gateway -# the version Workshop must print; the Gateway must print the -# version gateway-pin/pin.json names +# Usage: cell.sh +# the .dmg on macOS, the .run on Linux +# all, workshop, or gateway +# the version Workshop must print +# the version the Gateway must print: the release the +# Workshop checkout the installer was built from pins # # Environment: # CELL_ROOT install here (IFW --root) instead of the default location, @@ -22,10 +23,12 @@ fail() { exit 1 } -[ "$#" -eq 3 ] || fail "usage: cell.sh ; got $# arguments" +[ "$#" -eq 4 ] || fail "usage: cell.sh ; got $# arguments" installer=$1 selection=$2 version=$3 +gateway_version=$4 +[ -n "$gateway_version" ] || fail "the Gateway version is empty" [ -f "$installer" ] || fail "installer $installer does not exist" case "$installer" in /*) ;; @@ -43,10 +46,6 @@ esac ids="" [ "$want_workshop" = 0 ] || ids="$ids com.runvil.workshop" [ "$want_gateway" = 0 ] || ids="$ids com.runvil.gateway" -# The installers carry the Gateway release the repository pins. -pin="$(cd "$(dirname "$0")/../.." && pwd)/gateway-pin/pin.json" -gateway_version=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["version"])' "$pin") || - fail "cannot read the Gateway version from $pin" system=$(uname -s) profile="$HOME/.runvil" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efa31fe39..c4320a69d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,50 +26,38 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - run: npm ci --prefix crates/workshop - - name: Clippy env: CARGO_BUILD_WARNINGS: deny - run: cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features + run: cargo clippy --workspace --all-targets --all-features test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + # One test of build-installer reads the pinned Workshop: it compares + # the names the installers lay the payload out under with the names + # Workshop looks for. + - name: Check out Workshop + uses: ./.github/actions/workshop-checkout + - name: Install cargo-nextest uses: taiki-e/install-action@4dc1969decfa71b34f25aa7f3dd4656654d9ad1e # nextest - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - run: npm ci --prefix crates/workshop - - name: Test (concurrent via nextest) - run: cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features + run: cargo nextest run --locked --workspace --all-features - # No build step may write into the repository (the UI bundles build - # into OUT_DIR now); catch a regression here rather than in a diff. + # No test may write into the repository; catch a regression here + # rather than in a diff. - name: Clean tree shell: bash run: | if [ -n "$(git status --porcelain)" ]; then - echo "the build dirtied the repository:" + echo "the tests dirtied the repository:" git status --porcelain exit 1 fi @@ -84,128 +72,12 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - run: npm ci --prefix crates/workshop - - name: Docs - run: cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api - - check-workshop: - runs-on: windows-latest - steps: - - uses: actions/checkout@v4 - - - name: Install cargo-nextest - uses: taiki-e/install-action@4dc1969decfa71b34f25aa7f3dd4656654d9ad1e # nextest + run: cargo doc --workspace --no-deps --all-features - - name: Cache cargo - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - run: npm ci --prefix crates/workshop - - - name: Stage a stub Gateway sidecar - run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-pc-windows-msvc - - # workshop-server-api is the desktop app's re-export view of workshop-server, - # so it runs in this partition with the crates it fronts and stays - # out of the workspace-wide jobs like them. - - name: Clippy (workshop) - env: - CARGO_BUILD_WARNINGS: deny - run: cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets - - # The Workshop crates' suites, including the workspace jail's Windows - # paths. - - name: Test (Windows) - shell: bash - run: >- - cargo nextest run --locked - -p workshop -p workshop-server -p workshop-server-api -p workshop-workspace - -E 'package(workshop) | package(workshop-server) | package(workshop-server-api) - | package(workshop-workspace)' - - - name: Docs (workshop-server, private items) - env: - RUSTDOCFLAGS: -D warnings - run: cargo doc --locked --no-deps -p workshop-server --document-private-items - - - name: Remove Gateway sidecar - if: always() - run: node tools/stage-gateway-sidecar.mjs remove --target x86_64-pc-windows-msvc - - - name: Clean tree - shell: bash - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "the build dirtied the repository:" - git status --porcelain - exit 1 - fi - - # First Linux build of the desktop app: compile-only, against the Tauri - # system packages a Linux developer installs. Catches the - # missing-system-package class of failure the Windows-only job cannot see. - check-workshop-linux: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v4 - - - name: Cache cargo - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - - # A mirror that does not answer makes apt-get wait for a long time, so - # the step fails after 20 minutes. It normally takes under one. - - name: Install Tauri system packages - timeout-minutes: 20 - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - run: npm ci --prefix crates/workshop - - - name: Stage a stub Gateway sidecar - run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-unknown-linux-gnu - - - name: Build (workshop, Linux) - run: cargo build --locked -p workshop - - - name: Remove Gateway sidecar - if: always() - run: node tools/stage-gateway-sidecar.mjs remove --target x86_64-unknown-linux-gnu - - - name: Clean tree - shell: bash - run: | - if [ -n "$(git status --porcelain)" ]; then - echo "the build dirtied the repository:" - git status --porcelain - exit 1 - fi - - # The UI build is its own job: typecheck, bundle, and test. The cargo - # jobs drive the same bundle through the crates' build scripts, hence - # their npm ci. - ui: + # The wording scan reads the rulebooks with Node's own modules, so it + # needs no install. + wording: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -213,24 +85,9 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - working-directory: crates/workshop - run: npm ci - - - name: Typecheck - working-directory: crates/workshop - run: npm run typecheck --workspaces --if-present - - name: Build - working-directory: crates/workshop - run: npm run build --workspace ui - - - name: Test - working-directory: crates/workshop - run: npm test --workspaces --if-present + - name: Wording + run: node --test tools/docs-claims.mjs supply-chain: runs-on: ubuntu-latest @@ -247,35 +104,13 @@ jobs: - name: cargo audit run: cargo audit - # The Workshop binary links exactly one rustls crypto backend, - # aws-lc-rs. This fails when `ring` re-enters its normal-edge closure - # (through a reqwest bump or a Tauri plugin default) and when - # `cargo tree` itself cannot run; only a successful, ring-free result - # passes. The Gateway has its own repository and its own checks. - - name: One rustls crypto backend in the Workshop - shell: bash - run: | - set -euo pipefail - if ! out="$(cargo tree --locked -p workshop -e normal -i ring --depth 0 2>&1)"; then - echo "cargo tree failed:" - printf '%s\n' "$out" - exit 1 - fi - if printf '%s\n' "$out" | grep -v '^warning: nothing to print' | grep -q '^ring v'; then - echo "ring is linked into the workshop; aws-lc-rs is the only permitted rustls backend" - printf '%s\n' "$out" - exit 1 - fi - # The custom lints, the dylint library in cppalliance/dylints at the # rev the root Cargo.toml's [workspace.metadata.dylint] pins. `cargo # dylint` fetches it and builds it on the nightly in its # rust-toolchain.toml, which rustup installs with that file's components; - # the date is never repeated here. Its UI tests run in its own CI. - # `cargo dylint` checks every crate, the desktop app included, so this - # job installs what check-workshop-linux does. It runs on 24.04, not - # check-workshop-linux's 22.04: the prebuilt cargo-dylint and - # dylint-link binaries need glibc 2.38 or later, and 22.04 ships 2.35. + # the date is never repeated here. Its UI tests run in its own CI. The + # job runs on 24.04: the prebuilt cargo-dylint and dylint-link binaries + # need glibc 2.38 or later. dylint: runs-on: ubuntu-24.04 steps: @@ -290,33 +125,9 @@ jobs: - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - # A mirror that does not answer makes apt-get wait for a long time, so - # the step fails after 20 minutes. It normally takes under one. - - name: Install Tauri system packages - timeout-minutes: 20 - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev - - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - cache-dependency-path: crates/workshop/package-lock.json - - - name: Install UI dependencies - run: npm ci --prefix crates/workshop - - - name: Stage a stub Gateway sidecar - run: node tools/fetch-gateway-sidecar.mjs --stub --target x86_64-unknown-linux-gnu - - name: Dylint run: cargo dylint --all -- --locked --workspace --all-targets --all-features - - name: Remove Gateway sidecar - if: always() - run: node tools/stage-gateway-sidecar.mjs remove --target x86_64-unknown-linux-gnu - - name: Clean tree shell: bash run: | @@ -332,7 +143,7 @@ jobs: # or that job bypasses this gate. ci-green: runs-on: ubuntu-latest - needs: [fmt, clippy, test, docs, check-workshop, check-workshop-linux, ui, supply-chain, dylint] + needs: [fmt, clippy, test, docs, wording, supply-chain, dylint] if: always() steps: - name: Verify every job succeeded diff --git a/.github/workflows/installers.yml b/.github/workflows/installers.yml index 7dae76998..682277c83 100644 --- a/.github/workflows/installers.yml +++ b/.github/workflows/installers.yml @@ -6,13 +6,20 @@ run-name: Runvil installers (${{ github.event_name == 'push' && github.ref_name # each kind, and assembles and verifies latest.json and SHA256SUMS. A # vX.Y.Z tag then publishes the release; any failed cell means no release. # +# Workshop is its own product in cppalliance/runvil-workshop. Every job +# that builds takes it at the commit the root Cargo.toml pins in +# [workspace.metadata.workshop], and cargo installer builds its bundle +# there with cargo workshop bundle. The Gateway is the release that +# checkout pins. +# # Modes, which prepare reads from the event: # - release: a pushed vX.Y.Z tag naming the workspace version. Signs with -# the release key and publishes. +# the release key and publishes. It always builds the pinned Workshop. # - full: a dispatched run on any ref. Every cell runs; the installers stay -# as workflow artifacts for 7 days and nothing is published. -# - pull-request: a pull request touching the installer paths below. Two -# cells per platform. +# as workflow artifacts for 7 days and nothing is published. The +# workshop-ref input builds another Workshop commit than the pinned one. +# - pull-request: a pull request touching the installer paths below, the +# Workshop pin among them. Two cells per platform. # Every mode builds with --sign. Outside release the key is a run key that # prepare generates and nothing trusts, so the manifest job checks the same # signatures a release has. @@ -22,30 +29,22 @@ on: tags: - "v[0-9]+.[0-9]+.[0-9]+" workflow_dispatch: + inputs: + workshop-ref: + description: A runvil-workshop commit, branch or tag to build in place of the pinned commit + required: false + default: "" pull_request: paths: - .github/workflows/installers.yml - .github/installers/** + - .github/actions/** + - .cargo/config.toml - Cargo.toml - Cargo.lock - LICENSE - - crates/build-workshop/** - - crates/workshop/desktop/installer.nsi - - crates/workshop/desktop/build.rs - - crates/workshop/desktop/src/gateway/** - - crates/workshop/desktop/*.plist - - crates/workshop/desktop/tauri*.conf.json - - crates/workshop/desktop/icons/** - - crates/workshop/plugin-std/** - - crates/workshop/plugin-bashkit/** - - crates/workshop/package.json - - crates/workshop/package-lock.json - - crates/workshop/gateway-discovery/src/** - - gateway-pin/** - - tools/fetch-gateway-sidecar.mjs - - tools/gateway-archive.mjs - - tools/gateway-pin.mjs - - tools/stage-gateway-sidecar.mjs + - rust-toolchain.toml + - crates/build-installer/** permissions: contents: read @@ -62,6 +61,8 @@ jobs: version: ${{ steps.identity.outputs.version }} mode: ${{ steps.identity.outputs.mode }} cells: ${{ steps.cells.outputs.cells }} + workshop-sha: ${{ steps.workshop.outputs.sha }} + gateway-version: ${{ steps.gateway.outputs.version }} steps: - uses: actions/checkout@v4 with: @@ -147,12 +148,41 @@ jobs: echo "cells=$cells" >> "$GITHUB_OUTPUT" echo "Cells: $(jq '.include | length' <<< "$cells")" | tee -a "$GITHUB_STEP_SUMMARY" + # Every later job checks out the commit this step resolves, so a + # branch given as workshop-ref cannot move between jobs. A tag builds + # the pinned commit whatever the input says. + - name: Check out Workshop + id: workshop + uses: ./.github/actions/workshop-checkout + with: + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.workshop-ref || '' }} + + # The installers hold the Gateway release the Workshop checkout pins, + # and the install cells require that version of an installed Gateway. + - name: Read the Gateway version + id: gateway + shell: bash + env: + WORKSHOP_SHA: ${{ steps.workshop.outputs.sha }} + run: | + set -euo pipefail + pin=runvil-workshop/gateway-pin/pin.json + version=$(python3 -c 'import json, sys; print(json.load(open(sys.argv[1]))["version"])' "$pin") || + { echo "cannot read the Gateway version from $pin"; exit 1; } + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || + { echo "$pin names the Gateway version '$version', which is not a version"; exit 1; } + echo "version=$version" >> "$GITHUB_OUTPUT" + { + echo "Workshop SHA: $WORKSHOP_SHA" + echo "Gateway version: $version" + } | tee -a "$GITHUB_STEP_SUMMARY" + - uses: actions/setup-node@v4 if: steps.identity.outputs.mode != 'release' with: node-version: 22 cache: npm - cache-dependency-path: crates/workshop/package-lock.json + cache-dependency-path: runvil-workshop/crates/workshop/package-lock.json # Nothing trusts this key: released Workshops embed only the release # key, and tauri build only warns when the key does not match @@ -164,9 +194,9 @@ jobs: shell: bash run: | set -euo pipefail - npm ci --prefix crates/workshop + npm ci --prefix runvil-workshop/crates/workshop mkdir -p "$RUNNER_TEMP/run-key" - node crates/workshop/node_modules/@tauri-apps/cli/tauri.js signer generate --ci -w "$RUNNER_TEMP/run-key/run.key" + node runvil-workshop/crates/workshop/node_modules/@tauri-apps/cli/tauri.js signer generate --ci -w "$RUNNER_TEMP/run-key/run.key" test -s "$RUNNER_TEMP/run-key/run.key" test -s "$RUNNER_TEMP/run-key/run.key.pub" @@ -231,32 +261,45 @@ jobs: [ "$actual" = "$expected" ] || { echo "checkout $actual does not match execution SHA $expected"; exit 1; } echo "Execution SHA: $actual" + - name: Check out Workshop + uses: ./.github/actions/workshop-checkout + with: + ref: ${{ needs.prepare.outputs.workshop-sha }} + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - name: Add the Rust target run: rustup target add ${{ matrix.triple }} + # Workshop builds into its own target directory, which is by far the + # larger of the two. - name: Cache cargo uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 with: key: ${{ matrix.triple }} + workspaces: | + . -> target + runvil-workshop -> target - uses: actions/setup-node@v4 with: node-version: 22 cache: npm - cache-dependency-path: crates/workshop/package-lock.json + cache-dependency-path: runvil-workshop/crates/workshop/package-lock.json - # The workshop install brings the pinned Tauri CLI. + # Workshop's install brings the pinned Tauri CLI, which cargo workshop + # bundle runs. - name: Install UI dependencies shell: bash - run: npm ci --prefix crates/workshop + run: npm ci --prefix runvil-workshop/crates/workshop - # The Gateway is its own product: every installer carries the release - # gateway-pin/pin.json names, which the fetch verifies against the - # pin's sha256 and stages where cargo workshop installer reads it. + # The Gateway is its own product: every installer holds the release + # the Workshop checkout's gateway-pin/pin.json names, which its fetch + # script verifies against the pin's sha256 and stages where cargo + # workshop bundle and cargo installer read it. - name: Stage the pinned Gateway shell: bash + working-directory: runvil-workshop env: GH_TOKEN: ${{ github.token }} run: node tools/fetch-gateway-sidecar.mjs --target ${{ matrix.triple }} @@ -315,8 +358,8 @@ jobs: [ "$count" = 1 ] || { echo "the Qt IFW archive must hold exactly one bin/binarycreator, found $count: $found"; exit 1; } echo "QTIFWDIR=$(dirname "$(dirname "$found")")" >> "$GITHUB_ENV" - # The Gateway version check inside cargo workshop installer runs the - # x86_64 Gateway. + # The Gateway version checks inside cargo workshop bundle and cargo + # installer run the x86_64 Gateway. - name: Install Rosetta if: matrix.platform == 'macos-intel' run: | @@ -345,10 +388,11 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD= export TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD fi - cargo workshop installer --target ${{ matrix.triple }} --sign + cargo installer --target ${{ matrix.triple }} --sign - # The Gateway reaches the setup only through tauri.windows.conf.json's - # externalBin; 7-Zip lists an NSIS setup's files. + # The Gateway reaches the setup only through Workshop's + # tauri.windows.conf.json, as an externalBin; 7-Zip lists an NSIS + # setup's files. - name: The setup carries the Gateway if: matrix.platform == 'windows' shell: bash @@ -357,14 +401,20 @@ jobs: listing=$(7z l "$setup") grep -F runvil-gateway.exe <<< "$listing" || { echo "$listing"; echo "runvil-gateway.exe is not in $setup"; exit 1; } - - name: Clean tree + # Neither build may write into its checkout. This repository ignores + # the Workshop checkout, so each tree is asked for itself. + - name: Clean trees shell: bash run: | - if [ -n "$(git status --porcelain)" ]; then - echo "the installer build dirtied the repository:" - git status --porcelain - exit 1 - fi + status=0 + for tree in . runvil-workshop; do + if [ -n "$(git -C "$tree" status --porcelain)" ]; then + echo "the installer build dirtied the checkout at $tree:" + git -C "$tree" status --porcelain + status=1 + fi + done + exit $status - name: Upload the installer and updater files uses: actions/upload-artifact@v4 @@ -436,7 +486,7 @@ jobs: shopt -s nullglob installers=(dist/*-setup.dmg dist/*-setup.run) [ "${#installers[@]}" -eq 1 ] || { echo "the artifact must hold one installer, found ${#installers[@]}"; ls dist; exit 1; } - bash .github/installers/cell.sh "${installers[0]}" "${{ matrix.selection }}" "${{ needs.prepare.outputs.version }}" + bash .github/installers/cell.sh "${installers[0]}" "${{ matrix.selection }}" "${{ needs.prepare.outputs.version }}" "${{ needs.prepare.outputs.gateway-version }}" - name: Install and check (Windows) if: runner.os == 'Windows' @@ -446,7 +496,7 @@ jobs: run: | $setups = @(Get-ChildItem dist | Where-Object { $_.Name -like '*-setup.exe' }) if ($setups.Count -ne 1) { throw "the artifact must hold one setup, found $($setups.Count)" } - & .github/installers/cell.ps1 $setups[0].FullName '${{ matrix.selection }}' '${{ needs.prepare.outputs.version }}' + & .github/installers/cell.ps1 $setups[0].FullName '${{ matrix.selection }}' '${{ needs.prepare.outputs.version }}' '${{ needs.prepare.outputs.gateway-version }}' # cancelled() covers the job timeout, so a hung cell keeps its logs. - name: Upload the cell's logs @@ -474,6 +524,13 @@ jobs: [ "$actual" = "$expected" ] || { echo "checkout $actual does not match execution SHA $expected"; exit 1; } echo "Execution SHA: $actual" + # A release verifies against the updater key in Workshop's + # tauri.conf.json. + - name: Check out Workshop + uses: ./.github/actions/workshop-checkout + with: + ref: ${{ needs.prepare.outputs.workshop-sha }} + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - name: Cache cargo @@ -495,8 +552,9 @@ jobs: name: run-signing-key path: ${{ runner.temp }}/run-key - # A release verifies against plugins.updater.pubkey in tauri.conf.json, - # the key every installed copy trusts; other modes against the run key. + # A release verifies against plugins.updater.pubkey in Workshop's + # tauri.conf.json, the key every installed copy trusts; other modes + # against the run key. - name: Assemble and verify latest.json and SHA256SUMS shell: bash env: @@ -508,7 +566,7 @@ jobs: if [ "$MODE" != release ]; then key=(--public-key "$RUNNER_TEMP/run-key/run.key.pub") fi - cargo workshop manifest --assets "$RUNNER_TEMP/release" \ + cargo installer manifest --assets "$RUNNER_TEMP/release" \ --base-url "https://github.com/$GITHUB_REPOSITORY/releases/download/v$VERSION" "${key[@]}" cat "$RUNNER_TEMP/release/latest.json" "$RUNNER_TEMP/release/SHA256SUMS" diff --git a/.github/workflows/site.yml b/.github/workflows/site.yml index 80ff28f83..8b29dc60f 100644 --- a/.github/workflows/site.yml +++ b/.github/workflows/site.yml @@ -2,8 +2,9 @@ name: Deploy docs site to Pages on: push: branches: [main, master] - # The chapters live in cppalliance/runvil-docs, whose pushes do not - # start this workflow, so a nightly run publishes them. + # The chapters live in cppalliance/runvil-docs and the documented crates + # in cppalliance/runvil-harness. A push to either does not start this + # workflow, so a nightly run publishes them. schedule: - cron: "17 7 * * *" workflow_dispatch: @@ -28,6 +29,7 @@ jobs: env: MDBOOK_VERSION: 0.4.44 RUNVIL_DOCS: ${{ github.workspace }}/runvil-docs + RUNVIL_HARNESS: ${{ github.workspace }}/runvil-harness steps: - uses: actions/checkout@v4 @@ -36,6 +38,14 @@ jobs: repository: cppalliance/runvil-docs path: runvil-docs + # Only the full site documents the crates; a books-only build reads + # no harness checkout. + - uses: actions/checkout@v4 + if: github.event_name != 'pull_request' + with: + repository: cppalliance/runvil-harness + path: runvil-harness + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - name: Cache cargo @@ -46,8 +56,9 @@ jobs: wget -q "https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" tar xzf "mdbook-v${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" - # The ci.yml docs job already builds both facade rustdocs on every PR, - # so PR runs build only the books and the landing page. + # The documented crates are in cppalliance/runvil-harness, whose own CI + # builds their rustdoc on every PR, so PR runs here build only the + # books and the landing page. - name: Build books if: github.event_name == 'pull_request' run: MDBOOK="$PWD/mdbook" cargo xtask site --books-only diff --git a/.gitignore b/.gitignore index b5ffadc33..68f8dff7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -/.model-cache/ /target /vibe-review.md /conformance-audit.md @@ -7,19 +6,6 @@ *.env # Standing guard: the operator's real secrets file must never be tracked. /secrets.env -# Voice test fixtures, downloaded out of band (see design/design-runvil-workshop.md). -/crates/gateway/stt/backend-whisper/tests/fixtures/ -# UI build pipeline: npm install target and the esbuild output. The build -# scripts write the bundle to OUT_DIR; `npm run build`/`--watch` still write -# dist/ in place for the jsdom tests, and none of it is tracked. -/crates/workshop/**/node_modules/ -/crates/gateway/config-ui/ui/node_modules/ -/crates/workshop/ui/dist/ -/crates/gateway/config-ui/ui/dist/ -# tauri-build's generated ACL schemas, regenerated on every workshop build. -/crates/workshop/desktop/gen/ -# The Windows gateway sidecar staged for bundle.externalBin before -# `tauri build` (crates/workshop/desktop/tauri.windows.conf.json); a build -# artifact. -/crates/workshop/desktop/binaries/ -/plan-dist-manifest.json +# The runvil-workshop checkout CI places inside this one +# (.github/actions/workshop-checkout); a local checkout sits beside it. +/runvil-workshop/ diff --git a/AGENTS.md b/AGENTS.md index c0c2ecc2c..3bf58439f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Runvil -Multi-crate Rust workspace for Workshop, a Host, and the tooling that builds its installers. The Engine and the Harness are in their own repository, `cppalliance/runvil-harness`, which the root `Cargo.toml` pins by commit. The Gateway is a separate product in its own repository, `cppalliance/runvil-gateway`. +Multi-crate Rust workspace for the tooling that builds the Runvil installers and the documentation site. Workshop, a Host, is in its own repository, `cppalliance/runvil-workshop`, which the root `Cargo.toml` pins by commit in `[workspace.metadata.workshop]`; the installers are built from that commit. The Engine and the Harness are in `cppalliance/runvil-harness`, and the Gateway is a separate product in `cppalliance/runvil-gateway`. ## Definitions @@ -19,7 +19,7 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - "Host", in any capitalization, means only the Host. Other senses use another word: "machine", "real files", "Engine globals", "Engine call", the part's own name ("the desktop app"), or a verb such as run, serve, embed, or hold. - "Engine", "Harness", and "Plugin" mean only the defined terms. Anything else gets a qualified lowercase name, such as the Gateway's speech engine or a Tauri plugin. This repository's own checks are "structural checks", "test support", or "fixtures". - Names defined elsewhere keep their own meaning (the HTTP `Host` header, Cargo's `harness = false`), crate names are written as they are, and quotations of people stay verbatim. -- `crates/workshop/ui/test/docs-claims.mjs` enforces these rules in every `AGENTS.md`, every `## Invariants` crate doc, and every `.cursor/rules` file. +- `tools/docs-claims.mjs` enforces these rules in every `AGENTS.md`, every `## Invariants` crate doc, and every `.cursor/rules` file. ## Principles @@ -40,13 +40,13 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c ## Verification -- Tests: `cargo nextest run --locked --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-features`, and `cargo nextest run --locked -p workshop -p workshop-server -p workshop-server-api`. -- Lint: `cargo clippy --workspace --exclude workshop --exclude workshop-server --exclude workshop-server-api --all-targets --all-features`, and `cargo clippy -p workshop -p workshop-server -p workshop-server-api --all-targets`, both with `CARGO_BUILD_WARNINGS=deny`. Clippy covers `cargo check`, so never run a standalone `cargo check --workspace`. +- Tests: `cargo nextest run --locked --workspace --all-features`. One test of `build-installer` reads the Workshop checkout that `RUNVIL_WORKSHOP` names, which `.cargo/config.toml` sets to the checkout beside this repository unless the variable is already set. +- Lint: `cargo clippy --workspace --all-targets --all-features`, with `CARGO_BUILD_WARNINGS=deny`. Clippy covers `cargo check`, so never run a standalone `cargo check --workspace`. - Format: `cargo fmt --all --check`. -- Docs: `cargo doc --workspace --no-deps --all-features --exclude workshop --exclude workshop-server --exclude workshop-server-api`, with `RUSTDOCFLAGS="-D warnings"`. Clippy does not run rustdoc lints, so never skip this. +- Docs: `cargo doc --workspace --no-deps --all-features`, with `RUSTDOCFLAGS="-D warnings"`. Clippy does not run rustdoc lints, so never skip this. - Custom lints: `cargo dylint --all -- --locked --workspace --all-targets --all-features`, with `cargo-dylint` and `dylint-link` installed. The lints and their UI tests live in `cppalliance/dylints`, at the `rev` the root `Cargo.toml`'s `[workspace.metadata.dylint]` pins; a new lint lands there first. - Structural checks: `cargo test -p build-xtask`. -- Wording: `node --test crates/workshop/ui/test/docs-claims.mjs`, from any directory, no `npm ci` needed. +- Wording: `node --test tools/docs-claims.mjs`, from any directory. It needs Node and no install. ## Structural Rules @@ -56,8 +56,3 @@ Seven terms have exactly one meaning each, everywhere in this repository: code c - Production code uses the standard module layout: `foo.rs`, with `foo/bar.rs` for its children. No `#[path]`, and no inline `mod` blocks. A module never has exactly one child file (tests aside): merge it, or make it a peer module `foo_bar.rs` declared beside `foo.rs`. - Test code goes at the bottom of the file under test in a `#[cfg(test)] mod tests { }` block when practical (if in doubt, beside), and otherwise in `foo-tests.rs` beside it, wired with `#[path = "foo-tests.rs"] mod tests;`. A big test file may split by topic into siblings named after it, `-.rs` (so `foo-tests-topic.rs`). These are the only files wired with `#[path]`, so a hyphen in a file name means test code. Top-level `tests/` and `benches/` trees follow Cargo's conventions. - The crates in this repository keep their older layout until their own pass. - -## SPA and CSS Rules - -- Workshop UI component CSS uses the custom properties in `@workshop/look` and `ui/src/tokens/component.css`, never raw color, size, or spacing values. -- Every persisted value goes through the `ui-storage` adapter to the server. Account state (preferences and ephemera such as editor toggles, zoom, recent files, and command history) goes to `ui-state.json` in the state directory (`workshop-user-state`, `/user/state`). Workspace-scoped state, anything that should travel with the `.rvwork` document, goes to the workspace file (`/workspace/file/state`). A new persisted value is a new allow-listed key in one of those two buckets, added on the server first. diff --git a/Cargo.lock b/Cargo.lock index 3082ac661..803c481d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,8614 +3,728 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" +name = "anyhow" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "aead" -version = "0.5.2" +name = "base64" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common 0.1.7", - "generic-array", -] +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] -name = "aegis" -version = "0.9.16" +name = "block-buffer" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4542662923e93dc727bc364f885f6cf24adb4dc60d6fcce0e8d397d214179e14" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ - "cc", - "softaes", + "generic-array", ] [[package]] -name = "aes" -version = "0.8.4" +name = "block-buffer" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "hybrid-array", ] [[package]] -name = "aes-gcm" -version = "0.10.3" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", - "subtle", + "objc2", ] [[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +name = "build-installer" +version = "0.3.0" dependencies = [ - "memchr", + "anyhow", + "base64", + "ctrlc", + "minisign", + "minisign-verify", + "serde_json", + "sha2 0.11.0", + "tempfile", + "toml", ] [[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +name = "build-user-guide" +version = "0.3.0" +dependencies = [ + "tempfile", +] [[package]] -name = "alloc-stdlib" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +name = "build-xtask" +version = "0.0.0" dependencies = [ - "alloc-no-stdlib", + "proc-macro2", + "pulldown-cmark", + "syn 3.0.3", + "tempfile", + "toml", ] [[package]] -name = "allocator-api2" -version = "0.4.0" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "android_system_properties" -version = "0.1.5" +name = "cfg-if" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "anstyle" -version = "1.0.14" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "antithesis_sdk" -version = "0.2.9" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08410fcac93669a476c006cd6c4512ac1e2b30fd117231a5d55d8a2c76599b82" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "libc", - "libloading 0.8.9", - "linkme", - "once_cell", - "rand 0.8.8", - "rustc_version_runtime", - "serde", - "serde_json", + "crypto-common 0.1.7", + "inout", ] [[package]] -name = "anyhow" -version = "1.0.104" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] -name = "ar_archive_writer" -version = "0.5.5" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523e5e187fff36fcb8d1d19ccf0ca4ca43ec3d158316057f66dc6f72716bc5ec" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "object", + "libc", ] [[package]] -name = "arc-swap" -version = "1.9.2" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "rustversion", + "libc", ] [[package]] -name = "aristo" -version = "0.4.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcdeefa800110050103e2459a2f8dbdbd560ad046e30f1f87e24ce19c2cb8bde" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "aristo-macros", + "generic-array", + "typenum", ] [[package]] -name = "aristo-macros" -version = "0.4.1" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64a66d21a80182b35b1741997a6d2456911f54b7eb1918aa4e2382fc205268d" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "hybrid-array", ] [[package]] -name = "assoc" -version = "0.1.3" +name = "ct-codecs" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfdc70193dadb9d7287fa4b633f15f90c876915b31f6af17da307fc59c9859a8" +checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd" [[package]] -name = "async-broadcast" -version = "0.7.2" +name = "ctrlc" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ - "event-listener", - "event-listener-strategy", - "futures-core", - "pin-project-lite", + "dispatch2", + "nix", + "windows-sys", ] [[package]] -name = "async-channel" -version = "2.5.0" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", ] [[package]] -name = "async-compression" -version = "0.4.43" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", ] [[package]] -name = "async-executor" -version = "1.14.1" +name = "dispatch2" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "pin-project-lite", - "slab", + "bitflags", + "block2", + "libc", + "objc2", ] [[package]] -name = "async-io" -version = "2.6.0" +name = "equivalent" +version = "1.0.2" 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", -] +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "async-lock" -version = "3.4.2" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", + "libc", + "windows-sys", ] [[package]] -name = "async-process" +name = "fastrand" 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", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "async-recursion" -version = "1.1.1" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "typenum", + "version_check", ] [[package]] -name = "async-signal" -version = "0.2.14" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" 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", + "js-sys", + "libc", + "r-efi", + "wasm-bindgen", ] [[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - -[[package]] -name = "async-trait" -version = "0.1.92" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] -name = "atk" -version = "0.18.2" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "atk-sys", - "glib", - "libc", + "digest 0.10.7", ] [[package]] -name = "atk-sys" -version = "0.18.2" +name = "hybrid-array" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "typenum", ] [[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 = "aws-lc-rs" -version = "1.18.1" +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ - "aws-lc-sys", - "zeroize", + "equivalent", + "hashbrown", ] [[package]] -name = "aws-lc-sys" -version = "0.45.0" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", + "generic-array", ] [[package]] -name = "axum" -version = "0.8.9" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "base64 0.22.1", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "multer", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sha1 0.10.7", - "sync_wrapper", - "tokio", - "tokio-tungstenite", - "tower", - "tower-layer", - "tower-service", - "tracing", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "axum-core" -version = "0.5.6" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", + "cfg-if", + "wasm-bindgen", ] [[package]] -name = "base64" -version = "0.21.7" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "base64" -version = "0.22.1" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "base64" -version = "0.23.1" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "bashkit" -version = "0.21.0" +name = "minisign" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e2a19b3bae7c4efa43dd564e34f03acf9786b36814e2e7246f656379b9fd14" +checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586" dependencies = [ - "anyhow", - "async-trait", - "base64 0.23.1", - "bigdecimal", - "bitflags 2.13.1", - "blake2", - "bstr", - "bytes", - "bzip2", - "chrono", - "clap", - "fancy-regex", - "flate2", - "foldhash", - "futures-util", - "getrandom 0.4.3", - "hifijson", - "hmac 0.13.0", - "indexmap 2.14.0", - "jaq-core", - "jaq-std", - "md-5", - "num-bigint", - "num-traits", - "os_display", - "regex", - "regex-automata", - "ryu", - "self_cell", - "serde", - "serde_json", - "serde_norway", - "sha1 0.11.0", - "sha2 0.11.0", - "stacker", - "thiserror 2.0.19", - "tokio", - "unit-prefix", - "url", + "ct-codecs", + "getrandom", + "rpassword", + "scrypt", ] [[package]] -name = "bigdecimal" -version = "0.4.10" +name = "minisign-verify" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" -dependencies = [ - "autocfg", - "libm", - "num-bigint", - "num-integer", - "num-traits", -] +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" [[package]] -name = "bindgen" -version = "0.69.5" +name = "nix" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.13.1", - "cexpr", - "clang-sys", - "itertools 0.10.5", - "lazy_static", - "lazycell", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 1.1.0", - "shlex 1.3.0", - "syn 2.0.119", - "which 4.4.2", + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] -name = "bit-set" -version = "0.8.0" +name = "objc2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ - "bit-vec", + "objc2-encode", ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "objc2-encode" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] -name = "bitflags" -version = "1.3.2" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "bitflags" -version = "2.13.1" +name = "pbkdf2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "serde_core", + "digest 0.10.7", + "hmac", ] [[package]] -name = "bitvec" -version = "1.1.1" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "funty", - "radium", - "tap", - "wyz", + "unicode-ident", ] [[package]] -name = "blake2" -version = "0.11.0" +name = "pulldown-cmark" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "digest 0.11.3", + "bitflags", + "memchr", + "unicase", ] [[package]] -name = "block-buffer" -version = "0.10.4" +name = "quote" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ - "generic-array", + "proc-macro2", ] [[package]] -name = "block-buffer" -version = "0.12.1" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "block2" -version = "0.6.2" +name = "rpassword" +version = "7.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" dependencies = [ - "objc2", + "libc", + "rtoolbox", + "windows-sys", ] [[package]] -name = "blocking" -version = "1.7.0" +name = "rtoolbox" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" dependencies = [ - "async-channel", - "async-task", - "futures-io", - "futures-lite", - "piper", + "libc", + "windows-sys", ] [[package]] -name = "branches" -version = "0.4.6" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7436b21fd6195415058eb41c9090e156b556bc7e0377bb57c5c026a421521525" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "rustc_version", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] -name = "brotli" -version = "8.0.4" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "brotli-decompressor" -version = "5.0.3" +name = "salsa20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "cipher", ] [[package]] -name = "bs58" -version = "0.5.1" +name = "scrypt" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "tinyvec", + "pbkdf2", + "salsa20", + "sha2 0.10.9", ] [[package]] -name = "bstr" -version = "1.13.0" +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "memchr", - "regex-automata", "serde_core", ] [[package]] -name = "build-ui" -version = "0.3.0" +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "anyhow", - "serde_json", - "tempfile", + "serde_derive", ] [[package]] -name = "build-user-guide" -version = "0.3.0" +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ - "tempfile", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "build-workshop" -version = "0.3.0" +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "anyhow", - "base64 0.22.1", - "ctrlc", - "minisign", - "minisign-verify", - "serde_json", - "sha2 0.11.0", - "tempfile", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "build-xtask" -version = "0.0.0" +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ - "anyhow", - "proc-macro2", - "pulldown-cmark", - "syn 3.0.3", - "tempfile", - "toml 1.1.4+spec-1.1.0", + "serde_core", ] [[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" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "bytemuck_derive", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] -name = "bytemuck_derive" -version = "1.11.0" +name = "sha2" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] -name = "byteorder" -version = "1.5.0" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "bytes" -version = "1.12.1" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "serde", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "bzip2" -version = "0.6.1" +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ - "libbz2-rs-sys", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "cairo-rs" -version = "0.18.5" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "bitflags 2.13.1", - "cairo-sys-rs", - "glib", - "libc", + "fastrand", + "getrandom", "once_cell", - "thiserror 1.0.69", + "rustix", + "windows-sys", ] [[package]] -name = "cairo-sys-rs" -version = "0.18.2" +name = "toml" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ - "glib-sys", - "libc", - "system-deps", + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] -name = "camino" -version = "1.2.5" +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] -name = "cargo-platform" -version = "0.1.9" +name = "toml_parser" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "serde", + "winnow", ] [[package]] -name = "cargo_metadata" -version = "0.19.2" +name = "toml_writer" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" -dependencies = [ - "camino", - "cargo-platform", - "semver", - "serde", - "serde_json", - "thiserror 2.0.19", -] +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] -name = "cargo_toml" -version = "0.22.3" +name = "typenum" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" -dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", -] +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "cc" -version = "1.4.6" +name = "unicase" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex 2.0.1", -] +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] -name = "cesu8" -version = "1.1.0" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "cexpr" -version = "0.6.0" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom", -] +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "cfb" -version = "0.7.3" +name = "wasm-bindgen" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ - "byteorder", - "fnv", - "uuid", + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", ] [[package]] -name = "cfg-expr" -version = "0.15.8" +name = "wasm-bindgen-macro" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ - "smallvec", - "target-lexicon", + "quote", + "wasm-bindgen-macro-support", ] [[package]] -name = "cfg-if" -version = "1.0.4" +name = "wasm-bindgen-macro-support" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] [[package]] -name = "cfg_aliases" -version = "0.2.2" +name = "wasm-bindgen-shared" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] [[package]] -name = "cfg_block" -version = "0.1.1" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18758054972164c3264f7c8386f5fc6da6114cb46b619fd365d4e3b2dc3ae487" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "chacha20" -version = "0.10.2" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", + "windows-link", ] [[package]] -name = "chrono" -version = "0.4.45" +name = "winnow" +version = "1.0.4" 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 = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common 0.1.7", - "inout", -] - -[[package]] -name = "clang-sys" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" -dependencies = [ - "glob", - "libc", - "libloading 0.8.9", -] - -[[package]] -name = "clap" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" -dependencies = [ - "anstyle", - "clap_lex", -] - -[[package]] -name = "clap_derive" -version = "4.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - -[[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 = [ - "brotli", - "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 = "cookie" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" -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.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" -dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", - "core-graphics-types", - "foreign-types", - "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 = "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 = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", -] - -[[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-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" -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 = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "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.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" -dependencies = [ - "cssparser-macros", - "dtoa-short", - "itoa", - "phf", - "smallvec", -] - -[[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 = "ct-codecs" -version = "1.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd9f3db6f2cfef61c10613071955154ffdc9e515daebff26de4b54e35038fdd" - -[[package]] -name = "ctor" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] - -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - -[[package]] -name = "ctr" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" -dependencies = [ - "cipher", -] - -[[package]] -name = "ctrlc" -version = "3.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" -dependencies = [ - "dispatch2", - "nix", - "windows-sys 0.61.2", -] - -[[package]] -name = "ctutils" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" -dependencies = [ - "cmov", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "dbus" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" -dependencies = [ - "libc", - "libdbus-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "defmt" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" -dependencies = [ - "bitflags 1.3.2", - "defmt-macros", -] - -[[package]] -name = "defmt-macros" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" -dependencies = [ - "defmt-parser", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "defmt-parser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" -dependencies = [ - "thiserror 2.0.19", -] - -[[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_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 = [ - "proc-macro2", - "quote", - "rustc_version", - "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", - "subtle", -] - -[[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", - "ctutils", -] - -[[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 = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags 2.13.1", - "block2", - "libc", - "objc2", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", -] - -[[package]] -name = "dlopen2_derive" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "dom_query" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" -dependencies = [ - "bit-set", - "cssparser", - "foldhash", - "html5ever 0.38.0", - "precomputed-hash", - "selectors 0.36.1", - "tendril 0.5.1", -] - -[[package]] -name = "dpi" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -dependencies = [ - "serde", -] - -[[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 = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "ego-tree" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" - -[[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.4+spec-1.1.0", - "vswhom", - "winreg", -] - -[[package]] -name = "embed_plist" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" - -[[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 = "endi" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" - -[[package]] -name = "enumflags2" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" -dependencies = [ - "enumflags2_derive", - "serde", -] - -[[package]] -name = "enumflags2_derive" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "env_filter" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" -dependencies = [ - "env_filter", - "log", -] - -[[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 = "event-listener" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" -dependencies = [ - "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 = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fancy-regex" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2e16b9a584b602f1e0e5e22c30ca0906786088c27500653e432d086def8b53" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "fastbloom" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7f34442dbe69c60fe8eaf58a8cafff81a1f278816d8ab4db255b3bef4ac3c4" -dependencies = [ - "getrandom 0.3.4", - "libm", - "siphasher", -] - -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[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.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[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 = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.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 = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "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-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 = "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 = "gdkwayland-sys" -version = "0.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" -dependencies = [ - "gdk-sys", - "glib-sys", - "gobject-sys", - "libc", - "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 = "genawaiter" -version = "0.99.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c86bd0361bcbde39b13475e6e36cb24c329964aa2611be285289d1e4b751c1a0" -dependencies = [ - "genawaiter-macro", -] - -[[package]] -name = "genawaiter-macro" -version = "0.99.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b32dfe1fdfc0bbde1f22a5da25355514b5e450c33a6af6770884c8750aedfbc" - -[[package]] -name = "generator" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", -] - -[[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 = "getopts" -version = "0.2.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" -dependencies = [ - "unicode-width 0.2.2", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[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 = "ghash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" -dependencies = [ - "opaque-debug", - "polyval", -] - -[[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.2", - "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 = "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 = "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 = "harness-gateway-client" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "async-trait", - "bytes", - "plugin-web", - "reqwest", - "runvil", - "serde", - "serde_json", - "tokio", - "url", -] - -[[package]] -name = "harness-plugin-loader" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "libloading 0.8.9", - "runvil-plugin", - "runvil-plugin-sys", - "serde_json", -] - -[[package]] -name = "harness-runner" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "futures-util", - "rand 0.9.5", - "runvil-engine", - "runvil-plugin", - "serde_json", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[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 = "hifijson" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "242402749acf71e6f32f5857598b7002c4058a4e3c3b22b4c7d51cab9aea754e" - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", -] - -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest 0.11.3", -] - -[[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 = "htmd" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5a1c7113c831fec68cbd79cd8bf281a84e5b6943f51473dc266b0b88a6a017e" -dependencies = [ - "html5ever 0.38.0", - "markup5ever_rcdom", - "phf", -] - -[[package]] -name = "html5ever" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" -dependencies = [ - "log", - "markup5ever 0.36.1", -] - -[[package]] -name = "html5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" -dependencies = [ - "log", - "markup5ever 0.38.0", -] - -[[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 = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "humantime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" - -[[package]] -name = "hybrid-array" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" -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", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "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 = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "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 = "ico" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" -dependencies = [ - "byteorder", - "png 0.17.16", -] - -[[package]] -name = "icu_collator" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08984ed58ac439ebf3e13d2cf26b0c46a60afcd21721c1d14087c0b240344dda" -dependencies = [ - "icu_collator_data", - "icu_collections", - "icu_locale_core", - "icu_locale_fallback", - "icu_normalizer", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_collator_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7d7e54efdddeb1208c08dd5d32b53a879ac00d2d3d051b2255fd26821d16368" - -[[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "785f595c61ef57169a467eeed7b4b6936a66f6cacbb116a96ee86da983251bf4" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_locale_fallback", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d37d91460e362a5cf58907cd7ce871411775ee6294e49a5cc8e6cec16dfa75ea" - -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "utf16_iter", - "utf8_iter", - "write16", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "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 = "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 = "infer" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" -dependencies = [ - "cfb", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "generic-array", -] - -[[package]] -name = "intrusive-collections" -version = "0.9.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" -dependencies = [ - "memoffset", -] - -[[package]] -name = "io-uring" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed3bd0ecfbb87805f538bb7b32e5239ca0763890c623e349860ecba69469f2bb" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "libc", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -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 = "jaq-core" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "723ad9253808e95daa2d10ad5b258ac4e65c07003e223e08d1d8734c2942d606" -dependencies = [ - "dyn-clone", - "once_cell", - "typed-arena", -] - -[[package]] -name = "jaq-std" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56b378d4f7587d332111a92608348db6d6b9b8f7cb5ee1d2bd162ec715d7c5bf" -dependencies = [ - "aho-corasick", - "base64 0.22.1", - "bstr", - "jaq-core", - "jiff", - "libm", - "log", - "regex-bites", - "urlencoding", -] - -[[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 = "jiff" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" -dependencies = [ - "defmt", - "jiff-core", - "jiff-static", - "jiff-tzdb-platform", - "log", - "portable-atomic", - "portable-atomic-util", - "serde_core", - "windows-link 0.2.1", -] - -[[package]] -name = "jiff-core" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" -dependencies = [ - "defmt", -] - -[[package]] -name = "jiff-static" -version = "0.2.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" -dependencies = [ - "jiff-core", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "jiff-tzdb-platform" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" -dependencies = [ - "jiff-tzdb", -] - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys 0.4.1", - "log", - "simd_cesu8", - "thiserror 2.0.19", - "walkdir", - "windows-link 0.2.1", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn 2.0.119", -] - -[[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 = "json-patch" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" -dependencies = [ - "jsonptr", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "jsonptr" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "keyboard-types" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" -dependencies = [ - "bitflags 2.13.1", - "serde", - "unicode-segmentation", -] - -[[package]] -name = "kuchikikiki" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14683223e533503d404478bfd32826a4cba1b4906034ff74135404372390e87b" -dependencies = [ - "bitflags 2.13.1", - "crc", - "cssparser", - "html5ever 0.36.1", - "indexmap 2.14.0", - "precomputed-hash", - "selectors 0.33.0", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - -[[package]] -name = "libappindicator" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" -dependencies = [ - "glib", - "gtk", - "gtk-sys", - "libappindicator-sys", - "log", -] - -[[package]] -name = "libappindicator-sys" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" -dependencies = [ - "gtk-sys", - "libloading 0.7.4", - "once_cell", -] - -[[package]] -name = "libbz2-rs-sys" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f454fd6831a29c0e827ed075bca2a15be201bd57f4168f0fd1041124eab17e" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "pkg-config", -] - -[[package]] -name = "libloading" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" -dependencies = [ - "cfg-if", - "winapi", -] - -[[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.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" -dependencies = [ - "libc", -] - -[[package]] -name = "linkme" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3045e122bd98aef8ec3ad58ce84f0791f64e70163d1a02710af4aa11a4d54cc5" -dependencies = [ - "linkme-impl", -] - -[[package]] -name = "linkme-impl" -version = "0.3.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77060ebe535362c3da75682cd17b0431017b6e7c5661e714fc69a7ad017d1301" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[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" - -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lua-src" -version = "550.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c110c2fa33f34e0de05448e1f3eb2e0631e7a69e2d8ae1586cffc9fc9f9949" -dependencies = [ - "cc", -] - -[[package]] -name = "luajit-src" -version = "210.7.2+b925b3e" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920cf654b23d217c550ceea57c32cd2a413ea27b6d47ed77b5ee0cf655adefa6" -dependencies = [ - "cc", - "which 8.0.6", -] - -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - -[[package]] -name = "markup5ever" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" -dependencies = [ - "log", - "tendril 0.4.3", - "web_atoms", -] - -[[package]] -name = "markup5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" -dependencies = [ - "log", - "tendril 0.5.1", - "web_atoms", -] - -[[package]] -name = "markup5ever_rcdom" -version = "0.38.0+unofficial" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff" -dependencies = [ - "html5ever 0.38.0", - "markup5ever 0.38.0", - "tendril 0.5.1", - "xml5ever", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" -dependencies = [ - "cfg-if", - "digest 0.11.3", -] - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "memoffset" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" -dependencies = [ - "autocfg", -] - -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "cfg-if", - "miette-derive", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[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 = "minisign" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aefd422a7a8b5efced01cd7cdf3771e62ebecbc90e3d27695002d3af6af94586" -dependencies = [ - "ct-codecs", - "getrandom 0.4.3", - "rpassword", - "scrypt", -] - -[[package]] -name = "minisign-verify" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" - -[[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", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mlua" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad72ffa037cf5970c9860674f32f703fda25d86cf217475fe7a79c5f9961bcaa" -dependencies = [ - "bstr", - "either", - "erased-serde", - "libc", - "mlua-sys", - "num-traits", - "parking_lot", - "rustc-hash 2.1.3", - "serde", - "serde-value", -] - -[[package]] -name = "mlua-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92136787b906d4e55cfe96cd6c62e010bb1a56889d0d6cf83eb016dbad07576b" -dependencies = [ - "cc", - "cfg-if", - "libc", - "lua-src", - "luajit-src", - "pkg-config", -] - -[[package]] -name = "muda" -version = "0.19.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" -dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.19", - "windows-sys 0.61.2", -] - -[[package]] -name = "multer" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" -dependencies = [ - "bytes", - "encoding_rs", - "futures-util", - "http", - "httparse", - "memchr", - "mime", - "spin", - "version_check", -] - -[[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 = "nix" -version = "0.31.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" -dependencies = [ - "bitflags 2.13.1", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "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-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "num_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 = "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", - "objc2", - "objc2-core-foundation", - "objc2-foundation", -] - -[[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 = [ - "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", - "dispatch2", - "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", - "dispatch2", - "objc2", - "objc2-core-foundation", - "objc2-io-surface", -] - -[[package]] -name = "objc2-core-image" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -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-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-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-osa-kit" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" -dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-app-kit", - "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-core-foundation", - "objc2-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", - "block2", - "objc2", - "objc2-cloud-kit", - "objc2-core-data", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-core-image", - "objc2-core-location", - "objc2-core-text", - "objc2-foundation", - "objc2-quartz-core", - "objc2-user-notifications", -] - -[[package]] -name = "objc2-user-notifications" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" -dependencies = [ - "objc2", - "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", -] - -[[package]] -name = "object" -version = "0.39.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "open" -version = "5.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade3be4664bc1ef537ce133015f04c176b737815c2ba9fd60edf212d6e90dd55" -dependencies = [ - "dunce", - "is-wsl", - "libc", -] - -[[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 = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "ordered-stream" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" -dependencies = [ - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "os_display" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" -dependencies = [ - "unicode-width 0.2.2", -] - -[[package]] -name = "osakit" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" -dependencies = [ - "objc2", - "objc2-foundation", - "objc2-osa-kit", - "serde", - "serde_json", - "thiserror 2.0.19", -] - -[[package]] -name = "owo-colors" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f" - -[[package]] -name = "pack1" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3b7bb0ecf2e447b1f20ee94ee79ef6eed1e9d4b3c36ce1903b9dea3bf205523" -dependencies = [ - "bytemuck", -] - -[[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 = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "phf" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" -dependencies = [ - "phf_macros", - "phf_shared", - "serde", -] - -[[package]] -name = "phf_codegen" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" -dependencies = [ - "phf_generator", - "phf_shared", -] - -[[package]] -name = "phf_generator" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" -dependencies = [ - "fastrand", - "phf_shared", -] - -[[package]] -name = "phf_macros" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "phf_shared" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" -dependencies = [ - "siphasher", -] - -[[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 = "plist" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" -dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", - "quick-xml", - "serde", - "time", -] - -[[package]] -name = "plugin-bashkit" -version = "0.1.0" -source = "git+https://github.com/cppalliance/runvil-plugin-bashkit?rev=0864f1ff9cc1f4fad6c96f1e9f6d8a3ab85e9580#0864f1ff9cc1f4fad6c96f1e9f6d8a3ab85e9580" -dependencies = [ - "bashkit", - "runvil-plugin-sdk", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "plugin-mcp" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "reqwest", - "rmcp", - "runvil-plugin", - "serde_json", - "tokio", - "tracing", -] - -[[package]] -name = "plugin-std" -version = "0.1.0" -source = "git+https://github.com/cppalliance/runvil-plugin-std?rev=782ad23e5dfbe6fbfa5be7145fa73a9ca60ce8a8#782ad23e5dfbe6fbfa5be7145fa73a9ca60ce8a8" -dependencies = [ - "regex", - "runvil-plugin-sdk", - "serde", - "serde_json", -] - -[[package]] -name = "plugin-web" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "async-trait", - "encoding_rs", - "htmd", - "ipnet", - "mime", - "readabilityrs", - "reqwest", - "runvil-plugin", - "serde", - "serde_json", - "thiserror 2.0.19", - "tokio", - "tracing", - "url", -] - -[[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 = "polyval" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "portable-atomic" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" - -[[package]] -name = "portable-atomic-util" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" -dependencies = [ - "portable-atomic", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "serde_core", - "writeable", - "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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" -dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[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-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools 0.14.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "psm" -version = "0.1.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dcd034599e63b970727f70d79e02d62390a4a84f7c6b827c27c46d5ac3fa622" -dependencies = [ - "ar_archive_writer", - "cc", -] - -[[package]] -name = "pulldown-cmark" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" -dependencies = [ - "bitflags 2.13.1", - "memchr", - "unicase", -] - -[[package]] -name = "quick-xml" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" -dependencies = [ - "memchr", -] - -[[package]] -name = "quinn" -version = "0.11.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 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.19", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg 0.10.2", - "ring", - "rustc-hash 2.1.3", - "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 = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -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.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.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_pcg" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e" -dependencies = [ - "rand_core 0.6.4", -] - -[[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 = "rapidhash" -version = "4.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da7e78a036ce858e8d55b7e7dc8ba3a88b78350fd2155d3591bbd966b58589e" -dependencies = [ - "rustversion", -] - -[[package]] -name = "raw-window-handle" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" - -[[package]] -name = "readabilityrs" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d90c6e1dad698d9f3c80a8d91bc0efc8c2397cb5ca4bbffb9c0fb88a9a66e6b8" -dependencies = [ - "bitflags 2.13.1", - "kuchikikiki", - "once_cell", - "regex", - "scraper", - "serde", - "serde_json", - "thiserror 1.0.69", - "url", - "v_htmlescape", -] - -[[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 = "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-bites" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6a15a2fa0bfda9361941c45550896ae87b15cc6c8c939ea350079670332e211" - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "rfd" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" -dependencies = [ - "block2", - "dispatch2", - "glib-sys", - "gobject-sys", - "gtk-sys", - "js-sys", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "windows-sys 0.60.2", -] - -[[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 = "rmcp" -version = "3.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a7e92f4bcaa36b97fa93c4fa7ca8f3e669eced949345b3462dba333c7f909" -dependencies = [ - "base64 0.23.1", - "bytes", - "chrono", - "futures", - "http", - "indexmap 2.14.0", - "libc", - "pin-project-lite", - "reqwest", - "serde", - "serde_json", - "sha2 0.11.0", - "sse-stream", - "thiserror 2.0.19", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", -] - -[[package]] -name = "roaring" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1" -dependencies = [ - "bytemuck", - "byteorder", -] - -[[package]] -name = "rpassword" -version = "7.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" -dependencies = [ - "libc", - "rtoolbox", - "windows-sys 0.61.2", -] - -[[package]] -name = "rtoolbox" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "runvil" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "harness-plugin-loader", - "harness-runner", - "runvil-engine", - "runvil-plugin", -] - -[[package]] -name = "runvil-engine" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "runvil-lua", - "runvil-model-client", - "runvil-prompt", - "runvil-runtime", - "runvil-types", - "runvil-vfs", -] - -[[package]] -name = "runvil-lua" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "mlua", - "runvil-markdown", - "runvil-model-client", - "runvil-types", - "runvil-vfs", - "serde", - "serde_json", -] - -[[package]] -name = "runvil-markdown" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "pulldown-cmark", - "serde_json", - "serde_yaml_ng", -] - -[[package]] -name = "runvil-model-client" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "runvil-types", - "serde", - "serde_json", -] - -[[package]] -name = "runvil-plugin" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "runvil-types", - "runvil-vfs", - "serde_json", -] - -[[package]] -name = "runvil-plugin-sdk" -version = "0.4.0" -source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2#03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" -dependencies = [ - "runvil-plugin-sys", -] - -[[package]] -name = "runvil-plugin-sys" -version = "0.4.0" -source = "git+https://github.com/cppalliance/runvil-plugin-api?rev=03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2#03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" - -[[package]] -name = "runvil-prompt" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "pulldown-cmark", - "runvil-lua", - "runvil-types", - "serde", - "serde_yaml_ng", -] - -[[package]] -name = "runvil-runtime" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "mlua", - "regex", - "runvil-lua", - "runvil-markdown", - "runvil-model-client", - "runvil-prompt", - "runvil-types", - "runvil-vfs", - "serde", - "serde_json", - "tracing", -] - -[[package]] -name = "runvil-types" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" -dependencies = [ - "serde", - "serde_json", - "sha2 0.11.0", -] - -[[package]] -name = "runvil-vfs" -version = "0.3.0" -source = "git+https://github.com/cppalliance/runvil-harness?rev=5bca6f9fd9b398d5f221a5893570b8085bc283da#5bca6f9fd9b398d5f221a5893570b8085bc283da" - -[[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 = [ - "sha2 0.11.0", - "walkdir", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[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 = "rustc_version_runtime" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd18cd2bae1820af0b6ad5e54f4a51d0f3fcc53b05f845675074efcc7af071d" -dependencies = [ - "rustc_version", - "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.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "aws-lc-rs", - "once_cell", - "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-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-platform-verifier" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni 0.22.4", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "aws-lc-rs", - "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 = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - -[[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 = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "dyn-clone", - "indexmap 1.9.3", - "schemars_derive", - "serde", - "serde_json", - "url", - "uuid", -] - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.119", -] - -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scraper" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93cecd86d6259499c844440546d02f55f3e17bd286e529e48d1f9f67e92315cb" -dependencies = [ - "cssparser", - "ego-tree", - "getopts", - "html5ever 0.36.1", - "precomputed-hash", - "selectors 0.33.0", - "tendril 0.4.3", -] - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "pbkdf2", - "salsa20", - "sha2 0.10.9", -] - -[[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.33.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" -dependencies = [ - "bitflags 2.13.1", - "cssparser", - "derive_more", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "rustc-hash 2.1.3", - "servo_arc", - "smallvec", -] - -[[package]] -name = "selectors" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" -dependencies = [ - "bitflags 2.13.1", - "cssparser", - "derive_more", - "log", - "new_debug_unreachable", - "phf", - "phf_codegen", - "precomputed-hash", - "rustc-hash 2.1.3", - "servo_arc", - "smallvec", -] - -[[package]] -name = "self_cell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" -dependencies = [ - "serde", - "serde_core", -] - -[[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-untagged" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" -dependencies = [ - "erased-serde", - "serde", - "serde_core", - "typeid", -] - -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - -[[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_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_norway" -version = "0.9.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e408f29489b5fd500fab51ff1484fc859bb655f32c671f307dcd733b72e8168c" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml-norway", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_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.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" -dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.0", - "jiff", - "schemars 0.9.0", - "schemars 1.2.2", - "serde_core", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "serde_yaml_ng" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "serialize-to-javascript" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" -dependencies = [ - "serde", - "serde_json", - "serialize-to-javascript-impl", -] - -[[package]] -name = "serialize-to-javascript-impl" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "servo_arc" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" -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" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "digest 0.11.3", -] - -[[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 = "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 = "shuttle" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab17edba38d63047f46780cf7360acf7467fec2c048928689a5c1dd1c2b4e31" -dependencies = [ - "assoc", - "bitvec", - "cfg-if", - "generator", - "hex", - "owo-colors", - "rand 0.8.8", - "rand_core 0.6.4", - "rand_pcg 0.3.1", - "scoped-tls", - "smallvec", - "tracing", -] - -[[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_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "simsimd" -version = "6.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4fb3bc3cdce07a7d7d4caa4c54f8aa967f6be41690482b54b24100a2253fa70" -dependencies = [ - "cc", -] - -[[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 = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[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 = "softaes" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e14297decde697ddf377c25752aead0927d5cfc89c2684d2af96901a4ceeea" - -[[package]] -name = "softbuffer" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" -dependencies = [ - "bytemuck", - "js-sys", - "ndk", - "objc2", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "objc2-quartz-core", - "raw-window-handle", - "redox_syscall", - "tracing", - "wasm-bindgen", - "web-sys", - "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" - -[[package]] -name = "sse-stream" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" -dependencies = [ - "bytes", - "futures-util", - "http-body", - "http-body-util", - "pin-project-lite", -] - -[[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.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707f49d46706bacf8a2b00d51dace3f9de527c13eec3778f570c411f89e69967" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.60.2", -] - -[[package]] -name = "string_cache" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" -dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", - "serde", -] - -[[package]] -name = "string_cache_codegen" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator", - "phf_shared", - "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.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "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 = "swift-rs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "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_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 = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -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.2", - "version-compare", -] - -[[package]] -name = "tao" -version = "0.35.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" -dependencies = [ - "bitflags 2.13.1", - "block2", - "core-foundation 0.10.1", - "core-graphics", - "crossbeam-channel", - "dbus", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni 0.21.1", - "libc", - "log", - "ndk", - "ndk-sys", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "once_cell", - "parking_lot", - "percent-encoding", - "raw-window-handle", - "tao-macros", - "unicode-segmentation", - "url", - "windows", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "tao-macros" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tar" -version = "0.4.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 = "tauri" -version = "2.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" -dependencies = [ - "anyhow", - "bytes", - "cookie", - "dirs", - "dunce", - "embed_plist", - "getrandom 0.3.4", - "glob", - "gtk", - "heck 0.5.0", - "http", - "jni 0.21.1", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "swift-rs", - "tauri-build", - "tauri-macros", - "tauri-runtime", - "tauri-runtime-wry", - "tauri-utils", - "thiserror 2.0.19", - "tokio", - "tray-icon", - "url", - "webkit2gtk", - "webview2-com", - "window-vibrancy", - "windows", -] - -[[package]] -name = "tauri-build" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" -dependencies = [ - "anyhow", - "cargo_toml", - "dirs", - "glob", - "heck 0.5.0", - "json-patch", - "schemars 0.8.22", - "semver", - "serde", - "serde_json", - "tauri-utils", - "tauri-winres", - "walkdir", -] - -[[package]] -name = "tauri-codegen" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" -dependencies = [ - "base64 0.22.1", - "brotli", - "ico", - "json-patch", - "plist", - "png 0.17.16", - "proc-macro2", - "quote", - "semver", - "serde", - "serde_json", - "sha2 0.10.9", - "syn 2.0.119", - "tauri-utils", - "thiserror 2.0.19", - "time", - "url", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-macros" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", - "tauri-codegen", - "tauri-utils", -] - -[[package]] -name = "tauri-plugin" -version = "2.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74be5dd4bed9afbd145e5716b5fa2ec28cbc29c34ffa61c258c9273d896c8020" -dependencies = [ - "anyhow", - "glob", - "plist", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri-utils", - "walkdir", -] - -[[package]] -name = "tauri-plugin-dialog" -version = "2.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61854a36651aa48381e5e209f69a01273b77f3f9f91f0c430b1b98d33bd47229" -dependencies = [ - "log", - "raw-window-handle", - "rfd", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "tauri-plugin-fs", - "thiserror 2.0.19", - "url", -] - -[[package]] -name = "tauri-plugin-fs" -version = "2.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de22eef34fd78c0da050e748710edd50bf127e651d02ea1b2bfada1523cc5c51" -dependencies = [ - "anyhow", - "dunce", - "glob", - "log", - "objc2-foundation", - "percent-encoding", - "schemars 0.8.22", - "serde", - "serde_json", - "serde_repr", - "tauri", - "tauri-plugin", - "tauri-utils", - "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", - "url", -] - -[[package]] -name = "tauri-plugin-opener" -version = "2.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d60366174b745b4ef5824b8bbc1c457fd08f0ce101ff643c0a49181a9f4e91" -dependencies = [ - "dunce", - "glob", - "objc2-app-kit", - "objc2-foundation", - "open", - "schemars 0.8.22", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.19", - "url", - "windows", - "zbus", -] - -[[package]] -name = "tauri-plugin-process" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" -dependencies = [ - "tauri", - "tauri-plugin", -] - -[[package]] -name = "tauri-plugin-single-instance" -version = "2.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0cb5c412a5071b69bab6a6df1583cbb89460d4a83b6a24769b08d15b6b1e1" -dependencies = [ - "serde", - "serde_json", - "tauri", - "thiserror 2.0.19", - "tokio", - "tracing", - "windows-sys 0.60.2", - "zbus", -] - -[[package]] -name = "tauri-plugin-updater" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606" -dependencies = [ - "base64 0.22.1", - "dirs", - "flate2", - "futures-util", - "http", - "infer", - "log", - "minisign-verify", - "osakit", - "percent-encoding", - "reqwest", - "semver", - "serde", - "serde_json", - "tar", - "tauri", - "tauri-plugin", - "tempfile", - "thiserror 2.0.19", - "time", - "tokio", - "url", - "windows-sys 0.60.2", -] - -[[package]] -name = "tauri-runtime" -version = "2.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" -dependencies = [ - "cookie", - "dpi", - "gtk", - "http", - "jni 0.21.1", - "objc2", - "objc2-ui-kit", - "objc2-web-kit", - "raw-window-handle", - "serde", - "serde_json", - "tauri-utils", - "thiserror 2.0.19", - "url", - "webkit2gtk", - "webview2-com", - "windows", -] - -[[package]] -name = "tauri-runtime-wry" -version = "2.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" -dependencies = [ - "gtk", - "http", - "jni 0.21.1", - "log", - "objc2", - "objc2-app-kit", - "once_cell", - "percent-encoding", - "raw-window-handle", - "softbuffer", - "tao", - "tauri-runtime", - "tauri-utils", - "url", - "webkit2gtk", - "webview2-com", - "windows", - "wry", -] - -[[package]] -name = "tauri-utils" -version = "2.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" -dependencies = [ - "anyhow", - "brotli", - "cargo_metadata", - "ctor", - "dom_query", - "dunce", - "glob", - "http", - "infer", - "json-patch", - "log", - "memchr", - "phf", - "plist", - "proc-macro2", - "quote", - "regex", - "schemars 0.8.22", - "semver", - "serde", - "serde-untagged", - "serde_json", - "serde_with", - "swift-rs", - "thiserror 2.0.19", - "toml 1.1.4+spec-1.1.0", - "url", - "urlpattern", - "uuid", - "walkdir", -] - -[[package]] -name = "tauri-winres" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" -dependencies = [ - "dunce", - "embed-resource", - "toml 1.1.4+spec-1.1.0", -] - -[[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 = "tendril" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" -dependencies = [ - "new_debug_unreachable", -] - -[[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 = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -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 = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "serde_core", - "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.54.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" -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-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[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", -] - -[[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", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "toml" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" -dependencies = [ - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "toml_edit 0.20.2", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml" -version = "1.1.4+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" -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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" -dependencies = [ - "serde", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[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.3", - "winnow 0.5.40", -] - -[[package]] -name = "toml_edit" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" -dependencies = [ - "indexmap 2.14.0", - "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.3", - "winnow 0.5.40", -] - -[[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.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" -dependencies = [ - "winnow 1.0.4", -] - -[[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", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "async-compression", - "bitflags 2.13.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[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 = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.19", - "time", - "tracing-subscriber", -] - -[[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 = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "tray-icon" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" -dependencies = [ - "crossbeam-channel", - "dirs", - "libappindicator", - "muda", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.19", - "windows-sys 0.61.2", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[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.5", - "sha1 0.10.7", - "thiserror 2.0.19", -] - -[[package]] -name = "turso" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9491d7a80312c5abe66a4409e4dce02065503a235453c94b9e4133877e39ffc" -dependencies = [ - "thiserror 2.0.19", - "tracing", - "tracing-subscriber", - "turso_core", - "turso_sdk_kit", - "turso_sync_sdk_kit", -] - -[[package]] -name = "turso_core" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a833cc3bf8d4e6c101c504fa470f8ab4270c2202ff2591b61b2e373b4f20d9b" -dependencies = [ - "aegis", - "aes", - "aes-gcm", - "allocator-api2", - "antithesis_sdk", - "arc-swap", - "aristo", - "bigdecimal", - "bitflags 2.13.1", - "branches", - "bumpalo", - "bytemuck", - "cfg_aliases", - "cfg_block", - "chrono", - "crc32c", - "crossbeam-epoch", - "crossbeam-utils", - "either", - "fallible-iterator", - "fastbloom", - "hex", - "icu_collator", - "icu_locale", - "intrusive-collections", - "io-uring", - "libc", - "libloading 0.8.9", - "libm", - "loom", - "miette", - "num-bigint", - "num-traits", - "pack1", - "parking_lot", - "pastey", - "polling", - "rand 0.9.5", - "rapidhash", - "regex", - "regex-syntax", - "roaring", - "rustc-hash 2.1.3", - "rustix 1.1.4", - "ryu", - "serde_json", - "shuttle", - "simsimd", - "smallvec", - "strum", - "strum_macros", - "tempfile", - "thiserror 2.0.19", - "tracing", - "tracing-subscriber", - "turso_ext", - "turso_macros", - "turso_parser", - "twox-hash", - "uncased", - "uuid", - "windows-sys 0.61.2", -] - -[[package]] -name = "turso_ext" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7452fe676450b6840e9eb80e512d14293265221154eba66aba3845dfc0d659f" -dependencies = [ - "chrono", - "getrandom 0.4.3", - "turso_macros", -] - -[[package]] -name = "turso_macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2825eaa6b7b893693636947f988e252c1196393fa54d4b85637c70d616d92fa" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "turso_parser" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0e58d029f02e442df4be0b5036ce520b49c553505d9d52ef5a98fc4056e95b7" -dependencies = [ - "bitflags 2.13.1", - "memchr", - "miette", - "strum", - "strum_macros", - "thiserror 2.0.19", - "turso_macros", -] - -[[package]] -name = "turso_sdk_kit" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18c1dc1c0304348c39b97bc6b27cdcb1d7292454ebd0de0f30b5ee3a4c61f9bb" -dependencies = [ - "bindgen", - "env_logger", - "parking_lot", - "tracing", - "tracing-appender", - "tracing-subscriber", - "turso_core", - "turso_ext", - "turso_sdk_kit_macros", -] - -[[package]] -name = "turso_sdk_kit_macros" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4708b5fe678b8730c85964e3d378f75e18c23c4409971360b2209f4e53e4956" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "turso_sync_engine" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce8f03e430240d590d209ac874db52773b7382c627cd604a685affde77622b09" -dependencies = [ - "base64 0.22.1", - "bytes", - "crc32c", - "genawaiter", - "http", - "libc", - "prost", - "roaring", - "serde", - "serde_json", - "thiserror 2.0.19", - "tracing", - "turso_core", - "turso_parser", - "uuid", -] - -[[package]] -name = "turso_sync_sdk_kit" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d48cb47d056c3ec567745761fa6f832358ca30ef9eb0435fdcfb77c558e70089" -dependencies = [ - "bindgen", - "env_logger", - "genawaiter", - "parking_lot", - "tracing", - "tracing-appender", - "tracing-subscriber", - "turso_core", - "turso_sdk_kit", - "turso_sdk_kit_macros", - "turso_sync_engine", -] - -[[package]] -name = "twox-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" -dependencies = [ - "rand 0.10.2", -] - -[[package]] -name = "typed-arena" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" - -[[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 = "uds_windows" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" -dependencies = [ - "memoffset", - "tempfile", - "windows-sys 0.61.2", -] - -[[package]] -name = "uncased" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" -dependencies = [ - "version_check", -] - -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unit-prefix" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common 0.1.7", - "subtle", -] - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "unsafe-libyaml-norway" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[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", - "serde_derive", -] - -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - -[[package]] -name = "urlpattern" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" -dependencies = [ - "regex", - "serde", - "unic-ucd-ident", - "url", -] - -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[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_htmlescape" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[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 = "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.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -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 = "web_atoms" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" -dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", -] - -[[package]] -name = "webkit2gtk" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" -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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" -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-root-certs" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" -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", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", -] - -[[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", - "windows-core 0.61.2", -] - -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - -[[package]] -name = "which" -version = "8.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" -dependencies = [ - "libc", -] - -[[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 = "window-vibrancy" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" -dependencies = [ - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", - "raw-window-handle", - "windows-sys 0.59.0", - "windows-version", -] - -[[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-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.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "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", - "windows-interface", - "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.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.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.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -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.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.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[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" - -[[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 = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "workshop" -version = "0.3.0" -dependencies = [ - "anyhow", - "reqwest", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-dialog", - "tauri-plugin-opener", - "tauri-plugin-process", - "tauri-plugin-single-instance", - "tauri-plugin-updater", - "tempfile", - "url", - "webkit2gtk", - "webview2-com", - "windows-core 0.61.2", - "workshop-server-api", -] - -[[package]] -name = "workshop-agents" -version = "0.0.0" -dependencies = [ - "rand 0.9.5", - "runvil", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tracing", -] - -[[package]] -name = "workshop-gateway" -version = "0.0.0" -dependencies = [ - "arc-swap", - "axum", - "futures-util", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tokio-tungstenite", - "tracing", - "url", - "workshop-gateway-discovery", - "workshop-protocol", - "workshop-registry", - "workshop-support", -] - -[[package]] -name = "workshop-gateway-discovery" -version = "0.3.0" -dependencies = [ - "libc", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "windows-sys 0.61.2", - "workshop-gateway-discovery", -] - -[[package]] -name = "workshop-menu" -version = "0.0.0" -dependencies = [ - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tracing", - "workshop-protocol", - "workshop-registry", - "workshop-support", -] - -[[package]] -name = "workshop-plugin-bashkit" -version = "0.3.0" -dependencies = [ - "plugin-bashkit", - "runvil-plugin-sdk", -] - -[[package]] -name = "workshop-plugin-std" -version = "0.3.0" -dependencies = [ - "plugin-std", - "runvil-plugin-sdk", -] - -[[package]] -name = "workshop-protocol" -version = "0.0.0" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "workshop-registry" -version = "0.0.0" -dependencies = [ - "axum", - "serde_json", - "tokio", - "workshop-protocol", -] - -[[package]] -name = "workshop-run-log" -version = "0.0.0" -dependencies = [ - "runvil", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "turso", -] - -[[package]] -name = "workshop-server" -version = "0.3.0" -dependencies = [ - "async-trait", - "axum", - "build-ui", - "futures-util", - "harness-gateway-client", - "plugin-mcp", - "plugin-web", - "reqwest", - "runvil", - "rust-embed", - "serde", - "serde_json", - "socket2", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tokio-tungstenite", - "tower", - "tracing", - "url", - "workshop-agents", - "workshop-gateway", - "workshop-menu", - "workshop-plugin-bashkit", - "workshop-plugin-std", - "workshop-protocol", - "workshop-registry", - "workshop-run-log", - "workshop-server", - "workshop-status", - "workshop-support", - "workshop-user-state", - "workshop-workspace", -] - -[[package]] -name = "workshop-server-api" -version = "0.0.0" -dependencies = [ - "workshop-server", - "workshop-server-api", -] - -[[package]] -name = "workshop-status" -version = "0.0.0" -dependencies = [ - "tokio", - "workshop-protocol", - "workshop-registry", - "workshop-support", -] - -[[package]] -name = "workshop-support" -version = "0.0.0" -dependencies = [ - "axum", - "reqwest", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "toml 1.1.4+spec-1.1.0", - "tower", - "tracing", -] - -[[package]] -name = "workshop-user-state" -version = "0.0.0" -dependencies = [ - "axum", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tower", - "tracing", - "workshop-protocol", - "workshop-registry", - "workshop-support", -] - -[[package]] -name = "workshop-workspace" -version = "0.0.0" -dependencies = [ - "axum", - "dunce", - "humantime", - "percent-encoding", - "serde", - "serde_json", - "tempfile", - "thiserror 2.0.19", - "tokio", - "tower", - "tracing", - "turso", - "workshop-protocol", - "workshop-registry", - "workshop-support", - "workshop-workspace", -] - -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "wry" -version = "0.55.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" -dependencies = [ - "base64 0.22.1", - "block2", - "cookie", - "crossbeam-channel", - "dirs", - "dom_query", - "dpi", - "dunce", - "gdkx11", - "gtk", - "http", - "javascriptcore-rs", - "jni 0.21.1", - "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", - "windows-core 0.61.2", - "windows-version", - "x11-dl", -] - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[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 = "xml5ever" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9" -dependencies = [ - "log", - "markup5ever 0.38.0", -] - -[[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 = "zbus" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" -dependencies = [ - "async-broadcast", - "async-executor", - "async-io", - "async-lock", - "async-process", - "async-recursion", - "async-task", - "async-trait", - "blocking", - "enumflags2", - "event-listener", - "futures-core", - "futures-lite", - "hex", - "libc", - "ordered-stream", - "rustix 1.1.4", - "serde", - "serde_repr", - "tracing", - "uds_windows", - "uuid", - "windows-sys 0.61.2", - "winnow 1.0.4", - "zbus_macros", - "zbus_names", - "zvariant", -] - -[[package]] -name = "zbus_macros" -version = "5.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", - "zbus_names", - "zvariant", - "zvariant_utils", -] - -[[package]] -name = "zbus_names" -version = "4.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" -dependencies = [ - "serde", - "winnow 1.0.4", - "zvariant", -] - -[[package]] -name = "zcheapstr" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" -dependencies = [ - "serde", -] - -[[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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" - -[[package]] -name = "zvariant" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" -dependencies = [ - "endi", - "enumflags2", - "serde", - "winnow 1.0.4", - "zcheapstr", - "zvariant_derive", - "zvariant_utils", -] - -[[package]] -name = "zvariant_derive" -version = "5.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 3.0.3", - "zvariant_utils", -] - -[[package]] -name = "zvariant_utils" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn 3.0.3", - "winnow 1.0.4", -] diff --git a/Cargo.toml b/Cargo.toml index c0b73e9fb..0999f1470 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,6 @@ [workspace] resolver = "3" -members = ["crates/*", "crates/workshop/desktop", "crates/workshop/server", "crates/workshop/server-api", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/workshop/plugin-bashkit"] -# crates/workshop is a manifestless container holding Workshop's crates. -# Cargo prunes excluded subtrees from member globs, so a "crates/workshop/*" -# glob beside the exclude below would match nothing; the container crates -# are enumerated explicitly instead. -exclude = ["crates/workshop"] -# Plain `cargo build`/`cargo test` build every crate but the desktop app, -# the server it embeds, and the server's facade: the rest compile on a -# fresh macOS or Linux clone with no Node install and no Tauri system -# packages. The desktop app is an explicit choice: `cargo build -p workshop`. -default-members = ["crates/build-ui", "crates/build-user-guide", "crates/build-workshop", "crates/build-xtask", "crates/workshop/gateway", "crates/workshop/gateway-discovery", "crates/workshop/menu", "crates/workshop/protocol", "crates/workshop/registry", "crates/workshop/status", "crates/workshop/support", "crates/workshop/user-state", "crates/workshop/workspace", "crates/workshop/run-log", "crates/workshop/agents", "crates/workshop/plugin-std", "crates/workshop/plugin-bashkit"] +members = ["crates/*"] [workspace.package] version = "0.3.0" @@ -19,143 +9,30 @@ license = "BSL-1.0" repository = "https://github.com/cppalliance/runvil" [workspace.dependencies] +anyhow = "1" base64 = "0.22" -# The Engine and the Harness, from their own repository, pinned by commit: -# the `runvil` facade, which is the one crate a Host depends on, the -# standard Gateway client, and the two Rust Plugins that Workshop links -# in. All four name one commit, because two commits would be two copies -# of every type the crates share. `version` beside `git` keeps cargo-deny's -# wildcard check quiet. -runvil = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "5bca6f9fd9b398d5f221a5893570b8085bc283da" } -harness-gateway-client = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "5bca6f9fd9b398d5f221a5893570b8085bc283da" } -plugin-web = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "5bca6f9fd9b398d5f221a5893570b8085bc283da" } -plugin-mcp = { version = "0.3.0", git = "https://github.com/cppalliance/runvil-harness", rev = "5bca6f9fd9b398d5f221a5893570b8085bc283da" } -# The safe layer over the C Plugin contract, version 0.4, from its own -# repository, pinned by commit. Only the library Plugin crates under -# `crates/workshop/` name it, for `export_plugin!`. The commit is the one -# runvil-harness pins the contract's `runvil-plugin-sys` at: that crate -# sets `links`, so Cargo refuses two commits of the contract in one build. -runvil-plugin-sdk = { version = "0.4.0", git = "https://github.com/cppalliance/runvil-plugin-api", rev = "03a9fa37d2fe690d42ee0aa5c7a8289173e8e0f2" } -# The std Plugin, from its own repository, pinned by commit, which must be -# built against the contract commit above. default-features off drops its -# own export of `runvil_plugin`, which `workshop-plugin-std` makes instead. -# The key is `std-plugin` because that crate's own library is named -# `plugin_std`, the name of the file Workshop ships. -std-plugin = { package = "plugin-std", version = "0.1.0", git = "https://github.com/cppalliance/runvil-plugin-std", rev = "782ad23e5dfbe6fbfa5be7145fa73a9ca60ce8a8", default-features = false } -# The bashkit Plugin, from its own repository, pinned by commit and built -# against the same contract commit. default-features off drops its own -# export of `runvil_plugin`, which `workshop-plugin-bashkit` makes -# instead. The key is `bashkit-plugin` for the reason std's is -# `std-plugin`: the crate's own library has the name of the file Workshop -# ships, `plugin_bashkit`. -bashkit-plugin = { package = "plugin-bashkit", version = "0.1.0", git = "https://github.com/cppalliance/runvil-plugin-bashkit", rev = "0864f1ff9cc1f4fad6c96f1e9f6d8a3ab85e9580", default-features = false } -workshop-gateway-discovery = { path = "crates/workshop/gateway-discovery" } -workshop-server = { path = "crates/workshop/server" } -workshop-server-api = { path = "crates/workshop/server-api" } -workshop-gateway = { path = "crates/workshop/gateway" } -workshop-menu = { path = "crates/workshop/menu" } -workshop-protocol = { path = "crates/workshop/protocol" } -workshop-status = { path = "crates/workshop/status" } -workshop-support = { path = "crates/workshop/support" } -workshop-registry = { path = "crates/workshop/registry" } -workshop-workspace = { path = "crates/workshop/workspace" } -workshop-run-log = { path = "crates/workshop/run-log" } -workshop-agents = { path = "crates/workshop/agents" } -workshop-user-state = { path = "crates/workshop/user-state" } -workshop-plugin-std = { path = "crates/workshop/plugin-std" } -workshop-plugin-bashkit = { path = "crates/workshop/plugin-bashkit" } +# Cross-platform Ctrl-C handling for the installer build. +ctrlc = "3.5" +# `cargo installer manifest` checks each updater signature as the Tauri +# updater does: 0.2.5 is the release Workshop's `tauri-plugin-updater` +# verifies with. +minisign-verify = "0.2.5" +# Signs the manifest tests' fixtures with a key generated per run. A +# dev-dependency only: the release key never leaves CI. +minisign = "0.9" +# `span-locations` makes a span parsed outside a proc macro report its line, +# which the structural checks print with every violation. +proc-macro2 = { version = "1", features = ["span-locations"] } # default-features off: build-xtask's doctest scan reads only the core # event stream, never the `html` renderer or the `getopts` binary. pulldown-cmark = { version = "0.12", default-features = false } -serde = { version = "1", features = ["derive"] } -# `float_roundtrip` makes `from_str` reproduce the exact `f64` that -# `to_string` printed, so a payload the run log stores reads back bit-for-bit. -serde_json = { version = "1", features = ["float_roundtrip"] } -# 0.13 rather than 0.12 so the tree links exactly one rustls crypto backend: -# aws-lc-rs, the one Tauri already selects. 0.13 has no `ring` -# option, so a bump cannot re-enable it; CI checks that `ring` stays out of -# each Rust binary's normal closure. Root certificates come from the OS trust -# store through the platform verifier (the `rustls` feature's default), not -# from a bundled Mozilla list. `query` became opt-in in 0.13. -# `system-proxy` makes reqwest honor Windows registry and macOS proxies. -reqwest = { version = "0.13", default-features = false, features = ["json", "query", "rustls", "stream", "system-proxy"] } -rand = "0.9" -url = "2" -# Percent-decoding for path parameters; already in the tree as url's own core. -percent-encoding = "2" -dunce = "1" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "time", "sync"] } -thiserror = "2" -anyhow = "1" -axum = { version = "0.8", features = ["multipart", "ws"] } -# 0.29 rather than the newer 0.30 so the resolver unifies with the copy axum's -# `ws` feature already pulls in, instead of compiling two. -tokio-tungstenite = "0.29" -async-trait = "0.1" -futures-util = "0.3" -# 1.x rather than 0.8 so the resolver unifies with the copy tauri-utils -# already pulls in, instead of compiling two toml lines (and the 0.8 line's -# exclusive toml_edit 0.19 and winnow 0.5 tail). -toml = "1" +serde_json = "1" +sha2 = "0.11" # Parses each crate's sources for build-xtask's structural checks; `full` -# is the feature that parses items. 3 is the newest major the lockfile -# already carries. +# is the feature that parses items. syn = { version = "3", features = ["full"] } -# `span-locations` makes a span parsed outside a proc macro report its line, -# which the structural checks print with every violation. -proc-macro2 = { version = "1", features = ["span-locations"] } -tracing = "0.1" -tower = { version = "0.5", features = ["util"] } -arc-swap = "1" -# Serves the UIs: reads $OUT_DIR/ui-dist from disk in debug builds, embeds it -# into the binary in release builds. interpolate-folder-path resolves the -# $OUT_DIR prefix in the `folder` attribute. -rust-embed = { version = "8", features = ["interpolate-folder-path"] } -humantime = "2" tempfile = "3" -# Exact pin: turso is pre-1.0 and backs user-visible workspace files, so -# upgrades are deliberate, reviewed changes rather than resolver drift. -# default-features off: the `mimalloc` default installs a -# `#[global_allocator]` from inside the library (process-global state and a -# C build in a serve path), and `fts` is unused; the workspace schema is -# three plain tables. -turso = { version = "=0.7.2", default-features = false } -sha2 = "0.11" -# `cargo workshop manifest` checks each updater signature as the Tauri -# updater does; 0.2.5 is the copy tauri-plugin-updater already pulls in. -minisign-verify = "0.2.5" -# Signs the manifest tests' fixtures with a key generated per run. A -# dev-dependency only: the release key never leaves CI. -minisign = "0.9" -socket2 = { version = "0.6", features = ["all"] } -# Cross-platform Ctrl-C handling for the Workshop build orchestrator. -ctrlc = "3.5" -# Thin process-image shims for the Workshop gateway discovery reader's stale -# detection; both are already in the tree transitively. -libc = "0.2" -windows-sys = { version = "0.61", default-features = false } -# Tauri v2 desktop framework for workshop. tauri is pinned to the -# minor because `with_webview` exposes webview2-com/webkit2gtk types that may -# shift in a Tauri minor release (the upstream guidance for `with_webview`). -tauri = "~2.11" -tauri-build = "2.6.3" -tauri-plugin-dialog = "2.7.3" -tauri-plugin-opener = "2.5.5" -tauri-plugin-process = "2.3.1" -# default-features off drops the Tauri plugin's `rustls-tls` default, which is -# the one dependency in the tree that pins rustls to the `ring` backend (and -# installs it as the process-global CryptoProvider). The plugin's `rustls-tls` -# default would put `ring` into the Workshop binary. The plugin's reqwest -# calls still get TLS: cargo unifies reqwest's features per binary and the -# workspace pin above enables `rustls` (aws-lc-rs), so the Workshop links one -# crypto backend. `system-proxy` is the plugin's other default, kept as it -# was. The `zip` default stays off: it only unpacks the older v1Compatible -# update bundles (the Windows `.zip` and the Linux `.AppImage.tar.gz`), and -# the desktop app's `"createUpdaterArtifacts": true` ships the signed -# installers themselves (the NSIS `-setup.exe` and the raw `.AppImage`). The -# macOS `.app.tar.gz` path uses `tar` and `flate2` with no feature. -tauri-plugin-updater = { version = "2.11.0", default-features = false, features = ["system-proxy"] } -tauri-plugin-single-instance = "2.4.4" +toml = "1" # The dylint libraries `cargo dylint --all` runs. The lints live in their # own repository, shared across the runvil project, because they build on @@ -164,6 +41,17 @@ tauri-plugin-single-instance = "2.4.4" [workspace.metadata.dylint] libraries = [{ git = "https://github.com/cppalliance/dylints", rev = "982b297ede90d37cdf8f3bf9995d1d706dcc51d4" }] +# The Workshop commit the installers are built from. Workshop is its own +# product in its own repository, and `cargo installer` builds the checkout +# `RUNVIL_WORKSHOP` names, which `installers.yml` takes at this commit. +# Moving to a newer Workshop is a change of `rev`, together with the +# workspace version above and `rust-toolchain.toml` when Workshop changed +# either: `cargo installer` refuses a checkout whose version or toolchain +# differs from this workspace's. +[workspace.metadata.workshop] +git = "https://github.com/cppalliance/runvil-workshop" +rev = "e32d4caaaa60ad03d47fb807daa5d0d4b2c80ec4" + [workspace.lints.rust] unsafe_code = "deny" missing_docs = "warn" @@ -200,8 +88,3 @@ doc_markdown = "allow" # `assert_eq!(x, [] as [T; 0])`, which has to spell out the element type and # needs `T: PartialEq + Debug`, so it cannot be applied to most call sites. assert_is_empty = "allow" - -# The profile that 'dist' will build with -[profile.dist] -inherits = "release" -lto = "thin" diff --git a/README.md b/README.md index aac1d1d75..2b79872bc 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The [latest release](https://github.com/cppalliance/runvil/releases/latest) hold The installer offers Workshop, the desktop app, and the Gateway, the local gateway described below, with its tray icon and Settings page. On Windows it also offers Speech to Text, which needs the Gateway. The Gateway downloads its speech runtime and two speech models, about 0.6 GB, the first time it starts with speech configured, so dictation works offline after that. Workshop installed without the Gateway attaches to a gateway already running for you, or else uses the one `~/.runvil/workshop.toml` names. By default, Windows installs into `%LOCALAPPDATA%\Runvil`, macOS into `/Applications/Runvil`, and Linux into `~/Runvil`. -Workshop checks for updates when it starts and offers each new version, which it installs when you accept. The Gateway is its own product with its own releases, at [cppalliance/runvil-gateway](https://github.com/cppalliance/runvil-gateway); each installer carries the Gateway release this repository pins in `gateway-pin/pin.json`. +Workshop checks for updates when it starts and offers each new version, which it installs when you accept. The Gateway is its own product with its own releases, at [cppalliance/runvil-gateway](https://github.com/cppalliance/runvil-gateway); each installer holds the Gateway release that Workshop pins. The installers are not code-signed yet. On Windows, SmartScreen may stop the setup: choose More info, then Run anyway. On macOS, the first open is refused: open System Settings, then Privacy & Security, choose Open Anyway, and open the installer again. Until the apps are signed, the gateway's Launch at Login does not work on macOS. @@ -33,6 +33,8 @@ Workshop is the desktop app. You open your folders in it, edit files, and work w Workshop connects to a Gateway that's already running or starts the installed one, and the Gateway stays in the system tray after you close the window, so your loaded models stay loaded. +Workshop is developed in its own repository, [cppalliance/runvil-workshop](https://github.com/cppalliance/runvil-workshop). This repository builds the installers from the Workshop commit that its root `Cargo.toml` pins. + ## What a prompt looks like This is [`prompts/greet.md`](https://github.com/cppalliance/runvil-harness/blob/master/prompts/greet.md), one of the sample prompts in the Engine's repository: @@ -66,7 +68,7 @@ The prompt declares no models, so `models.infer(prose)` runs on the Host's defau ## How it fits together -The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a Pack's render of the tools a section offers, a Pack tool's result, a timer, or a file outside a prelude's install, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness. The Engine and the Harness are developed in their own repository, [cppalliance/runvil-harness](https://github.com/cppalliance/runvil-harness), which this repository pins by commit. +The Engine and the Harness are Rust libraries. The Engine parses a prompt and steps through it, and whenever the run needs a model reply, a Pack's render of the tools a section offers, a Pack tool's result, a timer, or a file outside a prelude's install, it asks for one and waits. The Harness answers each of those requests and records the run. A Host is any application that runs prompts through the Harness. Workshop is one, and your own program becomes one when it embeds the Harness. The Engine and the Harness are developed in their own repository, [cppalliance/runvil-harness](https://github.com/cppalliance/runvil-harness). The Gateway is the one process that holds model credentials, so no other process ever sees a vendor key. It serves an OpenAI-compatible API and routes each request to a cloud provider or to a model running on your own machine. It also provides web search, speech-to-text, text-to-speech, and a settings page in your browser, and you can run it on its own, on a desktop or a server. It is developed and released separately, in [cppalliance/runvil-gateway](https://github.com/cppalliance/runvil-gateway). diff --git a/crates/README.md b/crates/README.md index a684a3247..c5ab74349 100644 --- a/crates/README.md +++ b/crates/README.md @@ -1,3 +1,3 @@ # Crates -This directory holds the workspace's crates. The crates at this level are the build tooling. Workshop keeps its crates in a container directory of its own, `workshop/`, and only the build tooling may depend into it. +This directory holds the workspace's crates, which are all build tooling. `build-installer` builds the installers from a Workshop checkout, `build-user-guide` stages the books of the documentation site, and `build-xtask` holds the structural checks and builds the site. diff --git a/crates/build-installer/AGENTS.md b/crates/build-installer/AGENTS.md new file mode 100644 index 000000000..129fe1402 --- /dev/null +++ b/crates/build-installer/AGENTS.md @@ -0,0 +1,8 @@ +# build-installer + +- Nothing here builds Workshop or the Gateway. Both come from the Workshop checkout `RUNVIL_WORKSHOP` names. `cargo workshop bundle`, run there, builds Workshop's Tauri bundle (`bundle_command` in `src/main/workshop.rs`). The Gateway is the release that checkout's `gateway-pin/pin.json` names, from `--gateway-binary`, `RUNVIL_GATEWAY_SIDECAR`, or the file the checkout's `tools/fetch-gateway-sidecar.mjs` stages (`src/main/gateway.rs`). A Windows installer is the NSIS setup from that bundle, which already holds the Gateway, so only a macOS or Linux build reads the Gateway and the pin. The build leaves both checkouts clean: it writes only to Cargo's target directories. +- The installer mode checks the Workshop checkout before any command runs (`check` in `src/main/workshop.rs`). The `[workspace.package]` version in its `Cargo.toml` must equal this workspace's, because the release file names and the release tag check in `.github/workflows/installers.yml` use this workspace's version while Tauri names its files with Workshop's. The `[toolchain]` channel in its `rust-toolchain.toml` must equal this repository's, because the Cargo that runs the installer builds Workshop. A change of `rev` in the root `Cargo.toml`'s `[workspace.metadata.workshop]` to a Workshop commit that changed either value changes the workspace version or `rust-toolchain.toml` with it. +- Each release file name has one definition: `installer_name` and `workshop_updater_name` in `src/main/installer/collect.rs`. The installer build, the IFW stage, and the `manifest` mode call them; never spell a release name inline. The payload's bundle and AppImage names are the constants beside them, which must equal those `crates/workshop/gateway-discovery/src/peer.rs` in the Workshop checkout searches; a test in `src/main/tests/collect.rs` compares the two. It is the one test that reads the real checkout, the one `RUNVIL_WORKSHOP` names when the test runs; every other test builds its own Workshop tree in a temporary directory. The bare Gateway executable's name is `GATEWAY` in `collect.rs`. +- The Qt IFW pin is enforced in two places that change together: `IFW_VERSION` in `src/main/installer/ifw.rs`, and each `ifw-archive` with its `ifw-sha256` in `.github/workflows/installers.yml`'s build matrix. `USAGE` in `src/main/args.rs`, the workflow step's name, and this crate's README name the version or the archive paths too; update them in the same change. +- Files under `ifw/` are templates. The build fills `{{NAME}}` placeholders and fails on any left over; IFW's own `@Variable@` placeholders are left for IFW to expand at install time. +- `installscript.qs` runs only inside IFW and has no test support. A change to either script, or to what an install puts where, updates the install cells in `.github/installers/cell.sh` and `cell.ps1`, which are its tests. diff --git a/crates/build-workshop/Cargo.toml b/crates/build-installer/Cargo.toml similarity index 82% rename from crates/build-workshop/Cargo.toml rename to crates/build-installer/Cargo.toml index a296cb2ef..8742f7e08 100644 --- a/crates/build-workshop/Cargo.toml +++ b/crates/build-installer/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "build-workshop" +name = "build-installer" version.workspace = true edition.workspace = true license.workspace = true @@ -7,7 +7,7 @@ repository.workspace = true publish = false [[bin]] -name = "build-workshop" +name = "build-installer" path = "src/main.rs" [dependencies] @@ -21,6 +21,9 @@ ctrlc.workspace = true minisign-verify.workspace = true serde_json.workspace = true sha2.workspace = true +# Reads the version and the toolchain channel the Workshop checkout check +# compares. +toml.workspace = true [dev-dependencies] # Signs the `manifest` mode's test fixtures with a key generated per run. diff --git a/crates/build-installer/README.md b/crates/build-installer/README.md new file mode 100644 index 000000000..2fa786b2a --- /dev/null +++ b/crates/build-installer/README.md @@ -0,0 +1,79 @@ +# build-installer + +This crate is `cargo installer`, the one build path for Runvil's installers. Each installer installs Workshop, the desktop app, and the Gateway. CI's `installers.yml` runs the same commands described here, so a local build makes what a release ships. + +Workshop is its own product, in [cppalliance/runvil-workshop](https://github.com/cppalliance/runvil-workshop), so nothing here builds it. `cargo installer` runs that repository's own tool, `cargo workshop bundle`, in a checkout of it, and builds the installer from the Tauri bundle the tool leaves. The Gateway is its own product too, in [cppalliance/runvil-gateway](https://github.com/cppalliance/runvil-gateway), and nothing here builds it either. + +## Modes + +- `cargo installer [--target ] [--sign] [--gateway-binary ]` builds one platform's installer in release. +- `cargo installer manifest --assets --base-url [--public-key ]` checks that a directory holds exactly one release's files for every platform, verifies each updater signature, and writes `latest.json` and `SHA256SUMS` beside them. The key it verifies against is the updater public key in the Workshop checkout's `crates/workshop/desktop/tauri.conf.json`, unless `--public-key` names another. + +`cargo installer --help` prints the full usage. + +## The Workshop checkout + +The installer is built from a runvil-workshop checkout, and the `manifest` mode reads the updater public key from it. The `RUNVIL_WORKSHOP` environment variable names the checkout. When the variable is unset, `.cargo/config.toml` sets it to the checkout next to this repository, `../runvil-workshop`. `installers.yml` takes Workshop at the commit the root `Cargo.toml` pins in `[workspace.metadata.workshop]`, so a local build of a release starts from that commit. + +Do two things in that checkout before the first build. Install the UI's packages, which bring the pinned Tauri CLI, and stage the Gateway release that the checkout's `gateway-pin/pin.json` names, once per target: + +```text +npm ci --prefix crates/workshop +node tools/fetch-gateway-sidecar.mjs --target +``` + +That repository's `crates/build-workshop/README.md` lists what else its bundle build needs, such as Node 22 and Tauri's build packages on Linux. `cargo installer` passes its environment on to the bundle build, so a variable that README asks for, such as `APPIMAGE_EXTRACT_AND_RUN=1`, is set for `cargo installer`. + +`--gateway-binary `, or the `RUNVIL_GATEWAY_SIDECAR` environment variable, names another Gateway in place of the staged one, and `cargo installer` passes it on to the bundle build. The Plugins' libraries arrive inside Workshop's bundle. + +### The version and the toolchain + +`cargo installer` checks the checkout before it runs any command, and refuses one that differs from this workspace in either of two values: + +- The `[workspace.package]` version in the checkout's `Cargo.toml` must equal this workspace's. The release file names and the release tag check use this workspace's version, while Tauri names its files with Workshop's. +- The `[toolchain]` channel in the checkout's `rust-toolchain.toml` must equal the one in this repository's `rust-toolchain.toml`. The Cargo that runs the installer also builds Workshop, so Workshop is built on this workspace's toolchain. + +To build from a newer Workshop, change `rev` in `[workspace.metadata.workshop]`. When Workshop changed its version or its toolchain channel, change the workspace version in the root `Cargo.toml` or the channel in `rust-toolchain.toml` in the same change. + +## Building an installer + +The output lands in `target/installer//`: + +- `publish/`: the installer, and under `--sign` the updater files and their `.sig` signatures. Windows gets `Runvil__x64-setup.exe`, macOS `Runvil__-setup.dmg`, and Linux `Runvil__-setup.run`. +- `payload/` (macOS and Linux): the installed items, `Runvil.app` and `Runvil Gateway.app`, or `Runvil.AppImage` and `runvil-gateway`. +- `ifw/` (macOS and Linux): the Qt IFW definition filled in from `ifw/` in this crate, with the payload copied into its packages. + +Workshop's bundle itself is built in the checkout's own `target/`, and the build moves its files from there into the output, so the two target directories must be on one filesystem. When `CARGO_TARGET_DIR` is set, the bundle and the installer output both go to that directory. + +Windows needs nothing more: the installer is the NSIS setup from Workshop's bundle, with the Gateway inside. macOS and Linux install the Gateway beside Workshop, so their installers are built here with the Qt Installer Framework, from Workshop's bundle and the Gateway. That build runs the Gateway's `--version` and refuses one that is not the pinned version. + +Building the Intel macOS installer on Apple silicon needs Rosetta, since the build runs the x86_64 Gateway to check its version. + +### Qt Installer Framework + +macOS and Linux installers need Qt IFW 4.11.0 exactly; the build checks `installerbase --version` and refuses any other version. Download the archive for your machine from `https://download.qt.io/online/qtsdkrepository/`: + +- macOS (one universal build for both architectures): `mac_x64/ifw/tools_ifw_411/qt.tools.ifw.411/4.11.0-0-202603311245ifw-mac-universal.7z` +- Linux x64: `linux_x64/ifw/tools_ifw_411/qt.tools.ifw.411/4.11.0-0-202603231357ifw-linux-x64.7z` +- Linux ARM64: `linux_arm64/ifw/tools_ifw_411/qt.tools.ifw.411/4.11.0-0-202603231357ifw-linux-arm64.7z`, which runs only on glibc 2.38 or newer + +Check its SHA-256 against the `ifw-sha256` pinned beside the same path in `.github/workflows/installers.yml`. Extract it into a directory you own, with `bsdtar -xf` on macOS or `7z x` on Linux, and point `QTIFWDIR` at the extracted directory that holds `bin/`. Without `QTIFWDIR`, the build uses the first `binarycreator` on `PATH` and the `installerbase` beside it. + +On Linux the IFW tools load these libraries even without a display, and `p7zip-full` extracts the archive, as `installers.yml` installs them on Ubuntu: + +```text +p7zip-full +libxkbcommon-x11-0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 +libxcb-render-util0 libxcb-shape0 libxcb-shm0 libxcb-sync1 libxcb-xfixes0 +libxcb-xkb1 libxcb-util1 libfontconfig1 libfreetype6 libdbus-1-3 +``` + +### Signing + +`cargo installer` passes `--sign` on to the bundle build. Without it, the bundle build makes no updater files, and `publish/` holds only the installer. With it, Tauri signs Workshop's updater files with the minisign key in `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`, and `cargo installer` publishes them with their signatures. For a local try, make a throwaway key with the Tauri CLI in the Workshop checkout: + +```text +node crates/workshop/node_modules/@tauri-apps/cli/tauri.js signer generate --ci -w /run.key +``` + +Installed copies trust only the release key, so files signed with any other key never install as an update. diff --git a/crates/build-workshop/ifw/config/config.xml b/crates/build-installer/ifw/config/config.xml similarity index 100% rename from crates/build-workshop/ifw/config/config.xml rename to crates/build-installer/ifw/config/config.xml diff --git a/crates/build-workshop/ifw/config/installer-logo.png b/crates/build-installer/ifw/config/installer-logo.png similarity index 100% rename from crates/build-workshop/ifw/config/installer-logo.png rename to crates/build-installer/ifw/config/installer-logo.png diff --git a/crates/build-workshop/ifw/config/installer-logo@2x.png b/crates/build-installer/ifw/config/installer-logo@2x.png similarity index 100% rename from crates/build-workshop/ifw/config/installer-logo@2x.png rename to crates/build-installer/ifw/config/installer-logo@2x.png diff --git a/crates/build-workshop/ifw/config/installer-watermark.png b/crates/build-installer/ifw/config/installer-watermark.png similarity index 100% rename from crates/build-workshop/ifw/config/installer-watermark.png rename to crates/build-installer/ifw/config/installer-watermark.png diff --git a/crates/build-workshop/ifw/config/installer-watermark@2x.png b/crates/build-installer/ifw/config/installer-watermark@2x.png similarity index 100% rename from crates/build-workshop/ifw/config/installer-watermark@2x.png rename to crates/build-installer/ifw/config/installer-watermark@2x.png diff --git a/crates/build-workshop/ifw/packages/com.runvil.gateway/meta/installscript.qs b/crates/build-installer/ifw/packages/com.runvil.gateway/meta/installscript.qs similarity index 100% rename from crates/build-workshop/ifw/packages/com.runvil.gateway/meta/installscript.qs rename to crates/build-installer/ifw/packages/com.runvil.gateway/meta/installscript.qs diff --git a/crates/build-workshop/ifw/packages/com.runvil.gateway/meta/package.xml b/crates/build-installer/ifw/packages/com.runvil.gateway/meta/package.xml similarity index 100% rename from crates/build-workshop/ifw/packages/com.runvil.gateway/meta/package.xml rename to crates/build-installer/ifw/packages/com.runvil.gateway/meta/package.xml diff --git a/crates/build-workshop/ifw/packages/com.runvil.workshop/meta/installscript.qs b/crates/build-installer/ifw/packages/com.runvil.workshop/meta/installscript.qs similarity index 100% rename from crates/build-workshop/ifw/packages/com.runvil.workshop/meta/installscript.qs rename to crates/build-installer/ifw/packages/com.runvil.workshop/meta/installscript.qs diff --git a/crates/build-workshop/ifw/packages/com.runvil.workshop/meta/package.xml b/crates/build-installer/ifw/packages/com.runvil.workshop/meta/package.xml similarity index 100% rename from crates/build-workshop/ifw/packages/com.runvil.workshop/meta/package.xml rename to crates/build-installer/ifw/packages/com.runvil.workshop/meta/package.xml diff --git a/crates/build-workshop/packaging/gateway-Info.plist b/crates/build-installer/packaging/gateway-Info.plist similarity index 89% rename from crates/build-workshop/packaging/gateway-Info.plist rename to crates/build-installer/packaging/gateway-Info.plist index 9a2e7984c..016bb80be 100644 --- a/crates/build-workshop/packaging/gateway-Info.plist +++ b/crates/build-installer/packaging/gateway-Info.plist @@ -1,8 +1,8 @@ - com.apple.security.device.audio-input - - - diff --git a/crates/workshop/desktop/Info.plist b/crates/workshop/desktop/Info.plist deleted file mode 100644 index 0b6ecdf02..000000000 --- a/crates/workshop/desktop/Info.plist +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - NSMicrophoneUsageDescription - Runvil uses the microphone you select for local voice dictation. - - diff --git a/crates/workshop/desktop/README.md b/crates/workshop/desktop/README.md deleted file mode 100644 index 02e78cf12..000000000 --- a/crates/workshop/desktop/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Workshop desktop app - -This crate is the Workshop desktop app, `runvil-workshop`: a Tauri window around Workshop's server and page. It attaches to a gateway already running for the user, launches the installed one when none is, or uses the remote gateway that `~/.runvil/workshop.toml` names. - -## How it ships - -The Runvil installer offers Workshop and the gateway as separate components, and on Windows also Speech to Text. The gateway is the release `gateway-pin/pin.json` names, from [cppalliance/runvil-gateway](https://github.com/cppalliance/runvil-gateway). - -- Windows: one NSIS setup, built from `installer.nsi`, installs into `%LOCALAPPDATA%\Runvil`. The gateway, `runvil-gateway.exe`, travels inside Workshop's bundle and sits beside `runvil-workshop.exe`. -- macOS: a Qt IFW installer in a DMG places `Runvil.app` and `Runvil Gateway.app` side by side, in `/Applications/Runvil` by default. -- Linux: a Qt IFW `.run` installer places `Runvil.AppImage` and `runvil-gateway` side by side, in `~/Runvil` by default. - -On every platform Workshop finds the gateway beside its own install through `workshop-gateway-discovery`'s installed-peer lookup. - -## Plugins - -Workshop loads every library Plugin in its Plugin directory when it starts. The directory is named `plugins` and ships with Workshop itself, so whatever replaces Workshop replaces its Plugins too: - -- Windows: beside `runvil-workshop.exe`. -- macOS: inside the bundle, at `Runvil.app/Contents/Resources/plugins`. -- Linux: inside the AppImage, at `usr/lib/runvil/plugins`. - -`[agents] plugins` in `workshop.toml` names another directory instead. - -Each installer puts two libraries there, each from a repository of its own, pinned by commit in the root `Cargo.toml`: - -- The std Plugin, which holds the file tools: `plugin_std.dll`, `libplugin_std.dylib`, or `libplugin_std.so`, from [cppalliance/runvil-plugin-std](https://github.com/cppalliance/runvil-plugin-std). -- The bashkit Plugin, which holds the shell: `plugin_bashkit.dll`, `libplugin_bashkit.dylib`, or `libplugin_bashkit.so`, from [cppalliance/runvil-plugin-bashkit](https://github.com/cppalliance/runvil-plugin-bashkit). - -A Workshop with no Plugin directory, or one whose std library does not load, refuses an agent that requires `std` and says that `runvil/std` is not installed. It does the same for `bash` and the bashkit library. - -`runvil-workshop --plugins` prints each Plugin the installation loads, as its name and its version, and exits. A library that does not load goes to stderr with the reason. Workshop keeps no log, so this is the way to see why an agent has no file tools or no shell. - -## Updates - -Workshop checks for an update a few seconds after it starts and from Help, About, through the Tauri updater and the `latest.json` on the `workshop-latest` release, whose files are signed. On macOS and Linux it replaces its own `.app` or AppImage; on Windows it runs the new setup, which updates every installed component, the gateway included. - -An update replaces the Plugin directory with Workshop on every platform, because the directory sits inside what the updater replaces: the `.app`, the AppImage, or the files the Windows setup installs. A Plugin loads only in a Workshop that speaks its exact contract version, so the two must change together. - -## Building - -`cargo workshop` builds this app for development around the pinned gateway, and `cargo workshop installer` builds a platform's installer; `crates/build-workshop/README.md` covers both. diff --git a/crates/workshop/desktop/app-icon.png b/crates/workshop/desktop/app-icon.png deleted file mode 100644 index f567ea603..000000000 Binary files a/crates/workshop/desktop/app-icon.png and /dev/null differ diff --git a/crates/workshop/desktop/build.rs b/crates/workshop/desktop/build.rs deleted file mode 100644 index d22002714..000000000 --- a/crates/workshop/desktop/build.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Build script for the workshop desktop app: puts the Gateway sidecar in -//! the slot tauri-build copies from, then runs tauri-build so -//! `tauri::generate_context!` sees the config-derived environment it -//! requires. -//! -//! On Windows, `tauri.windows.conf.json` declares the Gateway as an -//! `externalBin`, so on every workshop build tauri-build copies -//! `binaries/runvil-gateway-.exe` to -//! `target//runvil-gateway.exe`. The sidecar step decides what that -//! slot holds before tauri-build reads it, and leaves an empty slot for -//! tauri-build to report. -//! -//! Two sources fill the slot, in order: -//! -//! 1. `RUNVIL_GATEWAY_SIDECAR`, an explicit file some other step staged. -//! `cargo workshop` sets it to the Gateway `--gateway-binary` or the -//! variable named. The named file is copied in as is. -//! 2. Whatever `tools/fetch-gateway-sidecar.mjs` left in place: a stub for -//! compile-only jobs or the pinned release binary. Nothing here touches -//! it, so a downloaded Gateway survives every rebuild. -//! -//! macOS and Linux bundle no Gateway: their installers place it beside -//! Workshop, so those targets skip the step. - -use std::env; -use std::error::Error; -use std::fs; -use std::path::{Path, PathBuf}; - -/// The variable naming a sidecar to install, shared with `build-workshop`. -const GATEWAY_SIDECAR_VARIABLE: &str = "RUNVIL_GATEWAY_SIDECAR"; - -fn main() -> Result<(), Box> { - install_gateway_sidecar()?; - tauri_build::try_build(tauri_build::Attributes::new().app_manifest( - tauri_build::AppManifest::new().commands(&["desktop_update_supported", "quit"]), - ))?; - Ok(()) -} - -/// Puts the sidecar where `bundle.externalBin` resolves it: an explicit -/// `RUNVIL_GATEWAY_SIDECAR` file when one is named, else what is already -/// staged, with a warning when the staged file is the stub. -fn install_gateway_sidecar() -> Result<(), Box> { - if env::var("CARGO_CFG_TARGET_OS")? != "windows" { - return Ok(()); - } - let target = env::var("TARGET")?; - let exe_suffix = ".exe"; - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?); - let sidecar = manifest_dir - .join("binaries") - .join(format!("runvil-gateway-{target}{exe_suffix}")); - - println!("cargo:rerun-if-env-changed={GATEWAY_SIDECAR_VARIABLE}"); - // The marker is written only by `tools/fetch-gateway-sidecar.mjs`, never - // here, so watching it cannot loop. Watching it means re-staging a - // release over a stub re-runs this script and the warning below clears. - println!("cargo:rerun-if-changed={}", pin_marker(&sidecar).display()); - if let Some(named) = env::var_os(GATEWAY_SIDECAR_VARIABLE) { - let named = PathBuf::from(named); - if named.as_os_str().is_empty() { - return Err(format!("{GATEWAY_SIDECAR_VARIABLE} names no file").into()); - } - if !named.is_file() { - return Err(format!( - "{GATEWAY_SIDECAR_VARIABLE} names {}, which is not a file", - named.display() - ) - .into()); - } - copy_forward(&named, &sidecar)?; - return Ok(()); - } - - if sidecar_is_stub(&sidecar) { - // CI stages a stub for compile-only jobs, so a build that copies - // one is expected there. A person building locally gets the same - // warning, which is the last chance to notice before an app that - // cannot start exists on disk. - println!( - "cargo:warning={} is a compile-only stub Gateway and cannot run; \ - run `node tools/fetch-gateway-sidecar.mjs --target {}` and rebuild for a \ - Gateway that starts", - sidecar.display(), - target - ); - } - Ok(()) -} - -/// Copies `source` over `sidecar`, creating the directory and reporting -/// the copy so a surprising sidecar is visible in the build log. -fn copy_forward(source: &Path, sidecar: &Path) -> Result<(), String> { - // `cargo workshop` stages the file it names here before this script - // runs. Copying it again would fail on Windows when the file is - // read-only, since the first copy carries that attribute to the slot: - // https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-copyfilew - if fs::read(sidecar).is_ok_and(|staged| fs::read(source).is_ok_and(|named| named == staged)) { - return Ok(()); - } - if let Some(parent) = sidecar.parent() { - fs::create_dir_all(parent).map_err(|e| format!("create {}: {e}", parent.display()))?; - } - fs::copy(source, sidecar).map_err(|e| { - format!( - "copy the gateway sidecar {} to {}: {e}", - source.display(), - sidecar.display() - ) - })?; - println!( - "cargo:warning=installed gateway sidecar {} from {}", - sidecar.display(), - source.display() - ); - Ok(()) -} - -/// The marker beside the sidecar: `.pinned`. -fn pin_marker(sidecar: &Path) -> PathBuf { - let mut marker = sidecar.as_os_str().to_owned(); - marker.push(".pinned"); - PathBuf::from(marker) -} - -/// Whether the marker says the staged sidecar is the compile-only stub -/// rather than the pinned release. The marker is the fetch script's JSON, -/// so a substring check keeps this script free of a JSON dependency. A -/// missing or malformed marker reads as unknown, and unknown is not a stub: -/// the app's own boot check still refuses to run one. -fn sidecar_is_stub(sidecar: &Path) -> bool { - let Ok(text) = fs::read_to_string(pin_marker(sidecar)) else { - return false; - }; - text.contains("\"kind\": \"stub\"") || text.contains("\"kind\":\"stub\"") -} diff --git a/crates/workshop/desktop/icons/128x128.png b/crates/workshop/desktop/icons/128x128.png deleted file mode 100644 index d7b7c171d..000000000 Binary files a/crates/workshop/desktop/icons/128x128.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/128x128@2x.png b/crates/workshop/desktop/icons/128x128@2x.png deleted file mode 100644 index b96caedb1..000000000 Binary files a/crates/workshop/desktop/icons/128x128@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/32x32.png b/crates/workshop/desktop/icons/32x32.png deleted file mode 100644 index 9a3d7a932..000000000 Binary files a/crates/workshop/desktop/icons/32x32.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/64x64.png b/crates/workshop/desktop/icons/64x64.png deleted file mode 100644 index 39330d8be..000000000 Binary files a/crates/workshop/desktop/icons/64x64.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/AGENTS.md b/crates/workshop/desktop/icons/AGENTS.md deleted file mode 100644 index 19fc70c20..000000000 --- a/crates/workshop/desktop/icons/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ -# Workshop icons - -- Keep the Gateway app, Gateway configuration UI, and Workshop UI icon copies synchronized with the corresponding master icons in this directory. -- `installer-header.png`, `installer-header.bmp`, `installer-sidebar.png`, and `installer-sidebar.bmp` are hand-crafted installer assets. Do not regenerate, resize, overwrite, or modify them as part of ordinary icon generation. -- The Qt IFW installer's `installer-logo.png`, `installer-logo@2x.png`, `installer-watermark.png`, and `installer-watermark@2x.png` in `crates/build-workshop/ifw/config/` are scaled copies of the two PNG masters. When a master changes, regenerate them from this directory: - - `sips -z 65 171 installer-header.png --out ../../../build-workshop/ifw/config/installer-logo.png` - - `sips -z 130 342 installer-header.png --out ../../../build-workshop/ifw/config/installer-logo@2x.png` - - `sips -z 310 162 installer-sidebar.png --out ../../../build-workshop/ifw/config/installer-watermark.png` - - `sips -z 620 324 installer-sidebar.png --out ../../../build-workshop/ifw/config/installer-watermark@2x.png` diff --git a/crates/workshop/desktop/icons/Square107x107Logo.png b/crates/workshop/desktop/icons/Square107x107Logo.png deleted file mode 100644 index b886857fa..000000000 Binary files a/crates/workshop/desktop/icons/Square107x107Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square142x142Logo.png b/crates/workshop/desktop/icons/Square142x142Logo.png deleted file mode 100644 index 016ba8cba..000000000 Binary files a/crates/workshop/desktop/icons/Square142x142Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square150x150Logo.png b/crates/workshop/desktop/icons/Square150x150Logo.png deleted file mode 100644 index 4f60ef442..000000000 Binary files a/crates/workshop/desktop/icons/Square150x150Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square284x284Logo.png b/crates/workshop/desktop/icons/Square284x284Logo.png deleted file mode 100644 index 0e6542e13..000000000 Binary files a/crates/workshop/desktop/icons/Square284x284Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square30x30Logo.png b/crates/workshop/desktop/icons/Square30x30Logo.png deleted file mode 100644 index 40cfc64ae..000000000 Binary files a/crates/workshop/desktop/icons/Square30x30Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square310x310Logo.png b/crates/workshop/desktop/icons/Square310x310Logo.png deleted file mode 100644 index 30e1dbf9e..000000000 Binary files a/crates/workshop/desktop/icons/Square310x310Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square44x44Logo.png b/crates/workshop/desktop/icons/Square44x44Logo.png deleted file mode 100644 index 23778164b..000000000 Binary files a/crates/workshop/desktop/icons/Square44x44Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square71x71Logo.png b/crates/workshop/desktop/icons/Square71x71Logo.png deleted file mode 100644 index c8644b8f4..000000000 Binary files a/crates/workshop/desktop/icons/Square71x71Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/Square89x89Logo.png b/crates/workshop/desktop/icons/Square89x89Logo.png deleted file mode 100644 index 94ac26fef..000000000 Binary files a/crates/workshop/desktop/icons/Square89x89Logo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/StoreLogo.png b/crates/workshop/desktop/icons/StoreLogo.png deleted file mode 100644 index 794d1b0c7..000000000 Binary files a/crates/workshop/desktop/icons/StoreLogo.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-anydpi-v26/ic_launcher.xml b/crates/workshop/desktop/icons/android/mipmap-anydpi-v26/ic_launcher.xml deleted file mode 100644 index 2ffbf24b6..000000000 --- a/crates/workshop/desktop/icons/android/mipmap-anydpi-v26/ic_launcher.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index cf0895827..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_foreground.png deleted file mode 100644 index 31e946de0..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_round.png deleted file mode 100644 index 168dd27d7..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 4df697eee..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_foreground.png deleted file mode 100644 index 9c0528b4e..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_round.png deleted file mode 100644 index 47760f4aa..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 7650b4b05..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_foreground.png deleted file mode 100644 index b6daad292..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_round.png deleted file mode 100644 index 7118123c4..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index ff9e48887..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png deleted file mode 100644 index 54c4e03dd..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_round.png deleted file mode 100644 index 70c4cb7ea..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher.png b/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index ac8e5a484..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png deleted file mode 100644 index bccf7e37e..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100644 index 650c7d2f6..000000000 Binary files a/crates/workshop/desktop/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/android/values/ic_launcher_background.xml b/crates/workshop/desktop/icons/android/values/ic_launcher_background.xml deleted file mode 100644 index ea9c223a6..000000000 --- a/crates/workshop/desktop/icons/android/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #fff - \ No newline at end of file diff --git a/crates/workshop/desktop/icons/icon.icns b/crates/workshop/desktop/icons/icon.icns deleted file mode 100644 index 15443b593..000000000 Binary files a/crates/workshop/desktop/icons/icon.icns and /dev/null differ diff --git a/crates/workshop/desktop/icons/icon.ico b/crates/workshop/desktop/icons/icon.ico deleted file mode 100644 index 875eb11cd..000000000 Binary files a/crates/workshop/desktop/icons/icon.ico and /dev/null differ diff --git a/crates/workshop/desktop/icons/icon.png b/crates/workshop/desktop/icons/icon.png deleted file mode 100644 index cae1d06d9..000000000 Binary files a/crates/workshop/desktop/icons/icon.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/installer-header.bmp b/crates/workshop/desktop/icons/installer-header.bmp deleted file mode 100644 index 9d435943c..000000000 Binary files a/crates/workshop/desktop/icons/installer-header.bmp and /dev/null differ diff --git a/crates/workshop/desktop/icons/installer-header.png b/crates/workshop/desktop/icons/installer-header.png deleted file mode 100644 index 07b2aba7d..000000000 Binary files a/crates/workshop/desktop/icons/installer-header.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/installer-sidebar.bmp b/crates/workshop/desktop/icons/installer-sidebar.bmp deleted file mode 100644 index a61c4e944..000000000 Binary files a/crates/workshop/desktop/icons/installer-sidebar.bmp and /dev/null differ diff --git a/crates/workshop/desktop/icons/installer-sidebar.png b/crates/workshop/desktop/icons/installer-sidebar.png deleted file mode 100644 index b15c5aa06..000000000 Binary files a/crates/workshop/desktop/icons/installer-sidebar.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-20x20@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@1x.png deleted file mode 100644 index 3cbc310ba..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-20x20@1x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x-1.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x-1.png deleted file mode 100644 index f616f43b2..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x-1.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x.png deleted file mode 100644 index f616f43b2..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-20x20@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-20x20@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-20x20@3x.png deleted file mode 100644 index 48de4b8d6..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-20x20@3x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-29x29@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@1x.png deleted file mode 100644 index d7930b7a3..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-29x29@1x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x-1.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x-1.png deleted file mode 100644 index 4f232b58e..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x-1.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x.png deleted file mode 100644 index 4f232b58e..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-29x29@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-29x29@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-29x29@3x.png deleted file mode 100644 index e8cd5da78..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-29x29@3x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-40x40@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@1x.png deleted file mode 100644 index f616f43b2..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-40x40@1x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x-1.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x-1.png deleted file mode 100644 index 88ff82662..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x-1.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x.png deleted file mode 100644 index 88ff82662..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-40x40@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-40x40@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-40x40@3x.png deleted file mode 100644 index 9c63d81c4..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-40x40@3x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-512@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-512@2x.png deleted file mode 100644 index 41c45ea40..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-512@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-60x60@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-60x60@2x.png deleted file mode 100644 index 9c63d81c4..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-60x60@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-60x60@3x.png b/crates/workshop/desktop/icons/ios/AppIcon-60x60@3x.png deleted file mode 100644 index 49897cff7..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-60x60@3x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-76x76@1x.png b/crates/workshop/desktop/icons/ios/AppIcon-76x76@1x.png deleted file mode 100644 index 3843b0eca..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-76x76@1x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-76x76@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-76x76@2x.png deleted file mode 100644 index 53e09c3c1..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-76x76@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/icons/ios/AppIcon-83.5x83.5@2x.png b/crates/workshop/desktop/icons/ios/AppIcon-83.5x83.5@2x.png deleted file mode 100644 index d47cb0b49..000000000 Binary files a/crates/workshop/desktop/icons/ios/AppIcon-83.5x83.5@2x.png and /dev/null differ diff --git a/crates/workshop/desktop/installer.nsi b/crates/workshop/desktop/installer.nsi deleted file mode 100644 index 26e684fec..000000000 --- a/crates/workshop/desktop/installer.nsi +++ /dev/null @@ -1,1309 +0,0 @@ -Unicode true -ManifestDPIAware true -; Add in `dpiAwareness` `PerMonitorV2` to manifest for Windows 10 1607+ (note this should not affect lower versions since they should be able to ignore this and pick up `dpiAware` `true` set by `ManifestDPIAware true`) -; Currently undocumented on NSIS's website but is in the Docs folder of source tree, see -; https://github.com/kichik/nsis/blob/5fc0b87b819a9eec006df4967d08e522ddd651c9/Docs/src/attributes.but#L286-L300 -; https://github.com/tauri-apps/tauri/pull/10106 -ManifestDPIAwareness PerMonitorV2 - -!if "{{compression}}" == "none" - SetCompress off -!else - ; Set the compression algorithm. We default to LZMA. - SetCompressor /SOLID "{{compression}}" -!endif - -; Keep above !include to stay ahead of any NSIS plugin command -; see https://github.com/tauri-apps/tauri/pull/15422#discussion_r3289239624 -{{#if signed_plugins_path}} -!addplugindir "{{signed_plugins_path}}" -{{/if}} - -; Runvil dark theme - defines must precede MUI2.nsh -!define MUI_BGCOLOR "0F0F0F" -!define MUI_TEXTCOLOR "E0E0E0" -!define MUI_INSTFILESPAGE_COLORS "E0E0E0 0F0F0F" - -!include MUI2.nsh -!include FileFunc.nsh -!include x64.nsh -!include WordFunc.nsh -!include Sections.nsh -!include "utils.nsh" -!include "FileAssociation.nsh" -!include "Win\COM.nsh" -!include "Win\Propkey.nsh" -!include "Win\RestartManager.nsh" -!include "StrFunc.nsh" -${StrCase} -${StrLoc} - -{{#if installer_hooks}} -!include "{{installer_hooks}}" -{{/if}} - -!define WEBVIEW2APPGUID "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" - -!define MANUFACTURER "{{manufacturer}}" -!define PRODUCTNAME "{{product_name}}" -!define VERSION "{{version}}" -!define VERSIONWITHBUILD "{{version_with_build}}" -!define HOMEPAGE "{{homepage}}" -!define INSTALLMODE "{{install_mode}}" -!define LICENSE "{{license}}" -!define INSTALLERICON "{{installer_icon}}" -!define SIDEBARIMAGE "{{sidebar_image}}" -!define HEADERIMAGE "{{header_image}}" -!define UNINSTALLERICON "{{uninstaller_icon}}" -!define UNINSTALLERHEADERIMAGE "{{uninstaller_header_image}}" -!define MAINBINARYNAME "{{main_binary_name}}" -!define MAINBINARYSRCPATH "{{main_binary_path}}" -; The libraries of the std Plugin and the bashkit Plugin. Cargo writes -; each to `deps` beside the main executable whenever it builds Workshop, -; so its path is the main executable's with the file name replaced. -!searchreplace STDPLUGINSRCPATH "${MAINBINARYSRCPATH}" "${MAINBINARYNAME}.exe" "deps\plugin_std.dll" -!searchreplace BASHKITPLUGINSRCPATH "${MAINBINARYSRCPATH}" "${MAINBINARYNAME}.exe" "deps\plugin_bashkit.dll" -; A main executable under another name would leave the path unchanged, and -; the setup would install the executable as each Plugin. -!if "${STDPLUGINSRCPATH}" == "${MAINBINARYSRCPATH}" - !error "cannot find the Plugins' libraries: ${MAINBINARYSRCPATH} does not end in ${MAINBINARYNAME}.exe" -!endif -!define BUNDLEID "{{bundle_id}}" -!define COPYRIGHT "{{copyright}}" -!define OUTFILE "{{out_file}}" -!define ARCH "{{arch}}" -!define ADDITIONALPLUGINSPATH "{{additional_plugins_path}}" -!define ALLOWDOWNGRADES "{{allow_downgrades}}" -!define DISPLAYLANGUAGESELECTOR "{{display_language_selector}}" -!define INSTALLWEBVIEW2MODE "{{install_webview2_mode}}" -!define WEBVIEW2INSTALLERARGS "{{webview2_installer_args}}" -!define WEBVIEW2BOOTSTRAPPERPATH "{{webview2_bootstrapper_path}}" -!define WEBVIEW2INSTALLERPATH "{{webview2_installer_path}}" -!define MINIMUMWEBVIEW2VERSION "{{minimum_webview2_version}}" -!define UNINSTKEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\${PRODUCTNAME}" -!define MANUKEY "Software\${MANUFACTURER}" -!define MANUPRODUCTKEY "${MANUKEY}\${PRODUCTNAME}" -!define UNINSTALLERSIGNCOMMAND "{{uninstaller_sign_cmd}}" -!define ESTIMATEDSIZE "{{estimated_size}}" -!define STARTMENUFOLDER "{{start_menu_folder}}" - -Var PassiveMode -Var UpdateMode -Var NoShortcutMode -Var WixMode -Var OldMainBinaryName -; Set when a running runvil-gateway.exe was stopped in the Gateway -; section, so the -Relaunch section can relaunch it after the update. -Var GatewayWasRunning - -; Persists one component's checkbox state as a DWORD (1 = installed, -; 0 = declined) under the product key, so every later install starts from -; it (see RestoreComponentSelections). -!macro PersistComponent SECTION_ID VALUE_NAME - SectionGetFlags ${SECTION_ID} $0 - IntOp $0 $0 & ${SF_SELECTED} - ${If} $0 = ${SF_SELECTED} - WriteRegDWORD HKCU "${MANUPRODUCTKEY}\Components" "${VALUE_NAME}" 1 - ${Else} - WriteRegDWORD HKCU "${MANUPRODUCTKEY}\Components" "${VALUE_NAME}" 0 - ${EndIf} -!macroend - -; Update mode installs over the top without uninstalling, so a component -; declined at the original install would linger on disk; delete its -; payload explicitly. -!macro DeleteComponentPayloadIfDeclined SECTION_ID FILE_PATH - ${If} $UpdateMode = 1 - SectionGetFlags ${SECTION_ID} $0 - IntOp $0 $0 & ${SF_SELECTED} - ${If} $0 <> ${SF_SELECTED} - Delete "${FILE_PATH}" - ${EndIf} - ${EndIf} -!macroend - -Name "${PRODUCTNAME}" -BrandingText "${COPYRIGHT}" -OutFile "${OUTFILE}" - -; We don't actually use this value as default install path, -; it's just for nsis to append the product name folder in the directory selector -; https://nsis.sourceforge.io/Reference/InstallDir -!define PLACEHOLDER_INSTALL_DIR "placeholder\${PRODUCTNAME}" -InstallDir "${PLACEHOLDER_INSTALL_DIR}" - -VIProductVersion "${VERSIONWITHBUILD}" -VIAddVersionKey "ProductName" "${PRODUCTNAME}" -VIAddVersionKey "FileDescription" "${PRODUCTNAME}" -VIAddVersionKey "LegalCopyright" "${COPYRIGHT}" -VIAddVersionKey "FileVersion" "${VERSION}" -VIAddVersionKey "ProductVersion" "${VERSION}" - -# additional NSIS plugins -!addplugindir "${ADDITIONALPLUGINSPATH}" - -; Uninstaller signing command -!if "${UNINSTALLERSIGNCOMMAND}" != "" - !uninstfinalize '${UNINSTALLERSIGNCOMMAND}' -!endif - -; Handle install mode, `perUser`, `perMachine` or `both` -!if "${INSTALLMODE}" == "perMachine" - RequestExecutionLevel admin -!endif - -!if "${INSTALLMODE}" == "currentUser" - RequestExecutionLevel user -!endif - -!if "${INSTALLMODE}" == "both" - !define MULTIUSER_MUI - !define MULTIUSER_INSTALLMODE_INSTDIR "${PRODUCTNAME}" - !define MULTIUSER_INSTALLMODE_COMMANDLINE - !if "${ARCH}" == "x64" - !define MULTIUSER_USE_PROGRAMFILES64 - !else if "${ARCH}" == "arm64" - !define MULTIUSER_USE_PROGRAMFILES64 - !endif - !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_KEY "${UNINSTKEY}" - !define MULTIUSER_INSTALLMODE_DEFAULT_REGISTRY_VALUENAME "CurrentUser" - !define MULTIUSER_INSTALLMODEPAGE_SHOWUSERNAME - !define MULTIUSER_INSTALLMODE_FUNCTION RestorePreviousInstallLocation - !define MULTIUSER_EXECUTIONLEVEL Highest - !include MultiUser.nsh -!endif - -; Installer icon -!if "${INSTALLERICON}" != "" - !define MUI_ICON "${INSTALLERICON}" -!endif - -; Installer sidebar image -!if "${SIDEBARIMAGE}" != "" - !define MUI_WELCOMEFINISHPAGE_BITMAP "${SIDEBARIMAGE}" -!endif - -; Enable header images for installer and uninstaller pages when either image is configured. -!if "${HEADERIMAGE}" != "" - !define MUI_HEADERIMAGE -!else if "${UNINSTALLERHEADERIMAGE}" != "" - !define MUI_HEADERIMAGE -!endif - -; Installer header image -!if "${HEADERIMAGE}" != "" - !define MUI_HEADERIMAGE_BITMAP "${HEADERIMAGE}" -!endif - -; Uninstaller header image -!if "${UNINSTALLERHEADERIMAGE}" != "" - !define MUI_HEADERIMAGE_UNBITMAP "${UNINSTALLERHEADERIMAGE}" -!endif - -; Uninstaller icon -!if "${UNINSTALLERICON}" != "" - !define MUI_UNICON "${UNINSTALLERICON}" -!endif - -; Define registry key to store installer language -!define MUI_LANGDLL_REGISTRY_ROOT "HKCU" -!define MUI_LANGDLL_REGISTRY_KEY "${MANUPRODUCTKEY}" -!define MUI_LANGDLL_REGISTRY_VALUENAME "Installer Language" - -; Installer pages, must be ordered as they appear -; 1. Welcome Page -!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive -!insertmacro MUI_PAGE_WELCOME - -; 2. License Page (if defined) -!if "${LICENSE}" != "" - !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive - !insertmacro MUI_PAGE_LICENSE "${LICENSE}" -!endif - -; 3. Install mode (if it is set to `both`) -!if "${INSTALLMODE}" == "both" - !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive - !insertmacro MULTIUSER_PAGE_INSTALLMODE -!endif - -; 4. Custom page to ask user if he wants to reinstall/uninstall -; only if a previous installation was detected -Var ReinstallPageCheck -Page custom PageReinstall PageLeaveReinstall -Function PageReinstall - ; Uninstall previous WiX installation if exists. - ; - ; A WiX installer stores the installation info in registry - ; using a UUID and so we have to loop through all keys under - ; `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` - ; and check if `DisplayName` and `Publisher` keys match ${PRODUCTNAME} and ${MANUFACTURER} - ; - ; This has a potential issue that there maybe another installation that matches - ; our ${PRODUCTNAME} and ${MANUFACTURER} but wasn't installed by our WiX installer, - ; however, this should be fine since the user will have to confirm the uninstallation - ; and they can chose to abort it if doesn't make sense. - StrCpy $0 0 - wix_loop: - EnumRegKey $1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" $0 - StrCmp $1 "" wix_loop_done ; Exit loop if there is no more keys to loop on - IntOp $0 $0 + 1 - ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "DisplayName" - ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "Publisher" - StrCmp "$R0$R1" "${PRODUCTNAME}${MANUFACTURER}" 0 wix_loop - ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" "UninstallString" - ${StrCase} $R1 $R0 "L" - ${StrLoc} $R0 $R1 "msiexec" ">" - StrCmp $R0 0 0 wix_loop_done - StrCpy $WixMode 1 - StrCpy $R6 "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$1" - Goto compare_version - wix_loop_done: - - ; Check if there is an existing installation, if not, abort the reinstall page - ReadRegStr $R0 SHCTX "${UNINSTKEY}" "" - ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" - ${IfThen} "$R0$R1" == "" ${|} Abort ${|} - - ; Compare this installar version with the existing installation - ; and modify the messages presented to the user accordingly - compare_version: - StrCpy $R4 "$(older)" - ${If} $WixMode = 1 - ReadRegStr $R0 HKLM "$R6" "DisplayVersion" - ${Else} - ReadRegStr $R0 SHCTX "${UNINSTKEY}" "DisplayVersion" - ${EndIf} - ${IfThen} $R0 == "" ${|} StrCpy $R4 "$(unknown)" ${|} - - nsis_tauri_utils::SemverCompare "${VERSION}" $R0 - Pop $R0 - ; Reinstalling the same version - ${If} $R0 = 0 - StrCpy $R1 "$(alreadyInstalledLong)" - StrCpy $R2 "$(addOrReinstall)" - StrCpy $R3 "$(uninstallApp)" - !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(chooseMaintenanceOption)" - ; Upgrading - ${ElseIf} $R0 = 1 - StrCpy $R1 "$(olderOrUnknownVersionInstalled)" - StrCpy $R2 "$(uninstallBeforeInstalling)" - StrCpy $R3 "$(dontUninstall)" - !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" - ; Downgrading - ${ElseIf} $R0 = -1 - StrCpy $R1 "$(newerVersionInstalled)" - StrCpy $R2 "$(uninstallBeforeInstalling)" - !if "${ALLOWDOWNGRADES}" == "true" - StrCpy $R3 "$(dontUninstall)" - !else - StrCpy $R3 "$(dontUninstallDowngrade)" - !endif - !insertmacro MUI_HEADER_TEXT "$(alreadyInstalled)" "$(choowHowToInstall)" - ${Else} - Abort - ${EndIf} - - ; Skip showing the page if passive - ; - ; Note that we don't call this earlier at the beginning - ; of this function because we need to populate some variables - ; related to current installed version if detected and whether - ; we are downgrading or not. - ${If} $PassiveMode = 1 - Call PageLeaveReinstall - ${Else} - nsDialogs::Create 1018 - Pop $R4 - ${IfThen} $(^RTL) = 1 ${|} nsDialogs::SetRTL $(^RTL) ${|} - - ${NSD_CreateLabel} 0 0 100% 24u $R1 - Pop $R1 - - ${NSD_CreateRadioButton} 30u 50u -30u 8u $R2 - Pop $R2 - ${NSD_OnClick} $R2 PageReinstallUpdateSelection - - ${NSD_CreateRadioButton} 30u 70u -30u 8u $R3 - Pop $R3 - ; Disable this radio button if downgrading and downgrades are disabled - !if "${ALLOWDOWNGRADES}" == "false" - ${IfThen} $R0 = -1 ${|} EnableWindow $R3 0 ${|} - !endif - ${NSD_OnClick} $R3 PageReinstallUpdateSelection - - ; Check the first radio button if this the first time - ; we enter this page or if the second button wasn't - ; selected the last time we were on this page - ${If} $ReinstallPageCheck <> 2 - SendMessage $R2 ${BM_SETCHECK} ${BST_CHECKED} 0 - ${Else} - SendMessage $R3 ${BM_SETCHECK} ${BST_CHECKED} 0 - ${EndIf} - - ${NSD_SetFocus} $R2 - nsDialogs::Show - ${EndIf} -FunctionEnd -Function PageReinstallUpdateSelection - ${NSD_GetState} $R2 $R1 - ${If} $R1 == ${BST_CHECKED} - StrCpy $ReinstallPageCheck 1 - ${Else} - StrCpy $ReinstallPageCheck 2 - ${EndIf} -FunctionEnd -Function PageLeaveReinstall - ${NSD_GetState} $R2 $R1 - - ; If migrating from Wix, always uninstall - ${If} $WixMode = 1 - Goto reinst_uninstall - ${EndIf} - - ; In update mode, always proceeds without uninstalling - ${If} $UpdateMode = 1 - Goto reinst_done - ${EndIf} - - ; $R0 holds whether same(0)/upgrading(1)/downgrading(-1) version - ; $R1 holds the radio buttons state: - ; 1 => first choice was selected - ; 0 => second choice was selected - ${If} $R0 = 0 ; Same version, proceed - ${If} $R1 = 1 ; User chose to add/reinstall - Goto reinst_done - ${Else} ; User chose to uninstall - Goto reinst_uninstall - ${EndIf} - ${ElseIf} $R0 = 1 ; Upgrading - ${If} $R1 = 1 ; User chose to uninstall - Goto reinst_uninstall - ${Else} - Goto reinst_done ; User chose NOT to uninstall - ${EndIf} - ${ElseIf} $R0 = -1 ; Downgrading - ${If} $R1 = 1 ; User chose to uninstall - Goto reinst_uninstall - ${Else} - Goto reinst_done ; User chose NOT to uninstall - ${EndIf} - ${EndIf} - - reinst_uninstall: - HideWindow - ClearErrors - - ${If} $WixMode = 1 - ReadRegStr $R1 HKLM "$R6" "UninstallString" - ExecWait '$R1' $0 - ${Else} - ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" - ReadRegStr $R1 SHCTX "${UNINSTKEY}" "UninstallString" - ${IfThen} $UpdateMode = 1 ${|} StrCpy $R1 "$R1 /UPDATE" ${|} ; append /UPDATE - ${IfThen} $PassiveMode = 1 ${|} StrCpy $R1 "$R1 /P" ${|} ; append /P - StrCpy $R1 "$R1 _?=$4" ; append uninstall directory - ExecWait '$R1' $0 - ${EndIf} - - BringToFront - - ${IfThen} ${Errors} ${|} StrCpy $0 2 ${|} ; ExecWait failed, set fake exit code - - ${If} $0 <> 0 - ${OrIf} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" - ; User canceled wix uninstaller? return to select un/reinstall page - ${If} $WixMode = 1 - ${AndIf} $0 = 1602 - Abort - ${EndIf} - - ; User canceled NSIS uninstaller? return to select un/reinstall page - ${If} $0 = 1 - Abort - ${EndIf} - - ; Other errors? show generic error message and return to select un/reinstall page - MessageBox MB_ICONEXCLAMATION "$(unableToUninstall)" - Abort - ${EndIf} - reinst_done: -FunctionEnd - -; 4b. Components page: Gateway, Workshop, and STT are checkable, checked -; as the previous install left them, or all checked on a first install; -; STT requires Gateway (.onSelChange). Skipped in passive and update -; modes, which install the selection .onInit applied. -!define MUI_COMPONENTSPAGE_NODESC -!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassiveOrUpdate -!insertmacro MUI_PAGE_COMPONENTS - -; 5. Choose install directory page -!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive -!insertmacro MUI_PAGE_DIRECTORY - -; 6. Start menu shortcut page -Var AppStartMenuFolder -!if "${STARTMENUFOLDER}" != "" - !define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive - !define MUI_STARTMENUPAGE_DEFAULTFOLDER "${STARTMENUFOLDER}" -!else - !define MUI_PAGE_CUSTOMFUNCTION_PRE Skip -!endif -!insertmacro MUI_PAGE_STARTMENU Application $AppStartMenuFolder - -; 7. Installation page -!insertmacro MUI_PAGE_INSTFILES - -; 8. Finish page -; -; Don't auto jump to finish page after installation page, -; because the installation page has useful info that can be used debug any issues with the installer. -!define MUI_FINISHPAGE_NOAUTOCLOSE -; Use show readme button in the finish page as a button create a desktop shortcut -!define MUI_FINISHPAGE_SHOWREADME -!define MUI_FINISHPAGE_SHOWREADME_TEXT "$(createDesktop)" -!define MUI_FINISHPAGE_SHOWREADME_FUNCTION CreateOrUpdateDesktopShortcut -; Show run app after installation. -!define MUI_FINISHPAGE_RUN -!define MUI_FINISHPAGE_RUN_FUNCTION RunMainBinary -!define MUI_PAGE_CUSTOMFUNCTION_PRE SkipIfPassive -!define MUI_PAGE_CUSTOMFUNCTION_SHOW FinishPageShow -!insertmacro MUI_PAGE_FINISH - -!define /ifndef WM_SETTEXT 0x000C - -Function FinishPageShow - ; Windows visual styles override SetCtlColors for checkbox text. Disable - ; the theme on these controls before restoring the dark palette. - ; https://sourceforge.net/p/nsis/bugs/443/ - System::Call 'UXTHEME::SetWindowTheme(p$mui.FinishPage.Run,w" ",w" ")' - SetCtlColors $mui.FinishPage.Run "${MUI_TEXTCOLOR}" "${MUI_BGCOLOR}" - System::Call 'UXTHEME::SetWindowTheme(p$mui.FinishPage.ShowReadme,w" ",w" ")' - SetCtlColors $mui.FinishPage.ShowReadme "${MUI_TEXTCOLOR}" "${MUI_BGCOLOR}" - ; The Run checkbox follows the components: the Workshop desktop app when - ; installed; on a Gateway-only install it becomes the first-run browser - ; handoff to the gateway's Settings page; hidden when neither landed. - ${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" - ; Default label and target: run the desktop app. - ${ElseIf} ${FileExists} "$INSTDIR\runvil-gateway.exe" - SendMessage $mui.FinishPage.Run ${WM_SETTEXT} 0 "STR:Open Runvil Gateway settings in your browser" - ${Else} - ShowWindow $mui.FinishPage.Run ${SW_HIDE} - ${EndIf} -FunctionEnd - -Function RunMainBinary - ; The finish-page Run checkbox follows the components: the Workshop desktop app - ; when installed; on a Gateway-only install it launches the gateway with - ; --browser, so the first boot opens the Settings page in the - ; default browser. - ${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" - nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "" - ${ElseIf} ${FileExists} "$INSTDIR\runvil-gateway.exe" - nsis_tauri_utils::RunAsUser "$INSTDIR\runvil-gateway.exe" "--browser" - ${EndIf} -FunctionEnd - -; Uninstaller Pages -; 1. Confirm uninstall page -Var DeleteAppDataCheckbox -Var DeleteAppDataCheckboxState -!define /ifndef WS_EX_LAYOUTRTL 0x00400000 -!define MUI_PAGE_CUSTOMFUNCTION_SHOW un.ConfirmShow -Function un.ConfirmShow ; Add add a `Delete app data` check box - ; $1 inner dialog HWND - ; $2 window DPI - ; $3 style - ; $4 x - ; $5 y - ; $6 width - ; $7 height - FindWindow $1 "#32770" "" $HWNDPARENT ; Find inner dialog - System::Call "user32::GetDpiForWindow(p r1) i .r2" - ${If} $(^RTL) = 1 - StrCpy $3 "${__NSD_CheckBox_EXSTYLE} | ${WS_EX_LAYOUTRTL}" - IntOp $4 50 * $2 - ${Else} - StrCpy $3 "${__NSD_CheckBox_EXSTYLE}" - IntOp $4 0 * $2 - ${EndIf} - IntOp $5 100 * $2 - IntOp $6 400 * $2 - IntOp $7 25 * $2 - IntOp $4 $4 / 96 - IntOp $5 $5 / 96 - IntOp $6 $6 / 96 - IntOp $7 $7 / 96 - System::Call 'user32::CreateWindowEx(i r3, w "${__NSD_CheckBox_CLASS}", w "$(deleteAppDataAndState)", i ${__NSD_CheckBox_STYLE}, i r4, i r5, i r6, i r7, p r1, i0, i0, i0) i .s' - Pop $DeleteAppDataCheckbox - SendMessage $HWNDPARENT ${WM_GETFONT} 0 0 $1 - SendMessage $DeleteAppDataCheckbox ${WM_SETFONT} $1 1 -FunctionEnd -!define MUI_PAGE_CUSTOMFUNCTION_LEAVE un.ConfirmLeave -Function un.ConfirmLeave - SendMessage $DeleteAppDataCheckbox ${BM_GETCHECK} 0 0 $DeleteAppDataCheckboxState -FunctionEnd -!define MUI_PAGE_CUSTOMFUNCTION_PRE un.SkipIfPassive -!insertmacro MUI_UNPAGE_CONFIRM - -; 2. Uninstalling Page -!insertmacro MUI_UNPAGE_INSTFILES - -;Languages -{{#each languages}} -!insertmacro MUI_LANGUAGE "{{this}}" -{{/each}} -!insertmacro MUI_RESERVEFILE_LANGDLL -{{#each language_files}} - !include "{{this}}" -{{/each}} -; The "Delete app data" check box also removes the state directory, so its -; text names what that wipes. It has its own string because redefining the -; Tauri CLI's deleteAppData makes makensis warn that it is set twice. -LangString deleteAppDataAndState ${LANG_ENGLISH} "Delete app data, including models, API keys, and settings" - -Function .onInit - ${GetOptions} $CMDLINE "/P" $PassiveMode - ${IfNot} ${Errors} - StrCpy $PassiveMode 1 - ${EndIf} - - ${GetOptions} $CMDLINE "/NS" $NoShortcutMode - ${IfNot} ${Errors} - StrCpy $NoShortcutMode 1 - ${EndIf} - - ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode - ${IfNot} ${Errors} - StrCpy $UpdateMode 1 - ${EndIf} - - ; Start from the persisted component selection, so no install resurrects - ; a component the user declined before: passive and update installs skip - ; the components page and install it as is, and the page starts from it. - ; A /COMPONENTS= list then replaces it. - Call RestoreComponentSelections - Call SelectComponentsFromCommandLine - Call EnforceSttNeedsGateway - - !if "${DISPLAYLANGUAGESELECTOR}" == "true" - !insertmacro MUI_LANGDLL_DISPLAY - !endif - - !insertmacro SetContext - - ${If} $INSTDIR == "${PLACEHOLDER_INSTALL_DIR}" - ; Set default install location - !if "${INSTALLMODE}" == "perMachine" - ${If} ${RunningX64} - !if "${ARCH}" == "x64" - StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" - !else if "${ARCH}" == "arm64" - StrCpy $INSTDIR "$PROGRAMFILES64\${PRODUCTNAME}" - !else - StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" - !endif - ${Else} - StrCpy $INSTDIR "$PROGRAMFILES\${PRODUCTNAME}" - ${EndIf} - !else if "${INSTALLMODE}" == "currentUser" - StrCpy $INSTDIR "$LOCALAPPDATA\${PRODUCTNAME}" - !endif - - Call RestorePreviousInstallLocation - ${EndIf} - - !if "${INSTALLMODE}" == "both" - !insertmacro MULTIUSER_INIT - !endif -FunctionEnd - -Section "-EarlyChecks" - ; Abort silent installer if downgrades is disabled - !if "${ALLOWDOWNGRADES}" == "false" - ${If} ${Silent} - ; If downgrading - ${If} $R0 = -1 - System::Call 'kernel32::AttachConsole(i -1)i.r0' - ${If} $0 <> 0 - System::Call 'kernel32::GetStdHandle(i -11)i.r0' - System::call 'kernel32::SetConsoleTextAttribute(i r0, i 0x0004)' ; set red color - FileWrite $0 "$(silentDowngrades)" - ${EndIf} - Abort - ${EndIf} - ${EndIf} - !endif - -SectionEnd - -Section "-WebView2" - ; Check if Webview2 is already installed and skip this section - ${If} ${RunningX64} - ReadRegStr $4 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" - ${Else} - ReadRegStr $4 HKLM "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" - ${EndIf} - ${If} $4 == "" - ReadRegStr $4 HKCU "SOFTWARE\Microsoft\EdgeUpdate\Clients\${WEBVIEW2APPGUID}" "pv" - ${EndIf} - - ${If} $4 == "" - ; Webview2 installation - ; - ; Skip if updating - ${If} $UpdateMode <> 1 - !if "${INSTALLWEBVIEW2MODE}" == "downloadBootstrapper" - Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" - DetailPrint "$(webview2Downloading)" - NSISdl::download "https://go.microsoft.com/fwlink/p/?LinkId=2124703" "$TEMP\MicrosoftEdgeWebview2Setup.exe" - Pop $0 - ${If} $0 == "success" - DetailPrint "$(webview2DownloadSuccess)" - ${Else} - DetailPrint "$(webview2DownloadError)" - Abort "$(webview2AbortError)" - ${EndIf} - StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" - Goto install_webview2 - !endif - - !if "${INSTALLWEBVIEW2MODE}" == "embedBootstrapper" - Delete "$TEMP\MicrosoftEdgeWebview2Setup.exe" - File "/oname=$TEMP\MicrosoftEdgeWebview2Setup.exe" "${WEBVIEW2BOOTSTRAPPERPATH}" - DetailPrint "$(installingWebview2)" - StrCpy $6 "$TEMP\MicrosoftEdgeWebview2Setup.exe" - Goto install_webview2 - !endif - - !if "${INSTALLWEBVIEW2MODE}" == "offlineInstaller" - Delete "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" - File "/oname=$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" "${WEBVIEW2INSTALLERPATH}" - DetailPrint "$(installingWebview2)" - StrCpy $6 "$TEMP\MicrosoftEdgeWebView2RuntimeInstaller.exe" - Goto install_webview2 - !endif - - Goto webview2_done - - install_webview2: - DetailPrint "$(installingWebview2)" - ; $6 holds the path to the webview2 installer - ExecWait "$6 ${WEBVIEW2INSTALLERARGS} /install" $1 - ${If} $1 = 0 - DetailPrint "$(webview2InstallSuccess)" - ${Else} - DetailPrint "$(webview2InstallError)" - Abort "$(webview2AbortError)" - ${EndIf} - webview2_done: - ${EndIf} - ${Else} - !if "${MINIMUMWEBVIEW2VERSION}" != "" - ${VersionCompare} "${MINIMUMWEBVIEW2VERSION}" "$4" $R0 - ${If} $R0 = 1 - update_webview: - DetailPrint "$(installingWebview2)" - ${If} ${RunningX64} - ReadRegStr $R1 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate" "path" - ${Else} - ReadRegStr $R1 HKLM "SOFTWARE\Microsoft\EdgeUpdate" "path" - ${EndIf} - ${If} $R1 == "" - ReadRegStr $R1 HKCU "SOFTWARE\Microsoft\EdgeUpdate" "path" - ${EndIf} - ${If} $R1 != "" - ; Chromium updater docs: https://source.chromium.org/chromium/chromium/src/+/main:docs/updater/user_manual.md - ; Modified from "HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\Microsoft EdgeWebView\ModifyPath" - ExecWait `"$R1" /install appguid=${WEBVIEW2APPGUID}&needsadmin=true` $1 - ${If} $1 = 0 - DetailPrint "$(webview2InstallSuccess)" - ${Else} - MessageBox MB_ICONEXCLAMATION|MB_ABORTRETRYIGNORE "$(webview2InstallError)" IDIGNORE ignore IDRETRY update_webview - Quit - ignore: - ${EndIf} - ${EndIf} - ${EndIf} - !endif - ${EndIf} -SectionEnd - -Section "-Prepare" - !ifmacrodef NSIS_HOOK_PREINSTALL - !insertmacro NSIS_HOOK_PREINSTALL - !endif -SectionEnd - -Section "Runvil Workshop" SecWorkshop - SetOutPath $INSTDIR - - !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}" - - ; Copy main executable - File "${MAINBINARYSRCPATH}" - - ; Copy the libraries of the std Plugin and the bashkit Plugin into the - ; Plugin directory, `plugins` beside the main executable, where Workshop - ; loads library Plugins from - CreateDirectory "$INSTDIR\plugins" - File "/oname=plugins\plugin_std.dll" "${STDPLUGINSRCPATH}" - File "/oname=plugins\plugin_bashkit.dll" "${BASHKITPLUGINSRCPATH}" - - ; Copy resources - {{#each resources_dirs}} - CreateDirectory "$INSTDIR\\{{this}}" - {{/each}} - {{#each resources}} - File /a "/oname={{this.[1]}}" "{{no-escape @key}}" - {{/each}} - - ; Create file associations - {{#each file_associations as |association| ~}} - {{#each association.ext as |ext| ~}} - !insertmacro APP_ASSOCIATE "{{ext}}" "{{or association.name ext}}" "{{association-description association.description ext}}" "$INSTDIR\${MAINBINARYNAME}.exe,0" "Open with ${PRODUCTNAME}" "$INSTDIR\${MAINBINARYNAME}.exe $\"%1$\"" - {{/each}} - {{/each}} - - ; Register deep links - {{#each deep_link_protocols as |protocol| ~}} - WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "URL Protocol" "" - WriteRegStr SHCTX "Software\Classes\\{{protocol}}" "" "URL:${BUNDLEID} protocol" - WriteRegStr SHCTX "Software\Classes\\{{protocol}}\DefaultIcon" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\",0" - WriteRegStr SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" - {{/each}} - - ; Remove old main binary if it doesn't match new main binary name - ReadRegStr $OldMainBinaryName SHCTX "${UNINSTKEY}" "MainBinaryName" - ${If} $OldMainBinaryName != "" - ${AndIf} $OldMainBinaryName != "${MAINBINARYNAME}.exe" - Delete "$INSTDIR\$OldMainBinaryName" - ${EndIf} - - ; Save current MAINBINARYNAME for future updates - WriteRegStr SHCTX "${UNINSTKEY}" "MainBinaryName" "${MAINBINARYNAME}.exe" - - ; Create start menu shortcut - !insertmacro MUI_STARTMENU_WRITE_BEGIN Application - Call CreateOrUpdateStartMenuShortcut - !insertmacro MUI_STARTMENU_WRITE_END - - ; Create desktop shortcut for silent and passive installers - ; because finish page will be skipped - ${If} $PassiveMode = 1 - ${OrIf} ${Silent} - Call CreateOrUpdateDesktopShortcut - ${EndIf} -SectionEnd - -Section "Runvil Gateway" SecGateway - SetOutPath $INSTDIR - - ; The updater's passive install only auto-kills the main binary (the - ; CheckIfAppIsRunning in the Workshop section), so a running gateway - ; would file-lock its own overwrite and fail the update. Detect it by - ; process name through nsis_tauri_utils - parsing - ; %USERPROFILE%\.runvil\run\gateway.json for the pid in NSIS buys - ; nothing when the image name is unique - stop it through the same - ; CheckIfAppIsRunning, and relaunch it in the -Relaunch section. Living - ; inside the Gateway section, the stop runs only when the component is - ; selected: a declined section leaves the payload untouched, and a - ; daemon the install does not overwrite is not the installer's to kill. - !if "${INSTALLMODE}" == "currentUser" - nsis_tauri_utils::FindProcessCurrentUser "runvil-gateway.exe" - !else - nsis_tauri_utils::FindProcess "runvil-gateway.exe" - !endif - Pop $R0 - ${If} $R0 = 0 - StrCpy $GatewayWasRunning 1 - !insertmacro CheckIfAppIsRunning "$INSTDIR\runvil-gateway.exe" "${PRODUCTNAME}" - ${EndIf} - - ; Copy external binaries (runvil-gateway.exe via bundle.externalBin, - ; the only entry today - split per-component if that changes) - {{#each binaries}} - File /a "/oname={{this}}" "{{no-escape @key}}" - {{/each}} -SectionEnd - -Section "Speech to Text (downloads about 0.6 GB, 1.1 GB with an NVIDIA GPU)" SecSTT - ; No files: STT is a config gate. The Finalize section records the - ; selection, and the Gateway downloads the speech runtime and models the - ; first time it starts with them in its config. -SectionEnd - -Section "-Finalize" - ; Create uninstaller - WriteUninstaller "$INSTDIR\uninstall.exe" - - ; Save $INSTDIR in registry for future installations - WriteRegStr SHCTX "${MANUPRODUCTKEY}" "" $INSTDIR - - !if "${INSTALLMODE}" == "both" - ; Save install mode to be selected by default for the next installation such as updating - ; or when uninstalling - WriteRegStr SHCTX "${UNINSTKEY}" $MultiUser.InstallMode 1 - !endif - - ; Registry information for add/remove programs - WriteRegStr SHCTX "${UNINSTKEY}" "DisplayName" "${PRODUCTNAME}" - ; The display icon follows the Workshop exe when it is installed, the - ; gateway exe on a Gateway-only install. - SectionGetFlags ${SecWorkshop} $0 - IntOp $0 $0 & ${SF_SELECTED} - ${If} $0 = ${SF_SELECTED} - WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\${MAINBINARYNAME}.exe$\"" - ${Else} - WriteRegStr SHCTX "${UNINSTKEY}" "DisplayIcon" "$\"$INSTDIR\runvil-gateway.exe$\"" - ${EndIf} - WriteRegStr SHCTX "${UNINSTKEY}" "DisplayVersion" "${VERSION}" - WriteRegStr SHCTX "${UNINSTKEY}" "Publisher" "${MANUFACTURER}" - WriteRegStr SHCTX "${UNINSTKEY}" "InstallLocation" "$\"$INSTDIR$\"" - WriteRegStr SHCTX "${UNINSTKEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\"" - WriteRegDWORD SHCTX "${UNINSTKEY}" "NoModify" "1" - WriteRegDWORD SHCTX "${UNINSTKEY}" "NoRepair" "1" - - ${GetSize} "$INSTDIR" "/M=uninstall.exe /S=0K /G=0" $0 $1 $2 - IntOp $0 $0 + ${ESTIMATEDSIZE} - IntFmt $0 "0x%08X" $0 - WriteRegDWORD SHCTX "${UNINSTKEY}" "EstimatedSize" "$0" - - !if "${HOMEPAGE}" != "" - WriteRegStr SHCTX "${UNINSTKEY}" "URLInfoAbout" "${HOMEPAGE}" - WriteRegStr SHCTX "${UNINSTKEY}" "URLUpdateInfo" "${HOMEPAGE}" - WriteRegStr SHCTX "${UNINSTKEY}" "HelpLink" "${HOMEPAGE}" - !endif - - ; Persist the component selection so every later install starts from it - ; (see RestoreComponentSelections). - !insertmacro PersistComponent ${SecGateway} Gateway - !insertmacro PersistComponent ${SecWorkshop} Workshop - !insertmacro PersistComponent ${SecSTT} STT - - ; The STT checkbox writes the Gateway's first-run config gate, which the - ; pinned Gateway release reads when it writes its default config: - ; checked deletes the value (absent = STT on), unchecked writes 0. Update - ; mode never touches it, since the first run already consumed the - ; original choice. - ${If} $UpdateMode <> 1 - SectionGetFlags ${SecSTT} $0 - IntOp $0 $0 & ${SF_SELECTED} - ${If} $0 = ${SF_SELECTED} - DeleteRegValue HKCU "${MANUPRODUCTKEY}" "InstallSTT" - ${Else} - WriteRegDWORD HKCU "${MANUPRODUCTKEY}" "InstallSTT" 0 - ${EndIf} - ${EndIf} - - ; Update mode installs over the top without uninstalling; delete the - ; payloads of declined components or they would linger. - !insertmacro DeleteComponentPayloadIfDeclined ${SecGateway} $INSTDIR\runvil-gateway.exe - !insertmacro DeleteComponentPayloadIfDeclined ${SecWorkshop} $INSTDIR\${MAINBINARYNAME}.exe - !insertmacro DeleteComponentPayloadIfDeclined ${SecWorkshop} $INSTDIR\plugins\plugin_std.dll - !insertmacro DeleteComponentPayloadIfDeclined ${SecWorkshop} $INSTDIR\plugins\plugin_bashkit.dll - ; Without /r, RMDir removes the Plugin directory only when it is empty, - ; which it is only after the lines above deleted the two libraries in it. - RMDir "$INSTDIR\plugins" - ClearErrors - -SectionEnd - -Section "-Relaunch" - ; Relaunch the gateway when the install stopped one and the component - ; stays installed. `--login` keeps the relaunch headless: no browser, no - ; window. - ${If} $GatewayWasRunning = 1 - SectionGetFlags ${SecGateway} $0 - IntOp $0 $0 & ${SF_SELECTED} - ${If} $0 = ${SF_SELECTED} - nsis_tauri_utils::RunAsUser "$INSTDIR\runvil-gateway.exe" "--login" - ${EndIf} - ${EndIf} - - !ifmacrodef NSIS_HOOK_POSTINSTALL - !insertmacro NSIS_HOOK_POSTINSTALL - !endif - - ; Auto close this page for passive mode - ${If} $PassiveMode = 1 - SetAutoClose true - ${EndIf} -SectionEnd - -Function .onInstSuccess - ; Check for `/R` flag only in silent and passive installers because - ; GUI installer has a toggle for the user to (re)start the app - ${If} $PassiveMode = 1 - ${OrIf} ${Silent} - ${GetOptions} $CMDLINE "/R" $R0 - ${IfNot} ${Errors} - ${GetOptions} $CMDLINE "/ARGS" $R0 - nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "$R0" - ${EndIf} - ${EndIf} -FunctionEnd - -Function un.onInit - !insertmacro SetContext - - !if "${INSTALLMODE}" == "both" - !insertmacro MULTIUSER_UNINIT - !endif - - !insertmacro MUI_UNGETLANGUAGE - - ${GetOptions} $CMDLINE "/P" $PassiveMode - ${IfNot} ${Errors} - StrCpy $PassiveMode 1 - ${EndIf} - - ${GetOptions} $CMDLINE "/UPDATE" $UpdateMode - ${IfNot} ${Errors} - StrCpy $UpdateMode 1 - ${EndIf} -FunctionEnd - -Section "un.Gateway" - !ifmacrodef NSIS_HOOK_PREUNINSTALL - !insertmacro NSIS_HOOK_PREUNINSTALL - !endif - - !insertmacro CheckIfAppIsRunning "$INSTDIR\runvil-gateway.exe" "${PRODUCTNAME}" - - ; Delete external binaries (runvil-gateway.exe) - {{#each binaries}} - Delete "$INSTDIR\\{{this}}" - {{/each}} -SectionEnd - -Section "un.Workshop" - !insertmacro CheckIfAppIsRunning "$INSTDIR\${MAINBINARYNAME}.exe" "${PRODUCTNAME}" - - ; Delete the main executable - Delete "$INSTDIR\${MAINBINARYNAME}.exe" - - ; Delete the Plugins' libraries, and the Plugin directory unless a - ; library the installer did not put there is left in it - Delete "$INSTDIR\plugins\plugin_std.dll" - Delete "$INSTDIR\plugins\plugin_bashkit.dll" - RMDir "$INSTDIR\plugins" - - ; Delete resources - {{#each resources}} - Delete "$INSTDIR\\{{this.[1]}}" - {{/each}} - - ; Delete app associations - {{#each file_associations as |association| ~}} - {{#each association.ext as |ext| ~}} - !insertmacro APP_UNASSOCIATE "{{ext}}" "{{or association.name ext}}" - {{/each}} - {{/each}} - - ; Delete deep links - {{#each deep_link_protocols as |protocol| ~}} - ReadRegStr $R7 SHCTX "Software\Classes\\{{protocol}}\shell\open\command" "" - ${If} $R7 == "$\"$INSTDIR\${MAINBINARYNAME}.exe$\" $\"%1$\"" - DeleteRegKey SHCTX "Software\Classes\\{{protocol}}" - ${EndIf} - {{/each}} -SectionEnd - -Section Uninstall - ; Delete uninstaller - Delete "$INSTDIR\uninstall.exe" - - {{#each resources_ancestors}} - RMDir /REBOOTOK "$INSTDIR\\{{this}}" - {{/each}} - RMDir "$INSTDIR" - - ; Remove shortcuts if not updating - ${If} $UpdateMode <> 1 - !insertmacro DeleteAppUserModelId - - ; Remove start menu shortcut - !insertmacro MUI_STARTMENU_GETFOLDER Application $AppStartMenuFolder - !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - Pop $0 - ${If} $0 = 1 - !insertmacro UnpinShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" - Delete "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" - RMDir "$SMPROGRAMS\$AppStartMenuFolder" - ${EndIf} - !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - Pop $0 - ${If} $0 = 1 - !insertmacro UnpinShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" - Delete "$SMPROGRAMS\${PRODUCTNAME}.lnk" - ${EndIf} - - ; Remove desktop shortcuts - !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - Pop $0 - ${If} $0 = 1 - !insertmacro UnpinShortcut "$DESKTOP\${PRODUCTNAME}.lnk" - Delete "$DESKTOP\${PRODUCTNAME}.lnk" - ${EndIf} - ${EndIf} - - ; Remove registry information for add/remove programs - !if "${INSTALLMODE}" == "both" - DeleteRegKey SHCTX "${UNINSTKEY}" - !else if "${INSTALLMODE}" == "perMachine" - DeleteRegKey HKLM "${UNINSTKEY}" - !else - DeleteRegKey HKCU "${UNINSTKEY}" - !endif - - ; Removes the Autostart entry for ${PRODUCTNAME} from the HKCU Run key if it exists. - ; This ensures the program does not launch automatically after uninstallation if it exists. - ; If it doesn't exist, it does nothing. - ; We do this when not updating (to preserve the registry value on updates) - ${If} $UpdateMode <> 1 - DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "${PRODUCTNAME}" - ; The gateway's Launch at Login entry (the tray writes this value name). - DeleteRegValue HKCU "Software\Microsoft\Windows\CurrentVersion\Run" "RunvilGateway" - ${EndIf} - - ; Remove the STT first-run gate when not updating (updates preserve - ; it). - ${If} $UpdateMode <> 1 - DeleteRegValue HKCU "${MANUPRODUCTKEY}" "InstallSTT" - ${EndIf} - - ; Delete app data if the checkbox is selected - ; and if not updating - ${If} $DeleteAppDataCheckboxState = 1 - ${AndIf} $UpdateMode <> 1 - ; Clear the install location $INSTDIR from registry - DeleteRegKey SHCTX "${MANUPRODUCTKEY}" - DeleteRegKey /ifempty SHCTX "${MANUKEY}" - - ; Clear the install language from registry - DeleteRegValue HKCU "${MANUPRODUCTKEY}" "Installer Language" - ; The persisted component selection lives as long as the config it - ; shaped: a reinstall over a kept speechless config would otherwise offer - ; Speech to Text checked, which that config ignores. SHCTX above already removed it on a - ; currentUser install; this covers a perMachine one, which keeps it in - ; HKCU. - DeleteRegKey HKCU "${MANUPRODUCTKEY}\Components" - DeleteRegKey /ifempty HKCU "${MANUPRODUCTKEY}" - DeleteRegKey /ifempty HKCU "${MANUKEY}" - - SetShellVarContext current - RmDir /r "$APPDATA\${BUNDLEID}" - RmDir /r "$LOCALAPPDATA\${BUNDLEID}" - ; The gateway and the Workshop keep their configuration, logs, sessions, - ; and downloaded models in the state directory, %USERPROFILE%\.runvil. - RmDir /r "$PROFILE\.runvil" - ${EndIf} - - !ifmacrodef NSIS_HOOK_POSTUNINSTALL - !insertmacro NSIS_HOOK_POSTUNINSTALL - !endif - - ; Auto close if passive mode or updating - ${If} $PassiveMode = 1 - ${OrIf} $UpdateMode = 1 - SetAutoClose true - ${EndIf} -SectionEnd - -Function RestorePreviousInstallLocation - ReadRegStr $4 SHCTX "${MANUPRODUCTKEY}" "" - StrCmp $4 "" +2 0 - StrCpy $INSTDIR $4 -FunctionEnd - -Function Skip - Abort -FunctionEnd - -Function SkipIfPassive - ${IfThen} $PassiveMode = 1 ${|} Abort ${|} -FunctionEnd - -Function SkipIfPassiveOrUpdate - ${If} $PassiveMode = 1 - ${OrIf} $UpdateMode = 1 - Abort - ${EndIf} -FunctionEnd - -; Forces the persisted component selection onto the sections, the -; selection passive and update installs install and the components page -; starts from. Values absent from the registry (a first install, one -; after an uninstall that deleted app data or ran an earlier release's -; uninstaller, or one predating persistence) keep the default: -; everything selected. -Function RestoreComponentSelections - ClearErrors - ReadRegDWORD $0 HKCU "${MANUPRODUCTKEY}\Components" "Gateway" - ${IfNot} ${Errors} - ${If} $0 = 1 - SectionSetFlags ${SecGateway} ${SF_SELECTED} - ${Else} - SectionSetFlags ${SecGateway} 0 - ${EndIf} - ${EndIf} - - ClearErrors - ReadRegDWORD $0 HKCU "${MANUPRODUCTKEY}\Components" "Workshop" - ${IfNot} ${Errors} - ${If} $0 = 1 - SectionSetFlags ${SecWorkshop} ${SF_SELECTED} - ${Else} - SectionSetFlags ${SecWorkshop} 0 - ${EndIf} - ${EndIf} - - ClearErrors - ReadRegDWORD $0 HKCU "${MANUPRODUCTKEY}\Components" "STT" - ${IfNot} ${Errors} - ${If} $0 = 1 - SectionSetFlags ${SecSTT} ${SF_SELECTED} - ${Else} - SectionSetFlags ${SecSTT} 0 - ${EndIf} - ${EndIf} -FunctionEnd - -; STT is a setting of the Gateway's config, so it requires Gateway: with -; Gateway unchecked, STT is cleared. -Function EnforceSttNeedsGateway - SectionGetFlags ${SecGateway} $0 - IntOp $0 $0 & ${SF_SELECTED} - ${If} $0 <> ${SF_SELECTED} - !insertmacro UnselectSection ${SecSTT} - ${EndIf} -FunctionEnd - -Function .onSelChange - Call EnforceSttNeedsGateway -FunctionEnd - -; Selects exactly the sections a /COMPONENTS= list names, for silent -; installs in CI: /COMPONENTS=gateway,stt. Names are workshop, gateway, -; and stt, comma separated, in any case. An empty list, any other name, -; or stt without gateway quits with exit code 2 rather than install a -; selection nobody asked for. -!macro SelectComponentIfListed LIST NAME SECTION_ID - ${StrLoc} $1 "${LIST}" ",${NAME}," ">" - ${If} $1 == "" - SectionSetFlags ${SECTION_ID} 0 - ${Else} - SectionSetFlags ${SECTION_ID} ${SF_SELECTED} - ${EndIf} -!macroend - -; Passive mode never prompts, so it reports through the exit code alone. -!macro RejectComponentsList MESSAGE - ${If} $PassiveMode <> 1 - MessageBox MB_OK|MB_ICONSTOP "${MESSAGE}" /SD IDOK - ${EndIf} - SetErrorLevel 2 - Quit -!macroend - -Function SelectComponentsFromCommandLine - ClearErrors - ${GetOptions} $CMDLINE "/COMPONENTS=" $0 - ${If} ${Errors} - Return - ${EndIf} - ${StrCase} $0 ",$0," "L" - ; Strip every known name from a copy until nothing changes; a valid - ; list leaves only its outer comma. Repeating covers a repeated name, - ; whose adjacent matches share a comma. - StrCpy $1 $0 - ${Do} - StrCpy $2 $1 - ${WordReplace} $1 ",workshop," "," "+" $1 - ${WordReplace} $1 ",gateway," "," "+" $1 - ${WordReplace} $1 ",stt," "," "+" $1 - ${LoopUntil} $1 == $2 - ${If} $1 != "," - !insertmacro RejectComponentsList "/COMPONENTS= lists unknown or empty names (left after removing the known ones: $1); the names are workshop, gateway, and stt, comma separated, without spaces." - ${EndIf} - ${StrLoc} $1 $0 ",stt," ">" - ${StrLoc} $2 $0 ",gateway," ">" - ${If} $1 != "" - ${AndIf} $2 == "" - !insertmacro RejectComponentsList "/COMPONENTS= lists stt without gateway; speech to text is a Gateway setting, so list gateway too." - ${EndIf} - !insertmacro SelectComponentIfListed $0 workshop ${SecWorkshop} - !insertmacro SelectComponentIfListed $0 gateway ${SecGateway} - !insertmacro SelectComponentIfListed $0 stt ${SecSTT} -FunctionEnd - -Function un.SkipIfPassive - ${IfThen} $PassiveMode = 1 ${|} Abort ${|} -FunctionEnd - -Function CreateOrUpdateStartMenuShortcut - ; We used to use product name as MAINBINARYNAME - ; migrate old shortcuts to target the new MAINBINARYNAME - StrCpy $R0 0 - - !insertmacro IsShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" - Pop $0 - ${If} $0 = 1 - !insertmacro SetShortcutTarget "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - StrCpy $R0 1 - ${EndIf} - - !insertmacro IsShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" - Pop $0 - ${If} $0 = 1 - !insertmacro SetShortcutTarget "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - StrCpy $R0 1 - ${EndIf} - - ${If} $R0 = 1 - Return - ${EndIf} - - ; Skip creating shortcut if in update mode or no shortcut mode - ; but always create if migrating from wix - ${If} $WixMode = 0 - ${If} $UpdateMode = 1 - ${OrIf} $NoShortcutMode = 1 - Return - ${EndIf} - ${EndIf} - - !if "${STARTMENUFOLDER}" != "" - CreateDirectory "$SMPROGRAMS\$AppStartMenuFolder" - CreateShortcut "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\$AppStartMenuFolder\${PRODUCTNAME}.lnk" - !else - CreateShortcut "$SMPROGRAMS\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - !insertmacro SetLnkAppUserModelId "$SMPROGRAMS\${PRODUCTNAME}.lnk" - !endif -FunctionEnd - -Function CreateOrUpdateDesktopShortcut - ; We used to use product name as MAINBINARYNAME - ; migrate old shortcuts to target the new MAINBINARYNAME - !insertmacro IsShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\$OldMainBinaryName" - Pop $0 - ${If} $0 = 1 - !insertmacro SetShortcutTarget "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - Return - ${EndIf} - - ; Skip creating shortcut if in update mode or no shortcut mode - ; but always create if migrating from wix - ${If} $WixMode = 0 - ${If} $UpdateMode = 1 - ${OrIf} $NoShortcutMode = 1 - Return - ${EndIf} - ${EndIf} - - CreateShortcut "$DESKTOP\${PRODUCTNAME}.lnk" "$INSTDIR\${MAINBINARYNAME}.exe" - !insertmacro SetLnkAppUserModelId "$DESKTOP\${PRODUCTNAME}.lnk" -FunctionEnd diff --git a/crates/workshop/desktop/permissions/autogenerated/desktop_update_supported.toml b/crates/workshop/desktop/permissions/autogenerated/desktop_update_supported.toml deleted file mode 100644 index 6cc836fa8..000000000 --- a/crates/workshop/desktop/permissions/autogenerated/desktop_update_supported.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -[[permission]] -identifier = "allow-desktop-update-supported" -description = "Enables the desktop_update_supported command without any pre-configured scope." -commands.allow = ["desktop_update_supported"] - -[[permission]] -identifier = "deny-desktop-update-supported" -description = "Denies the desktop_update_supported command without any pre-configured scope." -commands.deny = ["desktop_update_supported"] diff --git a/crates/workshop/desktop/permissions/autogenerated/quit.toml b/crates/workshop/desktop/permissions/autogenerated/quit.toml deleted file mode 100644 index 4a5d670b2..000000000 --- a/crates/workshop/desktop/permissions/autogenerated/quit.toml +++ /dev/null @@ -1,11 +0,0 @@ -# Automatically generated - DO NOT EDIT! - -[[permission]] -identifier = "allow-quit" -description = "Enables the quit command without any pre-configured scope." -commands.allow = ["quit"] - -[[permission]] -identifier = "deny-quit" -description = "Denies the quit command without any pre-configured scope." -commands.deny = ["quit"] diff --git a/crates/workshop/desktop/src/bridge.rs b/crates/workshop/desktop/src/bridge.rs deleted file mode 100644 index 438e946a6..000000000 --- a/crates/workshop/desktop/src/bridge.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! The Windows WebView2 bridge: Explorer path drops and the microphone -//! permission grant, over raw COM. -//! -//! The page cannot read real OS paths from an HTML5 drop (Chromium hides -//! them), and the app must not touch the OLE drop target either: Tauri's -//! drag-drop handler registers its own target on the WebView2 child -//! windows before Chromium can, which kills every HTML5 drag-and-drop -//! interaction inside the page (Dockview panel drags included), and once -//! Chromium does register, its target sits in the msedgewebview2 browser -//! process, so it cannot be wrapped from this process at all - a window -//! property holding another process's interface pointer is not callable -//! here. -//! -//! WebView2 has a supported channel for this: on a drop the page -//! calls `chrome.webview.postMessageWithAdditionalObjects` with the DOM -//! `File` objects, and the desktop app receives each one as an -//! [`ICoreWebView2File`] whose `Path` is the real OS path. [`attach`] -//! subscribes to those messages and feeds the paths into the -//! `runvil:file-drop` dispatch ([`crate::drops`]) the page already -//! listens for. -//! -//! The same subscription pass installs the `PermissionRequested` handler -//! that grants the microphone (and nothing else), replacing wry's -//! `with_permission_handler` from the tao/wry runtime. - -use std::path::PathBuf; -use std::sync::mpsc; - -use anyhow::Context as _; -use webview2_com::Microsoft::Web::WebView2::Win32::{ - COREWEBVIEW2_PERMISSION_KIND, COREWEBVIEW2_PERMISSION_KIND_MICROPHONE, - COREWEBVIEW2_PERMISSION_STATE_ALLOW, ICoreWebView2, ICoreWebView2File, - ICoreWebView2PermissionRequestedEventArgs, ICoreWebView2Settings3, - ICoreWebView2WebMessageReceivedEventArgs, ICoreWebView2WebMessageReceivedEventArgs2, -}; -use webview2_com::{PermissionRequestedEventHandler, WebMessageReceivedEventHandler, take_pwstr}; -use windows_core::{Interface as _, PWSTR}; - -use crate::drops::dispatch_file_drop; - -/// The web message the page sends with a drop's `File` objects attached. -/// Every other message on the shared channel is ignored. -const DROP_MESSAGE: &str = "workspace-drop"; - -/// Subscribes the window's webview to drop messages and permission -/// requests. The message handler reads the dropped paths off the event -/// and queues the `runvil:file-drop` dispatch ([`crate::drops`]) for -/// the next event-loop turn - never inside the callback, where the OLE -/// drop may still be on the stack; the permission handler grants the -/// microphone and leaves every other kind to WebView2's default handling. -/// -/// # Errors -/// Returns an error when the webview dispatch or a COM subscription fails; -/// the caller logs it and Explorer drops plus the mic grant degrade, -/// nothing else. -pub(crate) fn attach(window: &tauri::WebviewWindow) -> anyhow::Result<()> { - let window_for_handler = window.clone(); - let (tx, rx) = mpsc::channel(); - window - .with_webview(move |webview| { - let _ = tx.send(attach_handlers(&webview, window_for_handler)); - }) - .context("dispatch the bridge attach to the webview thread")?; - rx.recv() - .context("the webview bridge attach did not answer")? - .context("attach the WebView2 bridge") -} - -/// The COM subscriptions, run on the thread that owns the webview. -fn attach_handlers( - webview: &tauri::webview::PlatformWebview, - window: tauri::WebviewWindow, -) -> windows_core::Result<()> { - let controller = webview.controller(); - // SAFETY: `controller` is the live controller of this window's webview, - // called on the webview's own thread (with_webview guarantees it), and - // the generated out-param write is valid by construction. - let core = unsafe { controller.CoreWebView2()? }; - disable_browser_accelerator_keys(&core)?; - attach_drop_bridge(&core, window)?; - attach_permission_grant(&core)?; - Ok(()) -} - -/// WebView2's built-in keyboard shortcuts (Ctrl+=, Ctrl+-, F5, Ctrl+U, -/// etc.) race the app's JS keydown handler. Disabling them here makes -/// the JS handler in shortcuts.ts the sole zoom path. -fn disable_browser_accelerator_keys(core: &ICoreWebView2) -> windows_core::Result<()> { - // SAFETY: `core` is the live interface supplied by the webview controller, - // and this call runs on that webview's thread. - let settings = unsafe { core.Settings()? }; - let settings3: ICoreWebView2Settings3 = settings.cast()?; - // SAFETY: `settings3` was queried from the live settings interface and is - // called on the owning webview thread. - unsafe { settings3.SetAreBrowserAcceleratorKeysEnabled(false) } -} - -/// The drop half: forward the real OS paths of a `workspace-drop` -/// message's attached `File` objects into the page's grant flow. The -/// dispatch is queued onto the next event-loop turn, never run inside -/// the callback: on a real Explorer drop the message can arrive with -/// the OLE drop still on the UI thread's stack, and eval'ing into the -/// page from inside the callback is the reentrancy that hangs the app -/// (WebView2Feedback #2498/#3559). -fn attach_drop_bridge( - core: &ICoreWebView2, - window: tauri::WebviewWindow, -) -> windows_core::Result<()> { - let handler = WebMessageReceivedEventHandler::create(Box::new(move |_, args| { - let Some(args) = args else { - return Ok(()); - }; - let window = window.clone(); - handle_web_message(&args, move |paths| { - let queued = window.run_on_main_thread({ - let window = window.clone(); - move || dispatch_file_drop(&window, &paths) - }); - if let Err(error) = queued { - eprintln!("could not queue the file-drop dispatch: {error}"); - } - }); - Ok(()) - })); - let mut token = 0_i64; - // SAFETY: `core` is live on its owning thread, `handler` outlives the - // subscription (the webview holds it), and `token` is a valid out-pointer - // to a stack local. - unsafe { core.add_WebMessageReceived(&handler, &raw mut token) } -} - -/// Handles one web message: when it is the drop message with file -/// attachments, hands their real OS paths to `defer`. The event args die -/// with the callback, so the paths are read here; everything after - -/// above all the eval that dispatches the drop into the page - runs -/// outside the callback, which `defer` must arrange. -fn handle_web_message( - args: &ICoreWebView2WebMessageReceivedEventArgs, - defer: impl FnOnce(Vec), -) { - if message_string(args).as_deref() == Some(DROP_MESSAGE) { - let paths = dropped_paths(args); - if !paths.is_empty() { - defer(paths); - } - } -} - -/// The permission half: grant the microphone automatically. Every other -/// permission kind returns without a `SetState` call, keeping WebView2's -/// default handling. -fn attach_permission_grant(core: &ICoreWebView2) -> windows_core::Result<()> { - let handler = PermissionRequestedEventHandler::create(Box::new(move |_, args| { - let Some(args) = args else { - return Ok(()); - }; - grant_microphone(&args) - })); - let mut token = 0_i64; - // SAFETY: same preconditions as the drop subscription above. - unsafe { core.add_PermissionRequested(&handler, &raw mut token) } -} - -/// Answers one permission request: allow for the microphone, default -/// handling for everything else. -fn grant_microphone(args: &ICoreWebView2PermissionRequestedEventArgs) -> windows_core::Result<()> { - let mut kind = COREWEBVIEW2_PERMISSION_KIND::default(); - // SAFETY: `args` is the live event argument WebView2 handed the handler, - // and `kind` is a valid out-pointer to a stack local. - unsafe { args.PermissionKind(&raw mut kind) }?; - if kind == COREWEBVIEW2_PERMISSION_KIND_MICROPHONE { - // SAFETY: `args` is live as above. - unsafe { args.SetState(COREWEBVIEW2_PERMISSION_STATE_ALLOW) }?; - } - Ok(()) -} - -/// The message's string payload, or `None` when the message is not a -/// string (a JSON object posted by some other page code, say). -fn message_string(args: &ICoreWebView2WebMessageReceivedEventArgs) -> Option { - let mut message = PWSTR::null(); - // SAFETY: `args` is the live event argument, and `message` is a valid - // out-pointer; on success the returned PWSTR is owned and freed by - // take_pwstr. - unsafe { args.TryGetWebMessageAsString(&raw mut message) }.ok()?; - Some(take_pwstr(message)) -} - -/// The real OS paths of the message's attached `File` objects. Anything -/// else in the message (a message with no attachments, or attachments of -/// some other type) contributes nothing. -fn dropped_paths(args: &ICoreWebView2WebMessageReceivedEventArgs) -> Vec { - // AdditionalObjects arrived with WebView2 runtime 1.0.1518.46 (2022); - // an older runtime fails the cast and drops degrade gracefully. - let Ok(args) = args.cast::() else { - return Vec::new(); - }; - // SAFETY: `args` is live on the webview's thread; the returned - // collection pointer is owned by the event and read only here. - let Ok(objects) = (unsafe { args.AdditionalObjects() }) else { - return Vec::new(); - }; - let mut count = 0_u32; - // SAFETY: `objects` is the live collection returned above; `count` is a - // valid out-pointer to a stack local. - if unsafe { objects.Count(&raw mut count) }.is_err() { - return Vec::new(); - } - let mut paths = Vec::with_capacity(count as usize); - for index in 0..count { - // SAFETY: `objects` is live; `index` is below the count it reported. - let Ok(object) = (unsafe { objects.GetValueAtIndex(index) }) else { - continue; - }; - let Ok(file) = object.cast::() else { - continue; - }; - let mut path = PWSTR::null(); - // SAFETY: `file` is a live attachment; `path` is a valid - // out-pointer, and on success the returned PWSTR is owned and freed - // by take_pwstr. - if unsafe { file.Path(&raw mut path) }.is_ok() { - paths.push(PathBuf::from(take_pwstr(path))); - } - } - paths -} - -// The clippy expects cover code the #[implement] macro expands in tests. -#[cfg(test)] -#[expect( - clippy::inline_always, - clippy::ref_as_ptr, - reason = "the #[implement] macro expansion in tests triggers these lints" -)] -mod tests { - use std::path::PathBuf; - - use webview2_com::Microsoft::Web::WebView2::Win32::{ - ICoreWebView2File, ICoreWebView2File_Impl, ICoreWebView2ObjectCollectionView, - ICoreWebView2ObjectCollectionView_Impl, ICoreWebView2WebMessageReceivedEventArgs, - ICoreWebView2WebMessageReceivedEventArgs_Impl, ICoreWebView2WebMessageReceivedEventArgs2, - ICoreWebView2WebMessageReceivedEventArgs2_Impl, - }; - use webview2_com::pwstr_from_str; - use windows_core::{IUnknown, Interface as _, PWSTR, implement}; - - use super::{DROP_MESSAGE, dropped_paths, handle_web_message, message_string}; - - /// A fake dropped file holding one path. - #[implement(ICoreWebView2File)] - struct FakeFile { - path: &'static str, - } - - impl ICoreWebView2File_Impl for FakeFile_Impl { - fn Path(&self, value: *mut PWSTR) -> windows_core::Result<()> { - // SAFETY: `value` is the out-pointer the COM caller provided, - // valid for one write. - unsafe { *value = pwstr_from_str(self.path) }; - Ok(()) - } - } - - /// A fake attachment collection. - #[implement(ICoreWebView2ObjectCollectionView)] - struct FakeObjects { - objects: Vec, - } - - impl ICoreWebView2ObjectCollectionView_Impl for FakeObjects_Impl { - fn Count(&self, value: *mut u32) -> windows_core::Result<()> { - #[expect( - clippy::cast_possible_truncation, - reason = "the test fake holds a handful of objects, far below u32::MAX" - )] - // SAFETY: `value` is the caller's out-pointer, valid for one write. - unsafe { - *value = self.objects.len() as u32; - } - Ok(()) - } - - fn GetValueAtIndex(&self, index: u32) -> windows_core::Result { - Ok(self.objects[index as usize].clone()) - } - } - - /// A fake web-message event: a string message (or not) plus optional - /// attachments, mirroring what postMessageWithAdditionalObjects sends. - #[implement( - ICoreWebView2WebMessageReceivedEventArgs, - ICoreWebView2WebMessageReceivedEventArgs2 - )] - struct FakeArgs { - message: Option<&'static str>, - objects: Vec, - } - - impl ICoreWebView2WebMessageReceivedEventArgs_Impl for FakeArgs_Impl { - fn Source(&self, value: *mut PWSTR) -> windows_core::Result<()> { - // SAFETY: `value` is the caller's out-pointer, valid for one write. - unsafe { *value = pwstr_from_str("http://127.0.0.1/") }; - Ok(()) - } - - fn WebMessageAsJson(&self, value: *mut PWSTR) -> windows_core::Result<()> { - // SAFETY: `value` is the caller's out-pointer, valid for one write. - unsafe { *value = pwstr_from_str("{}") }; - Ok(()) - } - - fn TryGetWebMessageAsString(&self, value: *mut PWSTR) -> windows_core::Result<()> { - match self.message { - Some(message) => { - // SAFETY: `value` is the caller's out-pointer, valid for - // one write. - unsafe { *value = pwstr_from_str(message) }; - Ok(()) - } - None => Err(windows_core::Error::from_hresult(windows_core::HRESULT( - 0x8000_000B_u32.cast_signed(), // E_BOUNDS, what WebView2 answers - ))), - } - } - } - - impl ICoreWebView2WebMessageReceivedEventArgs2_Impl for FakeArgs_Impl { - fn AdditionalObjects(&self) -> windows_core::Result { - Ok(FakeObjects { - objects: self.objects.clone(), - } - .into()) - } - } - - fn args_with( - message: Option<&'static str>, - objects: Vec, - ) -> ICoreWebView2WebMessageReceivedEventArgs { - FakeArgs { message, objects }.into() - } - - /// A fake file attachment, as the plain IUnknown the collection holds. - fn file_object(path: &'static str) -> IUnknown { - let file = ICoreWebView2File::from(FakeFile { path }); - match file.cast() { - Ok(unknown) => unknown, - Err(error) => panic!("a file must cast to IUnknown: {error}"), - } - } - - #[test] - fn the_drop_message_string_is_read_back() { - let args = args_with(Some(DROP_MESSAGE), Vec::new()); - assert_eq!(message_string(&args).as_deref(), Some(DROP_MESSAGE)); - } - - #[test] - fn a_non_string_message_reads_as_none() { - let args = args_with(None, Vec::new()); - assert_eq!(message_string(&args), None); - } - - #[test] - fn file_attachments_become_their_os_paths() { - let files = vec![ - file_object(r"C:\Users\Vinnie\Documents\project"), - file_object(r"D:\src\notes.md"), - ]; - let args = args_with(Some(DROP_MESSAGE), files); - assert_eq!( - dropped_paths(&args), - vec![ - PathBuf::from(r"C:\Users\Vinnie\Documents\project"), - PathBuf::from(r"D:\src\notes.md"), - ] - ); - } - - #[test] - fn non_file_attachments_are_skipped() { - // The collection object itself is included as a non-file IUnknown. - let not_a_file: IUnknown = ICoreWebView2ObjectCollectionView::from(FakeObjects { - objects: Vec::new(), - }) - .cast() - .unwrap_or_else(|error| panic!("a collection must cast to IUnknown: {error}")); - let objects = vec![not_a_file, file_object(r"C:\only\this\one.txt")]; - let args = args_with(Some(DROP_MESSAGE), objects); - assert_eq!( - dropped_paths(&args), - vec![PathBuf::from(r"C:\only\this\one.txt")] - ); - } - - #[test] - fn a_message_with_no_attachments_yields_no_paths() { - let args = args_with(Some(DROP_MESSAGE), Vec::new()); - assert!(dropped_paths(&args).is_empty()); - } - - #[test] - fn a_drop_message_defers_its_paths() { - let args = args_with( - Some(DROP_MESSAGE), - vec![file_object(r"C:\Users\Vinnie\Documents\project")], - ); - let (tx, rx) = std::sync::mpsc::channel(); - handle_web_message(&args, move |paths| { - tx.send(paths).expect("the test channel is open"); - }); - assert_eq!( - rx.recv().expect("the drop's paths were deferred"), - vec![PathBuf::from(r"C:\Users\Vinnie\Documents\project")] - ); - } - - #[test] - fn a_non_drop_message_defers_nothing() { - let args = args_with(Some("other-message"), vec![file_object(r"D:\src\notes.md")]); - let called = std::cell::Cell::new(false); - handle_web_message(&args, |_| called.set(true)); - assert!(!called.get()); - } - - #[test] - fn a_drop_message_with_no_files_defers_nothing() { - let args = args_with(Some(DROP_MESSAGE), Vec::new()); - let called = std::cell::Cell::new(false); - handle_web_message(&args, |_| called.set(true)); - assert!(!called.get()); - } -} diff --git a/crates/workshop/desktop/src/config.rs b/crates/workshop/desktop/src/config.rs deleted file mode 100644 index c4e9493d8..000000000 --- a/crates/workshop/desktop/src/config.rs +++ /dev/null @@ -1,278 +0,0 @@ -//! The desktop app's workshop-server configuration: `workshop.toml` discovery, -//! the forced ephemeral loopback bind, and the installation's Plugin -//! directory. -//! -//! The desktop app runs the workshop server in-process, so the listener -//! settings are the desktop app's own: the bind is always `127.0.0.1:0` (an -//! OS-assigned port - a fixed port is a conflict class the -//! single-instance handoff cannot close). A discovered `workshop.toml` still -//! owns the `[gateway]` connection settings and the state and -//! agent prompt paths; the gateway endpoint itself resolves inside the -//! server, gateway discovery file first, explicit config second. A config -//! that names no Plugin directory gets the installation's own, `plugins` -//! where the installer lays it out: beside Workshop on Windows, and -//! inside Workshop's bundle on macOS and Linux. - -use std::path::{Path, PathBuf}; - -use anyhow::Context as _; -use workshop_server_api::Config; - -/// Canonical file name searched for at each candidate location. -const CONFIG_FILE_NAME: &str = "workshop.toml"; - -/// The desktop app's listener bind: loopback on an OS-assigned port, reported -/// back through the server handle once bound. -const DESKTOP_BIND: &str = "127.0.0.1:0"; - -/// Loads the desktop app's workshop-server configuration. -/// -/// A `workshop.toml` found in the search order - beside the executable, -/// then the current directory, then the user profile's `.runvil` -/// directory - supplies the `[gateway]` connection and the path -/// settings; the listener settings are forced to the desktop app's own. With -/// no file, the default config anchors its state in the profile's -/// `.runvil` directory and leaves the gateway to endpoint -/// resolution, which attaches through the gateway discovery -/// file or fails plainly. -/// -/// Either way, a config that names no Plugin directory gets the -/// installation's own. -/// -/// # Errors -/// Returns an error when a discovered file cannot be loaded or the -/// executable or current directory cannot be determined. -pub(crate) fn load() -> anyhow::Result { - let exe = std::env::current_exe().context("locate the executable")?; - let exe_dir = exe - .parent() - .context("the executable has no parent directory")?; - let cwd = std::env::current_dir().context("locate the current directory")?; - let appimage = workshop_server_api::discovery::running_appimage( - &exe, - std::env::var_os("APPIMAGE").as_deref(), - std::env::var_os("APPDIR").as_deref(), - ); - let plugins = workshop_server_api::discovery::installed_plugin_dir(&exe, appimage.as_deref()); - let mut config = load_in(exe_dir, &cwd, std::env::home_dir().as_deref())?; - name_plugin_dir(&mut config, plugins); - Ok(config) -} - -/// The testable core of [`load`], with the three base directories -/// injected. A missing home directory degrades to skipping the profile -/// candidate and anchoring the no-file default at the current directory, -/// matching `Config::parse`'s anchor degradation. -fn load_in(exe_dir: &Path, cwd: &Path, home: Option<&Path>) -> anyhow::Result { - let candidates = candidates_from(exe_dir, cwd, home); - match first_existing(&candidates) { - Some(path) => { - let mut config = - Config::load(&path).with_context(|| format!("load {}", path.display()))?; - shape_for_desktop(&mut config); - Ok(config) - } - None => Ok(default_config(home)), - } -} - -/// Gives `config` the installation's Plugin directory, `installed`, unless -/// it names one of its own. -fn name_plugin_dir(config: &mut Config, installed: Option) { - if config.agents.plugins.is_none() { - config.agents.plugins = installed; - } -} - -/// Builds the candidate list in search order from the three base -/// directories. -fn candidates_from(exe_dir: &Path, cwd: &Path, home: Option<&Path>) -> Vec { - let mut candidates = vec![exe_dir.join(CONFIG_FILE_NAME), cwd.join(CONFIG_FILE_NAME)]; - if let Some(home) = home { - candidates.push(profile_dir(home).join(CONFIG_FILE_NAME)); - } - candidates -} - -/// Returns the first candidate path that exists, if any. -fn first_existing(candidates: &[PathBuf]) -> Option { - candidates.iter().find(|path| path.is_file()).cloned() -} - -/// The profile directory: `/.runvil`. -fn profile_dir(home: &Path) -> PathBuf { - home.join(".runvil") -} - -/// Forces the listener settings the desktop app owns onto a loaded config: the -/// ephemeral loopback bind. -fn shape_for_desktop(config: &mut Config) { - config.server.bind = DESKTOP_BIND.to_string(); -} - -/// The no-file configuration: no explicit gateway (endpoint resolution -/// attaches through the gateway discovery file or fails plainly), with the -/// state and agent prompt paths anchored at the profile directory. -fn default_config(home: Option<&Path>) -> Config { - let mut config = Config { - gateway: workshop_server_api::GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }, - server: workshop_server_api::ServerConfig { - bind: DESKTOP_BIND.to_string(), - state_dir: PathBuf::new(), - }, - agents: workshop_server_api::AgentsConfig::default(), - }; - let anchor = home.map_or_else(|| PathBuf::from("."), profile_dir); - config.anchor_path_defaults(&anchor); - config -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Three empty candidate roots under one tempdir. - fn roots() -> (tempfile::TempDir, PathBuf, PathBuf, PathBuf) { - let temp = tempfile::TempDir::new().expect("tempdir"); - let exe = temp.path().join("exe"); - let cwd = temp.path().join("cwd"); - let home = temp.path().join("home"); - std::fs::create_dir_all(&exe).expect("exe dir"); - std::fs::create_dir_all(&cwd).expect("cwd dir"); - std::fs::create_dir_all(profile_dir(&home)).expect("profile dir"); - (temp, exe, cwd, home) - } - - fn write(path: &Path, gateway_url: &str) { - std::fs::write( - path, - format!("[gateway]\nbase_url = \"{gateway_url}\"\napi_key = \"k\"\n"), - ) - .expect("write fixture"); - } - - #[test] - fn candidates_are_ordered_exe_then_cwd_then_profile() { - let candidates = candidates_from( - Path::new("exe-dir"), - Path::new("cwd-dir"), - Some(Path::new("home-dir")), - ); - assert_eq!( - candidates, - vec![ - PathBuf::from("exe-dir/workshop.toml"), - PathBuf::from("cwd-dir/workshop.toml"), - PathBuf::from("home-dir/.runvil/workshop.toml"), - ] - ); - } - - #[test] - fn a_missing_home_skips_the_profile_candidate() { - let candidates = candidates_from(Path::new("exe-dir"), Path::new("cwd-dir"), None); - assert_eq!(candidates.len(), 2, "no profile candidate without a home"); - } - - #[test] - fn the_first_existing_candidate_wins() { - let (_temp, exe, cwd, home) = roots(); - let in_cwd = cwd.join(CONFIG_FILE_NAME); - let in_home = profile_dir(&home).join(CONFIG_FILE_NAME); - write(&in_cwd, "http://cwd:1"); - write(&in_home, "http://home:1"); - let config = load_in(&exe, &cwd, Some(&home)).expect("loads"); - assert_eq!( - config.gateway.base_url, "http://cwd:1", - "cwd beats the profile" - ); - - let in_exe = exe.join(CONFIG_FILE_NAME); - write(&in_exe, "http://exe:1"); - let config = load_in(&exe, &cwd, Some(&home)).expect("loads"); - assert_eq!( - config.gateway.base_url, "http://exe:1", - "beside the executable beats everything" - ); - } - - #[test] - fn a_discovered_file_keeps_its_gateway_and_paths_but_not_the_listener() { - let (_temp, exe, cwd, home) = roots(); - let path = profile_dir(&home).join(CONFIG_FILE_NAME); - std::fs::write( - &path, - "[gateway]\nbase_url = \"http://gateway.lan:9999\"\napi_key = \"k\"\n\n\ - [server]\nbind = \"127.0.0.1:7910\"\n", - ) - .expect("write fixture"); - let config = load_in(&exe, &cwd, Some(&home)).expect("the discovered file loads"); - assert_eq!(config.gateway.base_url, "http://gateway.lan:9999"); - assert_eq!( - config.server.bind, DESKTOP_BIND, - "the desktop app owns the listener: an OS-assigned loopback port" - ); - assert_eq!( - config.server.state_dir, - profile_dir(&home), - "state anchors beside the discovered file" - ); - } - - #[test] - fn no_file_defaults_to_discovery_with_profile_anchored_state() { - let (_temp, exe, cwd, home) = roots(); - let config = load_in(&exe, &cwd, Some(&home)).expect("the default config"); - assert_eq!( - config.gateway.base_url, "", - "an empty base_url is the not-explicit signal resolution reads" - ); - assert_eq!(config.server.bind, DESKTOP_BIND); - assert_eq!(config.server.state_dir, profile_dir(&home)); - assert_eq!(config.agents.path, profile_dir(&home).join("agents")); - } - - #[test] - fn a_config_that_names_no_plugin_directory_gets_the_installations() { - let (_temp, exe, cwd, home) = roots(); - let installed = exe.join("plugins"); - let mut config = load_in(&exe, &cwd, Some(&home)).expect("the default config"); - assert_eq!(config.agents.plugins, None); - name_plugin_dir(&mut config, Some(installed.clone())); - assert_eq!(config.agents.plugins, Some(installed.clone())); - - std::fs::write( - exe.join(CONFIG_FILE_NAME), - "[gateway]\nbase_url = \"http://x\"\napi_key = \"k\"\n\n[agents]\nplugins = \"mine\"\n", - ) - .expect("write fixture"); - let mut config = load_in(&exe, &cwd, Some(&home)).expect("the discovered file loads"); - name_plugin_dir(&mut config, Some(installed)); - assert_eq!( - config.agents.plugins, - Some(PathBuf::from("mine")), - "a Plugin directory the file names is kept" - ); - } - - #[test] - fn no_file_and_no_home_anchors_at_the_working_directory() { - let (_temp, exe, cwd, _home) = roots(); - let config = load_in(&exe, &cwd, None).expect("the default config"); - assert_eq!(config.server.state_dir, Path::new(".")); - } - - #[test] - fn a_malformed_discovered_file_is_an_error() { - let (_temp, exe, cwd, home) = roots(); - std::fs::write(exe.join(CONFIG_FILE_NAME), "[gateway\n").expect("write fixture"); - let error = load_in(&exe, &cwd, Some(&home)).expect_err("malformed TOML must fail"); - assert!( - error.to_string().contains(CONFIG_FILE_NAME), - "the error names the file: {error}" - ); - } -} diff --git a/crates/workshop/desktop/src/drops.rs b/crates/workshop/desktop/src/drops.rs deleted file mode 100644 index 753f318a9..000000000 --- a/crates/workshop/desktop/src/drops.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! The `runvil:file-drop` dispatch shared by both drop paths: the -//! Windows WebView2 web-message bridge ([`crate::bridge`]) and Tauri's own -//! drag-drop window event everywhere else. -//! -//! The page grants each path through the workspace HTTP API; the app never -//! reads file bytes merely because a file was dragged onto the window. - -use std::path::{Path, PathBuf}; - -/// Renders a dropped OS path for the browser event: the path's own text -/// with any Windows verbatim (`\\?\`) prefix stripped, so the page hands -/// the workspace API the same spelling Explorer shows. The verbatim UNC -/// form (`\\?\UNC\server\share`) collapses to the plain UNC spelling -/// (`\\server\share`). Separators stay native - the workspace server runs -/// on this same machine and canonicalizes whatever it receives. -#[must_use] -fn normalize_dropped_path(path: &Path) -> String { - let text = path.to_string_lossy().into_owned(); - if let Some(unc) = text.strip_prefix(r"\\?\UNC\") { - return format!(r"\\{unc}"); - } - match text.strip_prefix(r"\\?\") { - Some(stripped) => stripped.to_owned(), - None => text, - } -} - -/// Dispatches the `runvil:file-drop` event with the normalized -/// dropped paths. The page listens for the event and grants each path -/// through the workspace HTTP API. -pub(crate) fn dispatch_file_drop(window: &tauri::WebviewWindow, paths: &[PathBuf]) { - let normalized: Vec = paths - .iter() - .map(|path| normalize_dropped_path(path)) - .collect(); - let detail = match serde_json::to_string(&normalized) { - Ok(detail) => detail, - Err(error) => { - eprintln!("could not encode the dropped paths: {error}"); - return; - } - }; - let script = format!( - "window.dispatchEvent(new CustomEvent(\"runvil:file-drop\", {{detail: {{paths: {detail}}}}}));" - ); - if let Err(error) = window.eval(&script) { - eprintln!("could not dispatch the file-drop event: {error}"); - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use super::normalize_dropped_path; - - #[test] - fn dropped_paths_keep_backslashes_spaces_and_unicode() { - for path in [ - r"C:\Users\Vinnie\Documents\project", - r"C:\Users\Vinnie\My Documents\file name.txt", - "C:\\Users\\Vinnie\\caf\u{e9} \u{4e2d}\u{6587}.txt", - r"D:\src\runvil\crates", - ] { - assert_eq!(normalize_dropped_path(Path::new(path)), path, "{path}"); - } - } - - #[test] - fn dropped_paths_shed_the_verbatim_prefix() { - assert_eq!( - normalize_dropped_path(Path::new(r"\\?\C:\Users\Vinnie\file.txt")), - r"C:\Users\Vinnie\file.txt" - ); - assert_eq!( - normalize_dropped_path(Path::new("\\\\?\\D:\\src\\caf\u{e9}.txt")), - "D:\\src\\caf\u{e9}.txt" - ); - assert_eq!( - normalize_dropped_path(Path::new(r"\\?\UNC\server\share\file.txt")), - r"\\server\share\file.txt" - ); - } -} diff --git a/crates/workshop/desktop/src/gateway.rs b/crates/workshop/desktop/src/gateway.rs deleted file mode 100644 index 26fb360d4..000000000 --- a/crates/workshop/desktop/src/gateway.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! Attach-or-launch lifecycle for the desktop app's Gateway sidecar. -//! -//! The sidecar is the local `runvil-gateway` process found through -//! its gateway discovery file or launched detached beside the desktop app. -//! An explicit `workshop.toml` `[gateway]` endpoint is not a sidecar and -//! gets no supervision. -//! -//! Boot planning and detached spawn (`boot`), validated identity -//! (`identity`), and launch-and-wait plus continuous supervision -//! (`supervisor`) are private sibling modules whose imports run both -//! ways. `boot` and `supervisor` import each other: boot launches through -//! the supervisor's cancellable launch-and-wait and authenticates the -//! child as its `RecoveryCandidate`, and the supervisor relaunches -//! through boot's sibling lookup and detached spawn. `identity` and -//! `supervisor` import each other too: `GatewayAttachment` wraps that -//! candidate, and recovery compares processes with -//! `same_gateway_identity`. `boot` also returns `identity`'s -//! `GatewayAttachment`, and `identity` never imports `boot`. - -mod boot; -mod identity; -mod supervisor; - -pub(crate) use boot::ensure_gateway; -pub(crate) use identity::GatewayAttachment; -pub(crate) use supervisor::{GatewaySupervisor, SupervisorShutdown, supervise}; - -#[cfg(test)] -mod tests; diff --git a/crates/workshop/desktop/src/gateway/boot.rs b/crates/workshop/desktop/src/gateway/boot.rs deleted file mode 100644 index bd1e89e62..000000000 --- a/crates/workshop/desktop/src/gateway/boot.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Boot planning and detached Gateway spawn. - -use std::path::{Path, PathBuf}; -use std::process::ExitStatus; -use std::sync::{Arc, OnceLock}; - -use anyhow::Context as _; -use workshop_server_api::Config; -use workshop_server_api::discovery::{ - CancellationToken, GatewayDiscoveryFile, Resolution, SidecarError, ValidatedConnection, -}; - -use super::identity::GatewayAttachment; -use super::supervisor::{RecoveryCandidate, RecoveryOwnership, launch_and_attach_cancellable}; - -/// The Gateway executable's file name, which test fixtures plant. -#[cfg(all(test, windows))] -pub(super) const GATEWAY_EXE_NAME: &str = "runvil-gateway.exe"; -/// The Gateway executable's file name, which test fixtures plant. -#[cfg(all(test, not(windows)))] -pub(super) const GATEWAY_EXE_NAME: &str = "runvil-gateway"; - -#[cfg(windows)] -const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; -#[cfg(windows)] -const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; -#[cfg(windows)] -const DETACHED_PROCESS: u32 = 0x0000_0008; -#[cfg(windows)] -const WINDOWS_DETACHED_CREATION_FLAGS: u32 = CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS; -#[cfg(windows)] -const WINDOWS_BREAKAWAY_CREATION_FLAGS: u32 = - CREATE_BREAKAWAY_FROM_JOB | WINDOWS_DETACHED_CREATION_FLAGS; - -/// What the boot decision concluded. -#[derive(Debug, PartialEq, Eq)] -pub(super) enum GatewayPlan { - /// A live gateway discovery file exists. - Attach(GatewayDiscoveryFile), - /// No live file exists and an installed Gateway can be launched. - Launch(PathBuf), - /// Explicit configuration is the only available endpoint. - ConfigOnly, - /// No attachment path exists. - Fail, -} - -/// The result of a launch election, retaining the spawned pid only for the -/// branch that actually created a child. -#[derive(Debug)] -pub(super) enum RecoveryLaunch { - /// A race winner had already published a live Gateway. - Attached(GatewayDiscoveryFile), - /// This process spawned a child and observed a gateway discovery file. - Launched { - child_pid: u32, - file: GatewayDiscoveryFile, - }, -} - -/// Connects the Gateway for boot. -/// -/// # Errors -/// Returns an error when no attachment path exists or launch fails. -pub(crate) fn ensure_gateway(config: &Config) -> anyhow::Result { - let installed = installed_gateway()?; - let explicit = !config.gateway.base_url.is_empty(); - let Some(run_dir) = workshop_server_api::discovery::default_run_dir() else { - return if explicit { - Ok(GatewayAttachment::Config) - } else { - Err(no_gateway_error()) - }; - }; - match plan_gateway( - &run_dir, - installed, - explicit, - workshop_server_api::discovery::resolve, - ) { - GatewayPlan::Attach(file) => validated_attachment(file), - GatewayPlan::ConfigOnly => Ok(GatewayAttachment::Config), - GatewayPlan::Fail => Err(no_gateway_error()), - GatewayPlan::Launch(exe) => { - if sibling_is_placeholder(&exe) { - return Err(placeholder_gateway_error(&exe)); - } - launch_and_attach_cancellable(&run_dir, &exe, &CancellationToken::new()) - .and_then(validated_recovery_attachment) - .context("launch the sidecar gateway") - } - } -} - -/// Retains the selected process proof instead of reducing it to file fields. -fn validated_attachment(file: GatewayDiscoveryFile) -> anyhow::Result { - ValidatedConnection::validate(file) - .map(GatewayAttachment::Sidecar) - .context("validate the selected gateway process identity") -} - -fn validated_recovery_attachment(recovery: RecoveryLaunch) -> anyhow::Result { - match recovery { - RecoveryLaunch::Attached(file) => validated_attachment(file), - RecoveryLaunch::Launched { child_pid, file } => { - let validated = ValidatedConnection::validate(file) - .context("validate the launched gateway process identity")?; - Ok( - match RecoveryCandidate::authenticate(child_pid, validated) { - RecoveryOwnership::Owned(candidate) => GatewayAttachment::Launched(candidate), - RecoveryOwnership::Unowned(unowned) => GatewayAttachment::Sidecar(unowned), - }, - ) - } - } -} - -/// Chooses attach, launch, configured fallback, or failure. -pub(super) fn plan_gateway( - run_dir: &Path, - installed: Option, - explicit_config: bool, - resolve: fn(&Path) -> Result, -) -> GatewayPlan { - match resolve(run_dir) { - Ok(Resolution::Attach(file)) => return GatewayPlan::Attach(file), - Ok(_) => {} - Err(error) => { - eprintln!("could not resolve the gateway discovery file: {error}"); - } - } - match installed { - Some(exe) => GatewayPlan::Launch(exe), - None if explicit_config => GatewayPlan::ConfigOnly, - None => GatewayPlan::Fail, - } -} - -/// Locates the installed Gateway for the running Workshop: beside it on -/// Windows and in development, as the sibling `Runvil Gateway.app` on -/// macOS, and beside `Runvil.AppImage` on Linux, where Workshop runs from -/// the AppImage's mount. -/// -/// # Errors -/// Returns an error when the executable path cannot be read. -pub(super) fn installed_gateway() -> anyhow::Result> { - let exe = std::env::current_exe().context("locate the executable")?; - let appimage = workshop_server_api::discovery::running_appimage( - &exe, - std::env::var_os("APPIMAGE").as_deref(), - std::env::var_os("APPDIR").as_deref(), - ); - Ok(workshop_server_api::discovery::installed_gateway( - &exe, - appimage.as_deref(), - )) -} - -/// The text `tools/fetch-gateway-sidecar.mjs --stub` writes, and the only -/// content a file may hold and still be called a Gateway. Checked before a -/// launch because Windows answers a text file with "This version of %1 is -/// not compatible with the version of Windows you're running" and no clue -/// which file was at fault. -const STUB_PREFIX: &str = "This is a placeholder staged by"; - -/// Whether the installed Gateway is a compile-only stub rather than an -/// executable. A real Gateway starts with `MZ` on Windows and something -/// other than this text everywhere else, so a byte-prefix compare is the -/// whole test. -pub(super) fn sibling_is_placeholder(exe: &Path) -> bool { - use std::io::Read as _; - let Ok(mut file) = std::fs::File::open(exe) else { - return false; - }; - let mut prefix = vec![0_u8; STUB_PREFIX.len()]; - let Ok(read) = file.read(&mut prefix) else { - return false; - }; - prefix.truncate(read); - prefix == STUB_PREFIX.as_bytes() -} - -/// The failure for a stub sidecar, naming the file and the one command that -/// replaces it with a runnable Gateway. -pub(super) fn placeholder_gateway_error(exe: &Path) -> anyhow::Error { - anyhow::anyhow!( - "the gateway beside this program is a compile-only stub, not an executable: {}; \ - run `node tools/fetch-gateway-sidecar.mjs` from the repository root to stage the \ - pinned Gateway release, then rebuild", - exe.display() - ) -} - -/// Builds the loud boot failure naming both supported remedies. -pub(super) fn no_gateway_error() -> anyhow::Error { - anyhow::anyhow!( - "no gateway configured or running; install the Gateway component, which \ - puts runvil-gateway where Workshop looks for it (in a development tree, \ - build with `cargo workshop`, which places it beside Workshop), or set \ - gateway.base_url and gateway.api_key in workshop.toml to attach to \ - a gateway over the network" - ) -} - -#[cfg(windows)] -pub(super) fn spawn_detached_windows_with(mut spawn: Spawn) -> std::io::Result -where - Spawn: FnMut(u32) -> std::io::Result, -{ - match spawn(WINDOWS_BREAKAWAY_CREATION_FLAGS) { - Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => { - // Hosted runners can forbid breakaway from their job object. - // https://github.com/actions/runner/issues/595 - spawn(WINDOWS_DETACHED_CREATION_FLAGS) - } - result => result, - } -} - -fn detached_command(exe: &Path) -> std::process::Command { - let mut command = std::process::Command::new(exe); - command - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - command -} - -/// Spawns the Gateway detached from the desktop app's lifetime. The reaper -/// thread records the child's exit status in `exit`, so a launch wait can -/// stop as soon as the child dies instead of running out its budget. -pub(super) fn spawn_detached(exe: &Path, exit: Arc>) -> std::io::Result { - #[cfg(windows)] - let mut child = spawn_detached_windows_with(|flags| { - use std::os::windows::process::CommandExt as _; - detached_command(exe).creation_flags(flags).spawn() - })?; - #[cfg(unix)] - let mut child = { - use std::os::unix::process::CommandExt as _; - let mut command = detached_command(exe); - command.process_group(0); - command.spawn()? - }; - #[cfg(not(any(windows, unix)))] - let mut child = detached_command(exe).spawn()?; - let child_pid = child.id(); - if let Err(error) = std::thread::Builder::new().spawn(move || { - if let Ok(status) = child.wait() { - let _ = exit.set(status); - } - }) { - eprintln!("could not spawn the gateway reaper thread; the child goes unreaped: {error}"); - } - Ok(child_pid) -} diff --git a/crates/workshop/desktop/src/gateway/identity.rs b/crates/workshop/desktop/src/gateway/identity.rs deleted file mode 100644 index 57e889ac1..000000000 --- a/crates/workshop/desktop/src/gateway/identity.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Local Gateway attachment and validated process identity. - -use workshop_server_api::discovery::ValidatedConnection; - -use super::supervisor::RecoveryCandidate; - -/// How boot connected the Gateway. -#[derive(Debug)] -pub(crate) enum GatewayAttachment { - /// A local sidecar Gateway the desktop app attached to. - Sidecar(ValidatedConnection), - /// A child launched by this boot that has not yet entered server state. - Launched(RecoveryCandidate), - /// An explicit-config Gateway that the desktop app does not own. - Config, -} - -impl GatewayAttachment { - /// Returns the validated sidecar identity, when the Gateway is local. - pub(crate) fn sidecar_identity(&self) -> Option<&ValidatedConnection> { - match self { - Self::Sidecar(identity) => Some(identity), - Self::Launched(candidate) => Some(candidate.validated()), - Self::Config => None, - } - } - - /// Reconciles the desktop app's candidate with the identity the server actually - /// published, disarming launched-child cleanup only for an exact match. - pub(crate) fn reconcile_publication(self, published: Option) -> Self { - match (self, published) { - (Self::Launched(mut candidate), Some(published)) - if candidate.validated().same_boot(&published) => - { - candidate.mark_published(); - Self::Launched(candidate) - } - (_, Some(published)) => Self::Sidecar(published), - (_, None) => Self::Config, - } - } -} - -/// Whether two capabilities prove the same Gateway process boot. -pub(super) fn same_gateway_identity( - left: &ValidatedConnection, - right: &ValidatedConnection, -) -> bool { - left.same_boot(right) -} diff --git a/crates/workshop/desktop/src/gateway/supervisor.rs b/crates/workshop/desktop/src/gateway/supervisor.rs deleted file mode 100644 index 3264b162c..000000000 --- a/crates/workshop/desktop/src/gateway/supervisor.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Continuous local Gateway supervision and recovery. - -use std::time::Duration; - -use anyhow::Context as _; -use workshop_server_api::discovery::{ - CancellationToken, Resolution, SidecarError, ValidatedConnection, -}; - -use super::boot; -use super::identity::GatewayAttachment; - -mod launch; -mod lifecycle; -mod recovery; -mod signals; - -#[cfg(test)] -pub(super) use launch::{ - RECOVERY_POLL_INTERVAL, SystemClock, WaitClock, launch_and_attach_cancellable_with, - run_effect_if_active, wait_for_launched_file_cancellable_with, -}; -#[cfg(test)] -pub(super) use lifecycle::SUPERVISOR_SHUTDOWN_BUDGET; -pub(crate) use lifecycle::{GatewaySupervisor, SupervisorShutdown}; -use recovery::RecoveryIdentity; -pub(in crate::gateway) use recovery::{RecoveryCandidate, RecoveryOwnership}; - -pub(super) use launch::launch_and_attach_cancellable; - -/// Healthy-sidecar supervision cadence. -const SUPERVISION_INTERVAL: Duration = Duration::from_secs(5); - -/// First delay after a failed re-resolution or relaunch. -const SUPERVISION_BASE_DELAY: Duration = Duration::from_millis(250); - -/// Ceiling on repeated sidecar recovery attempts. -pub(super) const SUPERVISION_MAX_DELAY: Duration = Duration::from_secs(30); - -/// One sidecar liveness observation. -pub(super) enum SupervisionProbe { - /// Another process already published a live replacement. - Replacement(Identity), - /// No live local Gateway is currently discoverable. - Missing, -} - -/// A validated identity retained across supervision classifications. -pub(super) trait SupervisedGatewayIdentity { - /// Whether both values prove the same process boot. - fn same_boot(&self, other: &Self) -> bool; - - /// Disarms cleanup after this identity becomes authoritative. - fn publication_succeeded(&mut self) {} - - /// Shuts down an unpublished owned child through the explicit, - /// error-reporting path. Identities that own no child do nothing. - fn shutdown_unpublished(self) - where - Self: Sized, - { - } -} - -/// Starts runtime supervision only for a gateway discovery file sidecar. -/// -/// # Errors -/// Returns an error when the supervisor cannot locate its runtime paths or -/// spawn its owned thread. -pub(crate) fn supervise( - attachment: &GatewayAttachment, - updater: workshop_server_api::GatewayUpdater, -) -> anyhow::Result> { - let Some(initial) = attachment.sidecar_identity().cloned() else { - return Ok(None); - }; - let run_dir = workshop_server_api::discovery::default_run_dir() - .context("locate the sidecar run directory")?; - let sibling = boot::installed_gateway()?; - let supervisor_publication = updater.clone(); - GatewaySupervisor::spawn_with_publication(supervisor_publication, move |cancellation| { - run_supervision( - RecoveryIdentity::Stable(initial), - |_, cancellation| match workshop_server_api::discovery::resolve_cancellable( - &run_dir, - cancellation, - ) { - Ok(Resolution::Attach(file)) => { - match ValidatedConnection::validate_cancellable(file, cancellation) { - Ok(identity) => { - SupervisionProbe::Replacement(RecoveryIdentity::Stable(identity)) - } - Err(error) => { - eprintln!("could not retain the replacement gateway identity: {error}"); - SupervisionProbe::Missing - } - } - } - Ok(_) | Err(SidecarError::Canceled) => SupervisionProbe::Missing, - Err(error) => { - eprintln!("could not re-resolve the local gateway: {error}"); - SupervisionProbe::Missing - } - }, - |cancellation| { - let exe = sibling.as_deref().context( - "the local gateway disappeared and no gateway executable is installed", - )?; - let recovery = launch_and_attach_cancellable(&run_dir, exe, cancellation)?; - validate_recovery(recovery, cancellation) - }, - |identity, cancellation| { - if cancellation.is_canceled() { - anyhow::bail!("gateway publication was canceled"); - } - if updater.publication_closed() { - anyhow::bail!("gateway publication is closed"); - } - if updater - .replace_sidecar_cancellable(identity.validated(), cancellation) - .context("publish the replacement gateway endpoint")? - { - Ok(()) - } else { - anyhow::bail!("gateway publication was canceled") - } - }, - |delay, cancellation| cancellation.wait_timeout(delay), - &cancellation, - ); - }) - .map(Some) -} - -/// Validates a recovery result and authenticates child ownership by exact pid. -pub(super) fn validate_recovery( - recovery: boot::RecoveryLaunch, - cancellation: &CancellationToken, -) -> anyhow::Result { - let (child_pid, file) = match recovery { - boot::RecoveryLaunch::Attached(file) => (None, file), - boot::RecoveryLaunch::Launched { child_pid, file } => (Some(child_pid), file), - }; - let validated = ValidatedConnection::validate_cancellable(file, cancellation) - .context("retain the recovered gateway identity")?; - Ok(match child_pid { - Some(child_pid) => match RecoveryCandidate::authenticate(child_pid, validated) { - RecoveryOwnership::Owned(candidate) => RecoveryIdentity::Candidate(candidate), - RecoveryOwnership::Unowned(unowned) => RecoveryIdentity::Stable(unowned), - }, - None => RecoveryIdentity::Stable(validated), - }) -} - -/// Runs the supervision state machine with I/O injected for tests. -pub(super) fn run_supervision( - mut current: Identity, - mut probe: Probe, - mut recover: Recover, - mut publish: Publish, - mut wait: Wait, - cancellation: &CancellationToken, -) where - Identity: SupervisedGatewayIdentity, - Probe: FnMut(&Identity, &CancellationToken) -> SupervisionProbe, - Recover: FnMut(&CancellationToken) -> Result, - Publish: FnMut(&Identity, &CancellationToken) -> Result<(), Error>, - Wait: FnMut(Duration, &CancellationToken) -> bool, - Error: std::fmt::Display, -{ - let mut retry_delay = SUPERVISION_BASE_DELAY; - loop { - if cancellation.is_canceled() { - return; - } - let observation = probe(¤t, cancellation); - if cancellation.is_canceled() { - return; - } - match observation { - SupervisionProbe::Replacement(identity) if identity.same_boot(¤t) => { - retry_delay = SUPERVISION_BASE_DELAY; - if wait(SUPERVISION_INTERVAL, cancellation) { - return; - } - continue; - } - SupervisionProbe::Replacement(mut identity) => match publish(&identity, cancellation) { - Ok(()) => { - identity.publication_succeeded(); - if cancellation.is_canceled() { - return; - } - current = identity; - retry_delay = SUPERVISION_BASE_DELAY; - continue; - } - Err(error) => { - eprintln!("could not publish a replacement local gateway: {error}"); - } - }, - SupervisionProbe::Missing => match recover(cancellation) { - Ok(_) if cancellation.is_canceled() => return, - Ok(mut identity) => match publish(&identity, cancellation) { - Ok(()) => { - identity.publication_succeeded(); - if cancellation.is_canceled() { - return; - } - current = identity; - retry_delay = SUPERVISION_BASE_DELAY; - continue; - } - Err(error) => { - eprintln!("could not publish a replacement local gateway: {error}"); - // A recovered child this process launched stays - // unpublished, so it is shut down through the - // explicit, error-reporting path. - identity.shutdown_unpublished(); - } - }, - Err(error) => { - eprintln!("could not recover the local gateway: {error}"); - } - }, - } - if cancellation.is_canceled() || wait(retry_delay, cancellation) { - return; - } - retry_delay = retry_delay.saturating_mul(2).min(SUPERVISION_MAX_DELAY); - } -} diff --git a/crates/workshop/desktop/src/gateway/supervisor/launch.rs b/crates/workshop/desktop/src/gateway/supervisor/launch.rs deleted file mode 100644 index ffe36562a..000000000 --- a/crates/workshop/desktop/src/gateway/supervisor/launch.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Cancellable launch and readiness wait, shared by boot and recovery. - -use std::path::Path; -use std::process::ExitStatus; -use std::sync::{Arc, OnceLock}; -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use workshop_server_api::discovery::{ - CancellationToken, GatewayDiscoveryFile, LaunchDecision, Resolution, SidecarError, -}; - -use super::boot; - -/// Budget for the launch-race and readiness phases. -const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30); - -/// Delay between readiness polls. -pub(in crate::gateway) const RECOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); - -/// Longest single health probe, so a discovery file rewritten while a -/// probe is failing is read again well before the readiness budget ends. -const HEALTH_PROBE_WINDOW: Duration = Duration::from_millis(250); - -/// The readiness wait's only source of time. -pub(in crate::gateway) trait WaitClock { - /// The current instant. - fn now(&self) -> Instant; - - /// Pauses for `delay`, returning `true` when `cancellation` fires first. - fn pause(&self, delay: Duration, cancellation: &CancellationToken) -> bool; -} - -/// The monotonic clock and the token's own timed wait. -pub(in crate::gateway) struct SystemClock; - -impl WaitClock for SystemClock { - fn now(&self) -> Instant { - Instant::now() - } - - fn pause(&self, delay: Duration, cancellation: &CancellationToken) -> bool { - cancellation.wait_timeout(delay) - } -} - -/// Settles and performs a cancellable launch. Boot passes a token that is -/// never canceled. -pub(in crate::gateway) fn launch_and_attach_cancellable( - run_dir: &Path, - exe: &Path, - cancellation: &CancellationToken, -) -> anyhow::Result { - let exit = Arc::new(OnceLock::new()); - launch_and_attach_cancellable_with( - run_dir, - exe, - cancellation, - workshop_server_api::discovery::launch_or_attach_cancellable, - |exe, _| boot::spawn_detached(exe, Arc::clone(&exit)), - |run_dir, timeout, cancellation| { - wait_for_launched_file_cancellable(run_dir, timeout, cancellation, || { - exit.get().copied() - }) - }, - ) -} - -/// Launch with each blocking phase injected. -pub(in crate::gateway) fn launch_and_attach_cancellable_with( - run_dir: &Path, - exe: &Path, - cancellation: &CancellationToken, - settle: Settle, - spawn: Spawn, - wait: Wait, -) -> anyhow::Result -where - Settle: FnOnce(&Path, Duration, &CancellationToken) -> Result, - Spawn: FnOnce(&Path, &CancellationToken) -> std::io::Result, - Wait: FnOnce(&Path, Duration, &CancellationToken) -> anyhow::Result, -{ - match settle(run_dir, RECOVERY_TIMEOUT, cancellation) - .context("settle the gateway launch race")? - { - LaunchDecision::Attach(file) => { - if cancellation.is_canceled() { - anyhow::bail!("gateway attachment was canceled"); - } - Ok(boot::RecoveryLaunch::Attached(file)) - } - LaunchDecision::Launch(lock) => { - let child_pid = run_effect_if_active(cancellation, "gateway launch", |cancellation| { - if cancellation.is_canceled() { - anyhow::bail!("gateway launch was canceled"); - } - spawn(exe, cancellation).with_context(|| format!("spawn {}", exe.display())) - })?; - let file = wait(run_dir, RECOVERY_TIMEOUT, cancellation)?; - drop(lock); - Ok(boot::RecoveryLaunch::Launched { child_pid, file }) - } - decision => anyhow::bail!("an unknown launch decision: {decision:?}"), - } -} - -/// Linearizes one externally visible recovery effect with cancellation. -pub(in crate::gateway) fn run_effect_if_active( - cancellation: &CancellationToken, - phase: &'static str, - operation: impl FnOnce(&CancellationToken) -> anyhow::Result, -) -> anyhow::Result { - match cancellation.run_if_active(|| operation(cancellation)) { - Some(result) => result, - None => anyhow::bail!("{phase} was canceled"), - } -} - -/// Waits for a launched Gateway with production probes. -fn wait_for_launched_file_cancellable( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, - exited: impl FnMut() -> Option, -) -> anyhow::Result { - wait_for_launched_file_cancellable_with( - run_dir, - timeout, - cancellation, - &SystemClock, - workshop_server_api::discovery::wait_for_health_cancellable, - workshop_server_api::discovery::resolve_cancellable, - exited, - ) -} - -/// Readiness wait with time, health, validation, and the launched child's -/// exit status injected. A failed probe does not end the wait; the budget -/// does, reporting the last probe error. A child that has exited ends the -/// wait at once when no discovery file exists, naming the gateway log. -pub(in crate::gateway) fn wait_for_launched_file_cancellable_with( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, - clock: &Clock, - mut health: Health, - mut resolve: Resolve, - mut exited: Exited, -) -> anyhow::Result -where - Clock: WaitClock, - Health: FnMut( - &str, - Duration, - &CancellationToken, - ) -> Result<(), workshop_server_api::discovery::HealthError>, - Resolve: FnMut(&Path, &CancellationToken) -> Result, - Exited: FnMut() -> Option, -{ - let deadline = clock.now() + timeout; - let mut last_probe_error = None; - loop { - if cancellation.is_canceled() { - anyhow::bail!("the launched gateway wait was canceled"); - } - // Sampled before the read: a launched gateway that hands off to a - // running owner exits only after the owner publishes, so the read - // below sees the owner's file. - let exit = exited(); - if let Ok(Some(file)) = GatewayDiscoveryFile::read(run_dir) { - let remaining = deadline.saturating_duration_since(clock.now()); - let url = format!("http://127.0.0.1:{}", file.port); - match health(&url, remaining.min(HEALTH_PROBE_WINDOW), cancellation) { - Ok(()) => { - if cancellation.is_canceled() { - anyhow::bail!("the launched gateway wait was canceled"); - } - match resolve(run_dir, cancellation) { - Ok(Resolution::Attach(validated)) => { - if cancellation.is_canceled() { - anyhow::bail!("the launched gateway wait was canceled"); - } - return Ok(validated); - } - Err(SidecarError::Canceled) => { - anyhow::bail!("the launched gateway wait was canceled"); - } - Ok(Resolution::Stale(reason)) => { - last_probe_error = - Some(anyhow::Error::new(reason).context( - "the launched gateway discovery file failed validation", - )); - } - Err(error) => { - last_probe_error = Some( - anyhow::Error::new(error) - .context("resolve the launched gateway discovery file"), - ); - } - Ok(_) => {} - } - } - Err(workshop_server_api::discovery::HealthError::Canceled) => { - anyhow::bail!("the launched gateway wait was canceled"); - } - Err(error) => { - last_probe_error = Some( - anyhow::Error::new(error) - .context("the launched gateway did not answer its health probe"), - ); - } - } - } else if let Some(status) = exit { - let state_dir = run_dir.parent().unwrap_or(run_dir); - anyhow::bail!( - "the launched gateway exited ({status}) without writing a gateway discovery \ - file; see the gateway log at {}", - workshop_server_api::discovery::gateway_log_path(state_dir).display() - ); - } - if cancellation.is_canceled() { - anyhow::bail!("the launched gateway wait was canceled"); - } - if clock.now() >= deadline { - let failure = format!( - "the launched gateway wrote no validated gateway discovery file within {timeout:?}" - ); - return Err(match last_probe_error { - Some(error) => error.context(failure), - None => anyhow::anyhow!(failure), - }); - } - if clock.pause(RECOVERY_POLL_INTERVAL, cancellation) { - anyhow::bail!("the launched gateway wait was canceled"); - } - } -} diff --git a/crates/workshop/desktop/src/gateway/supervisor/lifecycle.rs b/crates/workshop/desktop/src/gateway/supervisor/lifecycle.rs deleted file mode 100644 index 6eb41e47f..000000000 --- a/crates/workshop/desktop/src/gateway/supervisor/lifecycle.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Supervisor thread lifecycle and bounded shutdown. - -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use workshop_server_api::discovery::CancellationToken; - -use super::signals::{Completion, StopSignal}; - -/// Maximum designed supervisor shutdown latency. -pub(in crate::gateway) const SUPERVISOR_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); - -/// The running local-sidecar supervisor. -#[derive(Debug)] -pub(crate) struct GatewaySupervisor { - stop: StopSignal, - completion: Completion, - stop_bridge_completion: Completion, - thread: Option>, - stop_bridge: Option>, - publication: Option, - shutdown_budget: Duration, -} - -/// How bounded supervisor shutdown ended. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SupervisorShutdown { - /// The worker completed and joined normally. - Joined, - /// The worker completed but panicked. - Panicked, - /// The deadline elapsed, so the worker handle was detached. - Detached, -} - -impl GatewaySupervisor { - /// Spawns one owned supervisor thread. - #[cfg(test)] - pub(in crate::gateway) fn spawn( - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - Self::spawn_inner(None, SUPERVISOR_SHUTDOWN_BUDGET, supervise) - } - - pub(in crate::gateway) fn spawn_with_publication( - publication: workshop_server_api::GatewayUpdater, - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - Self::spawn_inner(Some(publication), SUPERVISOR_SHUTDOWN_BUDGET, supervise) - } - - #[cfg(test)] - pub(in crate::gateway) fn spawn_with_budget( - shutdown_budget: Duration, - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - Self::spawn_inner(None, shutdown_budget, supervise) - } - - fn spawn_inner( - publication: Option, - shutdown_budget: Duration, - supervise: impl FnOnce(CancellationToken) + Send + 'static, - ) -> anyhow::Result { - let cancellation = CancellationToken::new(); - let stop = StopSignal::default(); - let worker_stop = stop.clone(); - let completion = Completion::default(); - let worker_completion = completion.clone(); - let stop_bridge_completion = Completion::default(); - let bridge_completion = stop_bridge_completion.clone(); - let bridge_cancellation = cancellation.clone(); - let stop_bridge = std::thread::Builder::new() - .name("gateway-supervisor-stop".to_owned()) - .spawn(move || { - let _completion = bridge_completion.guard(); - worker_stop.wait(); - bridge_cancellation.cancel(); - }) - .context("spawn the gateway supervisor stop bridge")?; - let thread = match std::thread::Builder::new() - .name("gateway-supervisor".to_owned()) - .spawn(move || { - let _completion = worker_completion.guard(); - supervise(cancellation); - }) { - Ok(thread) => thread, - Err(source) => { - stop.signal(); - let error = anyhow::Error::new(source).context("spawn the gateway supervisor"); - return match stop_bridge.join() { - Ok(()) => Err(error), - Err(_) => Err(error.context( - "the gateway supervisor stop bridge panicked during spawn rollback", - )), - }; - } - }; - Ok(Self { - stop, - completion, - stop_bridge_completion, - thread: Some(thread), - stop_bridge: Some(stop_bridge), - publication, - shutdown_budget, - }) - } - - /// Revokes publication, requests stop, and waits at most one deadline. - /// - /// This is the blocking, outcome-reporting path; `Drop` only signals - /// and detaches. - pub(crate) fn shutdown(mut self) -> SupervisorShutdown { - self.stop_and_join() - } - - fn stop_and_join(&mut self) -> SupervisorShutdown { - let deadline = Instant::now() + self.shutdown_budget; - if let Some(publication) = self.publication.as_ref() { - publication.close_publication(); - } - self.stop.signal(); - let thread = self.thread.take(); - let stop_bridge = self.stop_bridge.take(); - let (Some(thread), Some(stop_bridge)) = (thread, stop_bridge) else { - return SupervisorShutdown::Joined; - }; - if !self.completion.wait_until(deadline) - || !self.stop_bridge_completion.wait_until(deadline) - { - drop(thread); - drop(stop_bridge); - return SupervisorShutdown::Detached; - } - match (thread.join(), stop_bridge.join()) { - (Ok(()), Ok(())) => SupervisorShutdown::Joined, - (Err(_), _) | (_, Err(_)) => SupervisorShutdown::Panicked, - } - } - - #[cfg(test)] - pub(in crate::gateway) fn completion_waker_for_test(&self) -> impl Fn() + Send + 'static { - let completion = self.completion.clone(); - move || completion.wake() - } -} - -impl Drop for GatewaySupervisor { - fn drop(&mut self) { - // `shutdown()` is the bounded, outcome-reporting path. Drop can - // neither wait nor report, so it revokes publication, signals the - // stop, and detaches both threads; the worker captures only owned - // state, so a detached thread finishes on its own. - if let Some(publication) = self.publication.as_ref() { - publication.close_publication(); - } - self.stop.signal(); - drop(self.thread.take()); - drop(self.stop_bridge.take()); - } -} diff --git a/crates/workshop/desktop/src/gateway/supervisor/recovery.rs b/crates/workshop/desktop/src/gateway/supervisor/recovery.rs deleted file mode 100644 index 609b81a21..000000000 --- a/crates/workshop/desktop/src/gateway/supervisor/recovery.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Recovery-candidate ownership and identity classification. - -use std::time::{Duration, Instant}; - -use workshop_server_api::discovery::{ShutdownError, ValidatedConnection}; - -use super::super::identity::same_gateway_identity; -use super::SupervisedGatewayIdentity; - -/// Separate bound for authenticated cleanup of an unpublished owned child. -const LATE_CHILD_SHUTDOWN_BUDGET: Duration = Duration::from_secs(1); - -/// A validated recovery process whose pid proves it is the child we spawned. -#[derive(Debug)] -pub(crate) struct RecoveryCandidate { - child_pid: u32, - validated: ValidatedConnection, - published: bool, -} - -pub(in crate::gateway) enum RecoveryOwnership { - Owned(RecoveryCandidate), - Unowned(ValidatedConnection), -} - -impl RecoveryCandidate { - /// Claims cleanup authority only when validation names the spawned pid. - pub(in crate::gateway) fn authenticate( - child_pid: u32, - validated: ValidatedConnection, - ) -> RecoveryOwnership { - if validated.pid() != child_pid { - return RecoveryOwnership::Unowned(validated); - } - RecoveryOwnership::Owned(Self { - child_pid, - validated, - published: false, - }) - } - - pub(in crate::gateway) fn validated(&self) -> &ValidatedConnection { - &self.validated - } - - pub(in crate::gateway) fn mark_published(&mut self) { - self.published = true; - } - - /// Shuts down the unpublished recovered child within the late-child - /// budget. - /// - /// This is the blocking, error-reporting path; `Drop` only signals on - /// a detached thread. The drop signal is disarmed either way: the - /// caller receives the outcome, so a failed delivery is reported here - /// rather than retried silently. - pub(in crate::gateway) fn shutdown(mut self) -> Result<(), ShutdownError> { - if self.published { - return Ok(()); - } - debug_assert_eq!(self.child_pid, self.validated.pid()); - self.published = true; - let deadline = Instant::now() + LATE_CHILD_SHUTDOWN_BUDGET; - workshop_server_api::discovery::request_shutdown_before(&self.validated, deadline) - } -} - -impl Drop for RecoveryCandidate { - fn drop(&mut self) { - if self.published { - return; - } - debug_assert_eq!(self.child_pid, self.validated.pid()); - // Drop can neither block nor report: the bounded authenticated - // request runs on a detached thread, so a missed explicit - // `shutdown()` still signals the unpublished gateway process. - let validated = self.validated.clone(); - let signalled = std::thread::Builder::new() - .name("gateway-late-child-shutdown".to_owned()) - .spawn(move || { - let deadline = Instant::now() + LATE_CHILD_SHUTDOWN_BUDGET; - if let Err(error) = - workshop_server_api::discovery::request_shutdown_before(&validated, deadline) - { - // The detached signal has no error return channel, so - // diagnostics are the only place this cleanup failure - // can surface. - eprintln!("could not shut down an unpublished recovered gateway: {error}"); - } - }); - if let Err(error) = signalled { - eprintln!("could not signal an unpublished recovered gateway: {error}"); - } - } -} - -impl SupervisedGatewayIdentity for ValidatedConnection { - fn same_boot(&self, other: &Self) -> bool { - same_gateway_identity(self, other) - } -} - -#[derive(Debug)] -pub(in crate::gateway) enum RecoveryIdentity { - Stable(ValidatedConnection), - Candidate(RecoveryCandidate), -} - -impl RecoveryIdentity { - pub(super) fn validated(&self) -> &ValidatedConnection { - match self { - Self::Stable(validated) => validated, - Self::Candidate(candidate) => candidate.validated(), - } - } -} - -impl SupervisedGatewayIdentity for RecoveryIdentity { - fn same_boot(&self, other: &Self) -> bool { - self.validated().same_boot(other.validated()) - } - - fn publication_succeeded(&mut self) { - if let Self::Candidate(candidate) = self { - candidate.mark_published(); - } - } - - fn shutdown_unpublished(self) { - if let Self::Candidate(candidate) = self - && let Err(error) = candidate.shutdown() - { - eprintln!("could not shut down an unpublished recovered gateway: {error}"); - } - } -} diff --git a/crates/workshop/desktop/src/gateway/supervisor/signals.rs b/crates/workshop/desktop/src/gateway/supervisor/signals.rs deleted file mode 100644 index 374242065..000000000 --- a/crates/workshop/desktop/src/gateway/supervisor/signals.rs +++ /dev/null @@ -1,112 +0,0 @@ -//! Stop and completion signals for bounded supervisor shutdown. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Condvar, Mutex, PoisonError}; -use std::time::Instant; - -/// A stop request that never waits for in-progress supervisor work. -#[derive(Clone, Debug, Default)] -pub(super) struct StopSignal { - state: Arc, -} - -#[derive(Debug, Default)] -struct StopState { - requested: AtomicBool, - waiter: Mutex<()>, - wake: Condvar, -} - -impl StopSignal { - pub(super) fn signal(&self) { - let waiter = self - .state - .waiter - .lock() - .unwrap_or_else(PoisonError::into_inner); - self.state.requested.store(true, Ordering::SeqCst); - self.state.wake.notify_all(); - drop(waiter); - } - - pub(super) fn wait(&self) { - if self.state.requested.load(Ordering::SeqCst) { - return; - } - let waiter = self - .state - .waiter - .lock() - .unwrap_or_else(PoisonError::into_inner); - drop( - self.state - .wake - .wait_while(waiter, |()| !self.state.requested.load(Ordering::SeqCst)) - .unwrap_or_else(PoisonError::into_inner), - ); - } -} - -#[derive(Clone, Debug, Default)] -pub(super) struct Completion { - state: Arc, -} - -#[derive(Debug, Default)] -struct CompletionState { - finished: Mutex, - wake: Condvar, -} - -impl Completion { - pub(super) fn guard(&self) -> CompletionGuard { - CompletionGuard(self.clone()) - } - - pub(super) fn wait_until(&self, deadline: Instant) -> bool { - let mut finished = self - .state - .finished - .lock() - .unwrap_or_else(PoisonError::into_inner); - loop { - if *finished { - return true; - } - let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { - return false; - }; - if remaining.is_zero() { - return false; - } - let (next, timeout) = self - .state - .wake - .wait_timeout(finished, remaining) - .unwrap_or_else(PoisonError::into_inner); - finished = next; - if timeout.timed_out() && !*finished { - return false; - } - } - } - - #[cfg(test)] - pub(super) fn wake(&self) { - self.state.wake.notify_all(); - } -} - -pub(super) struct CompletionGuard(Completion); - -impl Drop for CompletionGuard { - fn drop(&mut self) { - *self - .0 - .state - .finished - .lock() - .unwrap_or_else(PoisonError::into_inner) = true; - self.0.state.wake.notify_all(); - } -} diff --git a/crates/workshop/desktop/src/gateway/tests.rs b/crates/workshop/desktop/src/gateway/tests.rs deleted file mode 100644 index a861bdc0f..000000000 --- a/crates/workshop/desktop/src/gateway/tests.rs +++ /dev/null @@ -1,216 +0,0 @@ -//! Shared fixtures for the split Gateway lifecycle tests. - -use std::io::{Read, Write as _}; -use std::net::{TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -use workshop_server_api::discovery::{ - GatewayDiscoveryFile, Resolution, SidecarError, ValidatedConnection, -}; - -use super::boot as gateway_boot; -use super::supervisor::{ - GatewaySupervisor, RecoveryCandidate, RecoveryOwnership, SupervisedGatewayIdentity, - SupervisorShutdown, -}; - -mod boot; -mod cancellation; -mod identity; -mod recovery; -mod shutdown; - -/// A supervised identity whose boot token the test chooses. -#[derive(Clone, Debug, PartialEq, Eq)] -struct TestIdentity { - file: GatewayDiscoveryFile, - process_boot: u64, -} - -impl SupervisedGatewayIdentity for TestIdentity { - fn same_boot(&self, other: &Self) -> bool { - self.process_boot == other.process_boot - && self.file.pid == other.file.pid - && self.file.epoch == other.file.epoch - && self.file.started_at == other.file.started_at - } -} - -fn test_identity(file: GatewayDiscoveryFile) -> TestIdentity { - TestIdentity { - process_boot: u64::from(file.pid), - file, - } -} - -/// Claims ownership of a validated child through its own pid. -fn owned_candidate(identity: ValidatedConnection) -> RecoveryCandidate { - match RecoveryCandidate::authenticate(identity.pid(), identity) { - RecoveryOwnership::Owned(candidate) => candidate, - RecoveryOwnership::Unowned(_) => panic!("the validated child pid authenticates ownership"), - } -} - -fn assert_bounded_supervisor_shutdown(supervisor: GatewaySupervisor, finished: &AtomicBool) { - assert_eq!( - supervisor.shutdown(), - SupervisorShutdown::Joined, - "Workshop exit joins the canceled supervisor within its budget" - ); - assert!( - finished.load(Ordering::SeqCst), - "shutdown returns only after the supervisor thread exits" - ); -} - -/// Resolves against the test process's own image. -fn probe_own_image(run_dir: &Path) -> Result { - let image = std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned(); - workshop_server_api::discovery::resolve_for_test(run_dir, &image) -} - -/// Plants an unreadable discovery-file path before resolving. -fn probe_read_failure(run_dir: &Path) -> Result { - std::fs::create_dir(workshop_server_api::discovery::gateway_discovery_file_path( - run_dir, - )) - .expect("plant the unreadable file"); - probe_own_image(run_dir) -} - -/// A gateway discovery file pointing at the test process itself. -fn live_file(port: u16, api_key: &str) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: api_key.to_owned(), - pid: std::process::id(), - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } -} - -/// Returns a pid whose short-lived child has been reaped. -fn dead_pid() -> u32 { - let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) - .arg("--list") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn a short-lived child"); - let pid = child.id(); - child.wait().expect("the child exits"); - pid -} - -/// Starts a lightweight same-process health and bearer fixture. -fn fixture_gateway(expected_key: impl Into) -> u16 { - let expected_key = Arc::new(expected_key.into()); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - let expected_key = Arc::clone(&expected_key); - std::thread::spawn(move || { - loop { - let mut buffer = [0_u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - break; - }; - if read == 0 { - break; - } - let request = String::from_utf8_lossy(&buffer[..read]); - let accepted = request.starts_with("GET /health ") - || accepts_bearer(&request, &expected_key); - let response = if accepted { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - if stream.write_all(response).is_err() { - break; - } - } - }); - } - }); - port -} - -fn accepts_bearer(request: &str, expected_key: &str) -> bool { - request.lines().any(|line| { - let Some((name, value)) = line.split_once(':') else { - return false; - }; - let Some((scheme, credential)) = value.trim_start().split_once(' ') else { - return false; - }; - name.eq_ignore_ascii_case("authorization") - && scheme.eq_ignore_ascii_case("bearer") - && credential == expected_key - }) -} - -/// Starts the shared named-process Gateway fixture in this test binary. -fn validated_gateway(expected_key: &str) -> workshop_server_api::fixtures::ValidatedGateway { - workshop_server_api::fixtures::ValidatedGateway::spawn_in( - expected_key, - "gateway::tests::validated_gateway_fixture_process", - ) -} - -#[test] -#[ignore = "runs only as a named child process"] -fn validated_gateway_fixture_process() { - workshop_server_api::fixtures::run_validated_gateway_fixture_process(); -} - -/// Sends one plain GET to the Workshop fixture. -fn get(url: &str, path: &str) -> String { - let url = url::Url::parse(url).expect("the Workshop URL parses"); - let host = url.host_str().expect("the Workshop URL has a host"); - let port = url.port().expect("the Workshop URL has a port"); - let mut stream = TcpStream::connect((host, port)).expect("connect to Workshop"); - write!( - stream, - "GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" - ) - .expect("send Workshop request"); - let mut response = String::new(); - stream - .read_to_string(&mut response) - .expect("read Workshop response"); - response -} - -/// An executable directory, with or without the Gateway beside Workshop. -fn exe_dir(with_gateway: bool) -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::TempDir::new().expect("tempdir"); - if with_gateway { - let exe = dir.path().join(gateway_boot::GATEWAY_EXE_NAME); - std::fs::write(&exe, b"").expect("plant the sibling exe"); - // The installed-Gateway lookup skips a file it cannot run. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)) - .expect("mark the sibling exe executable"); - } - } - let path = dir.path().to_owned(); - (dir, path) -} - -/// The Gateway the installed-Gateway lookup finds for a Workshop running -/// from `exe_dir`. -fn installed_in(exe_dir: &Path) -> Option { - workshop_server_api::discovery::installed_gateway(&exe_dir.join("runvil-workshop"), None) -} diff --git a/crates/workshop/desktop/src/gateway/tests/boot-launch-wait.rs b/crates/workshop/desktop/src/gateway/tests/boot-launch-wait.rs deleted file mode 100644 index 4a2b4ab92..000000000 --- a/crates/workshop/desktop/src/gateway/tests/boot-launch-wait.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Launch-wait coverage against a fake wait clock. - -use std::cell::Cell; -use std::path::Path; -use std::process::ExitStatus; -use std::time::{Duration, Instant}; - -use workshop_server_api::discovery::{ - CancellationToken, GatewayDiscoveryFile, HealthError, ProbeError, -}; - -use super::super::{fixture_gateway, live_file, probe_own_image}; -use crate::gateway::supervisor::{ - RECOVERY_POLL_INTERVAL, SystemClock, WaitClock, wait_for_launched_file_cancellable_with, -}; - -/// A discovery-file port the fake-clock tests never probe. -const UNPROBED_PORT: u16 = 9; - -/// A wait clock that advances only when the wait pauses, by exactly the -/// requested delay, and never reports cancellation. -struct FakeClock<'hook> { - now: Cell, - pauses: Cell, - on_pause: Option>, -} - -impl<'hook> FakeClock<'hook> { - fn new() -> Self { - Self { - now: Cell::new(Instant::now()), - pauses: Cell::new(0), - on_pause: None, - } - } - - /// Runs `hook` with the one-based pause number after each pause. - fn on_pause(hook: impl Fn(usize) + 'hook) -> Self { - Self { - on_pause: Some(Box::new(hook)), - ..Self::new() - } - } - - fn pauses(&self) -> usize { - self.pauses.get() - } -} - -impl WaitClock for FakeClock<'_> { - fn now(&self) -> Instant { - self.now.get() - } - - fn pause(&self, delay: Duration, _cancellation: &CancellationToken) -> bool { - let pause = self.pauses.get() + 1; - self.pauses.set(pause); - self.now.set(self.now.get() + delay); - if let Some(hook) = &self.on_pause { - hook(pause); - } - false - } -} - -/// A health failure built without any network probe. -fn probe_timeout(url: &str, status_line: String) -> HealthError { - HealthError::Timeout { - url: url.to_owned(), - timeout: Duration::from_millis(250), - source: ProbeError::UnexpectedStatus { status_line }, - } -} - -/// Runs the launch wait against the test binary's own process image, for a -/// launched child that never exits. -fn launch_wait_with( - run_dir: &Path, - budget: Duration, - clock: &Clock, - health: Health, -) -> anyhow::Result -where - Clock: WaitClock, - Health: FnMut(&str, Duration, &CancellationToken) -> Result<(), HealthError>, -{ - launch_wait_exiting(run_dir, budget, clock, health, || None) -} - -/// Runs the launch wait against the test binary's own process image, with -/// the launched child's exit status read from `exited`. -fn launch_wait_exiting( - run_dir: &Path, - budget: Duration, - clock: &Clock, - health: Health, - exited: Exited, -) -> anyhow::Result -where - Clock: WaitClock, - Health: FnMut(&str, Duration, &CancellationToken) -> Result<(), HealthError>, - Exited: FnMut() -> Option, -{ - wait_for_launched_file_cancellable_with( - run_dir, - budget, - &CancellationToken::new(), - clock, - health, - |run_dir, _| probe_own_image(run_dir), - exited, - ) -} - -/// The exit status of a child that exited with `code`. -#[cfg(windows)] -fn exit_status(code: u32) -> ExitStatus { - std::os::windows::process::ExitStatusExt::from_raw(code) -} - -/// The exit status of a child that exited with `code`. -#[cfg(unix)] -fn exit_status(code: i32) -> ExitStatus { - std::os::unix::process::ExitStatusExt::from_raw(code << 8) -} - -fn launch_wait(run_dir: &Path, budget: Duration) -> anyhow::Result { - launch_wait_with( - run_dir, - budget, - &SystemClock, - workshop_server_api::discovery::wait_for_health_cancellable, - ) -} - -#[test] -fn the_launch_wait_returns_once_the_file_appears_and_answers() { - let run = tempfile::TempDir::new().expect("tempdir"); - let file = live_file(fixture_gateway("key"), "key"); - let clock = FakeClock::on_pause(|pause| { - if pause == 1 { - file.write_to(run.path()) - .expect("the launched gateway writes"); - } - }); - - let waited = launch_wait_with( - run.path(), - Duration::from_secs(5), - &clock, - workshop_server_api::discovery::wait_for_health_cancellable, - ) - .expect("the validated file lands and answers"); - - assert_eq!(waited, file); - assert_eq!( - clock.pauses(), - 1, - "the file lands during the first pause and the next poll attaches" - ); -} - -#[test] -fn the_launch_wait_times_out_when_no_file_appears() { - let run = tempfile::TempDir::new().expect("tempdir"); - let error = launch_wait(run.path(), Duration::from_millis(150)) - .expect_err("a gateway that never writes must not hang boot"); - assert!( - error - .to_string() - .contains("no validated gateway discovery file"), - "the error names the missing file: {error}" - ); -} - -#[test] -fn the_launch_wait_rejects_a_key_the_live_process_does_not_accept() { - let run = tempfile::TempDir::new().expect("tempdir"); - live_file(fixture_gateway("accepted-key"), "rejected-key") - .write_to(run.path()) - .expect("write"); - - let error = launch_wait(run.path(), Duration::from_millis(150)) - .expect_err("an unaccepted discovery-file key must not publish"); - let message = format!("{error:#}"); - assert!( - message.contains("no validated gateway discovery file"), - "the error names the validation failure: {message}" - ); - assert!( - message.contains("bearer was rejected") && !message.contains("rejected-key"), - "the error reports the rejection without exposing the key: {message}" - ); -} - -#[test] -fn the_launch_wait_completes_when_a_dead_port_file_is_replaced_by_a_live_one() { - let run = tempfile::TempDir::new().expect("tempdir"); - let live = live_file(fixture_gateway("key"), "key"); - assert_ne!( - live.port, UNPROBED_PORT, - "the stale file names another port" - ); - live_file(UNPROBED_PORT, "key") - .write_to(run.path()) - .expect("write the stale file"); - let clock = FakeClock::new(); - let mut probes = 0; - - let waited = launch_wait_with( - run.path(), - Duration::from_secs(5), - &clock, - |url, budget, cancellation| { - probes += 1; - if probes == 1 { - assert_eq!(url, format!("http://127.0.0.1:{UNPROBED_PORT}")); - live.write_to(run.path()) - .expect("the gateway rewrites its discovery file"); - return Err(probe_timeout(url, "HTTP/1.1 503 Unavailable".to_owned())); - } - assert_eq!(url, format!("http://127.0.0.1:{}", live.port)); - workshop_server_api::discovery::wait_for_health_cancellable(url, budget, cancellation) - }, - ) - .expect("the wait polls past the stale file and attaches to the live gateway"); - - assert_eq!(waited, live); - assert_eq!( - probes, 2, - "one failed probe of the stale file, then one of the live gateway" - ); -} - -#[test] -fn the_launch_wait_fails_at_its_budget_with_the_last_probe_error() { - let run = tempfile::TempDir::new().expect("tempdir"); - live_file(UNPROBED_PORT, "key") - .write_to(run.path()) - .expect("write the stale file"); - let budget = Duration::from_millis(600); - let polls = usize::try_from( - budget - .as_nanos() - .div_ceil(RECOVERY_POLL_INTERVAL.as_nanos()), - ) - .expect("the poll count fits in usize"); - let clock = FakeClock::new(); - let mut probes = 0; - - let error = launch_wait_with(run.path(), budget, &clock, |url, _, _| { - probes += 1; - Err(probe_timeout(url, format!("HTTP/1.1 503 probe {probes}"))) - }) - .expect_err("a file that never names a live gateway must not hang boot"); - - assert_eq!( - probes, - polls + 1, - "one probe at the start and one after each poll interval up to the budget" - ); - assert_eq!( - clock.pauses(), - polls, - "the wait pauses only before the budget" - ); - let message = format!("{error:#}"); - assert!( - message.contains("no validated gateway discovery file"), - "the error names the budget failure: {message}" - ); - assert!( - message.contains(&format!( - "unexpected health response: HTTP/1.1 503 probe {probes}" - )), - "the error reports the last probe: {message}" - ); -} - -#[test] -fn the_launch_wait_fails_at_the_first_poll_after_the_gateway_exits_without_a_file() { - let state = tempfile::TempDir::new().expect("tempdir"); - let run_dir = state.path().join("run"); - std::fs::create_dir(&run_dir).expect("create the run directory"); - let clock = FakeClock::new(); - - let error = launch_wait_exiting( - &run_dir, - Duration::from_secs(30), - &clock, - |_, _, _| panic!("no discovery file means no health probe"), - || (clock.pauses() >= 2).then(|| exit_status(1)), - ) - .expect_err("a gateway that exits without a file must not hold boot for the budget"); - - assert_eq!( - clock.pauses(), - 2, - "the poll after the exit fails, long before the budget" - ); - let message = error.to_string(); - assert!( - message.contains(&format!("exited ({})", exit_status(1))), - "the error names the exit status: {message}" - ); - let log = workshop_server_api::discovery::gateway_log_path(state.path()); - assert!( - message.contains(&log.display().to_string()), - "the error names the gateway log: {message}" - ); -} - -#[test] -fn the_launch_wait_attaches_to_the_owner_a_launched_gateway_handed_off_to() { - let run = tempfile::TempDir::new().expect("tempdir"); - let owner = live_file(fixture_gateway("key"), "key"); - owner - .write_to(run.path()) - .expect("the owner publishes before the handed-off launch exits"); - - let waited = launch_wait_exiting( - run.path(), - Duration::from_secs(5), - &FakeClock::new(), - workshop_server_api::discovery::wait_for_health_cancellable, - || Some(exit_status(0)), - ) - .expect("an exited launch still attaches to the owner's file"); - - assert_eq!(waited, owner); -} diff --git a/crates/workshop/desktop/src/gateway/tests/boot.rs b/crates/workshop/desktop/src/gateway/tests/boot.rs deleted file mode 100644 index d06246f36..000000000 --- a/crates/workshop/desktop/src/gateway/tests/boot.rs +++ /dev/null @@ -1,377 +0,0 @@ -//! Boot planning and one-shot launch coverage. - -use std::path::PathBuf; -use std::sync::{Arc, OnceLock}; -use std::time::{Duration, Instant}; - -use workshop_server_api::discovery::GatewayDiscoveryFile; - -use super::{ - dead_pid, exe_dir, fixture_gateway, installed_in, live_file, owned_candidate, probe_own_image, - probe_read_failure, -}; -#[cfg(windows)] -use crate::gateway::boot::spawn_detached_windows_with; -use crate::gateway::boot::{ - GATEWAY_EXE_NAME, GatewayPlan, no_gateway_error, placeholder_gateway_error, plan_gateway, - sibling_is_placeholder, spawn_detached, -}; -use crate::gateway::identity::GatewayAttachment; -use crate::gateway::supervisor::RECOVERY_POLL_INTERVAL; - -#[path = "boot-launch-wait.rs"] -mod launch_wait; - -const FIXTURE_PHASE_TIMEOUT: Duration = Duration::from_secs(10); - -#[cfg(windows)] -#[test] -fn windows_detached_spawn_first_attempt_uses_all_required_flags() { - let mut attempts = Vec::new(); - - let child_pid = spawn_detached_windows_with(|flags| { - attempts.push(flags); - Ok(41) - }) - .expect("the first spawn succeeds"); - - assert_eq!(child_pid, 41); - assert_eq!(attempts, [0x0100_0208]); -} - -#[cfg(windows)] -#[test] -fn windows_detached_spawn_retries_access_denied_without_breakaway() { - let mut attempts = Vec::new(); - - let child_pid = spawn_detached_windows_with(|flags| { - attempts.push(flags); - if attempts.len() == 1 { - Err(std::io::Error::from_raw_os_error(5)) - } else { - Ok(42) - } - }) - .expect("access denied retries without breakaway"); - - assert_eq!(child_pid, 42); - assert_eq!(attempts, [0x0100_0208, 0x0000_0208]); -} - -#[cfg(windows)] -#[test] -fn windows_detached_spawn_does_not_retry_after_success() { - let mut attempts = 0; - - spawn_detached_windows_with(|_| { - attempts += 1; - Ok(43) - }) - .expect("the first spawn succeeds"); - - assert_eq!(attempts, 1); -} - -#[cfg(windows)] -#[test] -fn windows_detached_spawn_does_not_retry_other_errors() { - let mut attempts = 0; - - let error = spawn_detached_windows_with::(|_| { - attempts += 1; - Err(std::io::Error::from_raw_os_error(123)) - }) - .expect_err("non-permission errors propagate"); - - assert_eq!(error.raw_os_error(), Some(123)); - assert_eq!(attempts, 1); -} - -/// A program that exits by itself once it reads end of input from a null -/// stdin. -fn self_exiting_program() -> PathBuf { - if cfg!(windows) { - std::env::var_os("ComSpec").map_or_else(|| PathBuf::from("cmd.exe"), PathBuf::from) - } else { - PathBuf::from("/bin/sh") - } -} - -#[test] -fn the_reaper_records_the_exit_status_of_a_detached_child() { - let exit = Arc::new(OnceLock::new()); - spawn_detached(&self_exiting_program(), Arc::clone(&exit)) - .expect("spawn a child that exits by itself"); - - let deadline = Instant::now() + FIXTURE_PHASE_TIMEOUT; - while exit.get().is_none() { - assert!( - Instant::now() < deadline, - "the reaper records the child's exit within {FIXTURE_PHASE_TIMEOUT:?}" - ); - std::thread::sleep(RECOVERY_POLL_INTERVAL); - } -} - -fn workshop_server( - port: u16, - api_key: &str, -) -> (tempfile::TempDir, workshop_server_api::ServerHandle) { - let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); - let server = workshop_server_api::fixtures::spawn(workshop_server_api::Config { - gateway: workshop_server_api::GatewayConfig { - base_url: format!("http://127.0.0.1:{port}"), - api_key: api_key.to_owned(), - }, - server: workshop_server_api::ServerConfig { - bind: "127.0.0.1:0".to_owned(), - state_dir: state_dir.path().to_owned(), - }, - agents: workshop_server_api::AgentsConfig::default(), - }) - .expect("spawn Workshop fixture"); - (state_dir, server) -} - -#[test] -fn a_live_file_attaches_without_looking_for_a_sibling_exe() { - let run = tempfile::TempDir::new().expect("tempdir"); - let file = live_file(fixture_gateway("key"), "key"); - file.write_to(run.path()).expect("write"); - let (_exe, exe_dir) = exe_dir(false); - - match plan_gateway(run.path(), installed_in(&exe_dir), false, probe_own_image) { - GatewayPlan::Attach(attached) => assert_eq!(attached, file), - other => panic!("a live gateway must be attached, not {other:?}"), - } -} - -#[test] -fn no_file_and_a_sibling_exe_launches() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(true); - - match plan_gateway(run.path(), installed_in(&exe_dir), false, probe_own_image) { - GatewayPlan::Launch(exe) => assert_eq!(exe, exe_dir.join(GATEWAY_EXE_NAME)), - other => panic!("a full install with no running gateway must launch, not {other:?}"), - } -} - -#[test] -fn no_file_and_no_sibling_exe_falls_through_to_explicit_config() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(false); - - assert_eq!( - plan_gateway(run.path(), installed_in(&exe_dir), true, probe_own_image), - GatewayPlan::ConfigOnly, - "a Workshop-only install attaches to the configured LAN gateway" - ); -} - -#[test] -fn no_file_no_sibling_exe_and_no_config_fails() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(false); - - assert_eq!( - plan_gateway(run.path(), installed_in(&exe_dir), false, probe_own_image), - GatewayPlan::Fail, - "nothing to connect to must fail loud, not serve a broken window" - ); -} - -#[test] -fn a_stale_file_is_cleaned_and_the_sibling_exe_launches() { - let run = tempfile::TempDir::new().expect("tempdir"); - GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "k") - } - .write_to(run.path()) - .expect("write"); - let (_exe, exe_dir) = exe_dir(true); - - let plan = plan_gateway(run.path(), installed_in(&exe_dir), false, probe_own_image); - assert!( - matches!(plan, GatewayPlan::Launch(_)), - "a stale file must not block the relaunch: {plan:?}" - ); - assert!( - !workshop_server_api::discovery::gateway_discovery_file_path(run.path()).exists(), - "the stale file was cleaned" - ); -} - -#[test] -fn a_stale_file_with_no_sibling_exe_falls_through_to_explicit_config() { - let run = tempfile::TempDir::new().expect("tempdir"); - GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "k") - } - .write_to(run.path()) - .expect("write"); - let (_exe, exe_dir) = exe_dir(false); - - assert_eq!( - plan_gateway(run.path(), installed_in(&exe_dir), true, probe_own_image), - GatewayPlan::ConfigOnly, - "a stale file must not wedge the LAN fallback" - ); - assert!( - !workshop_server_api::discovery::gateway_discovery_file_path(run.path()).exists(), - "the stale file was cleaned" - ); -} - -#[test] -fn a_resolve_error_still_launches_the_sibling_exe() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(true); - - let plan = plan_gateway( - run.path(), - installed_in(&exe_dir), - false, - probe_read_failure, - ); - assert!( - matches!(plan, GatewayPlan::Launch(_)), - "a discovery error must not read as no-gateway: {plan:?}" - ); -} - -#[test] -fn the_installed_lookup_finds_only_the_gateway_exe_beside_the_desktop_app() { - let (_dir, with) = exe_dir(true); - assert_eq!( - installed_in(&with), - Some(with.join(GATEWAY_EXE_NAME)), - "the installed sibling is found" - ); - let (_dir, without) = exe_dir(false); - assert_eq!( - installed_in(&without), - None, - "a Workshop-only install has no Gateway" - ); -} - -#[test] -fn the_no_gateway_error_names_both_remedies() { - let message = no_gateway_error().to_string(); - assert!( - message.contains("runvil-gateway"), - "the error names the Gateway component remedy: {message}" - ); - assert!( - message.contains("workshop.toml"), - "the error names the explicit-config remedy: {message}" - ); -} - -/// The stub `tools/fetch-gateway-sidecar.mjs --stub` writes, which CI stages -/// so compile-only jobs need no Gateway release. -const STUB_TEXT: &str = "This is a placeholder staged by tools/fetch-gateway-sidecar.mjs --stub.\n\ - It stands in for the Gateway sidecar so compile-only jobs need no release.\n"; - -fn sibling_with(content: &[u8]) -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::TempDir::new().expect("tempdir"); - let exe = dir.path().join(GATEWAY_EXE_NAME); - std::fs::write(&exe, content).expect("write the sibling"); - (dir, exe) -} - -#[test] -fn a_stub_sibling_is_recognized_before_a_launch_attempt() { - let (_dir, exe) = sibling_with(STUB_TEXT.as_bytes()); - assert!( - sibling_is_placeholder(&exe), - "the compile-only stub must be caught before spawning it" - ); -} - -#[test] -fn a_real_executable_sibling_is_not_a_placeholder() { - let (_dir, exe) = sibling_with(b"MZ\x90\x00\x03\x00\x00\x00"); - assert!( - !sibling_is_placeholder(&exe), - "an executable is left to the spawn to judge" - ); -} - -#[test] -fn an_empty_sibling_is_not_treated_as_a_placeholder() { - let (_dir, exe) = sibling_with(b""); - assert!( - !sibling_is_placeholder(&exe), - "an unreadable prefix must not read as a stub" - ); -} - -#[test] -fn a_missing_sibling_is_not_treated_as_a_placeholder() { - let dir = tempfile::TempDir::new().expect("tempdir"); - assert!( - !sibling_is_placeholder(&dir.path().join(GATEWAY_EXE_NAME)), - "a file that is not there cannot be a stub" - ); -} - -#[test] -fn the_placeholder_error_names_the_file_and_the_fetch_command() { - let exe = PathBuf::from("/install/runvil-gateway"); - let message = placeholder_gateway_error(&exe).to_string(); - assert!( - message.contains("runvil-gateway"), - "the error names the file that is not runnable: {message}" - ); - assert!( - message.contains("node tools/fetch-gateway-sidecar.mjs"), - "the error names the one command that stages a real Gateway: {message}" - ); -} - -#[test] -fn matching_boot_publication_survives_closure_and_server_teardown() { - let mut launched = super::validated_gateway("launched-key"); - let identity = launched.validate("launched-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let attachment = GatewayAttachment::Launched(owned_candidate(identity.clone())); - let (_state_dir, server) = workshop_server(launched.port(), "launched-key"); - - let attachment = attachment.reconcile_publication(Some(identity)); - server.gateway_updater().close_publication(); - drop(attachment); - let outcome = server.shutdown().expect("server teardown continues"); - - assert_eq!(outcome, workshop_server_api::Termination::Graceful); - assert!( - !launched.received_shutdown(Duration::from_millis(100)), - "closure cannot clean up the exact child already published at boot" - ); -} - -#[test] -fn closed_boot_publication_cleans_unpublished_owner_then_continues_teardown() { - let mut launched = super::validated_gateway("launched-key"); - let published = super::validated_gateway("published-key"); - let launched_identity = - launched.validate("launched-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let published_identity = - published.validate("published-key", 1_778_000_002, "2026-09-08T18:00:02Z"); - let attachment = GatewayAttachment::Launched(owned_candidate(launched_identity)); - let (_state_dir, server) = workshop_server(published.port(), "published-key"); - - server.gateway_updater().close_publication(); - let attachment = attachment.reconcile_publication(Some(published_identity)); - drop(attachment); - assert!( - launched.received_shutdown(FIXTURE_PHASE_TIMEOUT), - "a closed boot cleans only its unpublished authenticated child" - ); - assert_eq!( - server.shutdown().expect("server teardown continues"), - workshop_server_api::Termination::Graceful - ); -} diff --git a/crates/workshop/desktop/src/gateway/tests/cancellation.rs b/crates/workshop/desktop/src/gateway/tests/cancellation.rs deleted file mode 100644 index 247ad6985..000000000 --- a/crates/workshop/desktop/src/gateway/tests/cancellation.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! Exit cancellation of a supervisor blocked inside each recovery phase. - -use std::path::Path; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; -use std::time::Duration; - -use workshop_server_api::discovery::Resolution; - -use super::{TestIdentity, assert_bounded_supervisor_shutdown, live_file, test_identity}; -use crate::gateway::supervisor::{ - GatewaySupervisor, SupervisionProbe, SystemClock, launch_and_attach_cancellable_with, - run_effect_if_active, run_supervision, wait_for_launched_file_cancellable_with, -}; - -#[test] -fn exit_joins_a_supervisor_blocked_in_resolve_before_recovery() { - let (entered, blocked) = mpsc::channel(); - let recoveries = Arc::new(AtomicUsize::new(0)); - let worker_recoveries = Arc::clone(&recoveries); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - test_identity(live_file(54_375, "stable-key")), - |_, cancellation| { - entered.send(()).expect("announce blocked resolve"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - SupervisionProbe::Missing - }, - |_| -> anyhow::Result { - worker_recoveries.fetch_add(1, Ordering::SeqCst); - anyhow::bail!("recovery must not start after cancellation") - }, - |_, _| Ok::<(), anyhow::Error>(()), - |delay, cancellation| cancellation.wait_timeout(delay), - &cancellation, - ); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the resolve phase blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - recoveries.load(Ordering::SeqCst), - 0, - "cancellation prevents every later recovery launch" - ); -} - -#[test] -fn exit_joins_a_supervisor_blocked_in_validation_before_publication() { - let (entered, blocked) = mpsc::channel(); - let publications = Arc::new(AtomicUsize::new(0)); - let worker_publications = Arc::clone(&publications); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - test_identity(live_file(54_375, "stable-key")), - |current, cancellation| { - entered.send(()).expect("announce blocked validation"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - SupervisionProbe::Replacement(current.clone()) - }, - |_| -> anyhow::Result { - panic!("canceled validation cannot start recovery") - }, - |_, _| { - worker_publications.fetch_add(1, Ordering::SeqCst); - Ok::<(), anyhow::Error>(()) - }, - |delay, cancellation| cancellation.wait_timeout(delay), - &cancellation, - ); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the validation phase blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - publications.load(Ordering::SeqCst), - 0, - "canceled validation cannot publish into the snapshot" - ); -} - -#[test] -fn exit_joins_a_supervisor_blocked_in_health_wait_before_resolve() { - let run = tempfile::TempDir::new().expect("tempdir"); - live_file(54_375, "stable-key") - .write_to(run.path()) - .expect("write candidate"); - let run_dir = run.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let resolves = Arc::new(AtomicUsize::new(0)); - let worker_resolves = Arc::clone(&resolves); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let result = wait_for_launched_file_cancellable_with( - &run_dir, - Duration::from_secs(30), - &cancellation, - &SystemClock, - |_, _, cancellation| { - entered.send(()).expect("announce blocked health wait"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - Err(workshop_server_api::discovery::HealthError::Canceled) - }, - |_, _| { - worker_resolves.fetch_add(1, Ordering::SeqCst); - Ok(Resolution::Absent) - }, - || None, - ); - assert!(result.is_err(), "the canceled health wait is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the health-wait phase blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - resolves.load(Ordering::SeqCst), - 0, - "canceled health waiting cannot start a later resolve" - ); -} - -#[test] -fn exit_joins_a_supervisor_blocked_in_launch_race_before_spawn() { - let run = tempfile::TempDir::new().expect("tempdir"); - let decision = - workshop_server_api::discovery::launch_or_attach(run.path(), Duration::from_secs(1)) - .expect("acquire a launch decision"); - let run_dir = run.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let launches = Arc::new(AtomicUsize::new(0)); - let worker_launches = Arc::clone(&launches); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let mut decision = Some(decision); - let result = launch_and_attach_cancellable_with( - &run_dir, - Path::new("unused-gateway"), - &cancellation, - |_, _, cancellation| { - entered.send(()).expect("announce blocked launch race"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - Ok(decision.take().expect("one launch decision")) - }, - |_, _| { - worker_launches.fetch_add(1, Ordering::SeqCst); - Ok(42) - }, - |_, _, _| anyhow::bail!("the canceled launch cannot wait for health"), - ); - assert!(result.is_err(), "the canceled launch race is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the launch race blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - launches.load(Ordering::SeqCst), - 0, - "canceled launch racing cannot create a process" - ); -} - -#[test] -fn exit_joins_a_supervisor_blocked_inside_launch_without_process_creation() { - let run = tempfile::TempDir::new().expect("tempdir"); - let decision = - workshop_server_api::discovery::launch_or_attach(run.path(), Duration::from_secs(1)) - .expect("acquire a launch decision"); - let run_dir = run.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let launches = Arc::new(AtomicUsize::new(0)); - let worker_launches = Arc::clone(&launches); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let mut decision = Some(decision); - let result = launch_and_attach_cancellable_with( - &run_dir, - Path::new("unused-gateway"), - &cancellation, - |_, _, _| Ok(decision.take().expect("one launch decision")), - |_, cancellation| { - entered.send(()).expect("announce blocked launch"); - if cancellation.wait_timeout(Duration::from_secs(30)) { - return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); - } - worker_launches.fetch_add(1, Ordering::SeqCst); - Ok(42) - }, - |_, _, _| anyhow::bail!("the canceled launch cannot wait for health"), - ); - assert!(result.is_err(), "the canceled launch is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the process launch blocks deterministically inside its effect gate"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - launches.load(Ordering::SeqCst), - 0, - "the canceled launch has no post-cancel effect" - ); -} - -#[test] -fn exit_joins_a_supervisor_blocked_inside_publication_without_replacement() { - let (entered, blocked) = mpsc::channel(); - let publications = Arc::new(AtomicUsize::new(0)); - let worker_publications = Arc::clone(&publications); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let result = run_effect_if_active(&cancellation, "gateway publication", |cancellation| { - entered.send(()).expect("announce blocked publication"); - if cancellation.wait_timeout(Duration::from_secs(30)) { - anyhow::bail!("gateway publication was canceled"); - } - worker_publications.fetch_add(1, Ordering::SeqCst); - Ok(()) - }); - assert!(result.is_err(), "the canceled publication is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("snapshot publication blocks deterministically inside its effect gate"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - publications.load(Ordering::SeqCst), - 0, - "cancellation prevents authoritative snapshot replacement" - ); -} diff --git a/crates/workshop/desktop/src/gateway/tests/identity.rs b/crates/workshop/desktop/src/gateway/tests/identity.rs deleted file mode 100644 index 1e6837823..000000000 --- a/crates/workshop/desktop/src/gateway/tests/identity.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Attachment and process-boot identity coverage. - -use super::{owned_candidate, validated_gateway}; -use crate::gateway::identity::{GatewayAttachment, same_gateway_identity}; - -#[test] -fn an_explicit_config_attachment_holds_no_local_sidecar_identity() { - let gateway = validated_gateway("k"); - let identity = gateway.validate("k", 1_757_000_000, "2026-09-03T12:00:00Z"); - let sidecar = GatewayAttachment::Sidecar(identity.clone()); - assert!( - sidecar - .sidecar_identity() - .is_some_and(|attached| attached.same_boot(&identity)), - "a sidecar attachment retains its validated process identity" - ); - let config = GatewayAttachment::Config; - assert_eq!( - config.sidecar_identity(), - None, - "a LAN Gateway from explicit config reports None for the sidecar identity" - ); -} - -#[test] -fn validated_process_boot_identity_distinguishes_live_gateway_children() { - let first = validated_gateway("stable-key"); - let second = validated_gateway("stable-key"); - let original = first.validate("stable-key", 1_757_000_000, "2026-09-03T12:00:00Z"); - let replacement = second.validate("stable-key", 1_757_000_000, "2026-09-03T12:00:00Z"); - - assert!(same_gateway_identity(&original, &original.clone())); - assert!( - !same_gateway_identity(&original, &replacement), - "equal file-supplied boot metadata cannot alias another validated process" - ); -} - -#[test] -fn server_publication_disarms_launched_cleanup_only_for_the_same_process_boot() { - let mut launched = validated_gateway("launched-key"); - let identity = launched.validate("launched-key", 1_757_000_000, "2026-09-03T12:00:00Z"); - let attachment = GatewayAttachment::Launched(owned_candidate(identity.clone())); - - let attachment = attachment.reconcile_publication(Some(identity)); - drop(attachment); - - assert!( - !launched.received_shutdown(std::time::Duration::from_millis(100)), - "the child the server actually published remains authoritative" - ); -} - -#[test] -fn server_publication_replaces_a_mismatched_candidate_and_cleans_it_up() { - let mut launched = validated_gateway("launched-key"); - let published = validated_gateway("published-key"); - let launched_identity = - launched.validate("launched-key", 1_757_000_000, "2026-09-03T12:00:00Z"); - let published_identity = - published.validate("published-key", 1_757_000_001, "2026-09-03T12:00:01Z"); - let attachment = GatewayAttachment::Launched(owned_candidate(launched_identity)); - - let attachment = attachment.reconcile_publication(Some(published_identity.clone())); - - assert!( - attachment - .sidecar_identity() - .is_some_and(|identity| identity.same_boot(&published_identity)), - "supervision follows the identity the server actually published" - ); - assert!( - launched.received_shutdown(std::time::Duration::from_secs(1)), - "the unpublished owned child receives authenticated cleanup" - ); -} diff --git a/crates/workshop/desktop/src/gateway/tests/recovery.rs b/crates/workshop/desktop/src/gateway/tests/recovery.rs deleted file mode 100644 index d8ed47ae0..000000000 --- a/crates/workshop/desktop/src/gateway/tests/recovery.rs +++ /dev/null @@ -1,444 +0,0 @@ -//! Continuous supervision, recovery, and joined-shutdown coverage. - -use std::cell::{Cell, RefCell}; -use std::io::{Read as _, Write as _}; -use std::net::TcpListener; -use std::path::Path; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use workshop_server_api::discovery::{ - CancellationToken, GatewayDiscoveryFile, ValidatedConnection, -}; - -use super::{ - TestIdentity, assert_bounded_supervisor_shutdown, get, live_file, test_identity, - validated_gateway, -}; -use crate::gateway::boot::RecoveryLaunch; -use crate::gateway::supervisor::{ - GatewaySupervisor, RecoveryCandidate, RecoveryOwnership, SUPERVISION_MAX_DELAY, - SupervisionProbe, launch_and_attach_cancellable_with, run_supervision, validate_recovery, -}; - -/// How long a test listens to prove the named fixture received no shutdown. -/// The fixture reports only accepted shutdowns over its control socket, and -/// the request a regression would add comes from a detached drop thread, so -/// no later event is ordered after it; absence needs a wall-clock window. -const NO_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100); - -#[test] -fn supervision_lives_past_sixty_seconds_then_propagates_a_configured_key_edit_atomically() { - let gateway = validated_gateway("new-key"); - let original = gateway.gateway_discovery_file("old-key", 1_757_000_000, "2026-09-03T12:00:00Z"); - let replacement = - gateway.gateway_discovery_file("new-key", 1_757_000_001, "2026-09-03T12:00:01Z"); - let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); - let server = workshop_server_api::fixtures::spawn(workshop_server_api::Config { - gateway: workshop_server_api::GatewayConfig { - base_url: format!("http://127.0.0.1:{}", original.port), - api_key: original.api_key.clone(), - }, - server: workshop_server_api::ServerConfig { - bind: "127.0.0.1:0".to_owned(), - state_dir: state_dir.path().to_owned(), - }, - agents: workshop_server_api::AgentsConfig::default(), - }) - .expect("spawn Workshop against the original same-port key"); - let updater = server.gateway_updater(); - let publish_replacement = |file: &GatewayDiscoveryFile| -> anyhow::Result<()> { - let validated = ValidatedConnection::validate(file.clone()) - .context("validate the named local Gateway")?; - updater - .replace_sidecar(&validated) - .context("publish through the production updater") - }; - let elapsed = Cell::new(Duration::ZERO); - let recoveries = Cell::new(0_u8); - let published = RefCell::new(Vec::new()); - let cancellation = CancellationToken::new(); - - run_supervision( - test_identity(original.clone()), - |current, _| { - if !published.borrow().is_empty() || elapsed.get() <= Duration::from_secs(65) { - SupervisionProbe::Replacement(current.clone()) - } else { - SupervisionProbe::Missing - } - }, - |_| { - recoveries.set(recoveries.get() + 1); - if recoveries.get() < 3 { - anyhow::bail!("injected launch failure"); - } - Ok(test_identity(replacement.clone())) - }, - |identity, _| { - publish_replacement(&identity.file)?; - published.borrow_mut().push(identity.file.clone()); - Ok::<(), anyhow::Error>(()) - }, - |delay, _| { - assert!( - delay <= SUPERVISION_MAX_DELAY, - "every supervision wait is capped: {delay:?}" - ); - elapsed.set(elapsed.get() + delay); - !published.borrow().is_empty() - }, - &cancellation, - ); - - assert!( - elapsed.get() > Duration::from_secs(60), - "the supervisor remains live beyond one minute" - ); - assert_eq!(recoveries.get(), 3, "failed launches retry under backoff"); - assert_eq!( - published.borrow().as_slice(), - [replacement], - "one successful relaunch publishes its exact discovery-file pair" - ); - assert_eq!( - published.borrow()[0].port, - original.port, - "an OS-assigned port may be reused" - ); - assert_ne!( - published.borrow()[0].api_key, - original.api_key, - "a configured key edit propagates with the replacement identity" - ); - let response = get(server.url(), "/gateway/api/admin/status"); - assert!( - response.starts_with("HTTP/1.1 200"), - "the real publisher replaces the bearer on the reused port: {response}" - ); - server.shutdown().expect("stop the Workshop fixture"); -} - -#[test] -fn reused_pid_and_file_metadata_still_publish_a_new_validated_process_boot() { - let file = live_file(54_375, "stable-key"); - let original = TestIdentity { - file: file.clone(), - process_boot: 41, - }; - let replacement = TestIdentity { - file: file.clone(), - process_boot: 42, - }; - let published = RefCell::new(Vec::new()); - let cancellation = CancellationToken::new(); - - run_supervision( - original.clone(), - |current, _| { - if published.borrow().is_empty() { - SupervisionProbe::Replacement(replacement.clone()) - } else { - SupervisionProbe::Replacement(current.clone()) - } - }, - |_| -> anyhow::Result { - panic!("a validated replacement does not need a relaunch") - }, - |identity, _| { - published.borrow_mut().push(identity.clone()); - Ok(()) - }, - |_, _| !published.borrow().is_empty(), - &cancellation, - ); - - assert_eq!( - published.borrow().as_slice(), - [replacement], - "the stable OS boot token prevents identical file fields from hiding pid reuse" - ); - assert_eq!(published.borrow()[0].file, original.file); -} - -#[test] -fn an_exact_spawned_pid_authenticates_late_child_cleanup() { - let mut gateway = validated_gateway("owned-key"); - let validated = gateway.validate("owned-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let RecoveryOwnership::Owned(candidate) = - RecoveryCandidate::authenticate(validated.pid(), validated) - else { - panic!("the validated file names the spawned child"); - }; - - drop(candidate); - - assert!( - gateway.received_shutdown(Duration::from_secs(1)), - "an unpublished owned child receives authenticated shutdown" - ); -} - -#[test] -fn explicit_candidate_shutdown_delivers_and_disarms_the_drop_signal() { - let mut gateway = validated_gateway("owned-key"); - let validated = gateway.validate("owned-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let RecoveryOwnership::Owned(candidate) = - RecoveryCandidate::authenticate(validated.pid(), validated) - else { - panic!("the validated file names the spawned child"); - }; - - candidate - .shutdown() - .expect("the fixture accepts the authenticated shutdown"); - - assert!( - gateway.received_shutdown(Duration::from_secs(1)), - "the explicit path signals the unpublished child" - ); - assert!( - !gateway.received_shutdown(NO_SHUTDOWN_TIMEOUT), - "the disarmed drop sends no second signal" - ); -} - -#[test] -fn explicit_candidate_shutdown_reports_a_delivery_failure() { - let validated = { - let gateway = validated_gateway("owned-key"); - gateway.validate("owned-key", 1_778_000_001, "2026-09-08T18:00:01Z") - }; - let RecoveryOwnership::Owned(candidate) = - RecoveryCandidate::authenticate(validated.pid(), validated) - else { - panic!("the validated file names the spawned child"); - }; - - let error = candidate - .shutdown() - .expect_err("a dead child cannot accept the shutdown"); - assert!( - matches!( - error, - workshop_server_api::discovery::ShutdownError::Io { .. } - ), - "the explicit path reports the delivery failure: {error}" - ); -} - -#[test] -fn dropping_a_candidate_signals_without_waiting_for_an_unresponsive_child() { - // The fixture child lends its validatable pid; the hanging listener - // answers the validation probe, then parks the shutdown connection. - let gateway = validated_gateway("hanging-key"); - let reference = gateway.validate("hanging-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let hang = Arc::new(AtomicBool::new(false)); - let (_hang_tx, hang_rx) = std::sync::mpsc::channel::<()>(); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind the hanging fixture"); - let port = listener.local_addr().expect("the fixture address").port(); - std::thread::spawn({ - let hang = Arc::clone(&hang); - move || { - while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0_u8; 1024]; - if hang.load(Ordering::SeqCst) { - let _ = stream.read(&mut buffer); - // Park the shutdown connection until the test drops the - // gate, so the child stays unresponsive for as long as - // the test needs, without a fixed wall-clock delay. - let _ = hang_rx.recv(); - continue; - } - while let Ok(read) = stream.read(&mut buffer) { - if read == 0 { - break; - } - if stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") - .is_err() - { - break; - } - } - } - } - }); - let file = GatewayDiscoveryFile { - port, - api_key: "hanging-key".to_owned(), - pid: reference.pid(), - epoch: 1_778_000_001, - version: "test".to_owned(), - started_at: "2026-09-08T18:00:01Z".to_owned(), - }; - let validated = ValidatedConnection::validate(file).expect("the hanging endpoint validates"); - hang.store(true, Ordering::SeqCst); - let RecoveryOwnership::Owned(candidate) = - RecoveryCandidate::authenticate(validated.pid(), validated) - else { - panic!("the validated file names the spawned child"); - }; - - let started = Instant::now(); - drop(candidate); - // The unresponsive child is a real socket, and a blocking drop would - // still return at the production late-child deadline, so only elapsed - // time separates it from a detached signal. - assert!( - started.elapsed() < Duration::from_millis(250), - "drop signals on a detached thread instead of waiting out the late-child budget" - ); -} - -#[test] -fn a_mismatched_spawned_pid_never_claims_or_cleans_the_validated_process() { - let mut gateway = validated_gateway("unowned-key"); - let validated = gateway.validate("unowned-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let run = tempfile::TempDir::new().expect("create run directory"); - gateway - .gateway_discovery_file("unowned-key", 1_778_000_001, "2026-09-08T18:00:01Z") - .write_to(run.path()) - .expect("retain the uncertain connection record"); - - let RecoveryOwnership::Unowned(unowned) = - RecoveryCandidate::authenticate(validated.pid() + 1, validated) - else { - panic!("a different validated pid cannot prove ownership"); - }; - drop(unowned); - - assert!( - !gateway.received_shutdown(NO_SHUTDOWN_TIMEOUT), - "an uncertain process never receives destructive cleanup" - ); - assert!( - workshop_server_api::discovery::gateway_discovery_file_path(run.path()).exists(), - "uncertain cleanup retains the connection record" - ); -} - -#[test] -fn successful_publication_disarms_late_child_cleanup() { - let mut gateway = validated_gateway("published-key"); - let validated = gateway.validate("published-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let RecoveryOwnership::Owned(mut candidate) = - RecoveryCandidate::authenticate(validated.pid(), validated) - else { - panic!("the validated file names the spawned child"); - }; - - candidate.mark_published(); - drop(candidate); - - assert!( - !gateway.received_shutdown(NO_SHUTDOWN_TIMEOUT), - "an authoritative published child remains running" - ); -} - -#[test] -fn launched_recovery_retains_the_spawned_pid_and_releases_launch_lock() { - let run = tempfile::TempDir::new().expect("create run directory"); - let decision = - workshop_server_api::discovery::launch_or_attach(run.path(), Duration::from_secs(1)) - .expect("acquire launch election"); - let file = live_file(54_375, "candidate-key"); - let cancellation = CancellationToken::new(); - - let recovery = launch_and_attach_cancellable_with( - run.path(), - Path::new("unused-gateway"), - &cancellation, - |_, _, _| Ok(decision), - |_, _| Ok(4242), - |_, _, _| Ok(file.clone()), - ) - .expect("complete injected launch"); - - match recovery { - RecoveryLaunch::Launched { - child_pid, - file: observed, - } => { - assert_eq!(child_pid, 4242); - assert_eq!(observed, file); - } - RecoveryLaunch::Attached(_) => panic!("the elected launcher retains child ownership"), - } - assert!( - matches!( - workshop_server_api::discovery::launch_or_attach(run.path(), Duration::from_secs(1)) - .expect("reacquire launch election"), - workshop_server_api::discovery::LaunchDecision::Launch(_) - ), - "returning the candidate releases LaunchLock" - ); -} - -#[test] -fn failed_validation_never_claims_or_removes_a_spawned_process_record() { - let run = tempfile::TempDir::new().expect("create run directory"); - let file = GatewayDiscoveryFile { - pid: super::dead_pid(), - ..live_file(54_375, "unvalidated-key") - }; - file.write_to(run.path()).expect("write uncertain record"); - let cancellation = CancellationToken::new(); - - let result = validate_recovery( - RecoveryLaunch::Launched { - child_pid: file.pid, - file, - }, - &cancellation, - ); - - assert!( - result.is_err(), - "failed validation creates no owned candidate" - ); - assert!( - workshop_server_api::discovery::gateway_discovery_file_path(run.path()).exists(), - "failed validation retains an uncertain process record" - ); -} - -#[test] -fn exit_wakes_the_supervision_wait_without_a_later_probe() { - let (entered, blocked) = mpsc::channel(); - let probes = Arc::new(AtomicUsize::new(0)); - let worker_probes = Arc::clone(&probes); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - test_identity(live_file(54_375, "stable-key")), - |current, _| { - worker_probes.fetch_add(1, Ordering::SeqCst); - SupervisionProbe::Replacement(current.clone()) - }, - |_| -> anyhow::Result { panic!("a healthy Gateway does not recover") }, - |_, _| Ok::<(), anyhow::Error>(()), - |delay, cancellation| { - entered.send(()).expect("announce supervision wait"); - cancellation.wait_timeout(delay) - }, - &cancellation, - ); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the supervision wait blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - probes.load(Ordering::SeqCst), - 1, - "cancellation prevents every later liveness probe" - ); -} diff --git a/crates/workshop/desktop/src/gateway/tests/shutdown.rs b/crates/workshop/desktop/src/gateway/tests/shutdown.rs deleted file mode 100644 index bef87469e..000000000 --- a/crates/workshop/desktop/src/gateway/tests/shutdown.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! Bounded Gateway supervisor and late-publication shutdown coverage. - -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; -use std::time::Duration; - -use super::validated_gateway; -use crate::gateway::supervisor::{ - GatewaySupervisor, SUPERVISOR_SHUTDOWN_BUDGET, SupervisedGatewayIdentity, SupervisionProbe, - SupervisorShutdown, run_supervision, -}; - -/// How long a test waits for a teardown that must finish while its worker -/// is still parked. Shorter than [`SUPERVISOR_SHUTDOWN_BUDGET`], so a -/// shutdown that ignores its injected budget and waits out the production -/// one fails instead of passing late. -const TEARDOWN_DEADLINE: Duration = Duration::from_secs(2); -const _: () = assert!(TEARDOWN_DEADLINE.as_millis() < SUPERVISOR_SHUTDOWN_BUDGET.as_millis()); - -/// A shutdown budget longer than [`TEARDOWN_DEADLINE`], so a teardown that -/// waits it out fails instead of passing late. -const UNREACHED_BUDGET: Duration = Duration::from_secs(3600); - -/// A supervised identity reduced to its process boot. -#[derive(Clone)] -struct Boot(u64); - -impl SupervisedGatewayIdentity for Boot { - fn same_boot(&self, other: &Self) -> bool { - self.0 == other.0 - } -} - -/// Runs `teardown` on its own thread while the caller keeps the worker -/// parked. A teardown that waits for the worker cannot finish, so it -/// reports a timeout instead of hanging the test. -fn teardown_while_parked( - teardown: impl FnOnce() -> T + Send + 'static, -) -> Result { - let (finished, outcome) = mpsc::channel(); - std::thread::spawn(move || { - let _ = finished.send(teardown()); - }); - outcome.recv_timeout(TEARDOWN_DEADLINE) -} - -#[test] -fn supervisor_shutdown_reports_a_worker_panic_without_unwinding_teardown() { - let supervisor = GatewaySupervisor::spawn(|_| panic!("injected supervisor panic")) - .expect("spawn test supervisor"); - - assert_eq!(supervisor.shutdown(), SupervisorShutdown::Panicked); -} - -#[test] -fn supervisor_shutdown_detaches_an_uncooperative_worker_at_one_deadline() { - let (release, blocked) = mpsc::channel(); - let supervisor = GatewaySupervisor::spawn_with_budget(Duration::from_millis(50), move |_| { - let _ = blocked.recv(); - }) - .expect("spawn test supervisor"); - - assert_eq!( - teardown_while_parked(move || supervisor.shutdown()), - Ok(SupervisorShutdown::Detached), - "an uncooperative worker cannot outlive the shutdown budget" - ); - release.send(()).expect("release the detached test worker"); -} - -#[test] -fn stop_signaling_never_waits_for_an_in_progress_effect_gate() { - let (entered, blocked) = mpsc::channel(); - let (release, await_release) = mpsc::channel(); - let supervisor = - GatewaySupervisor::spawn_with_budget(Duration::from_millis(50), move |cancellation| { - let _ = cancellation.run_if_active(|| { - entered.send(()).expect("announce admitted effect"); - let _ = await_release.recv(); - }); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the worker enters the effect gate"); - - assert_eq!( - teardown_while_parked(move || supervisor.shutdown()), - Ok(SupervisorShutdown::Detached), - "the stop signal cannot join an admitted effect before the bounded completion wait" - ); - release.send(()).expect("release the detached test worker"); -} - -#[test] -fn a_spurious_completion_wake_cannot_trigger_a_blocking_join() { - let (release, blocked) = mpsc::channel(); - let supervisor = GatewaySupervisor::spawn_with_budget(Duration::from_millis(50), move |_| { - let _ = blocked.recv(); - }) - .expect("spawn test supervisor"); - let wake = supervisor.completion_waker_for_test(); - let waking = Arc::new(AtomicBool::new(true)); - let waker = std::thread::spawn({ - let waking = Arc::clone(&waking); - move || { - while waking.load(Ordering::SeqCst) { - wake(); - std::thread::yield_now(); - } - } - }); - - let outcome = teardown_while_parked(move || supervisor.shutdown()); - waking.store(false, Ordering::SeqCst); - waker.join().expect("the waker thread stops"); - - assert_eq!( - outcome, - Ok(SupervisorShutdown::Detached), - "a wake without completion must keep waiting only to the original deadline" - ); - release.send(()).expect("release the detached test worker"); -} - -#[test] -fn dropping_a_supervisor_signals_and_detaches_without_waiting() { - let (release, blocked) = mpsc::channel(); - let supervisor = GatewaySupervisor::spawn_with_budget(UNREACHED_BUDGET, move |_| { - let _ = blocked.recv(); - }) - .expect("spawn test supervisor"); - - assert_eq!( - teardown_while_parked(move || drop(supervisor)), - Ok(()), - "Drop signals and detaches instead of waiting out the shutdown budget" - ); - release.send(()).expect("release the detached test worker"); -} - -#[test] -fn a_shut_down_supervisor_launches_nothing_when_its_gateway_later_disappears() { - let (entered, healthy) = mpsc::channel(); - let gone = Arc::new(AtomicBool::new(false)); - let worker_gone = Arc::clone(&gone); - let launches = Arc::new(AtomicUsize::new(0)); - let worker_launches = Arc::clone(&launches); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - Boot(1), - |current, _| { - if worker_gone.load(Ordering::SeqCst) { - SupervisionProbe::Missing - } else { - SupervisionProbe::Replacement(current.clone()) - } - }, - |_| -> anyhow::Result { - worker_launches.fetch_add(1, Ordering::SeqCst); - Ok(Boot(2)) - }, - |_, _| Ok::<(), anyhow::Error>(()), - |delay, cancellation| { - let _ = entered.send(()); - cancellation.wait_timeout(delay) - }, - &cancellation, - ); - }) - .expect("spawn test supervisor"); - healthy - .recv_timeout(Duration::from_secs(1)) - .expect("the supervisor observes the healthy gateway"); - gone.store(true, Ordering::SeqCst); - - assert_eq!(supervisor.shutdown(), SupervisorShutdown::Joined); - assert_eq!( - launches.load(Ordering::SeqCst), - 0, - "quit stops the gateway only after its supervisor, so nothing relaunches it" - ); -} - -#[test] -fn shutdown_revokes_publication_before_waking_a_late_worker() { - let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); - let server = workshop_server_api::fixtures::spawn(workshop_server_api::Config { - gateway: workshop_server_api::GatewayConfig { - base_url: "http://127.0.0.1:54375".to_owned(), - api_key: "old-key".to_owned(), - }, - server: workshop_server_api::ServerConfig { - bind: "127.0.0.1:0".to_owned(), - state_dir: state_dir.path().to_owned(), - }, - agents: workshop_server_api::AgentsConfig::default(), - }) - .expect("spawn Workshop"); - let updater = server.gateway_updater(); - let worker_updater = updater.clone(); - let replacement_gateway = validated_gateway("replacement-key"); - let replacement = - replacement_gateway.validate("replacement-key", 1_778_000_001, "2026-09-08T18:00:01Z"); - let rejected = Arc::new(AtomicBool::new(false)); - let worker_rejected = Arc::clone(&rejected); - let supervisor = GatewaySupervisor::spawn_with_publication(updater, move |cancellation| { - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - worker_rejected.store( - matches!( - worker_updater.replace_sidecar(&replacement), - Err(workshop_server_api::GatewayPublicationError::PublicationClosed) - ), - Ordering::SeqCst, - ); - }) - .expect("spawn late publisher"); - - assert_eq!(supervisor.shutdown(), SupervisorShutdown::Joined); - assert!( - rejected.load(Ordering::SeqCst), - "the worker wakes only after permanent publication closure" - ); - server.shutdown().expect("stop Workshop"); -} diff --git a/crates/workshop/desktop/src/linux_media.rs b/crates/workshop/desktop/src/linux_media.rs deleted file mode 100644 index 1cb72ba16..000000000 --- a/crates/workshop/desktop/src/linux_media.rs +++ /dev/null @@ -1,42 +0,0 @@ -//! Linux microphone permission for the workshop window. -//! -//! WebKitGTK ships two defaults that together break voice dictation: -//! `enable-media-stream` is off, and the stock `permission-request` handler -//! denies every request it never saw a listener override, including -//! microphone/camera grabs. Neither is fixable from outside the embedding -//! application, so the session opts in here and auto-allows only user-media -//! requests - every other permission kind (notifications, geolocation, ...) -//! keeps the default deny. - -use anyhow::Context as _; -use webkit2gtk::{ - PermissionRequestExt as _, SettingsExt as _, UserMediaPermissionRequest, WebViewExt as _, - glib::Cast as _, -}; - -/// Enables media capture on the window's webview and connects the -/// permission handler for the webview's lifetime. -/// -/// # Errors -/// Returns an error when the closure cannot be dispatched to the webview's -/// thread; the caller logs it and the mic stays unavailable, nothing else -/// breaks. -pub(crate) fn grant_media_permissions(window: &tauri::WebviewWindow) -> anyhow::Result<()> { - window - .with_webview(|webview| { - let webview = webview.inner(); - if let Some(settings) = webview.settings() { - settings.set_enable_media_stream(true); - } - webview.connect_permission_request(|_webview, request| match request - .downcast_ref::( - ) { - Some(request) => { - request.allow(); - true - } - None => false, - }); - }) - .context("dispatch the media permission setup to the webview thread") -} diff --git a/crates/workshop/desktop/src/main.rs b/crates/workshop/desktop/src/main.rs deleted file mode 100644 index 802b354e3..000000000 --- a/crates/workshop/desktop/src/main.rs +++ /dev/null @@ -1,502 +0,0 @@ -//! The `runvil-workshop` binary: the Runvil Workshop desktop app. -//! -//! Runs the workshop server in-process on a loopback listener with an -//! OS-assigned port and opens a Tauri window pointed at the in-process -//! listener's URL. Boot first connects the gateway: attach to a running -//! gateway through its gateway discovery file, or launch the sibling -//! `runvil-gateway` detached when none is running - a Workshop-only -//! install falls back to the explicit `workshop.toml` `[gateway]` config, -//! and with neither boot fails loud naming both remedies. The in-process -//! server then resolves the same endpoint itself. Closing the window -//! stops the in-process server only: the gateway is a separate process -//! and keeps running; the window menu's quit item is the one gesture -//! that also stops a local gateway. -//! -//! ## Invariants -//! -//! - The webview loads only the in-process server's exact origin; every -//! other http(s) target opens in the system browser. -//! - The `main` window's Tauri capability is granted to that exact bound -//! origin, built at setup, and never to a wildcard port. - -// Release builds are a GUI app: no console window when launched from the -// installer. Debug builds keep the console so the eprintln diagnostics show. -// The tradeoff: in release those diagnostics have nowhere to print, so a -// boot failure also shows its error chain in a dialog (`boot_and_open`). -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - -// The only unsafe module in the crate: the WebView2 COM surface that -// reads real OS paths out of dropped File objects and grants the -// microphone has no safe wrapper. -#[cfg(target_os = "windows")] -#[expect(unsafe_code, reason = "raw WebView2 COM has no safe wrapper")] -mod bridge; -mod config; -mod drops; -mod gateway; -#[cfg(target_os = "linux")] -mod linux_media; -mod menu; -mod navigation; -mod quit; -mod window_state; - -use std::ffi::OsStr; -use std::process::ExitCode; -use std::sync::{Mutex, PoisonError}; -use std::time::Duration; - -use anyhow::Context as _; -use tauri::ipc::CapabilityBuilder; -use tauri::{Manager as _, WebviewUrl, WebviewWindowBuilder}; -use tauri_plugin_dialog::{DialogExt as _, MessageDialogKind}; -use tauri_plugin_opener::OpenerExt as _; -use workshop_server_api::ServerHandle; - -/// How long the app waits for the in-process server's health endpoint -/// before giving up. -const HEALTH_TIMEOUT: Duration = Duration::from_secs(15); - -/// The managed slot holding the server until the `RunEvent::Exit` handler -/// shuts it down. `shutdown` consumes the handle, so the slot hands it over -/// exactly once. -type ServerSlot = Mutex>; - -/// The managed local-sidecar supervisor, absent for an explicit LAN Gateway. -type GatewaySupervisorSlot = Mutex>; - -/// The permission set the workshop page holds. The grant itself is built -/// in setup with the exact bound port: the OS assigns the port at boot, so -/// no capability file can name the origin. -const WINDOW_PERMISSIONS: &[&str] = &[ - "core:default", - "core:window:default", - "core:window:allow-minimize", - "core:window:allow-close", - "core:window:allow-start-dragging", - "core:window:allow-toggle-maximize", - "core:webview:allow-set-webview-zoom", - "core:event:default", - "dialog:allow-open", - "dialog:allow-save", - "updater:default", - "process:allow-restart", - "allow-desktop-update-supported", - "allow-quit", -]; - -fn main() -> ExitCode { - if std::env::args_os().any(|arg| arg == "--version") { - return print_version(); - } - if std::env::args_os().any(|arg| arg == "--plugins") { - return print_plugins(); - } - match run() { - Ok(()) => ExitCode::SUCCESS, - Err(error) => { - eprintln!("{error:?}"); - ExitCode::FAILURE - } - } -} - -/// `runvil-workshop --version`: print the version and exit, without -/// booting the server or opening a window. The release workflows smoke-test -/// the installed package with it. The release build is GUI-subsystem on -/// Windows, but a piped or inherited stdout still receives the line. -fn print_version() -> ExitCode { - println!("runvil-workshop {}", env!("CARGO_PKG_VERSION")); - ExitCode::SUCCESS -} - -/// `runvil-workshop --plugins`: print each library Plugin this -/// installation loads, as its name and its version, and exit, without -/// booting the server or opening a window. A library that does not load -/// goes to stderr with the reason. Workshop keeps no log, so this is how -/// a person sees why an agent has no file tools or no shell, and the -/// release workflows check the installed Plugins with it. -fn print_plugins() -> ExitCode { - let directory = match config::load() { - Ok(config) => config.agents.plugins, - Err(error) => { - eprintln!("{error:?}"); - return ExitCode::FAILURE; - } - }; - let Some(directory) = directory else { - eprintln!("this installation names no Plugin directory"); - return ExitCode::FAILURE; - }; - match workshop_server_api::list_plugin_dir(&directory) { - Ok(listing) => { - for line in &listing.loaded { - println!("{line}"); - } - for line in &listing.failed { - eprintln!("{line}"); - } - ExitCode::SUCCESS - } - Err(error) => { - eprintln!( - "cannot read the Plugin directory {}: {error}", - directory.display() - ); - ExitCode::FAILURE - } - } -} - -fn update_supported(os: &str, appimage: Option<&OsStr>) -> bool { - os != "linux" || appimage.is_some_and(|value| !value.is_empty()) -} - -#[tauri::command] -fn desktop_update_supported() -> bool { - update_supported( - std::env::consts::OS, - std::env::var_os("APPIMAGE").as_deref(), - ) -} - -fn run() -> anyhow::Result<()> { - let builder = tauri::Builder::default() - // Registered first so a second launch exits inside `build`, before - // its own setup could spawn a second in-process server and race - // the first instance's gateway attach. - .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { - if let Some(window) = app.get_webview_window("main") { - let _ = window.unminimize(); - let _ = window.set_focus(); - } - })) - .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_opener::init()) - .plugin(tauri_plugin_process::init()) - .plugin(tauri_plugin_updater::Builder::new().build()) - .invoke_handler(tauri::generate_handler![ - desktop_update_supported, - quit::quit - ]) - .on_menu_event(menu::handle_event) - .setup(boot_and_open); - // Off Windows, OS file drops arrive as Tauri's own drag-drop event and - // are dispatched into the same runvil:file-drop grant flow. On - // Windows the drag-drop handler is disabled (see open_window) and drops - // arrive over the WebView2 web-message bridge instead. - #[cfg(not(target_os = "windows"))] - let builder = builder.on_window_event(|window, event| { - if let tauri::WindowEvent::DragDrop(tauri::DragDropEvent::Drop { paths, .. }) = event - && let Some(webview) = window.get_webview_window(window.label()) - { - drops::dispatch_file_drop(&webview, paths); - } - }); - let app = builder - .build(tauri::generate_context!()) - .context("build the desktop application")?; - app.run(|handle, event| { - if let tauri::RunEvent::Exit = event { - let supervisor = handle.try_state::().and_then(|slot| { - slot.lock() - .unwrap_or_else(PoisonError::into_inner) - .take() - }); - let supervisor_outcome = supervisor.map(gateway::GatewaySupervisor::shutdown); - let server = handle - .try_state::() - .map(|slot| slot.lock().unwrap_or_else(PoisonError::into_inner).take()); - continue_teardown(supervisor_outcome, || { - if let Some(Some(server)) = server { - match server.shutdown() { - Ok(workshop_server_api::Termination::Graceful) => {} - Ok(termination) => { - eprintln!("the workshop server was forced down past its drain window: {termination:?}"); - } - Err(error) => { - eprintln!("could not shut the workshop server down cleanly: {error:?}"); - } - } - } - }); - } - }); - Ok(()) -} - -/// Reports a supervisor shutdown that did not join cleanly. `None` means -/// there was no supervisor, or quit already stopped it. -fn report_supervisor_shutdown(supervisor: Option) { - match supervisor { - Some(gateway::SupervisorShutdown::Joined) | None => {} - Some(gateway::SupervisorShutdown::Panicked) => { - eprintln!("the gateway supervisor panicked during shutdown"); - } - Some(gateway::SupervisorShutdown::Detached) => { - eprintln!("the gateway supervisor exceeded its shutdown budget and was detached"); - } - } -} - -fn continue_teardown( - supervisor: Option, - shutdown_server: impl FnOnce(), -) { - report_supervisor_shutdown(supervisor); - shutdown_server(); -} - -/// The setup hook: connects the gateway (attach or launch), boots the -/// in-process server, installs the window's exact-port capability and the -/// menu, and opens the window on the server's URL. A boot failure prints -/// its full error chain, shows it in an error dialog, and exits the process -/// directly once the dialog closes: a release build on Windows has no -/// console, and returning `Err` would surface as Tauri's "Failed to setup -/// app" panic, losing the chain and the failure exit code. The hook returns -/// while the dialog is up, because some platforms run dialogs on the event -/// loop. -fn boot_and_open(app: &mut tauri::App) -> Result<(), Box> { - match boot() { - Ok((server, url, attachment, supervisor)) => { - // The capability must exist before the window does: the - // authority resolves a window's grants at creation. - app.add_capability(window_capability(&url))?; - app.manage(GatewaySupervisorSlot::new(supervisor)); - app.manage(ServerSlot::new(Some(server))); - menu::install(app, attachment.sidecar_identity().is_some())?; - open_window(app, &url) - } - Err(error) => { - eprintln!("{error:?}"); - #[expect( - clippy::exit, - reason = "an Err from the setup hook becomes Tauri's \"Failed to setup app\" panic, \ - which loses the error chain and the failure exit code" - )] - app.dialog() - .message(format!("{error:?}")) - .title("Runvil could not start") - .kind(MessageDialogKind::Error) - .show(|_| std::process::exit(1)); - Ok(()) - } - } -} - -/// Connects the gateway (attach to a live gateway discovery file, or -/// launch the sibling `runvil-gateway` detached), then spawns the -/// in-process workshop server - which resolves the same endpoint itself, -/// gateway discovery file first, explicit `workshop.toml` config -/// second - and waits out its -/// health probe. A failure after the spawn shuts the server down before -/// propagating. -fn boot() -> anyhow::Result<( - ServerHandle, - url::Url, - gateway::GatewayAttachment, - Option, -)> { - let config = config::load().context("load the workshop configuration")?; - let attachment = gateway::ensure_gateway(&config).context("connect to the gateway")?; - let server = - workshop_server_api::spawn(config).context("start the in-process workshop server")?; - let attachment = attachment.reconcile_publication(server.initial_gateway_identity().cloned()); - match workshop_server_api::discovery::wait_for_health(server.url(), HEALTH_TIMEOUT) - .context("wait for the in-process workshop server") - { - Ok(()) => { - let url = url::Url::parse(server.url()).context("parse the workshop URL")?; - let supervisor = match gateway::supervise(&attachment, server.gateway_updater()) { - Ok(supervisor) => supervisor, - Err(error) => { - if let Err(shutdown_error) = server.shutdown() { - eprintln!("{shutdown_error:?}"); - } - return Err(error.context("supervise the local gateway")); - } - }; - Ok((server, url, attachment, supervisor)) - } - Err(error) => { - if let Err(shutdown_error) = server.shutdown() { - eprintln!("{shutdown_error:?}"); - } - Err(error) - } - } -} - -/// The `main` window's capability: the permission set granted to the -/// exact loopback origin the in-process server bound. Building it in -/// setup is what lets the bind stay an OS-assigned port - a capability -/// file could name only a wildcard port, which would hand the window's -/// Tauri API surface to any other loopback server on the machine. -fn window_capability(url: &url::Url) -> CapabilityBuilder { - let builder = CapabilityBuilder::new("workshop-main") - .local(false) - .remote(url.origin().ascii_serialization()) - .window("main"); - WINDOW_PERMISSIONS - .iter() - .fold(builder, |builder, permission| { - builder.permission(*permission) - }) -} - -/// Creates the workshop window on `url`: hidden while built so the -/// window-state restore never flashes, undecorated on Windows where the -/// custom HTML title bar replaces the native frame, overlay-titled on -/// macOS where the native traffic lights float over the custom bar's left -/// edge (the bar hides its Windows-style control cluster there), decorated -/// on Linux, restored to the open workspace file's saved -/// geometry, then shown. The saver that writes geometry back into the -/// file is installed before the show, so the first layout pass is saved. -fn open_window(app: &mut tauri::App, url: &url::Url) -> Result<(), Box> { - let server_origin = url.origin(); - let opener = app.handle().clone(); - let builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url.clone())) - .title("Runvil") - .inner_size(1024.0, 768.0) - .visible(false) - .on_navigation(move |target| { - match navigation::classify_navigation(&server_origin, target.as_str()) { - navigation::Navigation::Allow => true, - navigation::Navigation::OpenExternally => { - if let Err(error) = opener.opener().open_url(target.as_str(), None::<&str>) { - eprintln!("could not open {target} in the system browser: {error}"); - } - false - } - } - }); - // Tauri's drag-drop handler suppresses HTML5 drag events on Windows - // (https://github.com/tauri-apps/tauri/issues/15138), so Windows keeps - // the handler off and takes Explorer drops over the WebView2 - // web-message bridge instead (bridge.rs). - #[cfg(target_os = "windows")] - let builder = builder.disable_drag_drop_handler(); - // macOS gets VS Code's overlay treatment: the native traffic lights - // float over the webview's top-left with no native title bar, and the - // custom HTML bar hides its Windows-style control cluster - // (window-chrome.ts) and insets its left region clear of the lights. - #[cfg(target_os = "macos")] - let builder = builder - .title_bar_style(tauri::TitleBarStyle::Overlay) - .hidden_title(true); - let window = builder.build()?; - // An attach failure only degrades Explorer drops and the mic grant, - // never the app. - #[cfg(target_os = "windows")] - if let Err(error) = bridge::attach(&window) { - eprintln!( - "could not attach the WebView2 bridge; Explorer drops and the microphone grant are unavailable: {error}" - ); - } - #[cfg(target_os = "linux")] - if let Err(error) = linux_media::grant_media_permissions(&window) { - eprintln!( - "could not enable WebKitGTK media capture; the microphone is unavailable: {error}" - ); - } - // Decorations come off after construction, not at build time: the - // Groupy-compatible title bar pattern proven by other Tauri apps. - #[cfg(target_os = "windows")] - window.set_decorations(false)?; - // Geometry comes from the workspace file through the server. The - // server is healthy by now (boot waited on it), so the fetch is a - // loopback round trip; a failure leaves the default geometry and is - // reported, never fatal. - match window_state::ServerClient::new(url) { - Ok(client) => { - let restored = window_state::restore(&window, &client); - window_state::spawn_saver(&window, client, restored); - } - Err(error) => { - eprintln!("window geometry will not be restored or saved: {error:#}"); - } - } - window.show()?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - use tauri::ipc::RuntimeCapability as _; - use tauri::utils::acl::capability::CapabilityFile; - - #[test] - fn crate_is_named_workshop() { - assert_eq!(env!("CARGO_PKG_NAME"), "workshop"); - } - - #[test] - fn linux_updates_require_an_appimage_runtime() { - assert!(!update_supported("linux", None)); - assert!(!update_supported("linux", Some(OsStr::new("")))); - assert!(update_supported( - "linux", - Some(OsStr::new("/tmp/Runvil.AppImage")) - )); - assert!(update_supported("windows", None)); - assert!(update_supported("macos", None)); - } - - #[test] - fn the_window_capability_names_the_exact_bound_origin() { - let url = url::Url::parse("http://127.0.0.1:49152/").expect("the URL parses"); - let CapabilityFile::Capability(capability) = window_capability(&url).build() else { - panic!("the builder produces a single capability"); - }; - assert!( - !capability.local, - "the window loads no local content; the grant is remote-only" - ); - let remote = capability.remote.expect("the remote origins are set"); - assert_eq!( - remote.urls, - vec!["http://127.0.0.1:49152".to_string()], - "the grant names the exact bound origin - a wildcard port would hand \ - the Tauri API surface to any loopback server" - ); - assert_eq!(capability.windows, vec!["main".to_string()]); - let permissions = format!("{:?}", capability.permissions); - for permission in WINDOW_PERMISSIONS { - assert!( - permissions.contains(permission), - "the capability includes {permission}" - ); - } - } - - #[test] - fn the_window_grants_both_native_pickers() { - // Open Workspace from File... uses the open picker; Save Workspace - // As... and Duplicate Workspace... use the save picker. A missing - // grant fails silently in the webview as a canceled dialog. - for permission in ["dialog:allow-open", "dialog:allow-save"] { - assert!( - WINDOW_PERMISSIONS.contains(&permission), - "the page needs {permission} for its File menu pickers" - ); - } - } - - #[test] - fn every_supervisor_outcome_continues_server_teardown() { - for outcome in [ - gateway::SupervisorShutdown::Joined, - gateway::SupervisorShutdown::Panicked, - gateway::SupervisorShutdown::Detached, - ] { - let continued = std::cell::Cell::new(false); - continue_teardown(Some(outcome), || continued.set(true)); - assert!( - continued.get(), - "{outcome:?} cannot prevent application teardown" - ); - } - } -} diff --git a/crates/workshop/desktop/src/menu-tests.rs b/crates/workshop/desktop/src/menu-tests.rs deleted file mode 100644 index 29d007795..000000000 --- a/crates/workshop/desktop/src/menu-tests.rs +++ /dev/null @@ -1,15 +0,0 @@ -//! The quit item's accelerator: `CmdOrCtrl+Q` on macOS, where Cmd+Q is the -//! platform's quit gesture, and none on Windows and Linux, where Ctrl+Q -//! opens View in the SPA. - -use super::quit_accelerator; - -#[test] -fn the_quit_item_keeps_cmd_or_ctrl_q_on_macos() { - assert_eq!(quit_accelerator(true), Some("CmdOrCtrl+Q")); -} - -#[test] -fn the_quit_item_has_no_accelerator_on_windows_and_linux() { - assert_eq!(quit_accelerator(false), None); -} diff --git a/crates/workshop/desktop/src/menu.rs b/crates/workshop/desktop/src/menu.rs deleted file mode 100644 index 0a05b8101..000000000 --- a/crates/workshop/desktop/src/menu.rs +++ /dev/null @@ -1,105 +0,0 @@ -//! The window menu: the quit-everything affordance. -//! -//! The desktop app's only menu item quits the app through the shared -//! shutdown-then-exit path in `quit.rs`, which the SPA's File > Exit row -//! (the `quit` command) also runs: when boot attached to or launched a -//! local sidecar gateway, the item first posts the gateway's `/shutdown` -//! through the server's current validated Gateway snapshot, so one -//! gesture stops the window, the in-process server, and the Gateway. -//! Attached to a LAN Gateway through explicit config, the snapshot grants -//! no shutdown authority, so the item stops the desktop app only and says so. - -use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; -use tauri::{AppHandle, Wry}; - -/// The quit item's menu id, matched by the event handler. -const QUIT_MENU_ID: &str = "quit-runvil"; - -/// The quit item's accelerator. macOS keeps `CmdOrCtrl+Q`, the platform's -/// quit gesture. Windows and Linux have none: Ctrl+Q opens View in the SPA -/// (the Open View... row), so quitting there goes through the SPA's File > -/// Exit row and the `quit` command. -fn quit_accelerator(macos: bool) -> Option<&'static str> { - macos.then_some("CmdOrCtrl+Q") -} - -/// Builds and installs the app menu. A local sidecar makes the quit item -/// stop both products; a configured LAN Gateway makes it stop only the desktop app. -/// -/// # Errors -/// Returns an error when the menu cannot be built or installed. -pub(crate) fn install(app: &tauri::App, has_sidecar: bool) -> tauri::Result<()> { - let label = if has_sidecar { - "Quit Runvil and Gateway" - } else { - "Quit Runvil" - }; - let mut quit = MenuItemBuilder::with_id(QUIT_MENU_ID, label); - if let Some(accelerator) = quit_accelerator(cfg!(target_os = "macos")) { - quit = quit.accelerator(accelerator); - } - let quit = quit.build(app)?; - // Setting a menu replaces the stock one wholesale, so macOS - // re-declares the default layout (app, File, Edit, View, Window) with - // the stock Quit swapped for the quit-everything item. Windows and - // Linux get the single File submenu; the Windows window is - // undecorated, so the menu bar is hidden there, and with no - // accelerator (Ctrl+Q opens View there) the SPA's File > Exit row is - // the gesture. - #[cfg(target_os = "macos")] - let menu = { - let app_menu = SubmenuBuilder::new(app, "Runvil") - .about(None) - .separator() - .services() - .separator() - .hide() - .hide_others() - .show_all() - .separator() - .item(&quit) - .build()?; - let file = SubmenuBuilder::new(app, "File").close_window().build()?; - let edit = SubmenuBuilder::new(app, "Edit") - .undo() - .redo() - .separator() - .cut() - .copy() - .paste() - .select_all() - .build()?; - let view = SubmenuBuilder::new(app, "View").fullscreen().build()?; - let window = SubmenuBuilder::new(app, "Window") - .minimize() - .maximize() - .separator() - .close_window() - .build()?; - MenuBuilder::new(app) - .items(&[&app_menu, &file, &edit, &view, &window]) - .build()? - }; - #[cfg(not(target_os = "macos"))] - let menu = { - let file = SubmenuBuilder::new(app, "File").item(&quit).build()?; - MenuBuilder::new(app).item(&file).build()? - }; - let _previous = app.set_menu(menu)?; - Ok(()) -} - -/// Handles the quit item by running the shared stop-then-shutdown-then-exit -/// path (`quit::quit_everything`), the same path the SPA's File > Exit row -/// invokes through the `quit` command. -pub(crate) fn handle_event(app: &AppHandle, event: tauri::menu::MenuEvent) { - let tauri::menu::MenuEvent { id } = event; - if id != QUIT_MENU_ID { - return; - } - crate::quit::quit_everything(app); -} - -#[cfg(test)] -#[path = "menu-tests.rs"] -mod tests; diff --git a/crates/workshop/desktop/src/navigation.rs b/crates/workshop/desktop/src/navigation.rs deleted file mode 100644 index 72359eae0..000000000 --- a/crates/workshop/desktop/src/navigation.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! The navigation policy: the webview may load only the server's own -//! origin; every other http(s) target opens in the system browser. -//! -//! A clicked link never navigates the app away from itself, and no other -//! loopback server - same machine, different port, or a different -//! loopback spelling - gets the desktop app's Tauri API surface, folder -//! picker, or file-drop bridge. Other schemes (`about:blank`, `data:`) -//! and unparseable values are left to the webview. - -/// What the app does with a URL the webview wants to navigate to. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Navigation { - /// The webview loads the URL itself. - Allow, - /// The URL opens in the system browser; the webview stays put. - OpenExternally, -} - -/// Classifies a navigation target against the server's origin: a -/// same-origin http(s) URL (the in-process server) loads in the webview, -/// while any other absolute http(s) URL opens in the system browser. -#[must_use] -pub(crate) fn classify_navigation(server: &url::Origin, target: &str) -> Navigation { - let Ok(url) = url::Url::parse(target) else { - return Navigation::Allow; - }; - if !matches!(url.scheme(), "http" | "https") { - return Navigation::Allow; - } - if url.origin() == *server { - Navigation::Allow - } else { - Navigation::OpenExternally - } -} - -#[cfg(test)] -mod tests { - use super::{Navigation, classify_navigation}; - - /// The origin of the in-process server the test window was opened on. - fn server_origin(url: &str) -> url::Origin { - match url::Url::parse(url) { - Ok(parsed) => parsed.origin(), - Err(error) => panic!("the test server URL must parse: {error}"), - } - } - - #[test] - fn same_origin_urls_load_in_the_webview() { - let server = server_origin("http://127.0.0.1:7910/"); - for url in [ - "http://127.0.0.1:7910/", - "http://127.0.0.1:7910/ws", - "http://127.0.0.1:7910/settings?tab=keys#top", - ] { - assert_eq!( - classify_navigation(&server, url), - Navigation::Allow, - "{url}" - ); - } - } - - #[test] - fn a_default_port_normalizes_into_the_origin() { - let server = server_origin("https://127.0.0.1/"); - assert_eq!( - classify_navigation(&server, "https://127.0.0.1:443/"), - Navigation::Allow - ); - } - - #[test] - fn other_loopback_origins_open_in_the_system_browser() { - let server = server_origin("http://127.0.0.1:7910/"); - for url in [ - // Another loopback server is not the workshop, whatever the - // port, scheme, or loopback spelling. - "http://127.0.0.1:4000/", - "https://127.0.0.1:7910/", - "http://localhost:7910/", - "http://[::1]:7910/", - ] { - assert_eq!( - classify_navigation(&server, url), - Navigation::OpenExternally, - "{url}" - ); - } - } - - #[test] - fn external_urls_open_in_the_system_browser() { - let server = server_origin("http://127.0.0.1:7910/"); - for url in [ - "https://example.com/", - "http://192.168.1.10/", - "https://localhost.evil.example/", - ] { - assert_eq!( - classify_navigation(&server, url), - Navigation::OpenExternally, - "{url}" - ); - } - } - - #[test] - fn non_http_and_unparseable_targets_are_left_to_the_webview() { - let server = server_origin("http://127.0.0.1:7910/"); - for url in [ - "about:blank", - "data:text/html,

hi

", - "not a url", - "/relative/path", - ] { - assert_eq!( - classify_navigation(&server, url), - Navigation::Allow, - "{url}" - ); - } - } -} diff --git a/crates/workshop/desktop/src/quit-tests.rs b/crates/workshop/desktop/src/quit-tests.rs deleted file mode 100644 index bb72b7057..000000000 --- a/crates/workshop/desktop/src/quit-tests.rs +++ /dev/null @@ -1,108 +0,0 @@ -//! The quit ordering, and the shutdown-request half of quit against a -//! stubbed gateway: the sidecar snapshot receives the authenticated -//! `/shutdown`, a configured gateway is left running (no shutdown -//! authority), and a missing server is a no-op. - -use std::cell::RefCell; -use std::time::Duration; - -use super::{request_gateway_shutdown, stop_supervisor_then_request_shutdown}; - -/// How long a test waits for the stubbed gateway to report the shutdown. -const SHUTDOWN_OBSERVE_TIMEOUT: Duration = Duration::from_secs(10); -/// How long a test waits to prove no shutdown was sent. The stub is a -/// separate process that reports only accepted shutdowns over a real -/// socket, after answering them, so no event marks the absence of one and -/// the proof needs a wall-clock window. -const NO_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(250); - -#[test] -#[ignore = "runs only as a named child process"] -fn validated_gateway_fixture_process() { - workshop_server_api::fixtures::run_validated_gateway_fixture_process(); -} - -/// Starts the shared named-process Gateway stub in this test binary. -fn stubbed_gateway(expected_key: &str) -> workshop_server_api::fixtures::ValidatedGateway { - workshop_server_api::fixtures::ValidatedGateway::spawn_in( - expected_key, - "quit::tests::validated_gateway_fixture_process", - ) -} - -/// Spawns a Workshop fixture server configured against the gateway at -/// `port` - an explicit configuration, which alone grants no shutdown -/// authority. -fn workshop_server( - port: u16, - api_key: &str, -) -> (tempfile::TempDir, workshop_server_api::ServerHandle) { - let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); - let server = workshop_server_api::fixtures::spawn(workshop_server_api::Config { - gateway: workshop_server_api::GatewayConfig { - base_url: format!("http://127.0.0.1:{port}"), - api_key: api_key.to_owned(), - }, - server: workshop_server_api::ServerConfig { - bind: "127.0.0.1:0".to_owned(), - state_dir: state_dir.path().to_owned(), - }, - agents: workshop_server_api::AgentsConfig::default(), - }) - .expect("spawn Workshop fixture"); - (state_dir, server) -} - -#[test] -fn quit_stops_the_supervisor_before_requesting_the_gateway_shutdown() { - let calls = RefCell::new(Vec::new()); - - stop_supervisor_then_request_shutdown( - || calls.borrow_mut().push("stop the supervisor"), - || calls.borrow_mut().push("request the gateway shutdown"), - ); - - assert_eq!( - calls.into_inner(), - ["stop the supervisor", "request the gateway shutdown"], - "a supervisor still running when the gateway stops would relaunch it" - ); -} - -#[test] -fn quit_requests_shutdown_from_the_current_sidecar_snapshot() { - let mut gateway = stubbed_gateway("quit-key"); - let (_state_dir, server) = workshop_server(gateway.port(), "quit-key"); - let updater = server.gateway_updater(); - let sidecar = gateway.validate("quit-key", 1_778_000_001, "2026-09-15T18:00:01Z"); - updater - .replace_sidecar(&sidecar) - .expect("the sidecar identity publishes"); - - request_gateway_shutdown(Some(updater)); - - assert!( - gateway.received_shutdown(SHUTDOWN_OBSERVE_TIMEOUT), - "quit posts the authenticated shutdown to the current sidecar" - ); - server.shutdown().expect("stop the Workshop fixture"); -} - -#[test] -fn quit_leaves_a_configured_gateway_running() { - let mut gateway = stubbed_gateway("lan-key"); - let (_state_dir, server) = workshop_server(gateway.port(), "lan-key"); - - request_gateway_shutdown(Some(server.gateway_updater())); - - assert!( - !gateway.received_shutdown(NO_SHUTDOWN_TIMEOUT), - "an explicitly configured gateway grants no shutdown authority" - ); - server.shutdown().expect("stop the Workshop fixture"); -} - -#[test] -fn quit_without_a_server_requests_nothing() { - request_gateway_shutdown(None); -} diff --git a/crates/workshop/desktop/src/quit.rs b/crates/workshop/desktop/src/quit.rs deleted file mode 100644 index 14bd3d2e1..000000000 --- a/crates/workshop/desktop/src/quit.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! The quit-everything gesture, shared by the native menu's quit item and -//! the SPA's File > Exit row (the `quit` command): one -//! stop-then-shutdown-then-exit path so the two cannot diverge. The path -//! first stops the local-sidecar supervisor, so nothing relaunches the -//! gateway this gesture is about to stop. When boot attached to or -//! launched a local sidecar gateway, the path then posts the gateway's -//! `/shutdown` through the server's current validated Gateway snapshot, so -//! one gesture stops the window, the in-process server, and the Gateway. -//! Attached to a LAN Gateway through explicit config, there is no -//! supervisor and the snapshot grants no shutdown authority, so the gesture -//! stops the desktop app only. - -use std::sync::PoisonError; - -use tauri::{AppHandle, Manager as _, Wry}; -use workshop_server_api::GatewayUpdater; - -use crate::gateway::GatewaySupervisor; -use crate::{GatewaySupervisorSlot, ServerSlot}; - -/// The quit ordering: stop the supervisor, then request the gateway's -/// shutdown. The reverse order races: a supervisor still running when the -/// gateway stops sees it missing and launches a replacement. -fn stop_supervisor_then_request_shutdown( - stop_supervisor: impl FnOnce(), - request_shutdown: impl FnOnce(), -) { - stop_supervisor(); - request_shutdown(); -} - -/// The supervisor-stop half: takes the local-sidecar supervisor out of its -/// slot and shuts it down, reporting a worker that panicked or outlived its -/// budget. The `RunEvent::Exit` handler then finds the slot empty. -fn stop_supervisor(app: &AppHandle) { - let supervisor = app - .try_state::() - .and_then(|slot| slot.lock().unwrap_or_else(PoisonError::into_inner).take()); - crate::report_supervisor_shutdown(supervisor.map(GatewaySupervisor::shutdown)); -} - -/// The shutdown-request half: asks the server's current validated local -/// Gateway snapshot to post `/shutdown`. A configured LAN Gateway grants -/// no shutdown authority, so its snapshot sends nothing. A refused or -/// undeliverable request is reported and quit proceeds anyway - quit -/// always works, even when the Gateway is wedged. -fn request_gateway_shutdown(gateway: Option) { - if let Some(gateway) = gateway - && let Err(error) = gateway.request_shutdown() - { - eprintln!( - "the gateway did not accept the shutdown request; quitting the desktop app anyway: {error}" - ); - } -} - -/// The shared stop-then-shutdown-then-exit path: stop the supervisor, -/// request the local Gateway's shutdown, then exit the desktop app (the -/// `RunEvent::Exit` handler stops the in-process server). -pub(crate) fn quit_everything(app: &AppHandle) { - stop_supervisor_then_request_shutdown( - || stop_supervisor(app), - || { - let gateway = app.try_state::().and_then(|slot| { - slot.lock() - .unwrap_or_else(PoisonError::into_inner) - .as_ref() - .map(workshop_server_api::ServerHandle::gateway_updater) - }); - request_gateway_shutdown(gateway); - }, - ); - app.exit(0); -} - -/// The `quit` command behind the SPA's File > Exit row. It runs the same -/// stop-then-shutdown-then-exit path as the native menu's quit item so the -/// two gestures cannot diverge; a plain `process.exit` from the webview -/// would strand the sidecar gateway. -#[tauri::command] -#[expect( - clippy::needless_pass_by_value, - reason = "a Tauri command handler receives the app handle by value" -)] -pub(crate) fn quit(app: AppHandle) { - quit_everything(&app); -} - -#[cfg(test)] -#[path = "quit-tests.rs"] -mod tests; diff --git a/crates/workshop/desktop/src/window_state-tests.rs b/crates/workshop/desktop/src/window_state-tests.rs deleted file mode 100644 index 8c52ab095..000000000 --- a/crates/workshop/desktop/src/window_state-tests.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Window geometry without a window: the physical-to-logical mapping the -//! saver applies under a scale factor, the maximized merge that keeps the -//! normal rectangle, the empty-size guard on a saved geometry, the debounce -//! that turns a drag's burst of events into one save, and the wire shape -//! the server's workspace-file routes speak. - -use std::sync::mpsc; -use std::time::Duration; - -use tauri::{PhysicalPosition, PhysicalSize}; - -use super::{CurrentResponse, Debouncer, SavedResponse, WindowState, geometry, merge, usable}; - -/// How long a test waits for the debouncer to deliver a coalesced save. -const DELIVERY_TIMEOUT: Duration = Duration::from_secs(5); -/// The debounce a test runs with: long enough that a burst pushed in a -/// tight loop lands inside one window, short enough to keep the test fast. -const TEST_DEBOUNCE: Duration = Duration::from_millis(150); - -fn state(width: u32, height: u32, x: i32, y: i32, maximized: bool) -> WindowState { - WindowState { - width, - height, - x, - y, - maximized, - } -} - -#[test] -fn physical_geometry_maps_to_logical_under_the_scale_factor() { - // A 200% display: the OS reports twice the logical pixels in every - // axis, and the file keeps logical pixels so a 100% display restores - // the same apparent size. - let mapped = geometry( - PhysicalSize::new(2560, 1600), - PhysicalPosition::new(200, -100), - 2.0, - false, - ); - assert_eq!(mapped, state(1280, 800, 100, -50, false)); - - // At 100% the mapping is the identity. - let identity = geometry( - PhysicalSize::new(1024, 768), - PhysicalPosition::new(40, 60), - 1.0, - false, - ); - assert_eq!(identity, state(1024, 768, 40, 60, false)); - - // The maximized flag passes through untouched. - let maximized = geometry( - PhysicalSize::new(3840, 2100), - PhysicalPosition::new(-8, -8), - 1.5, - true, - ); - assert!(maximized.maximized); - assert_eq!((maximized.width, maximized.height), (2560, 1400)); -} - -#[test] -fn a_maximized_snapshot_keeps_the_last_normal_rectangle() { - let normal = state(1280, 800, 100, 50, false); - let maximized_rect = state(2560, 1400, -8, -8, true); - - // Maximizing reports the screen rectangle; the file keeps the normal - // one so un-maximizing after a restart lands where the user left it. - assert_eq!( - merge(Some(normal), maximized_rect), - state(1280, 800, 100, 50, true) - ); - // Un-maximizing takes the fresh rectangle again. - let moved = state(1000, 700, 300, 200, false); - assert_eq!(merge(Some(state(1280, 800, 100, 50, true)), moved), moved); - // With nothing remembered, the maximized rectangle is all there is. - assert_eq!(merge(None, maximized_rect), maximized_rect); -} - -#[test] -fn a_saved_geometry_with_an_empty_side_is_not_applied() { - assert!( - !usable(state(0, 800, 100, 50, false)), - "a zero width is empty" - ); - assert!( - !usable(state(1280, 0, 100, 50, false)), - "a zero height is empty" - ); - assert!( - !usable(state(0, 0, 0, 0, true)), - "a zero size is empty even when maximized" - ); - assert!( - usable(state(1, 1, -32_000, -32_000, false)), - "any nonzero size is usable; the position is checked against the monitors separately" - ); -} - -#[test] -fn a_burst_of_geometry_events_coalesces_into_one_save() { - let (sink, saved) = mpsc::channel(); - let debouncer = Debouncer::spawn(TEST_DEBOUNCE, move |value: WindowState| { - sink.send(value).expect("the test holds the receiver"); - }); - - // A drag: many events inside the debounce window. - for x in 0..50 { - debouncer.push(state(1280, 800, x, 0, false)); - } - - let first = saved - .recv_timeout(DELIVERY_TIMEOUT) - .expect("the burst produces one save"); - assert_eq!(first, state(1280, 800, 49, 0, false), "the last value wins"); - assert!( - saved.recv_timeout(TEST_DEBOUNCE * 3).is_err(), - "no intermediate value reaches the sink" - ); - - // A second, separate gesture saves again. - debouncer.push(state(1000, 700, 5, 5, true)); - let second = saved - .recv_timeout(DELIVERY_TIMEOUT) - .expect("a later gesture produces its own save"); - assert_eq!(second, state(1000, 700, 5, 5, true)); -} - -#[test] -fn the_wire_shape_matches_the_server_fixture() { - // Copied from the server's `WindowState` (the kv 'window' value and - // the `PUT /workspace/file/window-state` body) and the - // `GET /workspace/file/current` answer. - let request = serde_json::to_value(state(1280, 800, 100, -50, false)) - .expect("a five-field struct of plain values serializes"); - assert_eq!( - request, - serde_json::json!({ - "width": 1280, - "height": 800, - "x": 100, - "y": -50, - "maximized": false - }) - ); - - let current: CurrentResponse = serde_json::from_str( - r#"{"path":"C:\\ws\\Name.rvwork","name":"Name","grants":[{"path":"C:\\src","exists":true}],"window_state":{"width":1280,"height":800,"x":100,"y":-50,"maximized":true}}"#, - ) - .expect("the current-file answer parses"); - assert_eq!(current.window_state, Some(state(1280, 800, 100, -50, true))); - - let ephemeral: CurrentResponse = - serde_json::from_str(r#"{"path":null,"name":"Untitled","grants":[],"window_state":null}"#) - .expect("the ephemeral answer parses"); - assert_eq!(ephemeral.window_state, None); - - let saved: SavedResponse = - serde_json::from_str(r#"{"saved":false}"#).expect("the window-state answer parses"); - assert!(!saved.saved); -} diff --git a/crates/workshop/desktop/src/window_state.rs b/crates/workshop/desktop/src/window_state.rs deleted file mode 100644 index 7c60dca9f..000000000 --- a/crates/workshop/desktop/src/window_state.rs +++ /dev/null @@ -1,448 +0,0 @@ -//! Window geometry through the workspace file. The desktop app restores the -//! saved size, position, and maximized flag from -//! `GET /workspace/file/current` before the window shows, writes them -//! back through `PUT /workspace/file/window-state` - debounced while the -//! user drags, once more on close - and reapplies them when the SPA opens, -//! saves as, or duplicates a workspace file (its -//! `runvil:workspace-opened` event). The workspace file, not a -//! Tauri-plugin-owned location, owns the geometry: an ephemeral workspace -//! has nowhere to keep it and the server answers `saved: false`. -//! -//! Every failure here logs and continues: a window that opens at the -//! default size is a nuisance, a window that never opens is a boot -//! failure, and nothing in this module is allowed to cause the second. - -use std::fmt; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{self, RecvTimeoutError}; -use std::sync::{Arc, Mutex, PoisonError}; -use std::time::Duration; - -use anyhow::Context as _; -use serde::{Deserialize, Serialize}; -use tauri::{ - Listener as _, LogicalPosition, LogicalSize, Manager as _, PhysicalPosition, PhysicalSize, - WebviewWindow, WindowEvent, -}; - -/// How long a burst of resize and move events must go quiet before the -/// geometry is written. A drag fires dozens of events a second; one save -/// per gesture is plenty. -const SAVE_DEBOUNCE: Duration = Duration::from_millis(500); -/// The budget for the final save on close. Past it the window closes -/// with whatever the last debounced save wrote. -const CLOSE_SAVE_TIMEOUT: Duration = Duration::from_secs(2); -/// The budget for a fetch or a debounced save against the in-process -/// server: loopback, so generous. -const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); -/// The Tauri event the SPA emits after switching the workspace file, so -/// the desktop app can apply the file's geometry to the live window. -const WORKSPACE_OPENED_EVENT: &str = "runvil:workspace-opened"; -/// How far inside the saved top-left corner the monitor probe looks: a -/// window whose first title-bar pixels are on a monitor can be grabbed. -const VISIBILITY_INSET: i32 = 16; - -/// The window geometry as the workspace file keeps it: the server's -/// `WindowState` mirrored field for field, in logical pixels. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct WindowState { - /// Logical width. - width: u32, - /// Logical height. - height: u32, - /// Logical x position of the window's outer top-left corner. - x: i32, - /// Logical y position of the window's outer top-left corner. - y: i32, - /// Whether the window is maximized; the rectangle is the normal one - /// to return to. - maximized: bool, -} - -/// The part of the `GET /workspace/file/current` answer the desktop app reads. -/// The path, name, and grants belong to the SPA and are ignored here. -#[derive(Debug, Deserialize)] -struct CurrentResponse { - /// The saved geometry; `null` while ephemeral or never saved. - window_state: Option, -} - -/// The `PUT /workspace/file/window-state` answer. -#[derive(Debug, Deserialize)] -struct SavedResponse { - /// Whether the geometry was written; `false` for an ephemeral - /// workspace. - saved: bool, -} - -/// The desktop app's minimal client for the in-process server's workspace-file -/// routes. The server admits it as a native client: no `Origin`, a -/// loopback `Host`, and `application/json` on the body it sends. -#[derive(Debug, Clone)] -pub(crate) struct ServerClient { - /// The server's bound URL, the same one the window loads. - base_url: url::Url, - /// The HTTP client, bounded by [`REQUEST_TIMEOUT`] per request. - http: reqwest::Client, -} - -impl ServerClient { - /// Builds a client against the server at `base_url`. - pub(crate) fn new(base_url: &url::Url) -> anyhow::Result { - let http = reqwest::Client::builder() - .timeout(REQUEST_TIMEOUT) - .build() - .context("build the window-state HTTP client")?; - Ok(Self { - base_url: base_url.clone(), - http, - }) - } - - fn endpoint(&self, path: &str) -> anyhow::Result { - self.base_url - .join(path) - .with_context(|| format!("resolve {path} against {}", self.base_url)) - } - - /// Fetches the open workspace's saved geometry: `None` while the - /// workspace is ephemeral or has never saved one. - async fn current(&self) -> anyhow::Result> { - let url = self.endpoint("workspace/file/current")?; - let response = self - .http - .get(url) - .send() - .await - .context("request the current workspace file")? - .error_for_status() - .context("the current-workspace request was refused")?; - let body: CurrentResponse = response - .json() - .await - .context("parse the current workspace file")?; - Ok(body.window_state) - } - - /// Saves `state` into the open workspace file within `timeout`. - /// Returns whether the server wrote it (`false` for an ephemeral - /// workspace). - async fn put_window_state( - &self, - state: WindowState, - timeout: Duration, - ) -> anyhow::Result { - let url = self.endpoint("workspace/file/window-state")?; - let response = self - .http - .put(url) - .timeout(timeout) - .json(&state) - .send() - .await - .context("send the window state")? - .error_for_status() - .context("the window-state save was refused")?; - let body: SavedResponse = response - .json() - .await - .context("parse the window-state answer")?; - Ok(body.saved) - } -} - -/// The window's geometry in logical pixels, as the file keeps it. The OS -/// reports physical pixels; dividing by the scale factor keeps a window -/// the same apparent size when the file moves between displays. -fn geometry( - inner: PhysicalSize, - outer: PhysicalPosition, - scale_factor: f64, - maximized: bool, -) -> WindowState { - let size: LogicalSize = inner.to_logical(scale_factor); - let position: LogicalPosition = outer.to_logical(scale_factor); - WindowState { - width: size.width, - height: size.height, - x: position.x, - y: position.y, - maximized, - } -} - -/// Merges a fresh snapshot with the last known geometry. A maximized -/// window reports the screen rectangle, so only its flag is taken and the -/// normal rectangle stays what it was; anything else is taken whole. -fn merge(previous: Option, current: WindowState) -> WindowState { - match previous { - Some(previous) if current.maximized => WindowState { - maximized: true, - ..previous - }, - _ => current, - } -} - -/// Trailing-edge debounce on one worker thread: values arriving within -/// `delay` of each other replace one another, and the sink sees only the -/// last of a burst once it goes quiet. One thread, not one per event, -/// because a drag produces hundreds. -struct Debouncer { - /// The event side; the worker owns the receiver. - events: mpsc::Sender, -} - -impl fmt::Debug for Debouncer { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Debouncer").finish_non_exhaustive() - } -} - -impl Debouncer { - /// Starts the worker. If the thread cannot be spawned the debouncer - /// still exists and silently drops every value: geometry saves are - /// best effort. - pub(crate) fn spawn(delay: Duration, mut sink: impl FnMut(T) + Send + 'static) -> Self { - let (events, receiver) = mpsc::channel::(); - let spawned = std::thread::Builder::new() - .name("window-state-saver".to_owned()) - .spawn(move || { - while let Ok(mut latest) = receiver.recv() { - loop { - match receiver.recv_timeout(delay) { - Ok(value) => latest = value, - Err(RecvTimeoutError::Timeout) => break, - Err(RecvTimeoutError::Disconnected) => { - sink(latest); - return; - } - } - } - sink(latest); - } - }); - if let Err(error) = spawned { - eprintln!( - "could not start the window-state saver; geometry will not be saved: {error}" - ); - } - Self { events } - } - - /// Offers a value; the sink sees it only if nothing newer arrives - /// within the delay. - fn push(&self, value: T) { - // A dead worker means saves are off; there is nothing to report - // per event. - let _ = self.events.send(value); - } -} - -/// The geometry last applied or saved, shared by the saver's paths so a -/// maximized snapshot can keep the normal rectangle. -type LastGeometry = Arc>>; - -/// Restores the open workspace's saved geometry onto `window` - meant to -/// run after the server is healthy and before `show()`, so the restore -/// never flashes. Returns what was applied, for the saver to remember. -/// A missing or unusable geometry leaves the window at its defaults. -pub(crate) fn restore(window: &WebviewWindow, client: &ServerClient) -> Option { - let state = match tauri::async_runtime::block_on(client.current()) { - Ok(Some(state)) => state, - Ok(None) => return None, - Err(error) => { - eprintln!( - "could not read the saved window state; using the default geometry: {error:#}" - ); - return None; - } - }; - apply(window, state) -} - -/// Installs the save paths on `window`: a debounced save behind -/// [`SAVE_DEBOUNCE`] on resize and move, one best-effort save bounded by -/// [`CLOSE_SAVE_TIMEOUT`] when the window is asked to close, and a -/// listener that reapplies the geometry when the SPA switches workspace -/// files. `restored` is what [`restore`] applied, if anything. -pub(crate) fn spawn_saver( - window: &WebviewWindow, - client: ServerClient, - restored: Option, -) { - let last: LastGeometry = Arc::new(Mutex::new(restored)); - let saver = { - let client = client.clone(); - Debouncer::spawn(SAVE_DEBOUNCE, move |state: WindowState| { - save(&client, state, REQUEST_TIMEOUT); - }) - }; - let closing = AtomicBool::new(false); - let events_window = window.clone(); - let events_last = Arc::clone(&last); - let events_client = client.clone(); - window.on_window_event(move |event| match event { - WindowEvent::Resized(_) | WindowEvent::Moved(_) => { - if let Some(state) = remember(&events_window, &events_last) { - saver.push(state); - } - } - WindowEvent::CloseRequested { .. } => { - if closing.swap(true, Ordering::SeqCst) { - return; - } - if let Some(state) = remember(&events_window, &events_last) { - save(&events_client, state, CLOSE_SAVE_TIMEOUT); - } - } - _ => {} - }); - - let switched_window = window.clone(); - window - .app_handle() - .listen_any(WORKSPACE_OPENED_EVENT, move |_event| { - let window = switched_window.clone(); - let client = client.clone(); - let last = Arc::clone(&last); - tauri::async_runtime::spawn(async move { - match client.current().await { - Ok(Some(state)) if usable(state) => { - // Seed `last` before the setters run, not after: - // `apply` returns as soon as the setters are - // dispatched, while the `Resized` and `Moved` - // events they raise reach `remember` on the - // event-loop thread. A maximized snapshot merged - // there must keep this file's rectangle, not the - // previous workspace's. Then remember what was - // applied, since the position may have been - // skipped as off-monitor. - *last.lock().unwrap_or_else(PoisonError::into_inner) = Some(state); - if let Some(applied) = apply(&window, state) { - *last.lock().unwrap_or_else(PoisonError::into_inner) = Some(applied); - } - } - Ok(Some(_) | None) => {} - Err(error) => { - eprintln!( - "could not read the opened workspace's window state; keeping the current geometry: {error:#}" - ); - } - } - }); - }); -} - -/// Snapshots the window, merges it with the last known geometry, and -/// remembers the result. `None` while minimized (the OS reports a -/// nonsense position) or when the window cannot be read. -fn remember(window: &WebviewWindow, last: &LastGeometry) -> Option { - let current = snapshot(window)?; - let mut last = last.lock().unwrap_or_else(PoisonError::into_inner); - let merged = merge(*last, current); - *last = Some(merged); - Some(merged) -} - -/// Reads the window's geometry in logical pixels. Runs on the event-loop -/// thread inside a window event, where the getters answer directly. -fn snapshot(window: &WebviewWindow) -> Option { - let read = || -> tauri::Result> { - if window.is_minimized()? { - return Ok(None); - } - Ok(Some(geometry( - window.inner_size()?, - window.outer_position()?, - window.scale_factor()?, - window.is_maximized()?, - ))) - }; - match read() { - Ok(state) => state, - Err(error) => { - eprintln!("could not read the window geometry; skipping this save: {error}"); - None - } - } -} - -/// Writes `state` to the server within `timeout`, reporting a refusal -/// and shrugging off an ephemeral workspace's `saved: false`. -fn save(client: &ServerClient, state: WindowState, timeout: Duration) { - if let Err(error) = tauri::async_runtime::block_on(client.put_window_state(state, timeout)) { - eprintln!("could not save the window state: {error:#}"); - } -} - -/// Whether a saved geometry can be applied at all. An empty size is the -/// one shape the server cannot reject for us; it is reported here and the -/// window keeps its defaults. -fn usable(state: WindowState) -> bool { - if state.width == 0 || state.height == 0 { - eprintln!( - "saved window size {}x{} is empty; using the default geometry", - state.width, state.height - ); - return false; - } - true -} - -/// Applies `state` to `window`: size, then position when the saved -/// corner is on a monitor that still exists, then the maximized flag. -/// Returns the geometry as applied - the window's actual position when -/// the saved one was skipped as off-monitor, so a later maximized -/// snapshot does not re-save a corner the window never occupied - or -/// `None` when the state is unusable. -fn apply(window: &WebviewWindow, state: WindowState) -> Option { - if !usable(state) { - return None; - } - let outcome = || -> tauri::Result { - let mut applied = state; - window.set_size(LogicalSize::new(state.width, state.height))?; - if corner_is_visible(window, state)? { - window.set_position(LogicalPosition::new(state.x, state.y))?; - } else { - eprintln!( - "saved window position ({}, {}) is off every monitor; keeping the default position", - state.x, state.y - ); - let kept: LogicalPosition = - window.outer_position()?.to_logical(window.scale_factor()?); - applied.x = kept.x; - applied.y = kept.y; - } - if state.maximized { - window.maximize()?; - } - Ok(applied) - }; - match outcome() { - Ok(applied) => Some(applied), - Err(error) => { - eprintln!("could not apply the saved window state: {error}"); - None - } - } -} - -/// Whether the saved top-left corner, inset a little so the title bar's -/// first pixels count, lands on a connected monitor. A monitor that was -/// unplugged since the save would otherwise swallow the window. -fn corner_is_visible(window: &WebviewWindow, state: WindowState) -> tauri::Result { - let probe: PhysicalPosition = LogicalPosition::new( - state.x.saturating_add(VISIBILITY_INSET), - state.y.saturating_add(VISIBILITY_INSET), - ) - .to_physical(window.scale_factor()?); - Ok(window - .monitor_from_point(f64::from(probe.x), f64::from(probe.y))? - .is_some()) -} - -#[cfg(test)] -#[path = "window_state-tests.rs"] -mod tests; diff --git a/crates/workshop/desktop/tauri.conf.json b/crates/workshop/desktop/tauri.conf.json deleted file mode 100644 index 476da2ff5..000000000 --- a/crates/workshop/desktop/tauri.conf.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "productName": "Runvil", - "mainBinaryName": "runvil-workshop", - "identifier": "com.runvil.workshop", - "app": { - "withGlobalTauri": true, - "windows": [] - }, - "bundle": { - "active": true, - "createUpdaterArtifacts": true, - "icon": [ - "icons/32x32.png", - "icons/128x128.png", - "icons/128x128@2x.png", - "icons/icon.icns", - "icons/icon.ico" - ], - "windows": { - "webviewInstallMode": { - "type": "embedBootstrapper", - "silent": true - }, - "nsis": { - "template": "installer.nsi", - "headerImage": "icons/installer-header.bmp", - "sidebarImage": "icons/installer-sidebar.bmp", - "installerIcon": "icons/icon.ico", - "uninstallerIcon": "icons/icon.ico" - } - } - }, - "plugins": { - "updater": { - "endpoints": [ - "https://github.com/cppalliance/runvil/releases/download/workshop-latest/latest.json" - ], - "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEU3QUNCQ0M4Mzk4M0YwMEEKUldRSzhJTTV5THlzNXpnRXFQellRelNSNm1rTWJiNkNLL0FoL3Y4UFdLeGxRajJJTG96RGFNNEwK", - "windows": { - "installMode": "passive" - } - } - } -} diff --git a/crates/workshop/desktop/tauri.linux.conf.json b/crates/workshop/desktop/tauri.linux.conf.json deleted file mode 100644 index efc4fe095..000000000 --- a/crates/workshop/desktop/tauri.linux.conf.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "bundle": { - "targets": ["appimage"], - "linux": { - "appimage": { - "bundleMediaFramework": true - } - } - } -} diff --git a/crates/workshop/desktop/tauri.macos.conf.json b/crates/workshop/desktop/tauri.macos.conf.json deleted file mode 100644 index 631d1529c..000000000 --- a/crates/workshop/desktop/tauri.macos.conf.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "bundle": { - "targets": ["app"], - "macOS": { - "entitlements": "./Entitlements.plist", - "infoPlist": "./Info.plist" - } - } -} diff --git a/crates/workshop/desktop/tauri.unsigned.conf.json b/crates/workshop/desktop/tauri.unsigned.conf.json deleted file mode 100644 index 55a99462c..000000000 --- a/crates/workshop/desktop/tauri.unsigned.conf.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "bundle": { - "createUpdaterArtifacts": false - } -} diff --git a/crates/workshop/desktop/tauri.windows.conf.json b/crates/workshop/desktop/tauri.windows.conf.json deleted file mode 100644 index a4ee420d8..000000000 --- a/crates/workshop/desktop/tauri.windows.conf.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "bundle": { - "externalBin": ["binaries/runvil-gateway"], - "targets": ["nsis"] - } -} diff --git a/crates/workshop/gateway-discovery/CONTRACT.md b/crates/workshop/gateway-discovery/CONTRACT.md deleted file mode 100644 index f53d5e864..000000000 --- a/crates/workshop/gateway-discovery/CONTRACT.md +++ /dev/null @@ -1,198 +0,0 @@ -# The Gateway discovery contract - -The Gateway and Workshop meet through a file plus three HTTP calls. Nothing else is shared. This document is the whole of that meeting point: the file format, the two lock files, the stale rule, the shutdown route, the three calls, and the fixtures that pin them. - -Both sides are written against this text. Workshop's reader is [`workshop-gateway-discovery`](src/lib.rs), and the Gateway's writer lives in the `runvil-gateway` repository, which spells the same protocol from the other side. When the two disagree, this document is right and one of them has a bug. - -- Names in the file: `gateway.json`, `gateway.json.lock`, `gateway.instance.lock`, `gateway.log` -- Endpoints: `GET /health`, `GET /v1/models`, `POST /shutdown` -- Address: `127.0.0.1:{port}`, never `localhost` -- Contract fixtures: [`fixtures/`](fixtures/) - -## The discovery file - -The Gateway writes `gateway.json` into the run directory once it has bound its loopback port. A reader that finds a live file attaches to that Gateway instead of starting a second one. This is the Jupyter `connection file` pattern. - -### The six fields - -```json -{ - "port": 8081, - "api_key": "0e1f8f0d8f8c4e0f9a1c2b3d4e5f6a7b", - "pid": 4242, - "epoch": 1757000000, - "version": "0.2.0", - "started_at": "2026-09-03T12:00:00Z" -} -``` - -- `port` (integer, `u16`): the port the Gateway bound. A reader connects to `127.0.0.1:{port}`. Must not be `0`. -- `api_key` (string): the bearer key every authenticated request carries. Must not be empty and must not contain a carriage return or a line feed, because it is written into an `Authorization` header line. -- `pid` (integer, `u32`): the Gateway process id, used by stale detection. Must not be `0`. -- `epoch` (integer, `u64`): the boot instant in unix seconds. `0` means "not recorded", and a file with `0` carries no boot identity, so it reads as stale. -- `version` (string): the Gateway crate version, for diagnostics only. -- `started_at` (string): the same boot instant as an RFC 3339 timestamp, for diagnostics and for boot identity comparison. A whitespace-only value means "not recorded". - -### Unknown fields - -A reader must tolerate fields it does not know. serde's derived `Deserialize` ignores them, which is what both sides rely on, so a newer Gateway can add a field without breaking an older Workshop. - -### Validation - -A file is valid when `port` is nonzero, `api_key` is nonempty and holds no line break, and `pid` is nonzero. A file that fails validation is not "absent": it is stale with reason `Invalid`, and the reader deletes it. - -A file carries a **boot identity** when `epoch` is nonzero and `started_at` is not blank. A file with valid fields but no boot identity is stale with reason `BootIdentityInvalid`. - -### JSON Schema - -The schema below is the machine-readable form of the field table. It is written by hand and kept beside the fixtures rather than generated, because the contract is the input to both sides, not an output of either. - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://runvil.example/schemas/gateway.json", - "title": "Gateway discovery file", - "type": "object", - "required": ["port", "api_key", "pid", "epoch", "version", "started_at"], - "additionalProperties": true, - "properties": { - "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, - "api_key": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]*$" }, - "pid": { "type": "integer", "minimum": 1, "maximum": 4294967295 }, - "epoch": { "type": "integer", "minimum": 0 }, - "version": { "type": "string" }, - "started_at": { "type": "string" } - } -} -``` - -`additionalProperties` is `true` on purpose: unknown fields are tolerated. The `api_key` pattern is the "no line break" rule. `epoch` allows `0` even though a zero epoch makes the file stale, because the field is present-and-informational while the boot identity rule decides staleness. - -## The run directory and the two locks - -### Where things live - -- The run directory is `/.runvil/run`, where `` is the user profile directory the OS reports. -- The state directory is `/.runvil`, the run directory's parent. -- `gateway.json`, `gateway.json.lock`, and `gateway.instance.lock` all sit in the run directory, beside each other. -- `gateway.log` sits in `/.runvil/logs/gateway.log`, beside the run directory rather than in it, because the Gateway starts logging before it loads its configuration, so a Gateway that exits on its configuration still records why. - -### The atomic owner-only write - -The Gateway must never leave a truncated `gateway.json`. A reader racing a writer must see either the whole previous file or the whole new one. So every write goes: - -1. to a uniquely named sibling temp file, `gateway.json.-.rv-tmp`, so two concurrent writers never fill the same temp; -2. created with mode `0600` on Unix, so the bearer key never exists in a looser file and the rename carries that mode to the target; -3. synced to disk; -4. renamed over `gateway.json`, which is atomic within a directory. - -A failed write removes the temp and leaves the target's previous contents. On Windows there are no mode bits, and the file relies on the user profile's ACL, which already restricts it to the owner. - -Clean shutdown removes the file with `remove_if_mine`: it removes `gateway.json` only when the file's `pid` is the remover's own, so a shutting-down Gateway cannot delete a replacement's file. A missing file is not an error. An unreadable or foreign file is left for the next reader's stale cleanup. - -### `gateway.json.lock` - the launch election - -The problem it solves: two readers find no live Gateway at the same moment and both launch one. The Jupyter phantom-server bug class. The lock settles the race so exactly one launches. - -- It is an OS advisory lock on an ordinary file in the run directory. Whoever holds it is the launcher. -- The holder **re-validates** `gateway.json` after taking the lock before deciding to launch, because a previous winner may have written the file and released the lock in between. -- While another process holds it, a loser polls every 25 ms. As soon as the winner's file goes live the loser attaches to it. -- A loser never deletes anything. Cleanup of a stale file is the lock holder's privilege, because only the holder knows no launch is in flight. -- A loser that waits out its budget without an attachable Gateway reports a launch timeout. -- Dropping the file handle releases the lock; there is no pid file and no explicit unlock. - -### `gateway.instance.lock` - process-lifetime ownership - -A different question from the launch election: is a Gateway process already alive on this machine? - -- Every Gateway process acquires this lock before it performs any startup side effect, and holds it until the process exits. -- It is separate from `gateway.json.lock` because a Workshop parent may hold the launch election while the spawned Gateway acquires this lease. One lock cannot answer both questions. -- Acquisition is non-blocking. "Another process owns it" is a normal answer, not an error. -- The OS releases it when the owning process dies, so a killed Gateway frees it with no cleanup path. - -## The stale rule - -A reader attaches only to a file that proves a live, authorized Gateway **right now**. Everything else is stale: the file is deleted and the reader relaunches or falls back to explicit configuration. - -The rule, in order. Each step's failure is the reason recorded. - -1. **Read.** No file: nothing to attach to and nothing to clean. The file exists but cannot be read: an I/O error, not staleness. The file is not valid JSON, or fails field validation: stale, `Invalid`. The file is deleted. -2. **One process boot, first observation.** Look up the pid's image and start marker. No such process: stale, `ProcessDead`. The image's file name is not `runvil-gateway` (`runvil-gateway.exe` on Windows, case-insensitively): stale, `ImageMismatch`. This is the reused-pid case. -3. **Boot identity.** The file must carry one; see above. Missing: stale, `BootIdentityInvalid`. -4. **One connection, two proofs.** Open one TCP connection to `127.0.0.1:{port}` and, on that same connection, send `GET /health` and then the bearer proof on `GET /v1/models`. Same connection matters: authority cannot come from a listener that replaced the endpoint after the health answer. Failures: - - health did not answer `200`: stale, `HealthFailed`; - - the socket was lost after health succeeded: stale, `HealthFailed`, and the reader does not reconnect, because a new connection could reach a different endpoint; - - the bearer answer was not 2xx: stale, `KeyRejected`. -5. **One process boot, second observation.** Re-read the pid's image and start marker and require them identical to step 2's. The pid died or was reused: stale, `ProcessChanged`. - -The two observations bracket the network proof on purpose. That closes the health-to-bearer replacement gap and rejects pid reuse during the interval. The result is a point-in-time proof; it makes no claim that the process is still live after validation returns. - -A reader that must not delete (a launch-race loser) runs the same rule but treats the answer as a yes/no and leaves the file alone. - -### The stale reasons - -Every reader-visible reason, with what it means for an operator. - -- `Invalid` - the file was not valid JSON or failed field validation. Something wrote it badly, or it was truncated by a writer that is not following the atomic write. -- `ProcessDead` - the recorded pid names no process. The Gateway exited without withdrawing its file, typically a crash. -- `ImageMismatch` - the pid is alive but runs a different program, so the pid was reused. The recorded Gateway is gone. -- `BootIdentityInvalid` - the file carries no boot identity, so the reader cannot tell one boot from another on the same pid and port. -- `ProcessChanged` - the process identity changed while validation ran. The recorded Gateway died mid-proof. -- `HealthFailed` - the process is the right image but does not answer `GET /health` with `200`. It is wedged or still starting. -- `KeyRejected` - the process is the right image and is healthy, but refused the file's bearer key. The file is from a different boot than the listener. - -`HealthFailed` and `KeyRejected` are different operator problems: a wedged Gateway versus a stale key. Keep them distinct. - -## `POST /shutdown` and the log - -### The shutdown route - -A reader holding a validated connection asks the Gateway to exit by posting its bearer key to `POST /shutdown`. - -- The request is `POST /shutdown HTTP/1.0` with `Host: 127.0.0.1:{port}` and `Authorization: Bearer {api_key}`. -- A 2xx answer means the Gateway accepted and is draining. The caller returns without waiting for the process to die; the launch election's instance lock is what actually reports the process going away. -- Any other answer is a rejection, reported with its status line. -- A delivery failure is an I/O error, including connect refused and read timeout. -- The request carries an absolute deadline where the caller has one, so cleanup of a child that never answers cannot outlive its budget. - -The bearer is never in an error message or in `Debug` output. A rejected shutdown whose status line echoes the bearer must not surface the key. - -### The log path - -`/.runvil/logs/gateway.log`. A reader that sees a launched Gateway exit without writing a file names this path in the error, because that log is the only place the Gateway's own reason appears. - -## The three HTTP calls - -All three are raw HTTP over `std::net::TcpStream`: no HTTP client crate, no TLS, no chunked encoding, no keep-alive beyond the one validation connection. - -### `GET /health` - -- Request: `GET /health HTTP/1.0`, `Host: 127.0.0.1:{port}`, blank line. -- Success: the status code is `200`. The body is drained and ignored. -- Used two ways: as a readiness poll while a launched Gateway comes up, and as the first half of the validation proof. -- The readiness poll retries every 25 ms up to its budget, with a per-attempt connect and read timeout so one hung attempt cannot eat the whole budget. A failed attempt does not end the poll; the budget does, reporting the last attempt's error. - -### `GET /v1/models` - -- Request: `GET /v1/models HTTP/1.0`, `Host: 127.0.0.1:{port}`, `Authorization: Bearer {api_key}`, blank line. -- Success: a 2xx status. The body is drained and ignored. -- This is the authority proof: a 2xx says the bearer in the file is the bearer the listener accepts. It is sent on the same connection as the health request, in that order. - -### `POST /shutdown` - -Described above. - -### The `Host` header rule - -Every one of the three sends `Host: 127.0.0.1:{port}`, the address the caller actually connected to. The Gateway's loopback `Host` allowlist refuses anything else, so sending `localhost` (or a bare host with no port) fails the allowlist and reads as a rejection that has nothing to do with the key. Never send `localhost`. - -## Golden fixtures - -The `fixtures/` directory holds committed `gateway.json` bytes. They are the drift alarm: a change to the writer's serialization that a reader would misread shows up here. - -- [`fixtures/gateway.json`](fixtures/gateway.json) - a canonical file, the shape the Gateway writes. -- [`fixtures/gateway-unknown-field.json`](fixtures/gateway-unknown-field.json) - the same file with one field a reader does not know, which must still parse. -- [`fixtures/gateway-no-boot-identity.json`](fixtures/gateway-no-boot-identity.json) - a valid-fields file with a zero epoch and blank start time, which parses but carries no boot identity. -- [`fixtures/gateway-invalid-port.json`](fixtures/gateway-invalid-port.json) - a zero port, which parses and fails validation. - -The tests that read these fixtures live beside the reader. The Gateway's own writer is pinned by the same fixtures from its side. diff --git a/crates/workshop/gateway-discovery/Cargo.toml b/crates/workshop/gateway-discovery/Cargo.toml deleted file mode 100644 index b17c9d4e3..000000000 --- a/crates/workshop/gateway-discovery/Cargo.toml +++ /dev/null @@ -1,42 +0,0 @@ -[package] -name = "workshop-gateway-discovery" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "The Gateway discovery contract as Workshop reads it: gateway.json, the launch election, stale detection, and authenticated shutdown" - -[dependencies] -serde.workspace = true -serde_json.workspace = true -thiserror.workspace = true - -# Process-image lookup for stale detection (src/sys/macos.rs): proc_pidpath. -[target.'cfg(target_os = "macos")'.dependencies] -libc.workspace = true - -# Process-image lookup for stale detection (src/sys/windows.rs): -# OpenProcess + QueryFullProcessImageNameW. -[target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { workspace = true, features = [ - "Win32_Foundation", - "Win32_System_Threading", -] } - -[features] -# The writer half, which Workshop ships only in test builds: a test double -# writes a contract-shaped `gateway.json` and holds a process lease. No -# production Workshop code writes the file; the Gateway owns that. -test-fixtures = [] - -[dev-dependencies] -tempfile.workspace = true -# The crate's own `test-fixtures` feature, so `cargo test -p -# workshop-gateway-discovery` compiles the writer half its unit tests drive -# without `--all-features`. -workshop-gateway-discovery = { path = ".", features = ["test-fixtures"] } - -[lints] -workspace = true diff --git a/crates/workshop/gateway-discovery/fixtures/gateway-invalid-port.json b/crates/workshop/gateway-discovery/fixtures/gateway-invalid-port.json deleted file mode 100644 index ca9da3b23..000000000 --- a/crates/workshop/gateway-discovery/fixtures/gateway-invalid-port.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "port": 0, - "api_key": "0e1f8f0d8f8c4e0f9a1c2b3d4e5f6a7b", - "pid": 4242, - "epoch": 1757000000, - "version": "0.2.0", - "started_at": "2026-09-03T12:00:00Z" -} diff --git a/crates/workshop/gateway-discovery/fixtures/gateway-no-boot-identity.json b/crates/workshop/gateway-discovery/fixtures/gateway-no-boot-identity.json deleted file mode 100644 index 4ad8c3fbf..000000000 --- a/crates/workshop/gateway-discovery/fixtures/gateway-no-boot-identity.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "port": 8081, - "api_key": "0e1f8f0d8f8c4e0f9a1c2b3d4e5f6a7b", - "pid": 4242, - "epoch": 0, - "version": "0.2.0", - "started_at": "" -} diff --git a/crates/workshop/gateway-discovery/fixtures/gateway-unknown-field.json b/crates/workshop/gateway-discovery/fixtures/gateway-unknown-field.json deleted file mode 100644 index 0ada599d4..000000000 --- a/crates/workshop/gateway-discovery/fixtures/gateway-unknown-field.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "port": 8081, - "api_key": "0e1f8f0d8f8c4e0f9a1c2b3d4e5f6a7b", - "pid": 4242, - "epoch": 1757000000, - "version": "0.2.0", - "started_at": "2026-09-03T12:00:00Z", - "future_field": { - "nested": [1, 2, 3] - } -} diff --git a/crates/workshop/gateway-discovery/fixtures/gateway.json b/crates/workshop/gateway-discovery/fixtures/gateway.json deleted file mode 100644 index bce7df716..000000000 --- a/crates/workshop/gateway-discovery/fixtures/gateway.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "port": 8081, - "api_key": "0e1f8f0d8f8c4e0f9a1c2b3d4e5f6a7b", - "pid": 4242, - "epoch": 1757000000, - "version": "0.2.0", - "started_at": "2026-09-03T12:00:00Z" -} diff --git a/crates/workshop/gateway-discovery/src/atomic.rs b/crates/workshop/gateway-discovery/src/atomic.rs deleted file mode 100644 index 0b18716ca..000000000 --- a/crates/workshop/gateway-discovery/src/atomic.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! Crash-safe, owner-only file writes for the gateway discovery file: each write -//! lands in a uniquely named sibling temp file, is synced to disk, and is -//! renamed over the target, so a crash at any moment leaves either the old -//! contents or the new, never a truncation. Writes are owner-only because -//! the gateway discovery file holds the bearer key. -//! -//! This module exists only under the `test-fixtures` feature. Production -//! Workshop never writes the file; the Gateway owns that half of the -//! contract, and this is Workshop's test double copying its rule. - -use std::fs::{self, OpenOptions}; -use std::io::{self, Write as _}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; - -/// The suffix on every atomic-write temp file. -const TEMP_SUFFIX: &str = ".rv-tmp"; - -/// Process-wide counter making each temp name unique, so two concurrent -/// writes to the same target never interleave inside one temp file: each -/// writer fills its own temp completely and the last rename wins whole. -static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); - -/// The unique sibling temp path for one write to `path`, or `None` when -/// `path` has no file name to derive it from. -fn temp_path(path: &Path) -> Option { - let mut name = path.file_name()?.to_os_string(); - let counter = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed); - name.push(format!(".{}-{counter}{TEMP_SUFFIX}", std::process::id())); - Some(path.with_file_name(name)) -} - -/// Writes `bytes` to `path` atomically with owner-only permissions: the -/// bytes land in a unique sibling temp file created with mode `0600` on -/// Unix (the rename preserves it, so the target never exists with looser -/// permissions), are synced to disk, and the temp is renamed over `path`. -/// On failure the temp file is removed and the target keeps its previous -/// contents. -/// -/// On Windows there are no mode bits; the file sits under the user -/// profile, whose ACL already restricts it to the owner. Best-effort by -/// design. -pub(crate) fn write_atomic_owner_only(path: &Path, bytes: &[u8]) -> io::Result<()> { - let Some(temp) = temp_path(path) else { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "write target has no file name", - )); - }; - let result = write_temp_then_rename(&temp, path, bytes); - if result.is_err() { - // Best-effort: when the create itself failed there is nothing to - // remove. - let _ = fs::remove_file(&temp); - } - result -} - -/// The Unix write: the temp file is born owner-only. -#[cfg(unix)] -fn write_temp_then_rename(temp: &Path, path: &Path, bytes: &[u8]) -> io::Result<()> { - use std::os::unix::fs::OpenOptionsExt as _; - let mut file = OpenOptions::new() - .write(true) - .create_new(true) - .mode(0o600) - .open(temp)?; - file.write_all(bytes)?; - file.sync_data()?; - fs::rename(temp, path) -} - -/// The Windows write: no mode bits exist; the user profile's ACL is the -/// permission boundary. -#[cfg(not(unix))] -fn write_temp_then_rename(temp: &Path, path: &Path, bytes: &[u8]) -> io::Result<()> { - let mut file = OpenOptions::new().write(true).create_new(true).open(temp)?; - file.write_all(bytes)?; - file.sync_data()?; - fs::rename(temp, path) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// The sorted file names in `dir`. - fn dir_names(dir: &Path) -> Vec { - let mut names: Vec = fs::read_dir(dir) - .expect("the test directory is listable") - .map(|entry| { - entry - .expect("the test entry is readable") - .file_name() - .to_string_lossy() - .into_owned() - }) - .collect(); - names.sort(); - names - } - - #[test] - fn a_write_lands_whole_and_leaves_no_temp_behind() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let target = dir.path().join("gateway.json"); - write_atomic_owner_only(&target, b"one").expect("the first write succeeds"); - assert_eq!(fs::read(&target).expect("readable"), b"one"); - write_atomic_owner_only(&target, b"two").expect("the overwrite succeeds"); - assert_eq!(fs::read(&target).expect("readable"), b"two"); - assert_eq!(dir_names(dir.path()), ["gateway.json"]); - } - - #[test] - fn a_failed_rename_removes_the_temp_and_reports_the_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // A directory in the target's place fails the rename after the - // temp is fully written, exercising the cleanup path. - let target = dir.path().join("gateway.json"); - fs::create_dir(&target).expect("directory in the target's place"); - write_atomic_owner_only(&target, b"payload") - .expect_err("renaming over a directory must fail"); - assert_eq!(dir_names(dir.path()), ["gateway.json"]); - assert!(target.is_dir(), "the failed write touched nothing"); - } - - #[cfg(unix)] - #[test] - fn the_written_file_is_owner_only() { - use std::os::unix::fs::PermissionsExt as _; - let dir = tempfile::TempDir::new().expect("tempdir"); - let target = dir.path().join("gateway.json"); - write_atomic_owner_only(&target, b"payload").expect("the write succeeds"); - let mode = target.metadata().expect("metadata").permissions().mode(); - assert_eq!(mode & 0o777, 0o600, "the bearer key file is owner-only"); - } -} diff --git a/crates/workshop/gateway-discovery/src/cancellation.rs b/crates/workshop/gateway-discovery/src/cancellation.rs deleted file mode 100644 index 17268c387..000000000 --- a/crates/workshop/gateway-discovery/src/cancellation.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Cooperative cancellation for synchronous sidecar work. - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Condvar, Mutex, PoisonError}; -use std::time::Duration; - -/// A clonable cancellation signal for finite sidecar operations. -/// -/// Cancellation wakes retry waits immediately. [`run_if_active`](Self::run_if_active) -/// also provides a linearization point for effects such as process launch and -/// authoritative publication: cancellation first wakes a cancellable effect -/// already in progress, then waits for it, and no effect starts after -/// cancellation returns. -#[derive(Clone, Debug, Default)] -pub struct CancellationToken { - state: Arc, -} - -#[derive(Debug, Default)] -struct CancellationState { - canceled: AtomicBool, - effect: Mutex<()>, - waiter: Mutex<()>, - wake: Condvar, -} - -impl CancellationToken { - /// Creates an active cancellation token. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Cancels the token and wakes every retry wait. - pub fn cancel(&self) { - let waiter = self - .state - .waiter - .lock() - .unwrap_or_else(PoisonError::into_inner); - self.state.canceled.store(true, Ordering::SeqCst); - self.state.wake.notify_all(); - drop(waiter); - - // Wait for an operation that already crossed the effect gate. The - // cancellation flag and wake happen first, so a cancellable operation - // inside the gate can finish instead of deadlocking with `cancel`. - drop( - self.state - .effect - .lock() - .unwrap_or_else(PoisonError::into_inner), - ); - } - - /// Whether cancellation has been requested. - #[must_use] - pub fn is_canceled(&self) -> bool { - self.state.canceled.load(Ordering::SeqCst) - } - - /// Waits for cancellation or until `timeout` elapses. - /// - /// Returns `true` when the token is canceled. - #[must_use] - pub fn wait_timeout(&self, timeout: Duration) -> bool { - if self.is_canceled() { - return true; - } - let waiter = self - .state - .waiter - .lock() - .unwrap_or_else(PoisonError::into_inner); - if self.is_canceled() { - return true; - } - let (_waiter, _) = self - .state - .wake - .wait_timeout_while(waiter, timeout, |()| !self.is_canceled()) - .unwrap_or_else(PoisonError::into_inner); - self.is_canceled() - } - - /// Runs `operation` only while the token remains active. - /// - /// Cancellation and effect admission are mutually exclusive. Cancellation - /// is signalled before waiting for an admitted operation, so that operation - /// may call [`wait_timeout`](Self::wait_timeout) and stop within its bound. - /// The gate is not reentrant: `operation` must not call `cancel` or - /// `run_if_active` on this token. - pub fn run_if_active(&self, operation: impl FnOnce() -> T) -> Option { - let effect = self - .state - .effect - .lock() - .unwrap_or_else(PoisonError::into_inner); - if self.is_canceled() { - return None; - } - let result = operation(); - drop(effect); - Some(result) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::mpsc; - use std::time::Instant; - - #[test] - fn cancellation_wakes_an_operation_inside_the_effect_gate() { - let cancellation = CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let effects = Arc::new(AtomicUsize::new(0)); - let worker_effects = Arc::clone(&effects); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - worker_cancellation.run_if_active(|| { - entered.send(()).expect("announce the gated operation"); - if !worker_cancellation.wait_timeout(Duration::from_secs(1)) { - worker_effects.fetch_add(1, Ordering::SeqCst); - } - }); - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the operation enters the effect gate"); - - let started = Instant::now(); - cancellation.cancel(); - worker.join().expect("the canceled operation joins"); - - assert!( - started.elapsed() < Duration::from_millis(250), - "cancellation wakes an operation already inside the effect gate" - ); - assert_eq!( - effects.load(Ordering::SeqCst), - 0, - "the canceled operation performs no later effect" - ); - } -} diff --git a/crates/workshop/gateway-discovery/src/error.rs b/crates/workshop/gateway-discovery/src/error.rs deleted file mode 100644 index 3ee19670f..000000000 --- a/crates/workshop/gateway-discovery/src/error.rs +++ /dev/null @@ -1,102 +0,0 @@ -//! The gateway-discovery-file and launch-lock error type. -//! -//! [`SidecarError`] is what the file and lock operations return; the health -//! probe has its own [`crate::HealthError`]. - -use std::path::PathBuf; -use std::time::Duration; - -/// A failure of a gateway-discovery-file or launch-lock operation. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum SidecarError { - /// The caller canceled the sidecar operation. - #[error("the sidecar operation was canceled")] - Canceled, - - /// The run directory could not be created. - #[error("create the run directory {path}")] - CreateDir { - /// The directory that could not be created. - path: PathBuf, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The gateway discovery file existed but could not be read. - #[error("read {path}")] - Read { - /// The file that could not be read. - path: PathBuf, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The gateway discovery file was not valid JSON. - #[error("parse {path}")] - Parse { - /// The file that could not be parsed. - path: PathBuf, - /// The underlying JSON error. - #[source] - source: serde_json::Error, - }, - - /// The gateway discovery file failed validation. - #[error("invalid gateway discovery file {path}: {reason}")] - Invalid { - /// The file that failed validation. - path: PathBuf, - /// The broken invariant. - reason: String, - }, - - /// The gateway discovery file could not be serialized for writing. - #[cfg(feature = "test-fixtures")] - #[error("serialize the gateway discovery file")] - Serialize { - /// The underlying JSON error. - #[source] - source: serde_json::Error, - }, - - /// The atomic write of the gateway discovery file failed. - #[cfg(feature = "test-fixtures")] - #[error("write {path}")] - Write { - /// The file that could not be written. - path: PathBuf, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The gateway discovery file could not be removed. - #[error("remove {path}")] - Remove { - /// The file that could not be removed. - path: PathBuf, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The launch lock could not be opened or taken. - #[error("lock {path}")] - Lock { - /// The lock file that failed. - path: PathBuf, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The launch race produced no attachable gateway within the budget. - #[error("no launch winner became attachable within {timeout:?}")] - LaunchTimeout { - /// The budget that elapsed. - timeout: Duration, - }, -} diff --git a/crates/workshop/gateway-discovery/src/file-tests.rs b/crates/workshop/gateway-discovery/src/file-tests.rs deleted file mode 100644 index e58efa520..000000000 --- a/crates/workshop/gateway-discovery/src/file-tests.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! The `gateway.json` reader and its golden fixtures: the six fields, the -//! tolerated unknown field, the boot identity, and validation. - -use super::*; - -/// A canonical valid file, matching `fixtures/gateway.json`. -fn valid_file() -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port: 8081, - api_key: "0e1f8f0d8f8c4e0f9a1c2b3d4e5f6a7b".to_owned(), - pid: 4242, - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } -} - -/// One committed contract fixture's bytes. -fn fixture(name: &str) -> String { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("fixtures") - .join(name); - std::fs::read_to_string(&path) - .unwrap_or_else(|error| panic!("read the contract fixture {}: {error}", path.display())) -} - -/// Writes one fixture into a fresh run directory and parses it back. -fn parse_fixture(name: &str) -> Result, SidecarError> { - let dir = tempfile::TempDir::new().expect("tempdir"); - fs::write( - crate::paths::gateway_discovery_file_path(dir.path()), - fixture(name), - ) - .expect("write the fixture"); - GatewayDiscoveryFile::read(dir.path()) -} - -#[test] -fn the_golden_fixture_parses_to_the_canonical_file() { - let parsed = parse_fixture("gateway.json").expect("the golden fixture is valid"); - assert_eq!(parsed, Some(valid_file())); -} - -#[test] -#[cfg(feature = "test-fixtures")] -fn the_writer_reproduces_the_golden_fixture_byte_for_byte() { - // The committed bytes are the drift alarm for the serialization: a - // change to the field order, the pretty-printing, or a field name - // shows up here rather than in a Gateway that no longer attaches. The - // writer emits no trailing newline and the committed file carries one - // because a text file should end in a newline, so the comparison is - // against the fixture without it. - let dir = tempfile::TempDir::new().expect("tempdir"); - valid_file().write_to(dir.path()).expect("write"); - let written = - fs::read_to_string(crate::paths::gateway_discovery_file_path(dir.path())).expect("read"); - let golden = fixture("gateway.json"); - assert_eq!( - written, - golden.trim_end_matches('\n'), - "the writer's bytes are the committed contract bytes" - ); -} - -#[test] -fn an_unknown_field_is_tolerated() { - let parsed = parse_fixture("gateway-unknown-field.json") - .expect("a newer Gateway's extra field must not break this reader"); - assert_eq!( - parsed, - Some(valid_file()), - "the known fields read unchanged" - ); -} - -#[test] -fn a_file_without_a_boot_identity_parses_but_carries_none() { - let parsed = parse_fixture("gateway-no-boot-identity.json") - .expect("valid fields parse") - .expect("the file exists"); - assert!( - !parsed.has_boot_identity(), - "a zero epoch and a blank start time is no boot identity" - ); -} - -#[test] -fn a_zero_port_is_a_validation_error() { - let error = - parse_fixture("gateway-invalid-port.json").expect_err("a zero port names no endpoint"); - let SidecarError::Invalid { reason, .. } = &error else { - panic!("a zero port is a validation error, got {error}"); - }; - assert_eq!(reason, "port must not be 0"); -} - -#[test] -fn the_field_names_and_types_are_the_contract() { - // Pins the wire names and types independently of the fixture file, so - // a rename or a type change fails here even if nobody updates the - // fixture. The future OpenAPI spec describes the same two fields for - // the admin status route; this is the discovery file's six. - let value = serde_json::to_value(valid_file()).expect("the file is representable"); - let object = value.as_object().expect("the file is a JSON object"); - let mut names: Vec<&str> = object.keys().map(String::as_str).collect(); - names.sort_unstable(); - assert_eq!( - names, - ["api_key", "epoch", "pid", "port", "started_at", "version"], - "the contract names exactly these six fields" - ); - assert!(object["port"].is_u64(), "port is an integer"); - assert!(object["api_key"].is_string(), "api_key is a string"); - assert!(object["pid"].is_u64(), "pid is an integer"); - assert!(object["epoch"].is_u64(), "epoch is an integer"); - assert!(object["version"].is_string(), "version is a string"); - assert!(object["started_at"].is_string(), "started_at is a string"); -} - -#[test] -fn validation_rejects_the_three_attach_fields() { - let mut file = valid_file(); - assert_eq!(file.validation_error(), None); - file.port = 0; - assert_eq!(file.validation_error(), Some("port must not be 0")); - file = valid_file(); - file.api_key.clear(); - assert_eq!(file.validation_error(), Some("api_key must not be empty")); - file = valid_file(); - file.api_key = "key\r\ninjected: value".to_owned(); - assert_eq!( - file.validation_error(), - Some("api_key must not contain line breaks") - ); - file = valid_file(); - file.pid = 0; - assert_eq!(file.validation_error(), Some("pid must not be 0")); -} - -#[test] -fn debug_redacts_bearer_and_untrusted_metadata() { - let secret = "capability-secret"; - let mut file = valid_file(); - file.api_key = secret.to_owned(); - file.version = format!("version-{secret}\r\n"); - file.started_at = format!("started-{secret}\t"); - - let debug = format!("{file:?}"); - assert!(!debug.contains(secret), "debug output redacts the bearer"); - assert!( - !debug.contains(['\r', '\n', '\t']), - "untrusted metadata cannot inject debug output" - ); - assert!( - debug.contains(&file.port.to_string()), - "the endpoint remains visible" - ); -} - -#[test] -fn read_returns_none_when_no_file_exists() { - let dir = tempfile::TempDir::new().expect("tempdir"); - assert_eq!(GatewayDiscoveryFile::read(dir.path()).expect("read"), None); -} - -#[test] -fn read_rejects_corrupt_json() { - let dir = tempfile::TempDir::new().expect("tempdir"); - fs::write( - crate::paths::gateway_discovery_file_path(dir.path()), - b"not json", - ) - .expect("write fixture"); - let error = GatewayDiscoveryFile::read(dir.path()).expect_err("corrupt JSON must fail"); - assert!( - matches!(error, SidecarError::Parse { .. }), - "a corrupt file is a parse error: {error}" - ); -} - -#[test] -fn read_rejects_an_unreadable_path() { - let dir = tempfile::TempDir::new().expect("tempdir"); - fs::create_dir(crate::paths::gateway_discovery_file_path(dir.path())) - .expect("a directory in the file's place"); - let error = GatewayDiscoveryFile::read(dir.path()).expect_err("a directory is not a file"); - assert!( - matches!(error, SidecarError::Read { .. }), - "an unreadable path is a read error, not staleness: {error}" - ); -} - -#[test] -#[cfg(feature = "test-fixtures")] -fn write_refuses_an_invalid_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let mut file = valid_file(); - file.api_key.clear(); - let error = file - .write_to(dir.path()) - .expect_err("an invalid file must not be written"); - assert!(matches!(error, SidecarError::Invalid { .. })); - assert!( - !crate::paths::gateway_discovery_file_path(dir.path()).exists(), - "nothing was written" - ); -} - -#[test] -#[cfg(feature = "test-fixtures")] -fn write_creates_a_missing_run_directory() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let run_dir = dir.path().join("nested").join("run"); - valid_file().write_to(&run_dir).expect("write"); - assert!(crate::paths::gateway_discovery_file_path(&run_dir).exists()); -} - -#[test] -#[cfg(feature = "test-fixtures")] -fn remove_if_mine_removes_only_the_owning_pid() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = valid_file(); - file.write_to(dir.path()).expect("write"); - let path = crate::paths::gateway_discovery_file_path(dir.path()); - - remove_if_mine(dir.path(), 9999).expect("a foreign pid is tolerated"); - assert!(path.exists(), "a foreign pid's removal spares the file"); - - remove_if_mine(dir.path(), file.pid).expect("the owning pid removes"); - assert!(!path.exists(), "the owning pid's removal deletes the file"); -} - -#[test] -#[cfg(feature = "test-fixtures")] -fn remove_if_mine_tolerates_a_missing_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - remove_if_mine(dir.path(), 4242).expect("a missing file is not an error"); -} diff --git a/crates/workshop/gateway-discovery/src/file.rs b/crates/workshop/gateway-discovery/src/file.rs deleted file mode 100644 index 309a8baf9..000000000 --- a/crates/workshop/gateway-discovery/src/file.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! The `gateway.json` type: what the Gateway writes after a successful bind -//! and what a reader validates before attaching. - -use std::fmt; -use std::fs; -use std::io; -use std::path::Path; - -use serde::{Deserialize, Serialize}; - -use crate::error::SidecarError; -use crate::paths::gateway_discovery_file_path; - -/// The on-disk gateway discovery file naming a running gateway's loopback -/// port and bearer key. -/// -/// Readers must tolerate unknown fields: a newer gateway may write fields -/// an older reader does not know, and serde ignores them. This is the six -/// fields the contract names; see `CONTRACT.md`. -#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct GatewayDiscoveryFile { - /// The gateway's bound port; readers connect to `127.0.0.1:{port}`. - pub port: u16, - /// The bearer key the gateway expects. - pub api_key: String, - /// The gateway process id. - pub pid: u32, - /// The gateway's boot instant in unix seconds. - pub epoch: u64, - /// The gateway crate version. - pub version: String, - /// RFC 3339 rendering of the boot instant. - pub started_at: String, -} - -impl fmt::Debug for GatewayDiscoveryFile { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("GatewayDiscoveryFile") - .field("port", &self.port) - .field("api_key", &"[REDACTED]") - .field("pid", &self.pid) - .field("epoch", &self.epoch) - .field("version", &"[REDACTED]") - .field("started_at", &"[REDACTED]") - .finish() - } -} - -impl GatewayDiscoveryFile { - /// Whether the connection names one particular Gateway boot. - pub(crate) fn has_boot_identity(&self) -> bool { - self.epoch != 0 && !self.started_at.trim().is_empty() - } - - /// The reason the file fails validation, or `None` when it is valid. - /// - /// Validation covers the fields attach depends on: a real port, a - /// real key, a real pid. The remaining fields are informational. - #[must_use] - pub fn validation_error(&self) -> Option<&'static str> { - if self.port == 0 { - return Some("port must not be 0"); - } - if self.api_key.is_empty() { - return Some("api_key must not be empty"); - } - if self.api_key.contains(['\r', '\n']) { - return Some("api_key must not contain line breaks"); - } - if self.pid == 0 { - return Some("pid must not be 0"); - } - None - } - - /// Reads and validates the gateway discovery file in `run_dir`, - /// returning `None` when no file exists. - /// - /// # Errors - /// Returns [`SidecarError::Read`] when the file exists but cannot be - /// read, [`SidecarError::Parse`] when it is not valid JSON, and - /// [`SidecarError::Invalid`] when it fails validation. - pub fn read(run_dir: &Path) -> Result, SidecarError> { - let path = gateway_discovery_file_path(run_dir); - let raw = match fs::read_to_string(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => { - return Err(SidecarError::Read { - path, - source: error, - }); - } - }; - let file: GatewayDiscoveryFile = - serde_json::from_str(&raw).map_err(|source| SidecarError::Parse { - path: path.clone(), - source, - })?; - if let Some(reason) = file.validation_error() { - return Err(SidecarError::Invalid { - path, - reason: reason.to_owned(), - }); - } - Ok(Some(file)) - } -} - -/// The writer half of the contract, compiled only for Workshop's test -/// doubles. The Gateway owns the production writer. -#[cfg(feature = "test-fixtures")] -impl GatewayDiscoveryFile { - /// Writes the gateway discovery file into `run_dir`, creating the - /// directory when missing. The write is atomic (temp sibling, sync, - /// rename) and the file lands owner-only; see `CONTRACT.md` for the - /// permission contract. - /// - /// # Errors - /// Returns [`SidecarError::Invalid`] when the file fails validation, - /// [`SidecarError::CreateDir`] when the run directory cannot be - /// created, [`SidecarError::Serialize`] when the file cannot be - /// serialized, and [`SidecarError::Write`] when the atomic write - /// fails. - pub fn write_to(&self, run_dir: &Path) -> Result<(), SidecarError> { - let path = gateway_discovery_file_path(run_dir); - if let Some(reason) = self.validation_error() { - return Err(SidecarError::Invalid { - path, - reason: reason.to_owned(), - }); - } - fs::create_dir_all(run_dir).map_err(|source| SidecarError::CreateDir { - path: run_dir.to_owned(), - source, - })?; - let bytes = - serde_json::to_vec_pretty(self).map_err(|source| SidecarError::Serialize { source })?; - crate::atomic::write_atomic_owner_only(&path, &bytes) - .map_err(|source| SidecarError::Write { path, source }) - } -} - -/// Removes the gateway discovery file in `run_dir` when it still belongs to -/// process `pid`, so a shutting-down writer withdraws itself from discovery -/// but can never delete a replacement's file. A missing file is not an -/// error; an unreadable or foreign file is left alone for the next -/// [`crate::resolve`] to clean. -/// -/// A test-double seam: the Gateway's own writer removes its file when it -/// exits, and Workshop's doubles need the same withdrawal. -/// -/// # Errors -/// Returns [`SidecarError::Remove`] when the file is this process's own -/// but cannot be removed. -#[cfg(feature = "test-fixtures")] -pub fn remove_if_mine(run_dir: &Path, pid: u32) -> Result<(), SidecarError> { - let path = gateway_discovery_file_path(run_dir); - match fs::read_to_string(&path) { - Ok(raw) => { - let owned = serde_json::from_str::(&raw) - .is_ok_and(|file| file.pid == pid); - if !owned { - return Ok(()); - } - } - // A missing or unreadable file is left alone. - Err(_) => return Ok(()), - } - match fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(SidecarError::Remove { - path, - source: error, - }), - } -} - -#[cfg(test)] -#[path = "file-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway-discovery/src/health.rs b/crates/workshop/gateway-discovery/src/health.rs deleted file mode 100644 index 0b34aaed1..000000000 --- a/crates/workshop/gateway-discovery/src/health.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! Readiness and key probes: raw loopback HTTP/1.x over -//! `std::net::TcpStream`, enough to read a status line, with no HTTP -//! client dependency. -//! -//! The `Host` header is the bound loopback address, matching the gateway's -//! loopback `Host` allowlist. - -use std::io::{Read, Write as _}; -use std::net::{SocketAddr, TcpStream}; -use std::time::{Duration, Instant}; - -use crate::CancellationToken; - -mod connection; - -#[cfg(test)] -use connection::probe_connection; -pub(crate) use connection::{probe_connection_cancellable, probe_connection_until}; - -/// Delay between probes while the server comes up. -const RETRY_INTERVAL: Duration = Duration::from_millis(25); - -/// Per-attempt connect and read timeout, so one hung attempt cannot eat -/// the whole budget. -const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); - -/// Per-attempt I/O budget while a supervisor may be canceled. -const CANCELLABLE_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(100); -/// Maximum accepted response head for the two-request validation proof. -const RESPONSE_HEAD_LIMIT: usize = 16 * 1024; -/// Maximum body drained between the health and bearer responses. -const RESPONSE_BODY_LIMIT: usize = 64 * 1024; - -/// A failure of [`wait_for_health`]. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum HealthError { - /// The caller canceled the health wait. - #[error("the health wait was canceled")] - Canceled, - - /// The URL is not an `http://` URL. - #[error("health probe needs an http:// URL, got {url}")] - NotHttp { - /// The offending URL. - url: String, - }, - - /// No 200 answer arrived within the budget. - #[error("the server did not answer {url}/health within {timeout:?}")] - Timeout { - /// The probed base URL. - url: String, - /// The budget that elapsed. - timeout: Duration, - /// The last attempt's failure. - #[source] - source: ProbeError, - }, -} - -/// One probe attempt failed. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum ProbeError { - /// The connect, write, or read failed. - #[error("{operation}")] - Io { - /// The failed operation. - operation: &'static str, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The status line was not a 200. - #[error("unexpected health response: {status_line}")] - UnexpectedStatus { - /// The response's first line. - status_line: String, - }, -} - -/// The outcome of one bearer-key probe. -#[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum KeyProbe { - /// A 2xx answer: the key is accepted. - Accepted, - /// A non-2xx answer: the key is rejected (or the route is gone). - Rejected, - /// The server could not be queried at all. - Unreachable, -} - -/// The combined readiness and authority proof for one connection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ConnectionProbe { - /// Health answered and the bearer was accepted. - Accepted, - /// The caller canceled validation. - Canceled, - /// Health failed, including a bearer probe that became unreachable. - HealthFailed, - /// Health answered but the bearer was rejected. - KeyRejected, -} - -/// Connects within the one absolute attempt deadline. -fn connect_until(address: &SocketAddr, deadline: Instant) -> Result { - let timeout = remaining(deadline)?; - TcpStream::connect_timeout(address, timeout).map_err(|source| ProbeError::Io { - operation: "connect", - source, - }) -} - -/// Applies the remaining absolute attempt budget to reads and writes. -fn configure_stream(stream: &TcpStream, deadline: Instant) -> Result<(), ProbeError> { - let timeout = remaining(deadline)?; - stream - .set_read_timeout(Some(timeout)) - .map_err(|source| ProbeError::Io { - operation: "configure the read timeout", - source, - })?; - stream - .set_write_timeout(Some(timeout)) - .map_err(|source| ProbeError::Io { - operation: "configure the write timeout", - source, - }) -} - -fn remaining(deadline: Instant) -> Result { - deadline - .checked_duration_since(Instant::now()) - .filter(|remaining| !remaining.is_zero()) - .ok_or_else(|| ProbeError::Io { - operation: "validation deadline elapsed", - source: std::io::Error::from(std::io::ErrorKind::TimedOut), - }) -} - -/// Polls `GET {base_url}/health` until it answers 200 or `timeout` -/// elapses. -/// -/// # Errors -/// Returns [`HealthError::NotHttp`] when `base_url` is not an -/// `http://host:port` URL, and [`HealthError::Timeout`] when the endpoint -/// does not answer 200 within `timeout`. -pub fn wait_for_health(base_url: &str, timeout: Duration) -> Result<(), HealthError> { - wait_for_health_cancellable_with( - base_url, - timeout, - &CancellationToken::new(), - ATTEMPT_TIMEOUT, - probe_health_until, - ) -} - -/// Polls `GET {base_url}/health` until it answers 200, the timeout elapses, -/// or the caller cancels the wait. -/// -/// # Errors -/// Returns [`HealthError::Canceled`] when `cancellation` is signalled, plus -/// the URL and timeout failures documented by [`wait_for_health`]. -pub fn wait_for_health_cancellable( - base_url: &str, - timeout: Duration, - cancellation: &CancellationToken, -) -> Result<(), HealthError> { - wait_for_health_cancellable_with( - base_url, - timeout, - cancellation, - CANCELLABLE_ATTEMPT_TIMEOUT, - probe_health_until, - ) -} - -fn wait_for_health_cancellable_with( - base_url: &str, - timeout: Duration, - cancellation: &CancellationToken, - attempt_timeout: Duration, - mut probe: impl FnMut(&str, Instant) -> Result<(), ProbeError>, -) -> Result<(), HealthError> { - wait_for_health_cancellable_with_start( - base_url, - timeout, - cancellation, - attempt_timeout, - || {}, - |address, deadline| probe(address, deadline), - ) -} - -fn wait_for_health_cancellable_with_start( - base_url: &str, - timeout: Duration, - cancellation: &CancellationToken, - attempt_timeout: Duration, - mut before_probe: impl FnMut(), - mut probe: impl FnMut(&str, Instant) -> Result<(), ProbeError>, -) -> Result<(), HealthError> { - let address = base_url - .strip_prefix("http://") - .ok_or_else(|| HealthError::NotHttp { - url: base_url.to_owned(), - })?; - let deadline = Instant::now() + timeout; - loop { - let attempt_deadline = (Instant::now() + attempt_timeout).min(deadline); - before_probe(); - let Some(attempt) = cancellation.run_if_active(|| probe(address, attempt_deadline)) else { - return Err(HealthError::Canceled); - }; - match attempt { - Ok(()) => return Ok(()), - Err(error) => { - if cancellation.is_canceled() { - return Err(HealthError::Canceled); - } - if Instant::now() >= deadline { - return Err(HealthError::Timeout { - url: base_url.to_owned(), - timeout, - source: error, - }); - } - if cancellation.wait_timeout(RETRY_INTERVAL) { - return Err(HealthError::Canceled); - } - } - } - } -} - -fn probe_health_until(address: &str, deadline: Instant) -> Result<(), ProbeError> { - let head = request_head_until(address, "GET", "/health", None, deadline)?; - let status_ok = head - .split_whitespace() - .nth(1) - .is_some_and(|code| code == "200"); - if status_ok { - return Ok(()); - } - Err(ProbeError::UnexpectedStatus { - status_line: head.lines().next().unwrap_or("").to_owned(), - }) -} - -/// Issues one `GET {path}` presenting `api_key` as the bearer token and -/// classifies the answer. -#[cfg(test)] -fn probe_bearer(address: &str, path: &str, api_key: &str) -> KeyProbe { - match request_head(address, "GET", path, Some(api_key)) { - Ok(head) => { - let accepted = head - .split_whitespace() - .nth(1) - .and_then(|code| code.parse::().ok()) - .is_some_and(|code| (200..300).contains(&code)); - if accepted { - KeyProbe::Accepted - } else { - KeyProbe::Rejected - } - } - Err(_) => KeyProbe::Unreachable, - } -} - -/// One HTTP/1.0 request returning the response head. The `Host` header -/// is set to the bound address, never `localhost`: the gateway's loopback -/// `Host` allowlist refuses anything else. -pub(crate) fn request_head( - address: &str, - method: &str, - path: &str, - bearer: Option<&str>, -) -> Result { - request_head_with_timeout(address, method, path, bearer, ATTEMPT_TIMEOUT) -} - -fn request_head_with_timeout( - address: &str, - method: &str, - path: &str, - bearer: Option<&str>, - timeout: Duration, -) -> Result { - request_head_until(address, method, path, bearer, Instant::now() + timeout) -} - -pub(crate) fn request_head_until( - address: &str, - method: &str, - path: &str, - bearer: Option<&str>, - deadline: Instant, -) -> Result { - let socket = address - .parse::() - .map_err(|source| ProbeError::Io { - operation: "parse the loopback address", - source: std::io::Error::new(std::io::ErrorKind::InvalidInput, source), - })?; - let mut stream = connect_until(&socket, deadline)?; - let mut request = format!("{method} {path} HTTP/1.0\r\nHost: {address}\r\n"); - if let Some(key) = bearer { - request.push_str("Authorization: Bearer "); - request.push_str(key); - request.push_str("\r\n"); - } - request.push_str("\r\n"); - configure_stream(&stream, deadline)?; - stream - .write_all(request.as_bytes()) - .map_err(|source| ProbeError::Io { - operation: "write the request", - source, - })?; - let mut buffer = [0u8; 256]; - configure_stream(&stream, deadline)?; - let read = stream.read(&mut buffer).map_err(|source| ProbeError::Io { - operation: "read the status line", - source, - })?; - Ok(String::from_utf8_lossy(&buffer[..read]).into_owned()) -} - -#[cfg(test)] -mod tests; diff --git a/crates/workshop/gateway-discovery/src/health/connection.rs b/crates/workshop/gateway-discovery/src/health/connection.rs deleted file mode 100644 index 51439221d..000000000 --- a/crates/workshop/gateway-discovery/src/health/connection.rs +++ /dev/null @@ -1,282 +0,0 @@ -//! The one-connection health and bearer proof. - -use std::io::{Read, Write as _}; -use std::net::{SocketAddr, TcpStream}; -use std::time::{Duration, Instant}; - -use super::{ - ATTEMPT_TIMEOUT, CANCELLABLE_ATTEMPT_TIMEOUT, ConnectionProbe, ProbeError, RESPONSE_BODY_LIMIT, - RESPONSE_HEAD_LIMIT, RETRY_INTERVAL, configure_stream, connect_until, -}; -use crate::CancellationToken; - -/// Proves that one address is healthy and accepts the presented bearer. -/// Both requests use one TCP connection, so authority cannot come from a -/// listener that replaced the endpoint after the health response. -#[cfg(test)] -pub(super) fn probe_connection( - address: &str, - bearer_path: &str, - bearer: &str, - health_budget: Duration, -) -> ConnectionProbe { - probe_connection_until(address, bearer_path, bearer, Instant::now() + health_budget) -} - -/// Proves one connection within the caller's absolute deadline. -pub(crate) fn probe_connection_until( - address: &str, - bearer_path: &str, - bearer: &str, - deadline: Instant, -) -> ConnectionProbe { - probe_connection_with( - address, - bearer_path, - bearer, - deadline, - &CancellationToken::new(), - ATTEMPT_TIMEOUT, - ) -} - -/// Proves one connection while observing supervisor cancellation. -pub(crate) fn probe_connection_cancellable( - address: &str, - bearer_path: &str, - bearer: &str, - health_budget: Duration, - cancellation: &CancellationToken, -) -> ConnectionProbe { - probe_connection_with( - address, - bearer_path, - bearer, - Instant::now() + health_budget, - cancellation, - CANCELLABLE_ATTEMPT_TIMEOUT, - ) -} - -fn probe_connection_with( - address: &str, - bearer_path: &str, - bearer: &str, - deadline: Instant, - cancellation: &CancellationToken, - attempt_timeout: Duration, -) -> ConnectionProbe { - probe_connection_with_probe( - address, - bearer_path, - bearer, - deadline, - cancellation, - attempt_timeout, - probe_connection_once, - ) -} - -pub(super) fn probe_connection_with_probe( - address: &str, - bearer_path: &str, - bearer: &str, - deadline: Instant, - cancellation: &CancellationToken, - attempt_timeout: Duration, - mut probe: impl FnMut(&str, &str, &str, Instant) -> ConnectionAttempt, -) -> ConnectionProbe { - loop { - let attempt_deadline = (Instant::now() + attempt_timeout).min(deadline); - let Some(attempt) = - cancellation.run_if_active(|| probe(address, bearer_path, bearer, attempt_deadline)) - else { - return ConnectionProbe::Canceled; - }; - match attempt { - ConnectionAttempt::Accepted => return ConnectionProbe::Accepted, - ConnectionAttempt::KeyRejected => return ConnectionProbe::KeyRejected, - ConnectionAttempt::ProofIncomplete => return ConnectionProbe::HealthFailed, - ConnectionAttempt::HealthFailed if Instant::now() >= deadline => { - return ConnectionProbe::HealthFailed; - } - ConnectionAttempt::HealthFailed if cancellation.wait_timeout(RETRY_INTERVAL) => { - return ConnectionProbe::Canceled; - } - ConnectionAttempt::HealthFailed => {} - } - } -} - -/// One coherent proof attempt over one TCP connection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum ConnectionAttempt { - Accepted, - HealthFailed, - KeyRejected, - ProofIncomplete, -} - -/// Checks health and bearer acceptance over one socket. Once health has -/// succeeded, any socket loss fails the proof instead of reconnecting to a -/// potentially different endpoint. -pub(super) fn probe_connection_once( - address: &str, - bearer_path: &str, - bearer: &str, - deadline: Instant, -) -> ConnectionAttempt { - let Ok(socket) = address.parse::() else { - return ConnectionAttempt::HealthFailed; - }; - let Ok(mut stream) = connect_until(&socket, deadline) else { - return ConnectionAttempt::HealthFailed; - }; - if write_request(&mut stream, address, "/health", None, false, deadline).is_err() { - return ConnectionAttempt::HealthFailed; - } - let Ok(health_head) = read_framed_response_head(&mut stream, deadline) else { - return ConnectionAttempt::HealthFailed; - }; - if response_status(&health_head) != Some(200) { - return ConnectionAttempt::HealthFailed; - } - if write_request( - &mut stream, - address, - bearer_path, - Some(bearer), - true, - deadline, - ) - .is_err() - { - return ConnectionAttempt::ProofIncomplete; - } - let Ok(bearer_head) = read_framed_response_head(&mut stream, deadline) else { - return ConnectionAttempt::ProofIncomplete; - }; - if response_status(&bearer_head).is_some_and(|code| (200..300).contains(&code)) { - ConnectionAttempt::Accepted - } else { - ConnectionAttempt::KeyRejected - } -} - -/// Writes one GET request, retaining or closing the connection as directed. -fn write_request( - stream: &mut TcpStream, - address: &str, - path: &str, - bearer: Option<&str>, - close: bool, - deadline: Instant, -) -> Result<(), ProbeError> { - let connection = if close { "close" } else { "keep-alive" }; - let mut request = - format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: {connection}\r\n"); - if let Some(key) = bearer { - request.push_str("Authorization: Bearer "); - request.push_str(key); - request.push_str("\r\n"); - } - request.push_str("\r\n"); - configure_stream(stream, deadline)?; - stream - .write_all(request.as_bytes()) - .map_err(|source| ProbeError::Io { - operation: "write the validation request", - source, - }) -} - -/// Reads one response head and drains its fixed-length body so the next -/// response starts at a framing boundary on the same socket. -fn read_framed_response_head( - stream: &mut TcpStream, - deadline: Instant, -) -> Result { - let mut response = Vec::with_capacity(512); - let header_end = loop { - if let Some(end) = response.windows(4).position(|bytes| bytes == b"\r\n\r\n") { - break end + 4; - } - if response.len() >= RESPONSE_HEAD_LIMIT { - return Err(ProbeError::UnexpectedStatus { - status_line: "".to_owned(), - }); - } - let mut buffer = [0_u8; 512]; - let available = RESPONSE_HEAD_LIMIT - response.len(); - let chunk = available.min(buffer.len()); - configure_stream(stream, deadline)?; - let read = stream - .read(&mut buffer[..chunk]) - .map_err(|source| ProbeError::Io { - operation: "read the validation response", - source, - })?; - if read == 0 { - return Err(ProbeError::Io { - operation: "read the validation response", - source: std::io::Error::from(std::io::ErrorKind::UnexpectedEof), - }); - } - response.extend_from_slice(&buffer[..read]); - }; - let head = String::from_utf8_lossy(&response[..header_end]).into_owned(); - let content_length = - response_content_length(&head).ok_or_else(|| ProbeError::UnexpectedStatus { - status_line: "".to_owned(), - })?; - if content_length > RESPONSE_BODY_LIMIT { - return Err(ProbeError::UnexpectedStatus { - status_line: "".to_owned(), - }); - } - let body_already_read = response.len() - header_end; - if body_already_read > content_length { - return Err(ProbeError::UnexpectedStatus { - status_line: "".to_owned(), - }); - } - if body_already_read < content_length { - let mut remaining = content_length - body_already_read; - let mut buffer = [0_u8; 512]; - while remaining > 0 { - let chunk_len = remaining.min(buffer.len()); - configure_stream(stream, deadline)?; - let read = stream - .read(&mut buffer[..chunk_len]) - .map_err(|source| ProbeError::Io { - operation: "read the validation response body", - source, - })?; - if read == 0 { - return Err(ProbeError::Io { - operation: "read the validation response body", - source: std::io::Error::from(std::io::ErrorKind::UnexpectedEof), - }); - } - remaining -= read; - } - } - Ok(head) -} - -/// Parses a decimal Content-Length from a response head. -fn response_content_length(head: &str) -> Option { - head.lines().find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse().ok()) - .flatten() - }) -} - -/// Parses the three-digit response status. -fn response_status(head: &str) -> Option { - head.split_whitespace() - .nth(1) - .and_then(|code| code.parse::().ok()) -} diff --git a/crates/workshop/gateway-discovery/src/health/tests-connection.rs b/crates/workshop/gateway-discovery/src/health/tests-connection.rs deleted file mode 100644 index 711c394c1..000000000 --- a/crates/workshop/gateway-discovery/src/health/tests-connection.rs +++ /dev/null @@ -1,301 +0,0 @@ -//! Unit tests for the one-connection health and bearer proof. - -use super::*; - -use super::super::connection::{ - ConnectionAttempt, probe_connection_once, probe_connection_with_probe, -}; - -#[test] -fn connection_proof_cannot_mix_health_and_bearer_across_endpoints() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind replacement fixture"); - let address = listener - .local_addr() - .expect("replacement fixture address") - .to_string(); - let (accepted, received) = mpsc::channel(); - std::thread::spawn(move || { - let (mut health, _) = listener.accept().expect("accept health probe"); - let mut buffer = [0_u8; 1024]; - let read = health.read(&mut buffer).expect("read health probe"); - assert!( - String::from_utf8_lossy(&buffer[..read]).starts_with("GET /health "), - "the first endpoint receives health" - ); - health - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") - .expect("answer health"); - drop(health); - - listener - .set_nonblocking(true) - .expect("make replacement observation bounded"); - let deadline = Instant::now() + Duration::from_millis(500); - let mut connection_count = 1; - while Instant::now() < deadline { - match listener.accept() { - Ok((mut bearer, _)) => { - connection_count += 1; - let read = bearer.read(&mut buffer).expect("read bearer probe"); - assert!( - String::from_utf8_lossy(&buffer[..read]) - .contains("Authorization: Bearer accepted\r\n"), - "the replacement endpoint accepts the bearer" - ); - bearer - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") - .expect("answer bearer"); - break; - } - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - std::thread::sleep(Duration::from_millis(5)); - } - Err(error) => panic!("observe replacement connection: {error}"), - } - } - accepted - .send(connection_count) - .expect("report accepted connections"); - }); - - assert_eq!( - probe_connection( - &address, - "/v1/models", - "accepted", - Duration::from_millis(250) - ), - ConnectionProbe::HealthFailed, - "one capability cannot combine health from one socket with authority from another" - ); - assert_eq!( - received - .recv_timeout(Duration::from_secs(2)) - .expect("fixture reports its connection count"), - 1, - "validation never reconnects after health succeeds" - ); -} - -#[test] -fn a_real_connect_attempt_obeys_one_absolute_deadline() { - let started = Instant::now(); - let result = probe_connection_once( - "192.0.2.1:9", - "/v1/models", - "key", - Instant::now() + Duration::from_millis(100), - ); - - assert_eq!(result, ConnectionAttempt::HealthFailed); - assert!( - started.elapsed() < Duration::from_millis(500), - "connect_timeout bounds a stalled or unreachable route" - ); -} - -#[test] -fn slow_drip_response_head_cannot_refresh_the_attempt_budget() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow-head fixture"); - let address = listener - .local_addr() - .expect("slow-head address") - .to_string(); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept slow-head probe"); - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request); - for byte in b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" { - if stream.write_all(&[*byte]).is_err() { - return; - } - std::thread::sleep(Duration::from_millis(20)); - } - }); - - let started = Instant::now(); - let result = probe_connection_once( - &address, - "/v1/models", - "key", - Instant::now() + CANCELLABLE_ATTEMPT_TIMEOUT, - ); - - assert_eq!(result, ConnectionAttempt::HealthFailed); - assert!( - started.elapsed() < Duration::from_millis(500), - "one absolute deadline bounds a slow-drip response head" - ); -} - -#[test] -fn slow_drip_response_body_cannot_refresh_the_attempt_budget() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow-body fixture"); - let address = listener - .local_addr() - .expect("slow-body address") - .to_string(); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept slow-body probe"); - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request); - if stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\n") - .is_err() - { - return; - } - for byte in b"0123456789abcdef" { - if stream.write_all(&[*byte]).is_err() { - return; - } - std::thread::sleep(Duration::from_millis(20)); - } - }); - - let started = Instant::now(); - let result = probe_connection_once( - &address, - "/v1/models", - "key", - Instant::now() + CANCELLABLE_ATTEMPT_TIMEOUT, - ); - - assert_eq!(result, ConnectionAttempt::HealthFailed); - assert!( - started.elapsed() < Duration::from_millis(500), - "one absolute deadline bounds a slow-drip response body" - ); -} - -#[test] -fn an_unbounded_declared_body_is_rejected_without_draining() { - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", - RESPONSE_BODY_LIMIT + 1 - ); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind oversized-body fixture"); - let address = listener - .local_addr() - .expect("oversized-body address") - .to_string(); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept oversized-body probe"); - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request); - let _ = stream.write_all(response.as_bytes()); - std::thread::sleep(Duration::from_secs(1)); - }); - - let started = Instant::now(); - let result = probe_connection_once( - &address, - "/v1/models", - "key", - Instant::now() + CANCELLABLE_ATTEMPT_TIMEOUT, - ); - - assert_eq!(result, ConnectionAttempt::HealthFailed); - assert!( - started.elapsed() < Duration::from_millis(250), - "an oversized declared body is rejected before its bytes arrive" - ); -} - -#[test] -fn cancellation_joins_a_real_slow_drip_probe_without_retrying() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind canceled-drip fixture"); - let address = listener - .local_addr() - .expect("canceled-drip address") - .to_string(); - let connections = Arc::new(AtomicUsize::new(0)); - let server_connections = Arc::clone(&connections); - let (entered, blocked) = mpsc::channel(); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept canceled-drip probe"); - server_connections.fetch_add(1, Ordering::SeqCst); - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request); - entered.send(()).expect("announce real slow-drip probe"); - for byte in b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" { - if stream.write_all(&[*byte]).is_err() { - return; - } - std::thread::sleep(Duration::from_millis(20)); - } - }); - let cancellation = CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let worker = std::thread::spawn(move || { - probe_connection_cancellable( - &address, - "/v1/models", - "key", - Duration::from_secs(30), - &worker_cancellation, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the real probe begins its slow response"); - - let started = Instant::now(); - cancellation.cancel(); - let result = worker.join().expect("the real probe worker joins"); - - assert_eq!(result, ConnectionProbe::Canceled); - assert!( - started.elapsed() < Duration::from_millis(500), - "the attempt deadline bounds joined cancellation during real I/O" - ); - assert_eq!( - connections.load(Ordering::SeqCst), - 1, - "cancellation starts no later network probe" - ); -} - -#[test] -fn cancellation_at_validation_probe_start_prevents_the_probe() { - let cancellation = CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let probe_cancellation = worker_cancellation.clone(); - let probes = Arc::new(AtomicUsize::new(0)); - let worker_probes = Arc::clone(&probes); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - probe_connection_with_probe( - "127.0.0.1:1", - "/v1/models", - "key", - Instant::now() + Duration::from_secs(30), - &worker_cancellation, - CANCELLABLE_ATTEMPT_TIMEOUT, - |_, _, _, _| { - entered - .send(()) - .expect("announce validation probe boundary"); - if probe_cancellation.wait_timeout(Duration::from_secs(30)) { - ConnectionAttempt::HealthFailed - } else { - worker_probes.fetch_add(1, Ordering::SeqCst); - ConnectionAttempt::Accepted - } - }, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("validation pauses immediately before probe admission"); - - cancellation.cancel(); - let result = worker.join().expect("validation worker joins"); - - assert_eq!(result, ConnectionProbe::Canceled); - assert_eq!( - probes.load(Ordering::SeqCst), - 0, - "no validation probe starts after cancellation returns" - ); -} diff --git a/crates/workshop/gateway-discovery/src/health/tests.rs b/crates/workshop/gateway-discovery/src/health/tests.rs deleted file mode 100644 index 3928c23e6..000000000 --- a/crates/workshop/gateway-discovery/src/health/tests.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Unit tests for the health wait and the bearer probe. - -use super::*; - -use std::net::TcpListener; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; - -#[path = "tests-connection.rs"] -mod connection; - -/// Answers every connection with a canned response until the test -/// stops it. -fn spawn_stub_server(response: &'static [u8]) -> String { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub server"); - let address = listener.local_addr().expect("stub server address"); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - let _ = stream.write_all(response); - } - }); - format!("http://{address}") -} - -#[test] -fn a_200_answer_satisfies_the_probe() { - let base_url = spawn_stub_server(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); - wait_for_health(&base_url, Duration::from_secs(5)).expect("the stub answers 200"); -} - -#[test] -fn a_non_200_answer_is_retried_until_timeout() { - let base_url = - spawn_stub_server(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\n\r\n"); - let error = wait_for_health(&base_url, Duration::from_millis(150)) - .expect_err("a 503 never satisfies the probe"); - assert!( - matches!(error, HealthError::Timeout { .. }), - "a 503 past the budget is a timeout" - ); - let message = error.to_string(); - assert!( - message.contains("did not answer"), - "the error names the timeout: {message}" - ); -} - -#[test] -fn a_dead_port_times_out() { - // Port 1 is never listening, so every connect fails fast. - let error = wait_for_health("http://127.0.0.1:1", Duration::from_millis(150)) - .expect_err("a dead port never satisfies the probe"); - assert!( - matches!(error, HealthError::Timeout { .. }), - "a dead port past the budget is a timeout" - ); - let message = error.to_string(); - assert!( - message.contains("did not answer"), - "the error names the timeout: {message}" - ); -} - -#[test] -fn a_non_http_url_is_rejected() { - let error = wait_for_health("ftp://127.0.0.1:7910", Duration::from_millis(10)) - .expect_err("a non-http URL must fail"); - assert!( - matches!(error, HealthError::NotHttp { .. }), - "a non-http URL is rejected before any probe" - ); - assert!( - error.to_string().contains("http://"), - "the error names the scheme requirement: {error}" - ); -} - -#[test] -fn the_probe_sends_the_bound_address_as_host() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind recording server"); - let address = listener.local_addr().expect("recording server address"); - let (requests, received) = mpsc::channel(); - std::thread::spawn(move || { - if let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - if let Ok(read) = stream.read(&mut buffer) { - let _ = requests.send(String::from_utf8_lossy(&buffer[..read]).into_owned()); - } - let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); - } - }); - wait_for_health(&format!("http://{address}"), Duration::from_secs(5)) - .expect("the stub answers 200"); - let request = received - .recv_timeout(Duration::from_secs(5)) - .expect("the probe's request arrived"); - assert!( - request.contains(&format!("Host: {address}\r\n")), - "the Host header is the bound address, not localhost: {request:?}" - ); -} - -#[test] -fn a_bearer_probe_accepts_a_2xx_and_rejects_a_401() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind key fixture"); - let address = listener - .local_addr() - .expect("key fixture address") - .to_string(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - continue; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let response = if request.contains("Authorization: Bearer right\r\n") { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - let _ = stream.write_all(response); - } - }); - assert_eq!( - probe_bearer(&address, "/v1/models", "right"), - KeyProbe::Accepted - ); - assert_eq!( - probe_bearer(&address, "/v1/models", "wrong"), - KeyProbe::Rejected - ); - assert_eq!( - probe_bearer("127.0.0.1:1", "/v1/models", "right"), - KeyProbe::Unreachable - ); -} - -#[test] -fn cancellation_at_health_probe_start_prevents_the_probe() { - let cancellation = CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let probes = Arc::new(AtomicUsize::new(0)); - let worker_probes = Arc::clone(&probes); - let (entered, blocked) = mpsc::channel(); - let (release, released) = mpsc::channel(); - let worker = std::thread::spawn(move || { - wait_for_health_cancellable_with_start( - "http://127.0.0.1:1", - Duration::from_secs(30), - &worker_cancellation, - CANCELLABLE_ATTEMPT_TIMEOUT, - || { - entered.send(()).expect("announce health probe boundary"); - released.recv().expect("release health probe boundary"); - }, - |_, _| { - worker_probes.fetch_add(1, Ordering::SeqCst); - Ok(()) - }, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("health wait pauses immediately before probe admission"); - - cancellation.cancel(); - release.send(()).expect("release health probe boundary"); - let result = worker.join().expect("health worker joins"); - - assert!(matches!(result, Err(HealthError::Canceled))); - assert_eq!( - probes.load(Ordering::SeqCst), - 0, - "no health probe starts after cancellation returns" - ); -} - -#[test] -fn cancellation_joins_a_blocked_health_wait_without_another_probe() { - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let probes = Arc::new(AtomicUsize::new(0)); - let worker_probes = Arc::clone(&probes); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - wait_for_health_cancellable_with( - "http://127.0.0.1:1", - Duration::from_secs(30), - &worker_cancellation, - CANCELLABLE_ATTEMPT_TIMEOUT, - |_, _| { - worker_probes.fetch_add(1, Ordering::SeqCst); - entered.send(()).expect("announce blocked health probe"); - let _ = worker_cancellation.wait_timeout(Duration::from_secs(30)); - Err(ProbeError::UnexpectedStatus { - status_line: "blocked".to_owned(), - }) - }, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the health phase blocks deterministically"); - - let started = Instant::now(); - cancellation.cancel(); - let result = worker.join().expect("the health worker joins"); - - assert!( - started.elapsed() < Duration::from_millis(250), - "cancellation bounds the blocked health wait" - ); - assert!(matches!(result, Err(HealthError::Canceled))); - assert_eq!( - probes.load(Ordering::SeqCst), - 1, - "no probe starts after cancellation" - ); -} diff --git a/crates/workshop/gateway-discovery/src/lib.rs b/crates/workshop/gateway-discovery/src/lib.rs deleted file mode 100644 index 762ba3b97..000000000 --- a/crates/workshop/gateway-discovery/src/lib.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! The Gateway discovery contract, as Workshop reads it. -//! -//! Workshop attaches to an already-running Gateway instead of starting a -//! second one. It does that by reading the `gateway.json` file the Gateway -//! writes into the run directory (`/.runvil/run`) after a successful -//! bind: the file holds the loopback port, the bearer key, the pid, the boot -//! epoch, the version, and the start time. This crate is the reader half of -//! that protocol - read, validate, resolve, settle a launch race, wait for -//! health, and ask the Gateway to exit - and it is synchronous and -//! runtime-agnostic: no tokio, axum, or reqwest. -//! -//! The contract itself is written down in `CONTRACT.md` at the crate root, -//! which is the authority for both halves. The Gateway's writer lives in the -//! Gateway family; this crate never names it. -//! -//! The flow: -//! -//! 1. A reader ([`resolve`]) attaches only when the file is live: the pid -//! is alive, one OS process boot with a `runvil-gateway` image brackets a -//! same-socket health and bearer proof, and the file includes a boot -//! identity. Anything else is stale and the file is deleted. -//! [`ValidatedConnection`] holds that point-in-time proof without -//! exposing a forgeable constructor. -//! 2. Launch races take [`launch_or_attach`]: the `gateway.json.lock` -//! advisory lock elects one parent launcher; losers attach to the winner. -//! 3. A reader holding a [`ValidatedConnection`] asks the Gateway to exit -//! with [`request_shutdown`], which posts its bearer key to -//! `POST /shutdown`. -//! -//! Before any Gateway runs, Workshop finds the installed one it launches -//! with [`installed_gateway`], which knows each installer's layout. -//! [`installed_plugin_dir`] names Workshop's Plugin directory, which -//! ships inside Workshop's own bundle where the platform has one. -//! -//! URLs normalize to a literal `127.0.0.1`, never `localhost`, and probes -//! send the bound address as the `Host` header, matching the Gateway's -//! loopback `Host` allowlist. -//! -//! # Writing the file -//! -//! Production Workshop never writes `gateway.json`: the Gateway owns that -//! half, and a second writer would let Workshop forge a live endpoint. -//! Workshop's test doubles do need to write one, so the write and remove -//! functions and the process-lifetime lease are compiled only under the -//! `test-fixtures` feature. -//! -//! ## Invariants -//! -//! - Tier: vocabulary; may depend on no other `workshop-*` crate. It needs -//! no async runtime, so Workshop can hold the contract without adopting -//! one here. `cargo test -p build-xtask` enforces the product and -//! container boundaries. -//! - The discovery file, the two lock file names, the stale rule, and the -//! three HTTP calls are exactly what `CONTRACT.md` says. A behavior change -//! here is a contract change, and `CONTRACT.md` changes with it. -//! - A bearer key is never written to logs or `Debug` output. Every error -//! path that could echo a rejected request's status line redacts it. -//! - Reading the file tolerates fields this crate does not know, so a newer -//! Gateway never breaks an older Workshop. -//! - No writer ships in a production build: the file is the Gateway's to -//! write. - -#[cfg(feature = "test-fixtures")] -mod atomic; -mod cancellation; -mod error; -mod file; -mod health; -mod lock; -mod paths; -mod peer; -mod shutdown; -mod stale; -mod sys; -mod validated; - -pub use crate::cancellation::CancellationToken; -pub use crate::error::SidecarError; -pub use crate::file::GatewayDiscoveryFile; -#[cfg(feature = "test-fixtures")] -pub use crate::file::remove_if_mine; -pub use crate::health::{HealthError, ProbeError, wait_for_health, wait_for_health_cancellable}; -#[cfg(feature = "test-fixtures")] -pub use crate::lock::GatewayInstanceLease; -pub use crate::lock::{LaunchDecision, LaunchLock, launch_or_attach, launch_or_attach_cancellable}; -pub use crate::paths::{ - GATEWAY_DISCOVERY_FILE_NAME, GATEWAY_LOG_FILE_NAME, INSTANCE_LOCK_FILE_NAME, LOCK_FILE_NAME, - default_run_dir, gateway_discovery_file_path, gateway_log_path, instance_lock_file_path, - lock_file_path, run_dir, -}; -pub use crate::peer::{ - APPIMAGE_PLUGIN_PARENT, GATEWAY_BUNDLE_NAME, MACOS_PLUGIN_PARENT, PLUGIN_DIR_NAME, - WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, installed_gateway, installed_plugin_dir, - running_appimage, -}; -pub use crate::shutdown::{ShutdownError, request_shutdown, request_shutdown_before}; -#[cfg(any(test, feature = "test-fixtures"))] -#[doc(hidden)] -pub use crate::stale::resolve_for_test; -pub use crate::stale::{Resolution, StaleReason, is_running, resolve, resolve_cancellable}; -pub use crate::validated::{ValidatedConnection, ValidationError}; diff --git a/crates/workshop/gateway-discovery/src/lock-tests.rs b/crates/workshop/gateway-discovery/src/lock-tests.rs deleted file mode 100644 index 94f88e4b1..000000000 --- a/crates/workshop/gateway-discovery/src/lock-tests.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! Unit tests for the launch-race lock and the Gateway instance lease. - -use super::*; - -use std::io::{Read, Write as _}; -use std::net::TcpListener; -use std::sync::mpsc; - -/// The test process's own image name, so the pid and image checks -/// pass and the loser reaches the probe path. -fn own_image_name() -> String { - std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned() -} - -/// A fixture gateway answering health and the presented key with 200. -fn fixture_gateway() -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - for _ in 0..2 { - let mut buffer = [0u8; 1024]; - if stream.read(&mut buffer).is_err() { - break; - } - if stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") - .is_err() - { - break; - } - } - } - }); - port -} - -/// A live gateway discovery file on the fixture gateway, owned by the test -/// process. -fn live_file(port: u16) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: "key".to_owned(), - pid: std::process::id(), - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } -} - -#[test] -fn an_empty_run_dir_elects_a_launcher() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let decision = launch_or_attach_named(dir.path(), "irrelevant", Duration::from_secs(1)) - .expect("the race settles"); - assert!( - matches!(decision, LaunchDecision::Launch(_)), - "no file means this caller launches" - ); - assert!( - lock_file_path(dir.path()).exists(), - "the lock file was created" - ); -} - -#[test] -fn process_ownership_is_separate_from_parent_launch_election() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let lease = GatewayInstanceLease::try_acquire(dir.path()) - .expect("attempt the process lease") - .expect("the first process acquires its lease"); - let decision = launch_or_attach_named(dir.path(), "irrelevant", Duration::from_millis(100)) - .expect("the independent parent launch election settles"); - assert!( - matches!(decision, LaunchDecision::Launch(_)), - "holding the process lease never contends with LaunchLock" - ); - assert!( - GatewayInstanceLease::try_acquire(dir.path()) - .expect("contend for process ownership") - .is_none(), - "a second process lease cannot coexist" - ); - drop(lease); - assert!( - GatewayInstanceLease::try_acquire(dir.path()) - .expect("reacquire released process ownership") - .is_some(), - "dropping the handle releases ownership" - ); -} - -#[test] -fn a_live_file_attaches_without_contesting_the_lock() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = live_file(fixture_gateway()); - file.write_to(dir.path()).expect("write"); - - let decision = launch_or_attach_named(dir.path(), &own_image_name(), Duration::from_secs(5)) - .expect("the race settles"); - match decision { - LaunchDecision::Attach(attached) => assert_eq!(attached, file), - LaunchDecision::Launch(_) => panic!("a live gateway must be attached, not relaunched"), - } -} - -#[test] -fn the_race_loser_attaches_to_the_winners_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let image = own_image_name(); - // The winner holds the lock with no file written yet. - let LaunchDecision::Launch(winner) = - launch_or_attach_named(dir.path(), &image, Duration::from_secs(5)) - .expect("the first caller wins the lock") - else { - panic!("an empty run dir elects a launcher"); - }; - - // The loser waits; the winner's file appears mid-wait. - let loser_dir = dir.path().to_owned(); - let loser = std::thread::spawn(move || { - launch_or_attach_named(&loser_dir, &image, Duration::from_secs(10)) - .expect("the loser's race settles") - }); - std::thread::sleep(Duration::from_millis(100)); - let file = live_file(fixture_gateway()); - file.write_to(dir.path()).expect("the winner writes"); - - match loser.join().expect("the loser thread ran") { - LaunchDecision::Attach(attached) => assert_eq!(attached, file), - LaunchDecision::Launch(_) => { - panic!("the loser must attach to the winner, not launch a second gateway") - } - } - drop(winner); -} - -#[test] -fn a_loser_becomes_the_launcher_when_the_winner_dies_silent() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let image = own_image_name(); - { - let LaunchDecision::Launch(winner) = - launch_or_attach_named(dir.path(), &image, Duration::from_secs(5)) - .expect("the first caller wins the lock") - else { - panic!("an empty run dir elects a launcher"); - }; - // The winner dies (or gives up) without writing a file. - drop(winner); - } - - let decision = launch_or_attach_named(dir.path(), &image, Duration::from_secs(5)) - .expect("the race settles"); - assert!( - matches!(decision, LaunchDecision::Launch(_)), - "a dead winner's lock passes to the next launcher" - ); -} - -#[test] -fn a_loser_times_out_when_the_winner_stays_silent() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let image = own_image_name(); - let LaunchDecision::Launch(_winner) = - launch_or_attach_named(dir.path(), &image, Duration::from_secs(5)) - .expect("the first caller wins the lock") - else { - panic!("an empty run dir elects a launcher"); - }; - - let error = launch_or_attach_named(dir.path(), &image, Duration::from_millis(150)) - .expect_err("a silent winner starves the loser"); - assert!( - matches!(error, SidecarError::LaunchTimeout { .. }), - "the loser reports the timeout: {error}" - ); -} - -#[test] -fn cancellation_joins_a_blocked_launch_race_without_launching() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let image = own_image_name(); - let LaunchDecision::Launch(winner) = - launch_or_attach_named(dir.path(), &image, Duration::from_secs(5)) - .expect("the first caller wins the lock") - else { - panic!("an empty run dir elects a launcher"); - }; - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let run_dir = dir.path().to_owned(); - let worker_image = image.clone(); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - launch_or_attach_named_cancellable_with( - &run_dir, - &worker_image, - Duration::from_secs(30), - &worker_cancellation, - |delay| { - entered.send(()).expect("announce blocked launch race"); - worker_cancellation.wait_timeout(delay) - }, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the launch-race phase blocks deterministically"); - - let started = Instant::now(); - cancellation.cancel(); - let result = worker.join().expect("the launch-race worker joins"); - - assert!( - started.elapsed() < Duration::from_millis(250), - "cancellation bounds the blocked launch race" - ); - assert!(matches!(result, Err(SidecarError::Canceled))); - assert!( - !crate::paths::gateway_discovery_file_path(dir.path()).exists(), - "the canceled loser never publishes or launches" - ); - drop(winner); -} diff --git a/crates/workshop/gateway-discovery/src/lock.rs b/crates/workshop/gateway-discovery/src/lock.rs deleted file mode 100644 index 0b82e0c30..000000000 --- a/crates/workshop/gateway-discovery/src/lock.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! The launch-race lock: `gateway.json.lock` beside the gateway discovery file, -//! electing one launcher when two readers find no live gateway at the -//! same moment. The loser attaches to the winner. -//! -//! The protocol: the lock holder re-validates the gateway discovery file before -//! deciding, because a previous winner may have written it and released -//! the lock; a loser never deletes anything, it waits for the winner's -//! file to go live and attaches. Cleanup is the lock holder's privilege. -//! The OS-file-lock pattern mirrors the Gateway's own artifact lock: an -//! `OpenOptions` create plus a `std::fs::File` advisory lock, released by -//! dropping the handle. - -use std::fs::{File, OpenOptions, TryLockError}; -use std::path::Path; -use std::time::{Duration, Instant}; - -use crate::error::SidecarError; -use crate::paths::lock_file_path; -use crate::stale::{self, GATEWAY_IMAGE_NAME, Resolution}; -use crate::{CancellationToken, GatewayDiscoveryFile}; - -/// Delay between lock retries while a winner finishes its launch. -const RETRY_INTERVAL: Duration = Duration::from_millis(25); - -/// The held launch lock. The holder launches the gateway; dropping the -/// guard releases the lock. -#[derive(Debug)] -pub struct LaunchLock { - // The handle owns the OS lock; dropping it releases. - _file: File, -} - -/// The Gateway process-lifetime ownership lease, which only a test double -/// needs: production Workshop never claims to be a Gateway. -/// -/// This lock is distinct from [`LaunchLock`]: a Workshop parent may hold the -/// launch election while the spawned Gateway acquires this lease. The file -/// handle owns the operating-system lock, so dropping it or terminating its -/// process releases ownership without pid files or explicit cleanup. -#[cfg(feature = "test-fixtures")] -#[derive(Debug)] -pub struct GatewayInstanceLease { - // The handle owns the OS lock; dropping it releases. - _file: File, -} - -#[cfg(feature = "test-fixtures")] -impl GatewayInstanceLease { - /// Attempts to acquire process-lifetime Gateway ownership without - /// blocking. `Ok(None)` means another process owns the lease. - /// - /// # Errors - /// Returns [`SidecarError::CreateDir`] when the run directory cannot be - /// created, or [`SidecarError::Lock`] when the lease file cannot be opened - /// or an unexpected operating-system lock error occurs. - pub fn try_acquire(run_dir: &Path) -> Result, SidecarError> { - std::fs::create_dir_all(run_dir).map_err(|source| SidecarError::CreateDir { - path: run_dir.to_owned(), - source, - })?; - let lock_path = crate::paths::instance_lock_file_path(run_dir); - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| SidecarError::Lock { - path: lock_path.clone(), - source, - })?; - match file.try_lock() { - Ok(()) => Ok(Some(Self { _file: file })), - Err(TryLockError::WouldBlock) => Ok(None), - Err(TryLockError::Error(source)) => Err(SidecarError::Lock { - path: lock_path, - source, - }), - } - } -} - -/// What [`launch_or_attach`] decided. -#[derive(Debug)] -#[non_exhaustive] -pub enum LaunchDecision { - /// This caller holds the launch lock and no live gateway exists: - /// launch one. The lock stays held until the returned guard drops, so - /// a concurrent racer attaches to the launched gateway instead of - /// launching its own. - Launch(LaunchLock), - /// A live gateway already exists: attach to it. - Attach(GatewayDiscoveryFile), -} - -/// Settles a launch race in `run_dir`: returns [`LaunchDecision::Launch`] -/// with the held lock when no live gateway exists, or -/// [`LaunchDecision::Attach`] when one does - either already running, or -/// launched by the race winner while this caller waited. A loser waits up -/// to `timeout` for the winner's gateway to become attachable, and takes -/// the lock itself when the winner dies without writing one. -/// -/// # Errors -/// Returns [`SidecarError::CreateDir`] when the run directory cannot be -/// created, [`SidecarError::Lock`] when the lock file cannot be opened or -/// an unexpected lock error occurs, [`SidecarError::LaunchTimeout`] when -/// no winner became attachable within `timeout`, and the [`crate::resolve`] -/// errors when the lock holder's own re-validation fails. -pub fn launch_or_attach(run_dir: &Path, timeout: Duration) -> Result { - launch_or_attach_named(run_dir, GATEWAY_IMAGE_NAME, timeout) -} - -/// Settles a launch race while observing caller cancellation. -/// -/// # Errors -/// Returns [`SidecarError::Canceled`] when cancellation wins, plus the -/// file, lock, and timeout failures documented by [`launch_or_attach`]. -pub fn launch_or_attach_cancellable( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, -) -> Result { - launch_or_attach_named_cancellable(run_dir, GATEWAY_IMAGE_NAME, timeout, cancellation) -} - -/// [`launch_or_attach`] against a caller-named process image, so tests -/// can run the full liveness gauntlet from a test binary, which is never -/// named `runvil-gateway`. -fn launch_or_attach_named( - run_dir: &Path, - image_name: &str, - timeout: Duration, -) -> Result { - std::fs::create_dir_all(run_dir).map_err(|source| SidecarError::CreateDir { - path: run_dir.to_owned(), - source, - })?; - let lock_path = lock_file_path(run_dir); - let lock = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| SidecarError::Lock { - path: lock_path.clone(), - source, - })?; - let deadline = Instant::now() + timeout; - loop { - match lock.try_lock() { - Ok(()) => { - // The lock is held; a previous winner may have written a - // live file before releasing, so re-validate before - // launching. As the holder, cleanup is ours. - return Ok(match stale::resolve_named(run_dir, image_name)? { - Resolution::Attach(file) => LaunchDecision::Attach(file), - Resolution::Absent | Resolution::Stale(_) => { - LaunchDecision::Launch(LaunchLock { _file: lock }) - } - }); - } - Err(TryLockError::WouldBlock) => { - // Another process holds the lock and is mid-launch. Attach - // as soon as its file goes live; never delete here. - if let Ok(Some(file)) = GatewayDiscoveryFile::read(run_dir) - && stale::is_live(&file, image_name) - { - return Ok(LaunchDecision::Attach(file)); - } - if Instant::now() >= deadline { - return Err(SidecarError::LaunchTimeout { timeout }); - } - std::thread::sleep(RETRY_INTERVAL); - } - Err(TryLockError::Error(source)) => { - return Err(SidecarError::Lock { - path: lock_path.clone(), - source, - }); - } - } - } -} - -fn launch_or_attach_named_cancellable( - run_dir: &Path, - image_name: &str, - timeout: Duration, - cancellation: &CancellationToken, -) -> Result { - launch_or_attach_named_cancellable_with(run_dir, image_name, timeout, cancellation, |delay| { - cancellation.wait_timeout(delay) - }) -} - -fn launch_or_attach_named_cancellable_with( - run_dir: &Path, - image_name: &str, - timeout: Duration, - cancellation: &CancellationToken, - mut wait: impl FnMut(Duration) -> bool, -) -> Result { - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - std::fs::create_dir_all(run_dir).map_err(|source| SidecarError::CreateDir { - path: run_dir.to_owned(), - source, - })?; - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - let lock_path = lock_file_path(run_dir); - let lock = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .open(&lock_path) - .map_err(|source| SidecarError::Lock { - path: lock_path.clone(), - source, - })?; - let deadline = Instant::now() + timeout; - loop { - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - match lock.try_lock() { - Ok(()) => { - let resolution = - stale::resolve_named_cancellable(run_dir, image_name, cancellation)?; - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - return Ok(match resolution { - Resolution::Attach(file) => LaunchDecision::Attach(file), - Resolution::Absent | Resolution::Stale(_) => { - LaunchDecision::Launch(LaunchLock { _file: lock }) - } - }); - } - Err(TryLockError::WouldBlock) => { - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - if let Ok(Some(file)) = GatewayDiscoveryFile::read(run_dir) - && stale::is_live_cancellable(&file, image_name, cancellation)? - { - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - return Ok(LaunchDecision::Attach(file)); - } - if Instant::now() >= deadline { - return Err(SidecarError::LaunchTimeout { timeout }); - } - if wait(RETRY_INTERVAL) { - return Err(SidecarError::Canceled); - } - } - Err(TryLockError::Error(source)) => { - return Err(SidecarError::Lock { - path: lock_path.clone(), - source, - }); - } - } - } -} - -#[cfg(test)] -#[path = "lock-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway-discovery/src/paths.rs b/crates/workshop/gateway-discovery/src/paths.rs deleted file mode 100644 index f5bee0bf2..000000000 --- a/crates/workshop/gateway-discovery/src/paths.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! Run-directory, gateway-discovery-file, and gateway-log paths: the one -//! place that knows the `/.runvil/run` and -//! `/.runvil/logs` layout. - -use std::path::{Path, PathBuf}; - -/// The gateway discovery file's name inside the run directory. -pub const GATEWAY_DISCOVERY_FILE_NAME: &str = "gateway.json"; - -/// The gateway log's name inside the state directory's `logs` directory. -pub const GATEWAY_LOG_FILE_NAME: &str = "gateway.log"; - -/// The launch lock's name, beside the gateway discovery file. -pub const LOCK_FILE_NAME: &str = "gateway.json.lock"; - -/// The process-lifetime Gateway instance lock's name. -pub const INSTANCE_LOCK_FILE_NAME: &str = "gateway.instance.lock"; - -/// The run directory under the state dir: `/.runvil/run`. -#[must_use] -pub fn run_dir(home: &Path) -> PathBuf { - home.join(".runvil").join("run") -} - -/// This process's default run directory: the user profile's -/// `.runvil/run`. -/// -/// Returns `None` when the user profile directory cannot be located. -#[must_use] -pub fn default_run_dir() -> Option { - std::env::home_dir().map(|home| run_dir(&home)) -} - -/// The gateway discovery file inside `run_dir`. -#[must_use] -pub fn gateway_discovery_file_path(run_dir: &Path) -> PathBuf { - run_dir.join(GATEWAY_DISCOVERY_FILE_NAME) -} - -/// The launch lock inside `run_dir`. -#[must_use] -pub fn lock_file_path(run_dir: &Path) -> PathBuf { - run_dir.join(LOCK_FILE_NAME) -} - -/// The process-lifetime Gateway instance lock inside `run_dir`. Named here -/// for the contract's sake and used by the test-double lease. -#[must_use] -pub fn instance_lock_file_path(run_dir: &Path) -> PathBuf { - run_dir.join(INSTANCE_LOCK_FILE_NAME) -} - -/// The gateway log under `state_dir`, the directory that holds the run -/// directory: `/.runvil/logs/gateway.log`. The gateway starts -/// logging there before it loads its configuration, so a gateway that exits -/// on its configuration still records why. -#[must_use] -pub fn gateway_log_path(state_dir: &Path) -> PathBuf { - state_dir.join("logs").join(GATEWAY_LOG_FILE_NAME) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn the_run_dir_sits_under_the_profile_runvil_dir() { - assert_eq!( - run_dir(Path::new("home")), - Path::new("home").join(".runvil").join("run") - ); - } - - #[test] - fn the_gateway_discovery_file_and_locks_sit_beside_each_other() { - let dir = Path::new("run"); - assert_eq!( - gateway_discovery_file_path(dir), - Path::new("run").join("gateway.json") - ); - assert_eq!( - lock_file_path(dir), - Path::new("run").join("gateway.json.lock") - ); - assert_eq!( - instance_lock_file_path(dir), - Path::new("run").join("gateway.instance.lock") - ); - } - - #[test] - fn the_gateway_log_sits_in_a_logs_directory_beside_the_run_directory() { - let run = run_dir(Path::new("home")); - let state_dir = run.parent().expect("the run directory has a parent"); - assert_eq!( - gateway_log_path(state_dir), - Path::new("home") - .join(".runvil") - .join("logs") - .join("gateway.log") - ); - } -} diff --git a/crates/workshop/gateway-discovery/src/peer-tests.rs b/crates/workshop/gateway-discovery/src/peer-tests.rs deleted file mode 100644 index adc63843a..000000000 --- a/crates/workshop/gateway-discovery/src/peer-tests.rs +++ /dev/null @@ -1,345 +0,0 @@ -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; - -use super::{ - GATEWAY_BUNDLE_NAME, Layout, PLUGIN_DIR_NAME, WORKSHOP_APPIMAGE_NAME, WORKSHOP_BUNDLE_NAME, - app_bundle, bundle_exe, first_file, gateway_candidates, installed_gateway, - installed_plugin_dir, plugin_dir, running_appimage, -}; - -/// Plants an empty executable file at `root/relative`, creating its -/// directories. -fn plant(root: &Path, relative: &Path) -> PathBuf { - let path = root.join(relative); - std::fs::create_dir_all(path.parent().expect("a planted file has a parent")) - .expect("create fixture directories"); - std::fs::write(&path, b"").expect("plant fixture file"); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) - .expect("mark fixture executable"); - } - path -} - -fn gateway_for(layout: Layout, workshop_exe: &Path, appimage: Option<&Path>) -> Option { - first_file(gateway_candidates(layout, workshop_exe, appimage)) -} - -#[test] -fn windows_peers_sit_beside_each_other() { - let root = tempfile::TempDir::new().expect("tempdir"); - let workshop = plant(root.path(), Path::new("runvil-workshop.exe")); - let gateway = plant(root.path(), Path::new("runvil-gateway.exe")); - - assert_eq!(gateway_for(Layout::Windows, &workshop, None), Some(gateway)); -} - -#[test] -fn a_development_tree_finds_the_gateway_beside_workshop() { - let root = tempfile::TempDir::new().expect("tempdir"); - let debug = Path::new("target").join("debug"); - let workshop = plant(root.path(), &debug.join("runvil-workshop")); - let gateway = plant(root.path(), &debug.join("runvil-gateway")); - - for layout in [Layout::MacOs, Layout::Linux] { - assert_eq!( - gateway_for(layout, &workshop, None), - Some(gateway.clone()), - "{layout:?}" - ); - } -} - -#[test] -fn macos_peers_are_sibling_bundles() { - let root = tempfile::TempDir::new().expect("tempdir"); - let workshop = plant( - root.path(), - &bundle_exe(Path::new(""), WORKSHOP_BUNDLE_NAME, "runvil-workshop"), - ); - let gateway = plant( - root.path(), - &bundle_exe(Path::new(""), GATEWAY_BUNDLE_NAME, "runvil-gateway"), - ); - - assert_eq!(gateway_for(Layout::MacOs, &workshop, None), Some(gateway)); -} - -#[test] -fn macos_bundle_lookup_needs_the_bundle_structure() { - let root = tempfile::TempDir::new().expect("tempdir"); - plant( - root.path(), - &bundle_exe(Path::new(""), GATEWAY_BUNDLE_NAME, "runvil-gateway"), - ); - let loose = root - .path() - .join("Contents") - .join("MacOS") - .join("runvil-workshop"); - - assert_eq!( - gateway_for(Layout::MacOs, &loose, None), - None, - "an executable outside a .app bundle has no bundle siblings" - ); -} - -#[test] -fn linux_workshop_finds_the_gateway_beside_its_appimage() { - let root = tempfile::TempDir::new().expect("tempdir"); - let install = root.path().join("Runvil"); - let appimage = plant(&install, Path::new(WORKSHOP_APPIMAGE_NAME)); - let gateway = plant(&install, Path::new("runvil-gateway")); - let mount = root - .path() - .join("mount") - .join("usr") - .join("bin") - .join("runvil-workshop"); - - assert_eq!( - gateway_for(Layout::Linux, &mount, Some(appimage.as_path())), - Some(gateway) - ); -} - -#[test] -fn linux_workshop_prefers_the_gateway_beside_its_appimage_to_one_in_its_mount() { - let root = tempfile::TempDir::new().expect("tempdir"); - let install = root.path().join("Runvil"); - let appimage = plant(&install, Path::new(WORKSHOP_APPIMAGE_NAME)); - let installed = plant(&install, Path::new("runvil-gateway")); - let bin = root.path().join("mount").join("usr").join("bin"); - let workshop = plant(&bin, Path::new("runvil-workshop")); - plant(&bin, Path::new("runvil-gateway")); - - assert_eq!( - gateway_for(Layout::Linux, &workshop, Some(appimage.as_path())), - Some(installed) - ); -} - -#[test] -fn an_appimage_counts_only_when_workshop_runs_from_its_mount() { - let root = tempfile::TempDir::new().expect("tempdir"); - let mount = root.path().join(".mount_PromptXYZ"); - let workshop = mount.join("usr").join("bin").join("runvil-workshop"); - let appimage = root.path().join("Runvil").join(WORKSHOP_APPIMAGE_NAME); - let other_mount = root.path().join(".mount_CodeXYZ"); - let other_appimage = root.path().join("Apps").join("Code.AppImage"); - let found = |appimage: &OsStr, appdir: &OsStr| { - running_appimage(&workshop, Some(appimage), Some(appdir)) - }; - - assert_eq!( - found(appimage.as_os_str(), mount.as_os_str()), - Some(appimage.clone()) - ); - assert_eq!( - found(other_appimage.as_os_str(), other_mount.as_os_str()), - None, - "an $APPIMAGE inherited from another AppImage is ignored" - ); - assert_eq!( - found(OsStr::new(WORKSHOP_APPIMAGE_NAME), mount.as_os_str()), - None, - "a relative $APPIMAGE is ignored" - ); - assert_eq!( - found(OsStr::new(""), mount.as_os_str()), - None, - "empty $APPIMAGE" - ); - assert_eq!( - found(appimage.as_os_str(), OsStr::new("")), - None, - "empty $APPDIR" - ); - assert_eq!( - running_appimage(&workshop, Some(appimage.as_os_str()), None), - None - ); - assert_eq!( - running_appimage(&workshop, None, Some(mount.as_os_str())), - None - ); -} - -#[cfg(unix)] -#[test] -fn an_appimage_counts_when_appdir_reaches_the_mount_through_a_symlink() { - let root = tempfile::TempDir::new().expect("tempdir"); - let mount = root.path().join(".mount_PromptXYZ"); - std::fs::create_dir_all(&mount).expect("create the mount"); - let link = root.path().join("tmp-link"); - std::os::unix::fs::symlink(&mount, &link).expect("link to the mount"); - let workshop = std::fs::canonicalize(&mount) - .expect("resolve the mount") - .join("usr") - .join("bin") - .join("runvil-workshop"); - let appimage = root.path().join("Runvil").join(WORKSHOP_APPIMAGE_NAME); - - assert_eq!( - running_appimage( - &workshop, - Some(appimage.as_os_str()), - Some(link.as_os_str()) - ), - Some(appimage) - ); -} - -#[cfg(unix)] -#[test] -fn a_peer_without_an_execute_bit_is_not_found() { - use std::os::unix::fs::PermissionsExt as _; - let root = tempfile::TempDir::new().expect("tempdir"); - let workshop = plant(root.path(), Path::new("runvil-workshop")); - let gateway = plant(root.path(), Path::new("runvil-gateway")); - assert_eq!( - gateway_for(Layout::Linux, &workshop, None), - Some(gateway.clone()) - ); - std::fs::set_permissions(&gateway, std::fs::Permissions::from_mode(0o644)) - .expect("clear the execute bits"); - - assert_eq!(gateway_for(Layout::Linux, &workshop, None), None); -} - -#[test] -fn the_bundle_walks_up_from_contents_macos_to_the_app() { - let exe = Path::new("/Applications/Runvil.app/Contents/MacOS/runvil-gateway"); - assert_eq!(app_bundle(exe), Some(Path::new("/Applications/Runvil.app"))); -} - -#[test] -fn the_bundle_probe_rejects_unbundled_and_partial_paths() { - assert_eq!(app_bundle(Path::new("/usr/local/bin/runvil-gateway")), None); - assert_eq!( - app_bundle(Path::new("/Applications/Runvil.app/Contents/MacOS")), - None, - "the exe itself must sit below MacOS" - ); - assert_eq!( - app_bundle(Path::new("/opt/x/Contents/MacOS/runvil-gateway")), - None, - "the parent two levels up must be a .app" - ); - assert_eq!(app_bundle(Path::new("runvil-gateway")), None); -} - -#[test] -fn a_missing_peer_is_none_in_every_layout() { - for layout in [Layout::Windows, Layout::MacOs, Layout::Linux] { - let root = tempfile::TempDir::new().expect("tempdir"); - let exe = if matches!(layout, Layout::Windows) { - "runvil-workshop.exe" - } else { - "runvil-workshop" - }; - let workshop = plant(root.path(), Path::new(exe)); - plant( - &root.path().join("elsewhere"), - Path::new(layout.gateway_exe()), - ); - - assert_eq!(gateway_for(layout, &workshop, None), None, "{layout:?}"); - } -} - -#[test] -fn a_missing_install_directory_is_none() { - let root = tempfile::TempDir::new().expect("tempdir"); - let absent = root.path().join("absent").join("runvil-gateway"); - assert_eq!(installed_gateway(&absent, None), None); -} - -#[test] -fn the_plugin_directory_sits_beside_an_unbundled_workshop_in_every_layout() { - let install = Path::new("install"); - for layout in [Layout::Windows, Layout::MacOs, Layout::Linux] { - assert_eq!( - plugin_dir(layout, &install.join("runvil-workshop"), None), - Some(install.join(PLUGIN_DIR_NAME)), - "{layout:?}" - ); - } -} - -#[test] -fn the_macos_plugin_directory_sits_inside_the_workshop_bundle() { - let install = Path::new("Applications").join("Runvil"); - let workshop = bundle_exe(&install, WORKSHOP_BUNDLE_NAME, "runvil-workshop"); - assert_eq!( - plugin_dir(Layout::MacOs, &workshop, None), - Some( - install - .join(WORKSHOP_BUNDLE_NAME) - .join("Contents") - .join("Resources") - .join(PLUGIN_DIR_NAME) - ), - "the updater replaces the bundle, so the Plugins go with it" - ); - assert_eq!( - plugin_dir(Layout::Windows, &workshop, None), - Some( - install - .join(WORKSHOP_BUNDLE_NAME) - .join("Contents") - .join("MacOS") - .join(PLUGIN_DIR_NAME) - ), - "only the macOS layout reads the bundle structure" - ); -} - -#[test] -fn the_linux_plugin_directory_sits_inside_the_appimage_s_mount() { - let install = Path::new("opt").join("Runvil"); - let appimage = install.join(WORKSHOP_APPIMAGE_NAME); - let workshop = Path::new("mount") - .join("usr") - .join("bin") - .join("runvil-workshop"); - assert_eq!( - plugin_dir(Layout::Linux, &workshop, Some(appimage.as_path())), - Some( - Path::new("mount") - .join("usr") - .join("lib") - .join("runvil") - .join(PLUGIN_DIR_NAME) - ), - "the updater replaces the AppImage, so the Plugins go with it" - ); - assert_eq!( - plugin_dir(Layout::Linux, &workshop, None), - Some( - Path::new("mount") - .join("usr") - .join("bin") - .join(PLUGIN_DIR_NAME) - ), - "outside an AppImage the directory sits beside the executable" - ); - assert_eq!( - plugin_dir(Layout::Windows, &workshop, Some(appimage.as_path())), - Some( - Path::new("mount") - .join("usr") - .join("bin") - .join(PLUGIN_DIR_NAME) - ), - "only the Linux layout reads the AppImage" - ); -} - -#[test] -fn an_executable_without_a_directory_has_no_plugin_directory() { - assert_eq!(installed_plugin_dir(Path::new(""), None), None); -} diff --git a/crates/workshop/gateway-discovery/src/peer.rs b/crates/workshop/gateway-discovery/src/peer.rs deleted file mode 100644 index bfe4ef195..000000000 --- a/crates/workshop/gateway-discovery/src/peer.rs +++ /dev/null @@ -1,214 +0,0 @@ -//! Installed-peer lookup: where Workshop finds the Gateway it launches and -//! the Plugin directory it loads. -//! -//! The installers lay the two programs out three ways: -//! -//! - Beside each other in one directory (Windows, development builds). -//! - On macOS, as sibling bundles in one directory: `Runvil.app` and -//! `Runvil Gateway.app`, each running from `Contents/MacOS/`. -//! - On Linux, as `Runvil.AppImage` beside `runvil-gateway`. -//! Workshop runs from the AppImage's mount, so it finds the gateway -//! through `$APPIMAGE`, the path of the AppImage file, when `$APPDIR` -//! confirms Workshop runs from that AppImage, and looks there -//! before its own directory: a gateway inside the mount loses its files -//! when Workshop exits and the mount goes away. -//! -//! The Plugin directory, `plugins`, ships with Workshop itself, so that -//! whatever replaces Workshop replaces its Plugins too. On Windows and in -//! a development build it sits beside Workshop's executable. On macOS it -//! sits inside `Runvil.app`, and on Linux inside the AppImage, because -//! the updater replaces only that bundle or that file. - -use std::ffi::OsStr; -use std::path::{Path, PathBuf}; - -// `build-workshop` lays the payload out under these names and its tests -// read them from this file, so a rename here fails there. - -/// The macOS Workshop bundle's directory name. -pub const WORKSHOP_BUNDLE_NAME: &str = "Runvil.app"; - -/// The macOS gateway bundle's directory name. -pub const GATEWAY_BUNDLE_NAME: &str = "Runvil Gateway.app"; - -/// The Linux Workshop AppImage's file name. -pub const WORKSHOP_APPIMAGE_NAME: &str = "Runvil.AppImage"; - -/// The Plugin directory's name: the directory of dynamic libraries -/// Workshop loads as Plugins. -pub const PLUGIN_DIR_NAME: &str = "plugins"; - -/// The directory that holds the Plugin directory inside the macOS -/// Workshop bundle, from the bundle's `Contents`. -pub const MACOS_PLUGIN_PARENT: &str = "Resources"; - -/// The directory that holds the Plugin directory inside the Linux -/// AppImage, from the root of its mount. -pub const APPIMAGE_PLUGIN_PARENT: &str = "usr/lib/runvil"; - -/// The installer layout a lookup searches. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "a build constructs only its own target's layout; tests construct all three" - ) -)] -pub(crate) enum Layout { - Windows, - MacOs, - Linux, -} - -impl Layout { - #[cfg(target_os = "windows")] - pub(crate) const CURRENT: Self = Self::Windows; - #[cfg(target_os = "macos")] - pub(crate) const CURRENT: Self = Self::MacOs; - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - pub(crate) const CURRENT: Self = Self::Linux; - - pub(crate) const fn gateway_exe(self) -> &'static str { - match self { - Self::Windows => "runvil-gateway.exe", - Self::MacOs | Self::Linux => "runvil-gateway", - } - } -} - -/// The AppImage Workshop runs from, given its `$APPIMAGE` and `$APPDIR`. -/// -/// The AppImage runtime sets both: `$APPIMAGE` names the file and `$APPDIR` -/// the mount Workshop's executable runs from. A value counts only when both -/// are absolute and `workshop_exe` sits under `$APPDIR`, so a relative -/// value never resolves against the working directory and a value inherited -/// from another AppImage's environment is ignored. `$APPDIR` is compared -/// both as given and with symlinks resolved: the runtime builds it from the -/// temporary directory unresolved, while the executable path arrives -/// resolved. -#[must_use] -pub fn running_appimage( - workshop_exe: &Path, - appimage: Option<&OsStr>, - appdir: Option<&OsStr>, -) -> Option { - let appimage = Path::new(appimage?); - let appdir = Path::new(appdir?); - let under = |dir: &Path| workshop_exe.starts_with(dir); - let inside = appimage.is_absolute() - && appdir.is_absolute() - && (under(appdir) || std::fs::canonicalize(appdir).is_ok_and(|dir| under(&dir))); - inside.then(|| appimage.to_path_buf()) -} - -/// Locates the installed gateway executable for the Workshop running from -/// `workshop_exe`, inside `appimage` when [`running_appimage`] found one. -/// Checks, in order: on Linux `runvil-gateway` beside the AppImage, then -/// beside `workshop_exe`, then the sibling gateway bundle on macOS. -#[must_use] -pub fn installed_gateway(workshop_exe: &Path, appimage: Option<&Path>) -> Option { - first_file(gateway_candidates(Layout::CURRENT, workshop_exe, appimage)) -} - -fn first_file(candidates: Vec) -> Option { - candidates.into_iter().find(|candidate| runnable(candidate)) -} - -/// Whether `path` is a regular file this process can run. On Unix that -/// needs an execute bit: a downloaded AppImage starts without one, and -/// launching it would fail. -fn runnable(path: &Path) -> bool { - let Ok(metadata) = std::fs::metadata(path) else { - return false; - }; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt as _; - metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(unix))] - { - metadata.is_file() - } -} - -fn gateway_candidates( - layout: Layout, - workshop_exe: &Path, - appimage: Option<&Path>, -) -> Vec { - let mut candidates = Vec::new(); - if matches!(layout, Layout::Linux) - && let Some(dir) = appimage.and_then(Path::parent) - { - candidates.push(dir.join(layout.gateway_exe())); - } - if let Some(dir) = workshop_exe.parent() { - candidates.push(dir.join(layout.gateway_exe())); - } - if matches!(layout, Layout::MacOs) - && let Some(dir) = bundle_dir(workshop_exe) - { - candidates.push(bundle_exe(dir, GATEWAY_BUNDLE_NAME, layout.gateway_exe())); - } - candidates -} - -/// Names the Plugin directory of the installation the Workshop at -/// `workshop_exe` runs from, inside `appimage` when [`running_appimage`] -/// found one: `plugins` inside the AppImage's mount on Linux, inside -/// `Runvil.app` on macOS, and otherwise beside `workshop_exe`, as on -/// Windows and in a development tree. It matches the path shape only and -/// never touches the disk, so the directory it names may not exist. -/// `None` when `workshop_exe` has no parent directory. -#[must_use] -pub fn installed_plugin_dir(workshop_exe: &Path, appimage: Option<&Path>) -> Option { - plugin_dir(Layout::CURRENT, workshop_exe, appimage) -} - -fn plugin_dir(layout: Layout, workshop_exe: &Path, appimage: Option<&Path>) -> Option { - let beside = workshop_exe.parent()?; - let inside = match layout { - Layout::Windows => None, - Layout::MacOs => { - app_bundle(workshop_exe).map(|app| app.join("Contents").join(MACOS_PLUGIN_PARENT)) - } - // Workshop runs from `usr/bin` in the AppImage's mount. - Layout::Linux => appimage - .and_then(|_| beside.parent()?.parent()) - .map(|mount| mount.join(APPIMAGE_PLUGIN_PARENT)), - }; - Some( - inside - .unwrap_or_else(|| beside.to_path_buf()) - .join(PLUGIN_DIR_NAME), - ) -} - -/// The `.app` bundle `exe` runs from, when `exe` sits at -/// `.app/Contents/MacOS/`, or `None` for an unbundled executable -/// such as a development build. Matches the path shape only and never -/// touches the disk. -fn app_bundle(exe: &Path) -> Option<&Path> { - let macos = exe.parent()?; - let contents = macos.parent()?; - let bundle = contents.parent()?; - let is_bundle = macos.file_name()? == "MacOS" - && contents.file_name()? == "Contents" - && bundle.extension()? == "app"; - is_bundle.then_some(bundle) -} - -/// The directory holding the `.app` bundle `exe` runs from. -fn bundle_dir(exe: &Path) -> Option<&Path> { - app_bundle(exe)?.parent() -} - -fn bundle_exe(dir: &Path, bundle: &str, exe: &str) -> PathBuf { - dir.join(bundle).join("Contents").join("MacOS").join(exe) -} - -#[cfg(test)] -#[path = "peer-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway-discovery/src/shutdown.rs b/crates/workshop/gateway-discovery/src/shutdown.rs deleted file mode 100644 index 877192e3e..000000000 --- a/crates/workshop/gateway-discovery/src/shutdown.rs +++ /dev/null @@ -1,225 +0,0 @@ -//! The `POST /shutdown` request: how a reader asks the gateway to exit. -//! -//! The workshop shell's quit-everything menu item is the caller: it posts -//! the current validated connection's bearer key to the gateway's shutdown -//! route, which answers `202 Accepted` and drains. Raw HTTP/1.0 over -//! `TcpStream` like the health probe, matching the crate's dependency diet - -//! no HTTP client. - -use crate::health::{self, ProbeError}; -use crate::{GatewayDiscoveryFile, ValidatedConnection}; -use std::time::Instant; - -/// The shutdown route's path on the gateway. -const SHUTDOWN_PATH: &str = "/shutdown"; - -/// A failure of [`request_shutdown`]. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum ShutdownError { - /// The request could not be delivered or the answer read. - #[error("{operation}")] - Io { - /// The failed operation. - operation: &'static str, - /// The underlying I/O error. - #[source] - source: std::io::Error, - }, - - /// The gateway answered with a non-2xx status: the key was rejected - /// or the route is gone. - #[error("unexpected shutdown response: {status_line}")] - Rejected { - /// The response's first line. - status_line: String, - }, -} - -impl From for ShutdownError { - fn from(error: ProbeError) -> Self { - match error { - ProbeError::Io { operation, source } => Self::Io { operation, source }, - ProbeError::UnexpectedStatus { status_line } => Self::Rejected { status_line }, - } - } -} - -/// Posts `POST /shutdown` to the validated local Gateway, presenting its -/// bearer key. A 2xx answer means the Gateway accepted and is draining; -/// the caller exits without waiting for the process to die. -/// -/// # Errors -/// Returns [`ShutdownError::Io`] when the gateway cannot be connected or -/// its answer cannot be read, and [`ShutdownError::Rejected`] when the -/// answer's status is not 2xx. -pub fn request_shutdown(connection: &ValidatedConnection) -> Result<(), ShutdownError> { - request_shutdown_file(connection.gateway_discovery_file()) -} - -/// Posts authenticated shutdown without allowing network I/O to outlive the -/// caller's absolute deadline. -/// -/// # Errors -/// Returns the same delivery and rejection errors as [`request_shutdown`]. -pub fn request_shutdown_before( - connection: &ValidatedConnection, - deadline: Instant, -) -> Result<(), ShutdownError> { - request_shutdown_file_before(connection.gateway_discovery_file(), deadline) -} - -fn request_shutdown_file(file: &GatewayDiscoveryFile) -> Result<(), ShutdownError> { - let address = format!("127.0.0.1:{}", file.port); - let head = health::request_head(&address, "POST", SHUTDOWN_PATH, Some(&file.api_key))?; - accepted(&head) -} - -fn request_shutdown_file_before( - file: &GatewayDiscoveryFile, - deadline: Instant, -) -> Result<(), ShutdownError> { - let address = format!("127.0.0.1:{}", file.port); - let head = health::request_head_until( - &address, - "POST", - SHUTDOWN_PATH, - Some(&file.api_key), - deadline, - )?; - accepted(&head) -} - -fn accepted(head: &str) -> Result<(), ShutdownError> { - let status = head - .split_whitespace() - .nth(1) - .and_then(|code| code.parse::().ok()); - if status.is_some_and(|code| (200..300).contains(&code)) { - return Ok(()); - } - Err(ShutdownError::Rejected { - status_line: status.map_or_else(|| "".to_owned(), |code| code.to_string()), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::io::{Read, Write as _}; - use std::net::TcpListener; - use std::sync::mpsc; - use std::time::Duration; - - /// A gateway discovery file naming the fixture gateway's port. - fn file(port: u16) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: "key".to_owned(), - pid: 4242, - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } - } - - /// A fixture gateway recording each request and answering with the - /// canned response. - fn fixture_gateway(response: &'static [u8]) -> (u16, mpsc::Receiver) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - let (requests, received) = mpsc::channel(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - if let Ok(read) = stream.read(&mut buffer) { - let _ = requests.send(String::from_utf8_lossy(&buffer[..read]).into_owned()); - } - let _ = stream.write_all(response); - } - }); - (port, received) - } - - #[test] - fn an_accepted_shutdown_posts_the_route_with_the_files_key() { - let (port, received) = - fixture_gateway(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n"); - request_shutdown_file(&file(port)).expect("the gateway accepted"); - let request = received - .recv_timeout(Duration::from_secs(5)) - .expect("the request arrived"); - assert!( - request.starts_with("POST /shutdown HTTP/1.0\r\n"), - "the request is a POST to the shutdown route: {request:?}" - ); - assert!( - request.contains("Authorization: Bearer key\r\n"), - "the file's key is the bearer token: {request:?}" - ); - assert!( - request.contains(&format!("Host: 127.0.0.1:{port}\r\n")), - "the Host header is the bound address, not localhost: {request:?}" - ); - } - - #[test] - fn a_refused_shutdown_is_rejected() { - let (port, _received) = - fixture_gateway(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"); - let error = request_shutdown_file(&file(port)).expect_err("a 401 is a refusal"); - assert!( - matches!(error, ShutdownError::Rejected { .. }), - "a non-2xx answer is a rejection: {error}" - ); - } - - #[test] - fn a_reflected_bearer_is_absent_from_shutdown_errors() { - let secret = "capability-secret"; - let response = format!("HTTP/1.1 401 rejected-{secret}\r\nContent-Length: 0\r\n\r\n"); - let response: &'static [u8] = Box::leak(response.into_bytes().into_boxed_slice()); - let (port, _received) = fixture_gateway(response); - let mut connection = file(port); - connection.api_key = secret.to_owned(); - - let error = request_shutdown_file(&connection).expect_err("a 401 is a refusal"); - assert!( - !format!("{error:?} {error}").contains(secret), - "bearer values never enter error diagnostics" - ); - } - - #[test] - fn a_dead_gateway_is_an_io_error() { - // Port 1 is never listening, so the connect fails fast. - let error = request_shutdown_file(&file(1)).expect_err("a dead port cannot answer"); - assert!( - matches!(error, ShutdownError::Io { .. }), - "an undelivered request is an I/O error: {error}" - ); - } - - #[test] - fn an_absolute_deadline_bounds_an_unresponsive_shutdown_target() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow shutdown fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept shutdown"); - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request); - std::thread::sleep(Duration::from_secs(1)); - }); - let started = std::time::Instant::now(); - - let error = request_shutdown_file_before(&file(port), started + Duration::from_millis(100)) - .expect_err("an unresponsive child exceeds the cleanup deadline"); - - assert!(matches!(error, ShutdownError::Io { .. })); - assert!( - started.elapsed() < Duration::from_millis(500), - "late-child shutdown uses one absolute network deadline" - ); - } -} diff --git a/crates/workshop/gateway-discovery/src/stale-tests.rs b/crates/workshop/gateway-discovery/src/stale-tests.rs deleted file mode 100644 index 18888d894..000000000 --- a/crates/workshop/gateway-discovery/src/stale-tests.rs +++ /dev/null @@ -1,389 +0,0 @@ -//! Unit tests for stale detection and stale-file cleanup. - -use super::*; - -use std::io::{Read, Write as _}; -use std::net::TcpListener; -use std::sync::mpsc; - -use crate::paths::gateway_discovery_file_path; - -/// The test process's own image name, so the pid and image checks -/// pass and the test reaches the probe under test. -fn own_image_name() -> String { - std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned() -} - -/// A gateway discovery file pointing at the test process itself. -fn live_file(port: u16, api_key: &str) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: api_key.to_owned(), - pid: std::process::id(), - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } -} - -/// A pid guaranteed dead: a short-lived child, reaped and dropped so -/// no handle keeps the process object alive. -fn dead_pid() -> u32 { - let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) - .arg("--list") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn a short-lived child"); - let pid = child.id(); - child.wait().expect("the child exits"); - drop(child); - pid -} - -/// A fixture gateway: answers `GET /health` with 200 and the key -/// probe with 200 only when the bearer matches `expected_key`. -fn fixture_gateway(expected_key: &'static str) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - for _ in 0..2 { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - break; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let accepted = request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); - let response = if accepted { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - if stream.write_all(response).is_err() { - break; - } - } - } - }); - port -} - -#[test] -fn a_dead_pid_is_stale_and_cleaned() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = live_file(1, "key"); - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..file - }; - file.write_to(dir.path()).expect("write"); - - let resolution = resolve(dir.path()).expect("resolve"); - assert_eq!(resolution, Resolution::Stale(StaleReason::ProcessDead)); - assert!( - !gateway_discovery_file_path(dir.path()).exists(), - "the stale file was deleted" - ); -} - -#[test] -fn an_alive_pid_with_a_foreign_image_is_stale() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // The test binary is never named runvil-gateway, so the real - // image name must reject the test process's own pid. - live_file(1, "key").write_to(dir.path()).expect("write"); - - let resolution = resolve(dir.path()).expect("resolve"); - assert_eq!(resolution, Resolution::Stale(StaleReason::ImageMismatch)); - assert!( - !gateway_discovery_file_path(dir.path()).exists(), - "the stale file was deleted" - ); -} - -#[test] -fn a_silent_health_endpoint_is_stale() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // Port 1 never listens, so every probe is refused until the - // liveness budget elapses. - live_file(1, "key").write_to(dir.path()).expect("write"); - - let resolution = resolve_named(dir.path(), &own_image_name()).expect("resolve"); - assert_eq!(resolution, Resolution::Stale(StaleReason::HealthFailed)); - assert!( - !gateway_discovery_file_path(dir.path()).exists(), - "the stale file was deleted" - ); -} - -#[test] -fn a_transiently_silent_health_endpoint_is_not_stale() { - let dir = tempfile::TempDir::new().expect("tempdir"); - // The first probe connection is hung up without a response; only - // a retried probe learns the gateway is live. - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - let mut first = true; - while let Ok((mut stream, _)) = listener.accept() { - if std::mem::take(&mut first) { - drop(stream); - continue; - } - for _ in 0..2 { - let mut buffer = [0u8; 1024]; - if stream.read(&mut buffer).is_err() { - break; - } - if stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") - .is_err() - { - break; - } - } - } - }); - let file = live_file(port, "key"); - file.write_to(dir.path()).expect("write"); - - let resolution = resolve_named(dir.path(), &own_image_name()).expect("resolve"); - assert_eq!( - resolution, - Resolution::Attach(file), - "one dropped probe must not reject a live gateway" - ); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "a live file is left in place" - ); -} - -#[test] -fn a_rejected_key_is_stale_and_cleaned() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - live_file(port, "wrong") - .write_to(dir.path()) - .expect("write"); - - let resolution = resolve_named(dir.path(), &own_image_name()).expect("resolve"); - assert_eq!(resolution, Resolution::Stale(StaleReason::KeyRejected)); - assert!( - !gateway_discovery_file_path(dir.path()).exists(), - "the stale file was deleted" - ); -} - -#[test] -fn a_fully_live_file_attaches() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - let file = live_file(port, "right"); - file.write_to(dir.path()).expect("write"); - - let resolution = resolve_named(dir.path(), &own_image_name()).expect("resolve"); - assert_eq!(resolution, Resolution::Attach(file)); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "a live file is left in place" - ); -} - -#[test] -fn cancellation_during_resolve_leaves_the_gateway_discovery_file_untouched() { - let dir = tempfile::TempDir::new().expect("tempdir"); - live_file(1, "key").write_to(dir.path()).expect("write"); - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let run_dir = dir.path().to_owned(); - let image_name = own_image_name(); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - resolve_named_cancellable_with( - &run_dir, - &image_name, - &worker_cancellation, - |_, _, cancellation| { - entered.send(()).expect("announce blocked resolve"); - let _ = cancellation.wait_timeout(std::time::Duration::from_secs(30)); - Err(crate::ValidationError::Canceled) - }, - ) - }); - blocked - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("the resolve phase blocks deterministically"); - - let started = std::time::Instant::now(); - cancellation.cancel(); - let result = worker.join().expect("the resolve worker joins"); - - assert!( - started.elapsed() < std::time::Duration::from_millis(250), - "cancellation bounds the blocked resolve" - ); - assert!(matches!(result, Err(SidecarError::Canceled))); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "cancellation never classifies or deletes the gateway discovery file" - ); -} - -#[test] -fn cancellation_immediately_before_invalid_file_removal_preserves_the_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - fs::write(gateway_discovery_file_path(dir.path()), b"not json").expect("write invalid fixture"); - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let run_dir = dir.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let (release, released) = mpsc::channel(); - let removals = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let worker_removals = std::sync::Arc::clone(&removals); - let worker = std::thread::spawn(move || { - resolve_named_cancellable_with_effects( - &run_dir, - "unused", - &worker_cancellation, - |_, _, _| -> Result { - panic!("an invalid file never reaches validation") - }, - || { - entered.send(()).expect("announce invalid-file removal"); - released.recv().expect("release invalid-file removal"); - }, - |run_dir| { - worker_removals.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - remove_stale(run_dir) - }, - ) - }); - blocked - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("resolution pauses immediately before invalid-file removal"); - - cancellation.cancel(); - release.send(()).expect("release invalid-file removal"); - let result = worker.join().expect("resolve worker joins"); - - assert!(matches!(result, Err(SidecarError::Canceled))); - assert_eq!( - removals.load(std::sync::atomic::Ordering::SeqCst), - 0, - "invalid-file cleanup cannot begin after cancellation returns" - ); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "the canceled invalid file remains untouched" - ); -} - -#[test] -fn cancellation_immediately_before_failed_validation_removal_preserves_the_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - live_file(1, "key").write_to(dir.path()).expect("write"); - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let run_dir = dir.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let (release, released) = mpsc::channel(); - let removals = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); - let worker_removals = std::sync::Arc::clone(&removals); - let worker = std::thread::spawn(move || { - resolve_named_cancellable_with_effects( - &run_dir, - "unused", - &worker_cancellation, - |_, _, _| Err(ValidationError::Stale(StaleReason::HealthFailed)), - || { - entered.send(()).expect("announce stale-file removal"); - released.recv().expect("release stale-file removal"); - }, - |run_dir| { - worker_removals.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - remove_stale(run_dir) - }, - ) - }); - blocked - .recv_timeout(std::time::Duration::from_secs(1)) - .expect("resolution pauses immediately before stale-file removal"); - - cancellation.cancel(); - release.send(()).expect("release stale-file removal"); - let result = worker.join().expect("resolve worker joins"); - - assert!(matches!(result, Err(SidecarError::Canceled))); - assert_eq!( - removals.load(std::sync::atomic::Ordering::SeqCst), - 0, - "failed-validation cleanup cannot begin after cancellation returns" - ); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "the canceled stale file remains untouched" - ); -} - -#[test] -fn is_running_reports_a_live_gateway_without_touching_the_file() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - live_file(port, "right") - .write_to(dir.path()) - .expect("write"); - - assert!( - is_running_named(dir.path(), &own_image_name()), - "a fully live file reads as running" - ); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "the read-only check never deletes" - ); -} - -#[test] -fn is_running_leaves_a_stale_file_in_place() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "key") - }; - file.write_to(dir.path()).expect("write"); - - assert!( - !is_running_named(dir.path(), &own_image_name()), - "a dead pid reads as not-running" - ); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "stale-file deletion is the prospective owner's privilege" - ); -} - -#[test] -fn is_running_reads_absent_and_corrupt_files_as_not_running() { - let dir = tempfile::TempDir::new().expect("tempdir"); - assert!( - !is_running_named(dir.path(), &own_image_name()), - "no gateway discovery file reads as not-running" - ); - fs::write(gateway_discovery_file_path(dir.path()), b"not json").expect("write fixture"); - assert!( - !is_running_named(dir.path(), &own_image_name()), - "a corrupt file reads as not-running and is left alone" - ); - assert!( - gateway_discovery_file_path(dir.path()).exists(), - "the corrupt file was not deleted" - ); -} diff --git a/crates/workshop/gateway-discovery/src/stale.rs b/crates/workshop/gateway-discovery/src/stale.rs deleted file mode 100644 index 761674b54..000000000 --- a/crates/workshop/gateway-discovery/src/stale.rs +++ /dev/null @@ -1,258 +0,0 @@ -//! Stale detection: decide whether a gateway discovery file names a live -//! gateway, and remove it when it does not. -//! -//! A file is live when one OS process boot with a `runvil-gateway` -//! image is unchanged across a same-socket health and bearer proof, and -//! the file includes a boot identity. Anything else is stale - the Jupyter -//! phantom-server bug class - and the file is deleted so the next reader -//! relaunches instead of retrying a corpse. - -use std::fs; -use std::io; -use std::path::Path; - -use crate::error::SidecarError; -use crate::paths::gateway_discovery_file_path; -pub(crate) use crate::validated::GATEWAY_IMAGE_NAME; -use crate::validated::{ValidatedConnection, ValidationError}; -use crate::{CancellationToken, GatewayDiscoveryFile}; - -/// What [`resolve`] found in the run directory. -#[derive(Debug, Clone, PartialEq, Eq)] -#[non_exhaustive] -pub enum Resolution { - /// A live gateway: attach with these parameters. - Attach(GatewayDiscoveryFile), - /// No gateway discovery file exists: nothing to attach to, nothing to clean. - Absent, - /// A gateway discovery file existed but was stale; it was removed. - Stale(StaleReason), -} - -/// Why a gateway discovery file was judged stale. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum StaleReason { - /// The file was not valid JSON or failed validation. - #[error("the gateway discovery file is invalid")] - Invalid, - /// The pid is dead. - #[error("the recorded gateway process is dead")] - ProcessDead, - /// The pid is alive but its image is not a `runvil-gateway` - /// binary (a reused pid). - #[error("the recorded pid belongs to another process image")] - ImageMismatch, - /// The gateway discovery file has no usable boot identity. - #[error("the gateway discovery file has no usable boot identity")] - BootIdentityInvalid, - /// The pid changed process boot while validation was in progress. - #[error("the recorded process identity changed during validation")] - ProcessChanged, - /// The health endpoint did not answer 200. - #[error("the recorded gateway does not answer its health probe")] - HealthFailed, - /// The bearer key was rejected. - #[error("the gateway discovery file bearer was rejected")] - KeyRejected, -} - -/// Resolves the gateway discovery file in `run_dir`: attach parameters for a -/// live gateway, or stale-file cleanup plus the reason. -/// -/// # Errors -/// Returns [`SidecarError::Read`] when the file exists but cannot be -/// read, and [`SidecarError::Remove`] when a stale file cannot be -/// deleted. -pub fn resolve(run_dir: &Path) -> Result { - resolve_named(run_dir, GATEWAY_IMAGE_NAME) -} - -/// Resolves the gateway discovery file while observing caller cancellation. -/// -/// Cancellation never classifies or deletes the current file. -/// -/// # Errors -/// Returns [`SidecarError::Canceled`] when cancellation wins, plus the -/// read and remove failures documented by [`resolve`]. -pub fn resolve_cancellable( - run_dir: &Path, - cancellation: &CancellationToken, -) -> Result { - resolve_named_cancellable(run_dir, GATEWAY_IMAGE_NAME, cancellation) -} - -/// [`resolve`] against a caller-named process image. Test builds only. -/// -/// This exists for resolution tests that run in process and write a -/// `gateway.json` naming the test binary's own pid. A test binary is never -/// named `runvil-gateway`, so such a file would read as `ImageMismatch` -/// before the resolution logic under test was reached. -/// -/// It is not the way to exercise the real process-image step. Tests that -/// want the full gauntlet use the image-copy helper in `test_gateway.rs` -/// instead, which spawns a child named `runvil-gateway` and calls -/// [`resolve`] with no seam at all. -#[cfg(any(test, feature = "test-fixtures"))] -#[doc(hidden)] -pub fn resolve_for_test(run_dir: &Path, image_name: &str) -> Result { - resolve_named(run_dir, image_name) -} - -/// [`resolve`] against a caller-named process image, so tests can run the -/// full liveness gauntlet from a test binary, which is never named -/// `runvil-gateway`. -pub(crate) fn resolve_named(run_dir: &Path, image_name: &str) -> Result { - let file = match GatewayDiscoveryFile::read(run_dir) { - Ok(Some(file)) => file, - Ok(None) => return Ok(Resolution::Absent), - Err(SidecarError::Parse { .. } | SidecarError::Invalid { .. }) => { - remove_stale(run_dir)?; - return Ok(Resolution::Stale(StaleReason::Invalid)); - } - Err(error) => return Err(error), - }; - match ValidatedConnection::validate_named(file, image_name) { - Ok(validated) => Ok(Resolution::Attach(validated.into_gateway_discovery_file())), - Err(reason) => { - remove_stale(run_dir)?; - Ok(Resolution::Stale(reason)) - } - } -} - -pub(crate) fn resolve_named_cancellable( - run_dir: &Path, - image_name: &str, - cancellation: &CancellationToken, -) -> Result { - resolve_named_cancellable_with( - run_dir, - image_name, - cancellation, - ValidatedConnection::validate_named_cancellable, - ) -} - -fn resolve_named_cancellable_with( - run_dir: &Path, - image_name: &str, - cancellation: &CancellationToken, - validate: impl FnOnce( - GatewayDiscoveryFile, - &str, - &CancellationToken, - ) -> Result, -) -> Result { - resolve_named_cancellable_with_effects( - run_dir, - image_name, - cancellation, - validate, - || {}, - remove_stale, - ) -} - -fn resolve_named_cancellable_with_effects( - run_dir: &Path, - image_name: &str, - cancellation: &CancellationToken, - validate: impl FnOnce( - GatewayDiscoveryFile, - &str, - &CancellationToken, - ) -> Result, - mut before_remove: impl FnMut(), - mut remove: impl FnMut(&Path) -> Result<(), SidecarError>, -) -> Result { - if cancellation.is_canceled() { - return Err(SidecarError::Canceled); - } - let file = match GatewayDiscoveryFile::read(run_dir) { - Ok(Some(file)) => file, - Ok(None) if cancellation.is_canceled() => return Err(SidecarError::Canceled), - Ok(None) => return Ok(Resolution::Absent), - Err(SidecarError::Parse { .. } | SidecarError::Invalid { .. }) => { - before_remove(); - remove_stale_if_active(run_dir, cancellation, &mut remove)?; - return Ok(Resolution::Stale(StaleReason::Invalid)); - } - Err(error) => return Err(error), - }; - match validate(file, image_name, cancellation) { - Ok(validated) => Ok(Resolution::Attach(validated.into_gateway_discovery_file())), - Err(ValidationError::Canceled) => Err(SidecarError::Canceled), - Err(ValidationError::Stale(reason)) => { - before_remove(); - remove_stale_if_active(run_dir, cancellation, &mut remove)?; - Ok(Resolution::Stale(reason)) - } - } -} - -fn remove_stale_if_active( - run_dir: &Path, - cancellation: &CancellationToken, - remove: &mut impl FnMut(&Path) -> Result<(), SidecarError>, -) -> Result<(), SidecarError> { - cancellation - .run_if_active(|| remove(run_dir)) - .unwrap_or(Err(SidecarError::Canceled)) -} - -/// Whether the gateway discovery file in `run_dir` names a live gateway right -/// now, with no cleanup: the read-only check a diagnostics report runs. -/// Stale-file deletion is the prospective owner's privilege, so a stale -/// file reads as not-running and stays on disk for the next launch to -/// clean. -#[must_use] -pub fn is_running(run_dir: &Path) -> bool { - is_running_named(run_dir, GATEWAY_IMAGE_NAME) -} - -/// [`is_running`] against a caller-named process image, so a test binary - -/// never named `runvil-gateway` - can run the full liveness gauntlet. -fn is_running_named(run_dir: &Path, image_name: &str) -> bool { - match GatewayDiscoveryFile::read(run_dir) { - Ok(Some(file)) => is_live(&file, image_name), - // A missing, unreadable, or invalid file reads as not-running. - Ok(None) | Err(_) => false, - } -} - -/// Whether the file's gateway is live right now, with no cleanup: the -/// check a launch-race loser runs, since deleting is the lock holder's -/// privilege. -pub(crate) fn is_live(file: &GatewayDiscoveryFile, image_name: &str) -> bool { - ValidatedConnection::validate_named(file.clone(), image_name).is_ok() -} - -pub(crate) fn is_live_cancellable( - file: &GatewayDiscoveryFile, - image_name: &str, - cancellation: &CancellationToken, -) -> Result { - match ValidatedConnection::validate_named_cancellable(file.clone(), image_name, cancellation) { - Ok(_) => Ok(true), - Err(ValidationError::Stale(_)) => Ok(false), - Err(ValidationError::Canceled) => Err(SidecarError::Canceled), - } -} - -/// Deletes the stale gateway discovery file, tolerating a concurrent deletion. -fn remove_stale(run_dir: &Path) -> Result<(), SidecarError> { - let path = gateway_discovery_file_path(run_dir); - match fs::remove_file(&path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(SidecarError::Remove { - path, - source: error, - }), - } -} - -#[cfg(test)] -#[path = "stale-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway-discovery/src/sys.rs b/crates/workshop/gateway-discovery/src/sys.rs deleted file mode 100644 index 046f28b9e..000000000 --- a/crates/workshop/gateway-discovery/src/sys.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Process identity lookup for stale detection: one shim per platform, -//! each answering "what binary does this pid run, and which process boot -//! owns the pid", so pid reuse cannot join separate validation observations. - -use std::path::PathBuf; - -/// An OS-observed process boot, stable for one lifetime and different -/// when the pid is reused. -#[derive(Clone, PartialEq, Eq)] -pub(crate) struct ProcessIdentity { - /// The process executable observed for this boot. - pub(crate) image: PathBuf, - /// Platform start marker: FILETIME, proc start ticks, or timeval. - started: u128, -} - -impl ProcessIdentity { - /// Assembles one platform observation. - pub(crate) const fn new(image: PathBuf, started: u128) -> Self { - Self { image, started } - } - - /// Assembles a deterministic identity for validation regressions. - #[cfg(test)] - pub(crate) const fn for_test(image: PathBuf, started: u128) -> Self { - Self::new(image, started) - } -} - -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "macos")] -#[expect( - unsafe_code, - reason = "proc_pidpath and proc_pidinfo are raw C APIs with no safe wrappers" -)] -mod macos; -#[cfg(windows)] -#[expect( - unsafe_code, - reason = "process identity uses raw Win32 handle and query APIs" -)] -mod windows; - -#[cfg(target_os = "linux")] -pub(crate) use linux::process_identity; -#[cfg(target_os = "macos")] -pub(crate) use macos::process_identity; -#[cfg(windows)] -pub(crate) use windows::process_identity; - -/// Every other platform fails closed: no process identity means the -/// gateway discovery file is always stale, so a reader relaunches rather than -/// attaching to an unverified process. -#[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] -pub(crate) fn process_identity(_pid: u32) -> Option { - None -} diff --git a/crates/workshop/gateway-discovery/src/sys/linux.rs b/crates/workshop/gateway-discovery/src/sys/linux.rs deleted file mode 100644 index 7b06f1c49..000000000 --- a/crates/workshop/gateway-discovery/src/sys/linux.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! Linux process identity lookup: `/proc//exe` supplies the image and -//! field 22 of `/proc//stat` supplies the kernel start tick. - -use super::ProcessIdentity; - -/// The image and start tick for process `pid`, or `None` when the process -/// disappears, changes identity during observation, or `/proc` is unreadable. -pub(crate) fn process_identity(pid: u32) -> Option { - let first_start = process_start(pid)?; - let image = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?; - let second_start = process_start(pid)?; - (first_start == second_start).then(|| ProcessIdentity::new(image, u128::from(first_start))) -} - -/// Reads Linux `/proc//stat` field 22. The command field is enclosed -/// in parentheses and may itself contain spaces or closing parentheses, -/// so fields are counted only after its final delimiter. -fn process_start(pid: u32) -> Option { - let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; - let after_command = stat.get(stat.rfind(')')? + 1..)?; - after_command.split_whitespace().nth(19)?.parse().ok() -} diff --git a/crates/workshop/gateway-discovery/src/sys/macos.rs b/crates/workshop/gateway-discovery/src/sys/macos.rs deleted file mode 100644 index 598c294da..000000000 --- a/crates/workshop/gateway-discovery/src/sys/macos.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! macOS process identity lookup via libproc: `proc_pidpath` supplies the -//! image and `PROC_PIDTBSDINFO` supplies the process start timeval. - -use std::ffi::OsString; -use std::mem::{MaybeUninit, size_of}; -use std::os::unix::ffi::OsStringExt as _; -use std::path::PathBuf; - -use super::ProcessIdentity; - -/// Buffer size for `proc_pidpath`: `PROC_PIDPATHINFO_MAXSIZE` from -/// `libproc.h` (4 * MAXPATHLEN). -const PROC_PIDPATHINFO_MAXSIZE: u32 = 4096; -/// `PROC_PIDTBSDINFO` from `libproc.h`. -const PROC_PIDTBSDINFO: i32 = 3; - -/// The stable prefix and start fields of Darwin's `proc_bsdinfo`. -#[repr(C)] -struct ProcBsdInfo { - pbi_flags: u32, - pbi_status: u32, - pbi_xstatus: u32, - pbi_pid: u32, - pbi_ppid: u32, - pbi_uid: u32, - pbi_gid: u32, - pbi_ruid: u32, - pbi_rgid: u32, - pbi_svuid: u32, - pbi_svgid: u32, - rfu_1: u32, - pbi_comm: [libc::c_char; 16], - pbi_name: [libc::c_char; 32], - pbi_nfiles: u32, - pbi_pgid: u32, - pbi_pjobc: u32, - e_tdev: u32, - e_tpgid: u32, - pbi_nice: i32, - pbi_start_tvsec: u64, - pbi_start_tvusec: u64, -} - -/// The image and start timeval for process `pid`, or `None` when the -/// process disappears, changes identity during observation, or refuses -/// either query. -pub(crate) fn process_identity(pid: u32) -> Option { - let first_start = process_start(pid)?; - let image = process_image_path(pid)?; - let second_start = process_start(pid)?; - (first_start == second_start).then(|| ProcessIdentity::new(image, first_start)) -} - -/// Reads the kernel's path for one live process. -fn process_image_path(pid: u32) -> Option { - let pid = i32::try_from(pid).ok()?; - let mut buffer = vec![0u8; PROC_PIDPATHINFO_MAXSIZE as usize]; - // SAFETY: `buffer` is a live allocation of exactly - // PROC_PIDPATHINFO_MAXSIZE bytes and the size handed over matches it; - // proc_pidpath writes at most that many bytes and returns the count - // written, or a value <= 0 on error. - let written = - unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), PROC_PIDPATHINFO_MAXSIZE) }; - if written <= 0 { - return None; - } - let written = usize::try_from(written).ok()?; - buffer.truncate(written); - Some(PathBuf::from(OsString::from_vec(buffer))) -} - -/// Reads the process start timeval through `PROC_PIDTBSDINFO`. -fn process_start(pid: u32) -> Option { - let pid = i32::try_from(pid).ok()?; - let buffer_size = i32::try_from(size_of::()).ok()?; - let mut info = MaybeUninit::::uninit(); - // SAFETY: `info` points to writable storage of exactly `buffer_size` - // bytes. A full-size success initializes the complete structure before - // `assume_init`; every other result returns without reading it. - let written = unsafe { - libc::proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr().cast(), - buffer_size, - ) - }; - if written != buffer_size { - return None; - } - // SAFETY: the full-size `proc_pidinfo` success above initialized every - // byte of the `ProcBsdInfo` output structure. - let info = unsafe { info.assume_init() }; - Some(u128::from(info.pbi_start_tvsec) << 64 | u128::from(info.pbi_start_tvusec)) -} diff --git a/crates/workshop/gateway-discovery/src/sys/windows.rs b/crates/workshop/gateway-discovery/src/sys/windows.rs deleted file mode 100644 index 58731f16a..000000000 --- a/crates/workshop/gateway-discovery/src/sys/windows.rs +++ /dev/null @@ -1,79 +0,0 @@ -//! Windows process identity lookup: one process handle supplies the image -//! and creation FILETIME, so pid reuse cannot join two observations. - -use std::ffi::OsString; -use std::os::windows::ffi::OsStringExt as _; -use std::path::PathBuf; - -use windows_sys::Win32::Foundation::{CloseHandle, FILETIME, HANDLE}; -use windows_sys::Win32::System::Threading::{ - GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, -}; - -use super::ProcessIdentity; - -/// The image and creation time of process `pid`, or `None` when the -/// process is dead or refuses a limited query. -pub(crate) fn process_identity(pid: u32) -> Option { - // SAFETY: `OpenProcess` takes a valid access mask and pid; the - // returned handle is either null (checked) or a live process handle - // that `CloseHandle` below releases exactly once. - let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; - if handle.is_null() { - return None; - } - let identity = query_identity(handle); - // SAFETY: `handle` is the live process handle returned by the - // `OpenProcess` above, closed exactly once here. - unsafe { - CloseHandle(handle); - } - identity -} - -/// Reads one coherent image and creation time from an open process handle. -fn query_identity(handle: HANDLE) -> Option { - let image = query_image_path(handle)?; - let mut creation = FILETIME { - dwLowDateTime: 0, - dwHighDateTime: 0, - }; - let mut exit = creation; - let mut kernel = creation; - let mut user = creation; - // SAFETY: all pointers name initialized writable FILETIME values, and - // `handle` remains open for the complete query. - let ok = unsafe { - GetProcessTimes( - handle, - &raw mut creation, - &raw mut exit, - &raw mut kernel, - &raw mut user, - ) - }; - if ok == 0 { - return None; - } - let started = u128::from(creation.dwHighDateTime) << 32 | u128::from(creation.dwLowDateTime); - Some(ProcessIdentity::new(image, started)) -} - -/// Reads the image path from an open process handle. -fn query_image_path(handle: HANDLE) -> Option { - // The long-path ceiling: QueryFullProcessImageNameW fails rather than - // truncates when the buffer is too small, so one max-sized buffer - // needs no grow loop. - const BUFFER_LEN: u32 = 32768; - let mut buffer = vec![0u16; BUFFER_LEN as usize]; - let mut size = BUFFER_LEN; - // SAFETY: `buffer` is valid for `size` UTF-16 code units and `size` - // starts at its length; on success the call writes the path length - // back into `size`, never exceeding the buffer. - let ok = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &raw mut size) }; - if ok == 0 { - return None; - } - let length = usize::try_from(size).ok()?; - Some(PathBuf::from(OsString::from_wide(buffer.get(..length)?))) -} diff --git a/crates/workshop/gateway-discovery/src/validated-tests.rs b/crates/workshop/gateway-discovery/src/validated-tests.rs deleted file mode 100644 index 071da1a2c..000000000 --- a/crates/workshop/gateway-discovery/src/validated-tests.rs +++ /dev/null @@ -1,373 +0,0 @@ -//! Unit tests for building a validated Gateway connection. - -use super::*; - -use std::io::{Read, Write as _}; -use std::net::TcpListener; -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, mpsc}; - -use crate::GatewayDiscoveryFile; - -fn own_image_name() -> String { - std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned() -} - -fn connection(port: u16, api_key: &str) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: api_key.to_owned(), - pid: std::process::id(), - epoch: 1_778_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-05-05T12:00:00Z".to_owned(), - } -} - -fn dead_pid() -> u32 { - let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) - .arg("--list") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn a short-lived child"); - let pid = child.id(); - child.wait().expect("the child exits"); - pid -} - -fn fixture_gateway(expected_key: &'static str) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - for _ in 0..2 { - let mut buffer = [0_u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - break; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let accepted = request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); - let response = if accepted { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - if stream.write_all(response).is_err() { - break; - } - } - } - }); - port -} - -#[test] -fn a_wrong_process_image_cannot_create_a_capability() { - let file = connection(1, "key"); - - assert_eq!( - ValidatedConnection::validate_named(file, "not-the-test-binary"), - Err(StaleReason::ImageMismatch) - ); -} - -#[test] -fn a_dead_process_cannot_create_a_capability() { - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..connection(1, "key") - }; - - assert_eq!( - ValidatedConnection::validate_named(file, &own_image_name()), - Err(StaleReason::ProcessDead) - ); -} - -#[test] -fn a_stale_boot_identity_cannot_create_a_capability() { - let missing_epoch = GatewayDiscoveryFile { - epoch: 0, - ..connection(1, "key") - }; - assert_eq!( - ValidatedConnection::validate_named(missing_epoch, &own_image_name()), - Err(StaleReason::BootIdentityInvalid) - ); - - let missing_start = GatewayDiscoveryFile { - started_at: String::new(), - ..connection(1, "key") - }; - assert_eq!( - ValidatedConnection::validate_named(missing_start, &own_image_name()), - Err(StaleReason::BootIdentityInvalid) - ); -} - -#[test] -fn an_invalid_raw_connection_cannot_create_a_capability() { - let file = GatewayDiscoveryFile { - port: 0, - ..connection(1, "key") - }; - - assert_eq!( - ValidatedConnection::validate_named(file, &own_image_name()), - Err(StaleReason::Invalid) - ); -} - -#[test] -fn failed_health_cannot_create_a_capability() { - let file = connection(1, "key"); - - assert_eq!( - ValidatedConnection::validate_named(file, &own_image_name()), - Err(StaleReason::HealthFailed) - ); -} - -#[test] -fn an_absolute_validation_deadline_bounds_a_slow_owner() { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow owner"); - let port = listener.local_addr().expect("slow owner address").port(); - std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept validation"); - let mut request = [0_u8; 1024]; - let _ = stream.read(&mut request); - std::thread::sleep(Duration::from_secs(1)); - }); - let timeout = Duration::from_millis(100); - let started = Instant::now(); - - let result = ValidatedConnection::validate_named_before( - connection(port, "key"), - &own_image_name(), - started + timeout, - ); - - assert_eq!(result, Err(StaleReason::HealthFailed)); - assert!( - started.elapsed() < Duration::from_millis(500), - "the caller's deadline caps the real network proof" - ); -} - -#[test] -fn a_rejected_bearer_cannot_create_a_capability() { - let port = fixture_gateway("accepted"); - let file = connection(port, "rejected"); - - assert_eq!( - ValidatedConnection::validate_named(file, &own_image_name()), - Err(StaleReason::KeyRejected) - ); -} - -#[test] -fn a_new_boot_with_the_same_port_and_key_creates_a_distinct_capability() { - let port = fixture_gateway("stable-key"); - let original = - ValidatedConnection::validate_named(connection(port, "stable-key"), &own_image_name()) - .expect("the original connection validates"); - let replacement_file = GatewayDiscoveryFile { - epoch: original.epoch() + 1, - started_at: "2026-05-05T12:00:01Z".to_owned(), - ..connection(port, "stable-key") - }; - let replacement = ValidatedConnection::validate_named(replacement_file, &own_image_name()) - .expect("the replacement connection validates"); - - assert_eq!(replacement.port(), original.port()); - assert_eq!(replacement.api_key(), original.api_key()); - assert!(!replacement.same_boot(&original)); -} - -#[test] -fn debug_output_never_contains_the_bearer() { - let secret = "capability-secret"; - let port = fixture_gateway(secret); - let mut raw = connection(port, secret); - raw.version = format!("version-{secret}\r\n"); - raw.started_at = format!("started-{secret}\t"); - let validated = ValidatedConnection::validate_named(raw, &own_image_name()) - .expect("the connection validates"); - - let debug = format!("{validated:?}"); - assert!(!debug.contains(secret), "debug output redacts the bearer"); - assert!( - !debug.contains(['\r', '\n', '\t']), - "untrusted metadata cannot inject debug output" - ); - assert!(debug.contains(&port.to_string()), "the endpoint is visible"); -} - -#[test] -fn a_process_boot_change_during_the_network_proof_is_rejected() { - let image = std::path::PathBuf::from(own_image_name()); - let before = ProcessIdentity::for_test(image.clone(), 41); - let after = ProcessIdentity::for_test(image, 42); - let mut observations = [Some(before), Some(after)].into_iter(); - - assert_eq!( - validate_with( - connection(8081, "key"), - &own_image_name(), - |_| observations.next().flatten(), - |_, _, _| ConnectionProbe::Accepted, - ), - Err(StaleReason::ProcessChanged), - "a reused pid cannot complete a mixed proof" - ); -} - -#[test] -fn cancellation_inside_the_first_identity_probe_prevents_observation() { - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let image_name = own_image_name(); - let identity = ProcessIdentity::for_test(std::path::PathBuf::from(&image_name), 41); - let observations = Arc::new(AtomicUsize::new(0)); - let worker_observations = Arc::clone(&observations); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - validate_cancellable_with( - connection(8081, "key"), - &image_name, - &worker_cancellation, - |_| { - entered.send(()).expect("announce identity probe"); - if !worker_cancellation.wait_timeout(Duration::from_secs(30)) { - worker_observations.fetch_add(1, Ordering::SeqCst); - } - Some(identity.clone()) - }, - |_, _, _, _| ConnectionProbe::Accepted, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the first process identity probe blocks deterministically"); - - let started = std::time::Instant::now(); - cancellation.cancel(); - let result = worker.join().expect("the validation worker joins"); - - assert!( - started.elapsed() < Duration::from_millis(250), - "cancellation wakes the in-progress process identity probe" - ); - assert!(matches!(result, Err(ValidationError::Canceled))); - assert_eq!( - observations.load(Ordering::SeqCst), - 0, - "no process observation occurs after cancellation" - ); -} - -#[test] -fn cancellation_during_validation_prevents_the_second_identity_observation() { - let cancellation = crate::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let image_name = own_image_name(); - let identity = ProcessIdentity::for_test(std::path::PathBuf::from(&image_name), 41); - let observations = Arc::new(AtomicUsize::new(0)); - let worker_observations = Arc::clone(&observations); - let (entered, blocked) = mpsc::channel(); - let worker = std::thread::spawn(move || { - validate_cancellable_with( - connection(8081, "key"), - &image_name, - &worker_cancellation, - |_| { - worker_observations.fetch_add(1, Ordering::SeqCst); - Some(identity.clone()) - }, - |_, _, _, cancellation| { - entered.send(()).expect("announce blocked validation"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - ConnectionProbe::Accepted - }, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the validation phase blocks deterministically"); - - let started = std::time::Instant::now(); - cancellation.cancel(); - let result = worker.join().expect("the validation worker joins"); - - assert!( - started.elapsed() < Duration::from_millis(250), - "cancellation bounds the blocked validation" - ); - assert!(matches!(result, Err(ValidationError::Canceled))); - assert_eq!( - observations.load(Ordering::SeqCst), - 1, - "validation performs no post-cancel process probe" - ); -} - -#[test] -fn forged_file_identity_cannot_alias_a_reused_process() { - let raw = connection(8081, "key"); - let image = std::path::PathBuf::from(own_image_name()); - let first_identity = ProcessIdentity::for_test(image.clone(), 41); - let second_identity = ProcessIdentity::for_test(image, 42); - let first = validate_with( - raw.clone(), - &own_image_name(), - |_| Some(first_identity.clone()), - |_, _, _| ConnectionProbe::Accepted, - ) - .expect("the first coherent proof validates"); - let second = validate_with( - raw, - &own_image_name(), - |_| Some(second_identity.clone()), - |_, _, _| ConnectionProbe::Accepted, - ) - .expect("the replacement's coherent proof validates"); - - assert!( - !first.same_boot(&second), - "identical attacker-controlled file fields cannot forge process identity" - ); -} - -#[test] -fn validation_errors_never_contain_the_bearer_or_metadata() { - let secret = "capability-secret"; - let mut raw = connection(8081, secret); - raw.version = format!("version-{secret}\r\n"); - raw.started_at = format!("started-{secret}\t"); - let image = std::path::PathBuf::from(own_image_name()); - let identity = ProcessIdentity::for_test(image, 41); - let error = validate_with( - raw, - &own_image_name(), - |_| Some(identity.clone()), - |_, _, _| ConnectionProbe::KeyRejected, - ) - .expect_err("the bearer is rejected"); - - let debug = format!("{error:?}"); - let display = format!("{error}"); - for rendered in [debug, display] { - assert!(!rendered.contains(secret), "errors redact the bearer"); - assert!( - !rendered.contains(['\r', '\n', '\t']), - "untrusted metadata cannot inject errors" - ); - } -} diff --git a/crates/workshop/gateway-discovery/src/validated.rs b/crates/workshop/gateway-discovery/src/validated.rs deleted file mode 100644 index 9be99eee7..000000000 --- a/crates/workshop/gateway-discovery/src/validated.rs +++ /dev/null @@ -1,364 +0,0 @@ -//! A live Gateway connection whose process and authority have been -//! validated. - -use std::ffi::OsStr; -use std::fmt; -use std::path::Path; -use std::time::{Duration, Instant}; - -use crate::health::{self, ConnectionProbe}; -use crate::stale::StaleReason; -use crate::sys::{ProcessIdentity, process_identity}; -use crate::{CancellationToken, GatewayDiscoveryFile}; - -/// The image file name a live Gateway process must have. -#[cfg(windows)] -pub(crate) const GATEWAY_IMAGE_NAME: &str = "runvil-gateway.exe"; -/// The image file name a live Gateway process must have. -#[cfg(not(windows))] -pub(crate) const GATEWAY_IMAGE_NAME: &str = "runvil-gateway"; - -/// The bearer-gated route used to prove the presented key is accepted. -const KEY_PROBE_PATH: &str = "/v1/models"; - -/// Budget for proving health without rejecting one transient failure. -const LIVENESS_BUDGET: Duration = Duration::from_secs(2); - -/// A cancellable validation failure. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -pub enum ValidationError { - /// The caller canceled validation. - #[error("connection validation was canceled")] - Canceled, - /// The connection failed one of the liveness or authority checks. - #[error(transparent)] - Stale(#[from] StaleReason), -} - -/// A Gateway connection proven live and authorized at construction time. -/// -/// Safe code outside this crate cannot construct the capability directly. -/// Construction succeeds only after checking the process image, boot -/// identity, health endpoint, and bearer acceptance. -/// -/// Validation observes the OS process boot immediately before and after -/// the one TCP connection that makes both network checks. This closes the -/// health-to-bearer replacement gap and rejects pid reuse during that -/// interval. The capability is a point-in-time proof and makes no claim -/// that the process remains live after validation returns. -/// -/// The bearer is deliberately absent from [`Debug`](fmt::Debug) output. -/// -/// No test-fixture feature exposes another production-capability -/// constructor. -/// -/// The crate-private named validator is equally unavailable. -#[derive(Clone, PartialEq, Eq)] -#[non_exhaustive] -pub struct ValidatedConnection { - connection: GatewayDiscoveryFile, - process_identity: ProcessIdentity, -} - -impl ValidatedConnection { - /// Validates a raw gateway discovery file against the production Gateway image. - /// - /// # Errors - /// Returns the first [`StaleReason`] that prevents the raw connection - /// from proving a live, authorized Gateway boot. - pub fn validate(connection: GatewayDiscoveryFile) -> Result { - Self::validate_named(connection, GATEWAY_IMAGE_NAME) - } - - /// Validates a raw connection against the production Gateway image - /// without allowing the network proof to outlive `deadline`. - /// - /// # Errors - /// Returns the first [`StaleReason`] that prevents the raw connection - /// from proving a live, authorized Gateway boot before `deadline`. - pub fn validate_before( - connection: GatewayDiscoveryFile, - deadline: Instant, - ) -> Result { - Self::validate_named_before(connection, GATEWAY_IMAGE_NAME, deadline) - } - - /// Validates a raw connection while observing caller cancellation. - /// - /// # Errors - /// Returns [`ValidationError::Canceled`] when cancellation wins, or - /// [`ValidationError::Stale`] when a liveness or authority check fails. - pub fn validate_cancellable( - connection: GatewayDiscoveryFile, - cancellation: &CancellationToken, - ) -> Result { - Self::validate_named_cancellable(connection, GATEWAY_IMAGE_NAME, cancellation) - } - - pub(crate) fn validate_named( - connection: GatewayDiscoveryFile, - image_name: &str, - ) -> Result { - validate_with( - connection, - image_name, - process_identity, - |address, bearer, deadline| { - health::probe_connection_until(address, KEY_PROBE_PATH, bearer, deadline) - }, - ) - } - - fn validate_named_before( - connection: GatewayDiscoveryFile, - image_name: &str, - deadline: Instant, - ) -> Result { - validate_before_with( - connection, - image_name, - deadline, - process_identity, - |address, bearer, deadline| { - health::probe_connection_until(address, KEY_PROBE_PATH, bearer, deadline) - }, - ) - } - - pub(crate) fn validate_named_cancellable( - connection: GatewayDiscoveryFile, - image_name: &str, - cancellation: &CancellationToken, - ) -> Result { - validate_cancellable_with( - connection, - image_name, - cancellation, - process_identity, - |address, bearer, budget, cancellation| { - health::probe_connection_cancellable( - address, - KEY_PROBE_PATH, - bearer, - budget, - cancellation, - ) - }, - ) - } - - /// The validated Gateway's loopback port. - #[must_use] - pub const fn port(&self) -> u16 { - self.connection.port - } - - /// The validated Gateway process identifier. - #[must_use] - pub const fn pid(&self) -> u32 { - self.connection.pid - } - - /// The validated Gateway boot epoch. - #[must_use] - pub const fn epoch(&self) -> u64 { - self.connection.epoch - } - - /// The validated Gateway version. - #[must_use] - pub fn version(&self) -> &str { - &self.connection.version - } - - /// The validated Gateway boot timestamp. - #[must_use] - pub fn started_at(&self) -> &str { - &self.connection.started_at - } - - /// Whether both capabilities name the same validated Gateway boot. - #[must_use] - pub fn same_boot(&self, other: &Self) -> bool { - self.process_identity == other.process_identity - && self.pid() == other.pid() - && self.epoch() == other.epoch() - && self.started_at() == other.started_at() - } - - /// The validated bearer required to build an authorized consumer. - /// - /// Callers must keep this value out of diagnostics. The capability's - /// own [`Debug`](fmt::Debug) implementation always redacts it. - #[doc(hidden)] - #[must_use] - pub fn api_key(&self) -> &str { - &self.connection.api_key - } - - pub(crate) fn gateway_discovery_file(&self) -> &GatewayDiscoveryFile { - &self.connection - } - - pub(crate) fn into_gateway_discovery_file(self) -> GatewayDiscoveryFile { - self.connection - } -} - -impl fmt::Debug for ValidatedConnection { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("ValidatedConnection") - .field("port", &self.port()) - .field("pid", &self.pid()) - .field("epoch", &self.epoch()) - .field("version", &"[REDACTED]") - .field("started_at", &"[REDACTED]") - .field("api_key", &"[REDACTED]") - .finish() - } -} - -/// Performs one validation while allowing deterministic observation and -/// network seams in unit tests. Production passes only the platform process -/// observer and the private shared probe. -fn validate_with( - connection: GatewayDiscoveryFile, - image_name: &str, - observe_process: impl FnMut(u32) -> Option, - prove_connection: impl FnOnce(&str, &str, Instant) -> ConnectionProbe, -) -> Result { - validate_before_with( - connection, - image_name, - Instant::now() + LIVENESS_BUDGET, - observe_process, - prove_connection, - ) -} - -fn validate_before_with( - connection: GatewayDiscoveryFile, - image_name: &str, - deadline: Instant, - mut observe_process: impl FnMut(u32) -> Option, - prove_connection: impl FnOnce(&str, &str, Instant) -> ConnectionProbe, -) -> Result { - if Instant::now() >= deadline { - return Err(StaleReason::HealthFailed); - } - if connection.validation_error().is_some() { - return Err(StaleReason::Invalid); - } - let Some(before) = observe_process(connection.pid) else { - return Err(StaleReason::ProcessDead); - }; - if !image_name_matches(&before.image, image_name) { - return Err(StaleReason::ImageMismatch); - } - if !connection.has_boot_identity() { - return Err(StaleReason::BootIdentityInvalid); - } - if Instant::now() >= deadline { - return Err(StaleReason::HealthFailed); - } - let address = format!("127.0.0.1:{}", connection.port); - match prove_connection(&address, &connection.api_key, deadline) { - ConnectionProbe::Canceled | ConnectionProbe::HealthFailed => { - return Err(StaleReason::HealthFailed); - } - ConnectionProbe::KeyRejected => return Err(StaleReason::KeyRejected), - ConnectionProbe::Accepted => {} - } - let Some(after) = observe_process(connection.pid) else { - return Err(StaleReason::ProcessChanged); - }; - if before != after { - return Err(StaleReason::ProcessChanged); - } - Ok(ValidatedConnection { - connection, - process_identity: before, - }) -} - -fn validate_cancellable_with( - connection: GatewayDiscoveryFile, - image_name: &str, - cancellation: &CancellationToken, - mut observe_process: impl FnMut(u32) -> Option, - prove_connection: impl FnOnce(&str, &str, Duration, &CancellationToken) -> ConnectionProbe, -) -> Result { - if cancellation.is_canceled() { - return Err(ValidationError::Canceled); - } - if connection.validation_error().is_some() { - return Err(StaleReason::Invalid.into()); - } - let Some(before_observation) = cancellation.run_if_active(|| observe_process(connection.pid)) - else { - return Err(ValidationError::Canceled); - }; - let Some(before) = before_observation else { - return Err(StaleReason::ProcessDead.into()); - }; - if cancellation.is_canceled() { - return Err(ValidationError::Canceled); - } - if !image_name_matches(&before.image, image_name) { - return Err(StaleReason::ImageMismatch.into()); - } - if !connection.has_boot_identity() { - return Err(StaleReason::BootIdentityInvalid.into()); - } - let address = format!("127.0.0.1:{}", connection.port); - let proof = prove_connection(&address, &connection.api_key, LIVENESS_BUDGET, cancellation); - if cancellation.is_canceled() || proof == ConnectionProbe::Canceled { - return Err(ValidationError::Canceled); - } - match proof { - ConnectionProbe::HealthFailed => return Err(StaleReason::HealthFailed.into()), - ConnectionProbe::KeyRejected => return Err(StaleReason::KeyRejected.into()), - ConnectionProbe::Accepted => {} - ConnectionProbe::Canceled => return Err(ValidationError::Canceled), - } - let Some(after_observation) = cancellation.run_if_active(|| observe_process(connection.pid)) - else { - return Err(ValidationError::Canceled); - }; - let Some(after) = after_observation else { - return Err(StaleReason::ProcessChanged.into()); - }; - if cancellation.is_canceled() { - return Err(ValidationError::Canceled); - } - if before != after { - return Err(StaleReason::ProcessChanged.into()); - } - Ok(ValidatedConnection { - connection, - process_identity: before, - }) -} - -fn image_name_matches(image: &Path, expected: &str) -> bool { - let Some(name) = image.file_name() else { - return false; - }; - image_file_name_matches(name, expected) -} - -#[cfg(windows)] -fn image_file_name_matches(name: &OsStr, expected: &str) -> bool { - name.to_string_lossy().eq_ignore_ascii_case(expected) -} - -#[cfg(not(windows))] -fn image_file_name_matches(name: &OsStr, expected: &str) -> bool { - name == OsStr::new(expected) -} - -#[cfg(test)] -#[path = "validated-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway/Cargo.toml b/crates/workshop/gateway/Cargo.toml deleted file mode 100644 index 815d0c712..000000000 --- a/crates/workshop/gateway/Cargo.toml +++ /dev/null @@ -1,49 +0,0 @@ -[package] -name = "workshop-gateway" -version = "0.0.0" -publish = false -edition.workspace = true -license.workspace = true -repository.workspace = true - -[features] -test-fixtures = [ - "dep:tempfile", - "workshop-gateway-discovery/test-fixtures", - "workshop-support/test-fixtures", -] - -[dependencies] -arc-swap.workspace = true -futures-util.workspace = true -reqwest.workspace = true -serde.workspace = true -serde_json.workspace = true -workshop-gateway-discovery.workspace = true -thiserror.workspace = true -tokio.workspace = true -tokio-tungstenite.workspace = true -tracing.workspace = true -url.workspace = true -workshop-protocol.workspace = true -workshop-registry.workspace = true -workshop-support.workspace = true -tempfile = { workspace = true, optional = true } - -[dev-dependencies] -axum.workspace = true -tempfile.workspace = true -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } -# The gateway's tests call `workshop_support::fixtures::serve`; enabling the -# feature here (not only through the crate's own `test-fixtures` feature) -# keeps `cargo test -p workshop-gateway` compiling without `--all-features`. -workshop-support = { workspace = true, features = ["test-fixtures"] } -# The discovery fork's `test-fixtures` feature supplies the `gateway.json` -# writer and the process lease Workshop's test doubles need. -workshop-gateway-discovery = { workspace = true, features = ["test-fixtures"] } - -[build-dependencies] -serde_json.workspace = true - -[lints] -workspace = true diff --git a/crates/workshop/gateway/build.rs b/crates/workshop/gateway/build.rs deleted file mode 100644 index d0e8dc8c2..000000000 --- a/crates/workshop/gateway/build.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Embeds the Gateway pin's `min_gateway` so Workshop can compare it with -//! the version of the Gateway it attaches to. -//! -//! The pin is one file for the whole repository, and this crate is where -//! the Gateway version is known, so the value is baked into this crate's -//! environment and read back with `env!`. - -use std::path::PathBuf; - -fn main() { - embed_min_gateway(); -} - -/// Reads `gateway-pin/pin.json` and sets `RUNVIL_MIN_GATEWAY` to its -/// `min_gateway` value. A pin that is missing or still a placeholder sets -/// an empty value, which the comparison reads as "no minimum recorded": a -/// fresh checkout has to build before anyone can fill the pin in. -fn embed_min_gateway() { - let pin = pin_path(); - if let Some(path) = &pin { - println!("cargo:rerun-if-changed={}", path.display()); - } - let value = pin - .and_then(|path| std::fs::read_to_string(path).ok()) - .and_then(|text| serde_json::from_str::(&text).ok()) - .and_then(|pin| pin.get("min_gateway")?.as_str().map(str::to_owned)) - .filter(|value| value != "PLACEHOLDER") - .unwrap_or_default(); - println!("cargo:rustc-env=RUNVIL_MIN_GATEWAY={value}"); -} - -/// `/gateway-pin/pin.json`, from this crate's manifest directory. -fn pin_path() -> Option { - let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR")?); - let root = manifest.parent()?.parent()?.parent()?; - Some(root.join("gateway-pin").join("pin.json")) -} diff --git a/crates/workshop/gateway/src/binding.rs b/crates/workshop/gateway/src/binding.rs deleted file mode 100644 index ed4ff4d7b..000000000 --- a/crates/workshop/gateway/src/binding.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! Atomically replaceable gateway endpoint and credential state. -//! -//! Every gateway-dependent workshop path loads one immutable snapshot -//! containing the HTTP client, base URL, bearer, and generation. -//! Publication installs a validated local-sidecar replacement as the next -//! generation: it builds the complete next snapshot before one atomic -//! store, then notifies long-lived tasks to reconnect. Teardown closes -//! publication permanently. Explicitly configured endpoints never receive -//! an updater from the desktop app. - -mod publication; -mod shutdown; - -use std::fmt; -use std::sync::{Arc, Mutex, PoisonError}; - -use arc_swap::ArcSwap; -use tokio::sync::watch; - -use crate::client::{GatewayClient, GatewayError}; - -/// One immutable generation of every gateway client credential. -pub struct GatewaySnapshot { - /// The one client, serving HTTP requests and the Realtime socket alike, - /// used by workshop routes and the heartbeat. It holds this - /// generation's normalized base URL and bearer. - client: GatewayClient, - /// Monotonic generation assigned before this snapshot is published. - generation: u64, - /// Proven local gateway boot, absent for an explicitly configured endpoint. - identity: Option, -} - -impl fmt::Debug for GatewaySnapshot { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("GatewaySnapshot") - .field("client", &self.client) - .field("generation", &self.generation) - .field("identity", &self.identity) - .finish_non_exhaustive() - } -} - -impl GatewaySnapshot { - /// The gateway client in this generation, for HTTP requests and the - /// Realtime socket alike. - #[must_use] - pub fn client(&self) -> &GatewayClient { - &self.client - } - - /// The gateway base URL in this generation. - #[must_use] - pub fn base_url(&self) -> &str { - self.client.base_url() - } - - /// The gateway bearer in this generation, for consumers that - /// authenticate outside the HTTP client. - #[must_use] - pub fn api_key(&self) -> &str { - &self.client.api_key - } - - /// This snapshot's monotonic generation. - #[must_use] - pub fn generation(&self) -> u64 { - self.generation - } -} - -/// Shared atomic gateway snapshot and replacement notification. -#[derive(Clone)] -pub struct GatewayBinding { - current: Arc>, - changed: watch::Sender, - publication: Arc>, -} - -#[derive(Debug)] -struct PublicationState { - next_generation: u64, - closed: bool, -} - -/// A failure to publish a replacement gateway generation. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum GatewayPublicationError { - /// The replacement client could not be built. - #[error(transparent)] - #[non_exhaustive] - Build(#[from] GatewayError), - /// The binding has permanently revoked replacement publication. - #[error("gateway replacement publication is permanently closed")] - PublicationClosed, -} - -impl fmt::Debug for GatewayBinding { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("GatewayBinding") - .field("current", &self.snapshot()) - .finish() - } -} - -impl GatewayBinding { - /// Builds generation zero from one endpoint and credential pair. - /// - /// # Errors - /// Returns [`GatewayError::Build`] if the HTTP client cannot be built. - #[cfg(any(test, feature = "test-fixtures"))] - pub fn new(base_url: &str, api_key: &str) -> Result { - Self::new_with_identity(base_url, api_key, None) - } - - /// Builds generation zero with an optional validated local identity. - /// - /// # Errors - /// Returns [`GatewayError::Build`] if the HTTP client cannot be built. - pub fn new_with_identity( - base_url: &str, - api_key: &str, - identity: Option, - ) -> Result { - Ok(Self::from_snapshot(build_snapshot( - base_url, api_key, identity, - )?)) - } - - /// Builds a binding around a client with test-specific timeouts. - #[cfg(any(test, feature = "test-fixtures"))] - #[must_use] - pub fn from_client(client: GatewayClient) -> Self { - Self::from_snapshot(GatewaySnapshot { - client, - generation: 0, - identity: None, - }) - } - - /// Publishes `snapshot` as generation zero, with replacement - /// publication open from generation one. - fn from_snapshot(snapshot: GatewaySnapshot) -> Self { - Self { - current: Arc::new(ArcSwap::from_pointee(snapshot)), - changed: watch::channel(0).0, - publication: Arc::new(Mutex::new(PublicationState { - next_generation: 1, - closed: false, - })), - } - } - - /// Loads one endpoint and credential generation atomically. - #[must_use] - pub fn snapshot(&self) -> Arc { - self.current.load_full() - } - - /// Subscribes to replacements after loading the current generation. - #[must_use] - pub fn subscribe(&self) -> watch::Receiver { - self.changed.subscribe() - } - - /// The currently published generation. - #[must_use] - pub fn generation(&self) -> u64 { - self.snapshot().generation() - } - - /// Builds and atomically publishes a replacement, then wakes consumers. - /// - /// # Errors - /// Returns [`GatewayPublicationError::Build`] if the replacement HTTP - /// client cannot initialize, or - /// [`GatewayPublicationError::PublicationClosed`] after teardown. - #[cfg(any(test, feature = "test-fixtures"))] - pub fn replace(&self, base_url: &str, api_key: &str) -> Result<(), GatewayPublicationError> { - self.replace_with_identity(base_url, api_key, None) - } - - /// Builds and atomically publishes a complete replacement generation. - #[cfg(any(test, feature = "test-fixtures"))] - fn replace_with_identity( - &self, - base_url: &str, - api_key: &str, - identity: Option, - ) -> Result<(), GatewayPublicationError> { - let snapshot = build_snapshot(base_url, api_key, identity)?; - self.publish_snapshot(snapshot) - } - - #[cfg(any(test, feature = "test-fixtures"))] - fn publish_snapshot(&self, snapshot: GatewaySnapshot) -> Result<(), GatewayPublicationError> { - let mut publication = self - .publication - .lock() - .unwrap_or_else(PoisonError::into_inner); - self.commit(&mut publication, snapshot) - } - - /// Stores `snapshot` as the next generation under the held publication - /// lock, then wakes consumers, unless teardown closed publication. - fn commit( - &self, - publication: &mut PublicationState, - mut snapshot: GatewaySnapshot, - ) -> Result<(), GatewayPublicationError> { - if publication.closed { - return Err(GatewayPublicationError::PublicationClosed); - } - let generation = publication.next_generation; - publication.next_generation = generation.saturating_add(1); - snapshot.generation = generation; - self.current.store(Arc::new(snapshot)); - self.changed.send_replace(generation); - Ok(()) - } - - fn close_publication(&self) { - self.publication - .lock() - .unwrap_or_else(PoisonError::into_inner) - .closed = true; - } - - fn publication_closed(&self) -> bool { - self.publication - .lock() - .unwrap_or_else(PoisonError::into_inner) - .closed - } - - /// Creates the restricted handle the desktop app uses for sidecar updates. - #[must_use] - pub fn updater(&self) -> GatewayUpdater { - GatewayUpdater { - binding: self.clone(), - } - } -} - -/// Restricted local-gateway authority for the embedding desktop app. -/// -/// Replacements accept only validated capabilities, and shutdown reads the -/// same current immutable snapshot as every workshop consumer. Raw connection -/// files cannot cross the publication boundary. -#[derive(Clone)] -pub struct GatewayUpdater { - binding: GatewayBinding, -} - -impl fmt::Debug for GatewayUpdater { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("GatewayUpdater") - .finish_non_exhaustive() - } -} - -impl GatewayUpdater { - /// Atomically replaces the local gateway port and bearer, waking every - /// long-lived workshop consumer only after the complete snapshot is live. - /// - /// # Errors - /// Returns [`GatewayPublicationError::Build`] if the replacement HTTP - /// client cannot initialize, or - /// [`GatewayPublicationError::PublicationClosed`] after teardown revokes - /// replacement publication. - #[cfg(any(test, feature = "test-fixtures"))] - pub fn replace_sidecar( - &self, - connection: &crate::discovery::ValidatedConnection, - ) -> Result<(), GatewayPublicationError> { - self.binding.replace_with_identity( - &format!("http://127.0.0.1:{}", connection.port()), - connection.api_key(), - Some(connection.clone()), - ) - } - - /// Replaces the configured gateway in the crate's integration fixtures - /// without manufacturing a production sidecar capability. - /// - /// # Errors - /// Returns [`GatewayPublicationError::Build`] if the replacement HTTP - /// client cannot initialize, or - /// [`GatewayPublicationError::PublicationClosed`] after teardown. - #[cfg(feature = "test-fixtures")] - pub fn replace_fixture( - &self, - base_url: &str, - api_key: &str, - ) -> Result<(), GatewayPublicationError> { - self.binding.replace(base_url, api_key) - } - - /// Permanently revokes replacement publication for this binding and every - /// updater clone. - pub fn close_publication(&self) { - self.binding.close_publication(); - } - - /// Whether replacement publication has been permanently revoked. - #[must_use] - pub fn publication_closed(&self) -> bool { - self.binding.publication_closed() - } -} - -/// Builds all clients before publication so URL and bearer never tear. -/// Publication assigns the generation. -fn build_snapshot( - base_url: &str, - api_key: &str, - identity: Option, -) -> Result { - Ok(GatewaySnapshot { - client: GatewayClient::new(base_url, api_key)?, - generation: 0, - identity, - }) -} - -#[cfg(test)] -mod tests; diff --git a/crates/workshop/gateway/src/binding/publication.rs b/crates/workshop/gateway/src/binding/publication.rs deleted file mode 100644 index d1f1b0f00..000000000 --- a/crates/workshop/gateway/src/binding/publication.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Cancellation-aware publication of validated gateway replacements. - -use std::sync::TryLockError; -use std::time::Duration; - -use super::{GatewayBinding, GatewayPublicationError, GatewayUpdater, build_snapshot}; - -/// Cancellable replacement lock retry cadence. -const REPLACEMENT_RETRY_INTERVAL: Duration = Duration::from_millis(10); - -impl GatewayBinding { - fn replace_with_identity_cancellable( - &self, - base_url: &str, - api_key: &str, - identity: crate::discovery::ValidatedConnection, - cancellation: &crate::discovery::CancellationToken, - ) -> Result { - self.replace_with_identity_cancellable_with_wait( - base_url, - api_key, - identity, - cancellation, - crate::discovery::CancellationToken::wait_timeout, - ) - } - - pub(super) fn replace_with_identity_cancellable_with_wait( - &self, - base_url: &str, - api_key: &str, - identity: crate::discovery::ValidatedConnection, - cancellation: &crate::discovery::CancellationToken, - mut wait: impl FnMut(&crate::discovery::CancellationToken, Duration) -> bool, - ) -> Result { - if cancellation.is_canceled() { - return if self.publication_closed() { - Err(GatewayPublicationError::PublicationClosed) - } else { - Ok(false) - }; - } - let snapshot = build_snapshot(base_url, api_key, Some(identity))?; - cancellation - .run_if_active(|| { - let mut publication = loop { - match self.publication.try_lock() { - Ok(publication) => break publication, - Err(TryLockError::Poisoned(error)) => break error.into_inner(), - Err(TryLockError::WouldBlock) => { - if wait(cancellation, REPLACEMENT_RETRY_INTERVAL) { - return Ok(false); - } - } - } - }; - if cancellation.is_canceled() { - return Ok(false); - } - self.commit(&mut publication, snapshot).map(|()| true) - }) - .unwrap_or_else(|| { - if self.publication_closed() { - Err(GatewayPublicationError::PublicationClosed) - } else { - Ok(false) - } - }) - } -} - -impl GatewayUpdater { - /// Atomically replaces the local gateway unless caller cancellation wins. - /// - /// Returns `Ok(false)` without publication when cancellation wins while - /// another publisher owns the replacement lock. - /// - /// # Errors - /// Returns [`GatewayPublicationError::Build`] if the replacement clients - /// cannot initialize, or - /// [`GatewayPublicationError::PublicationClosed`] after teardown revokes - /// replacement publication. - pub fn replace_sidecar_cancellable( - &self, - connection: &crate::discovery::ValidatedConnection, - cancellation: &crate::discovery::CancellationToken, - ) -> Result { - self.binding.replace_with_identity_cancellable( - &format!("http://127.0.0.1:{}", connection.port()), - connection.api_key(), - connection.clone(), - cancellation, - ) - } -} diff --git a/crates/workshop/gateway/src/binding/shutdown.rs b/crates/workshop/gateway/src/binding/shutdown.rs deleted file mode 100644 index cef97ddc2..000000000 --- a/crates/workshop/gateway/src/binding/shutdown.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Shutdown authority derived from a gateway snapshot's validated identity. - -use super::{GatewaySnapshot, GatewayUpdater}; - -impl GatewaySnapshot { - /// Whether this generation is a supervised local sidecar: it holds - /// a validated local gateway boot, so the workshop may shut it down - /// and its supervisor relaunches the sibling. An explicitly configured - /// endpoint (a LAN gateway) never is. - #[must_use] - pub fn is_sidecar(&self) -> bool { - self.identity.is_some() - } - - /// Requests shutdown from the validated local gateway this snapshot - /// names, posting the authenticated `POST /shutdown`. - /// - /// Returns `Ok(false)` without sending a request when the snapshot - /// came from explicit configuration and therefore grants no local - /// shutdown authority. The request is blocking I/O; an async caller - /// runs it off the executor. - /// - /// # Errors - /// Returns [`crate::discovery::ShutdownError`] when the local gateway - /// refuses the request or cannot be reached. - pub fn request_shutdown(&self) -> Result { - let Some(identity) = self.identity.as_ref() else { - return Ok(false); - }; - crate::discovery::request_shutdown(identity)?; - Ok(true) - } -} - -impl GatewayUpdater { - /// Requests shutdown from the validated local gateway in the current - /// consumer snapshot, as that snapshot's - /// [`GatewaySnapshot::request_shutdown`] does. - /// - /// Returns `Ok(false)` without sending a request when the current gateway - /// came from explicit configuration and therefore grants no local shutdown - /// authority. - /// - /// # Errors - /// Returns [`crate::discovery::ShutdownError`] when the current local - /// gateway refuses the request or cannot be reached. - pub fn request_shutdown(&self) -> Result { - self.binding.snapshot().request_shutdown() - } -} diff --git a/crates/workshop/gateway/src/binding/tests.rs b/crates/workshop/gateway/src/binding/tests.rs deleted file mode 100644 index bc72a4551..000000000 --- a/crates/workshop/gateway/src/binding/tests.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Gateway binding tests: capability replacement publishes one coherent snapshot and a new identity. - -use super::*; - -mod atomic; -mod publication; -mod shutdown; - -fn validated_connection( - gateway: &crate::test_gateway::ValidatedGateway, - api_key: &str, - epoch: u64, - started_at: &str, -) -> crate::discovery::ValidatedConnection { - gateway.validate(api_key, epoch, started_at) -} - -#[test] -fn capability_replacement_publishes_one_coherent_snapshot() { - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let old = binding.snapshot(); - let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); - let port = gateway.port(); - let validated = - validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); - - binding - .updater() - .replace_sidecar(&validated) - .expect("replacement builds"); - let new = binding.snapshot(); - - assert_eq!(old.client().base_url, "http://127.0.0.1:54375"); - assert_eq!(old.client().api_key, "old-key"); - assert_eq!(old.base_url(), "http://127.0.0.1:54375"); - assert_eq!(old.api_key(), "old-key"); - assert!( - old.identity.is_none(), - "configured gateways have no local identity" - ); - assert!( - !old.is_sidecar(), - "a configured gateway is never a supervised sidecar" - ); - assert_eq!(new.client().base_url, format!("http://127.0.0.1:{port}")); - assert_eq!(new.client().api_key, "new-key"); - assert_eq!(new.base_url(), format!("http://127.0.0.1:{port}")); - assert_eq!(new.api_key(), "new-key"); - let identity = new - .identity - .as_ref() - .expect("the validated identity is published"); - assert_eq!(identity, &validated); - assert!( - new.is_sidecar(), - "a validated identity makes the snapshot a supervised sidecar" - ); - assert!(new.generation() > old.generation()); - assert_eq!(binding.generation(), new.generation()); -} - -#[test] -fn same_port_and_key_new_boot_still_publishes_a_new_identity() { - let gateway = crate::test_gateway::ValidatedGateway::spawn("stable-key"); - let first = validated_connection( - &gateway, - "stable-key", - 1_778_000_001, - "2026-09-07T18:00:01Z", - ); - let replacement = validated_connection( - &gateway, - "stable-key", - 1_778_000_002, - "2026-09-07T18:00:02Z", - ); - let binding = - GatewayBinding::new("http://127.0.0.1:54375", "stable-key").expect("binding builds"); - binding - .updater() - .replace_sidecar(&first) - .expect("the first identity publishes"); - let first_snapshot = binding.snapshot(); - binding - .updater() - .replace_sidecar(&replacement) - .expect("the replacement identity publishes"); - let replacement_snapshot = binding.snapshot(); - - assert_eq!(replacement_snapshot.base_url(), first_snapshot.base_url()); - assert_eq!(replacement_snapshot.api_key(), first_snapshot.api_key()); - assert!( - replacement_snapshot.generation() > first_snapshot.generation(), - "identity replacement advances the generation even with a stable endpoint and bearer" - ); - assert_eq!( - replacement_snapshot.identity.as_ref(), - Some(&replacement), - "the snapshot holds the new validated boot" - ); -} diff --git a/crates/workshop/gateway/src/binding/tests/atomic.rs b/crates/workshop/gateway/src/binding/tests/atomic.rs deleted file mode 100644 index e4d263f61..000000000 --- a/crates/workshop/gateway/src/binding/tests/atomic.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Binding atomicity: synchronized reads never observe a torn replacement snapshot. - -use super::*; - -use std::sync::Arc; - -#[test] -fn synchronized_reads_never_observe_a_torn_replacement_snapshot() { - let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); - let validated = - validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); - let new_url = format!("http://127.0.0.1:{}", gateway.port()); - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let updater = binding.updater(); - let (target_tx, target_rx) = std::sync::mpsc::sync_channel(0); - let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(0); - let (observed_tx, observed_rx) = std::sync::mpsc::sync_channel(0); - - let samples = std::thread::scope(|scope| { - let reader_binding = binding.clone(); - let reader_validated = validated.clone(); - let reader_url = new_url.clone(); - let reader = scope.spawn(move || { - let mut samples = 0_usize; - while let Ok(target_generation) = target_rx.recv() { - ready_tx.send(()).expect("the publisher remains live"); - loop { - let snapshot = reader_binding.snapshot(); - samples += 1; - if snapshot.generation().is_multiple_of(2) { - assert_eq!(snapshot.client().base_url, "http://127.0.0.1:54375"); - assert_eq!(snapshot.client().api_key, "old-key"); - assert_eq!(snapshot.base_url(), "http://127.0.0.1:54375"); - assert_eq!(snapshot.api_key(), "old-key"); - assert!(snapshot.identity.is_none()); - } else { - assert_eq!(snapshot.client().base_url, reader_url); - assert_eq!(snapshot.client().api_key, "new-key"); - assert_eq!(snapshot.base_url(), reader_url); - assert_eq!(snapshot.api_key(), "new-key"); - assert_eq!(snapshot.identity.as_ref(), Some(&reader_validated)); - } - if snapshot.generation() >= target_generation { - break; - } - std::thread::yield_now(); - } - observed_tx - .send(()) - .expect("the publisher observes the sample"); - } - samples - }); - - for generation in 1_u64..=256 { - target_tx.send(generation).expect("the reader remains live"); - ready_rx.recv().expect("the reader begins sampling"); - if generation % 2 == 0 { - binding - .replace("http://127.0.0.1:54375", "old-key") - .expect("the configured snapshot republishes"); - } else { - updater - .replace_sidecar(&validated) - .expect("the sidecar snapshot publishes"); - } - observed_rx - .recv() - .expect("the reader observes the published generation"); - } - drop(target_tx); - reader.join().expect("the reader does not panic") - }); - - assert!(samples >= 256, "every synchronized publication is sampled"); -} - -#[test] -fn close_before_publish_preserves_the_complete_original_generation() { - let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); - let validated = - validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let original = binding.snapshot(); - let updater = binding.updater(); - - updater.close_publication(); - assert!(matches!( - updater.replace_sidecar(&validated), - Err(GatewayPublicationError::PublicationClosed) - )); - - let observed = binding.snapshot(); - assert!( - Arc::ptr_eq(&observed, &original), - "close-first ordering neither stores nor advances a generation" - ); - assert_eq!(observed.generation(), 0); - assert_eq!(observed.base_url(), "http://127.0.0.1:54375"); - assert_eq!(observed.api_key(), "old-key"); - assert!(observed.identity.is_none()); -} - -#[test] -fn publish_before_close_preserves_the_complete_final_generation() { - let first_gateway = crate::test_gateway::ValidatedGateway::spawn("first-key"); - let first = validated_connection( - &first_gateway, - "first-key", - 1_778_000_001, - "2026-09-07T18:00:01Z", - ); - let second_gateway = crate::test_gateway::ValidatedGateway::spawn("second-key"); - let second = validated_connection( - &second_gateway, - "second-key", - 1_778_000_002, - "2026-09-07T18:00:02Z", - ); - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let updater = binding.updater(); - updater - .replace_sidecar(&first) - .expect("publish wins before close"); - let published = binding.snapshot(); - - updater.close_publication(); - assert!(matches!( - updater.replace_sidecar(&second), - Err(GatewayPublicationError::PublicationClosed) - )); - - let observed = binding.snapshot(); - assert!( - Arc::ptr_eq(&observed, &published), - "publish-first ordering retains exactly its final stored generation" - ); - assert_eq!(observed.generation(), 1); - assert_eq!( - observed.base_url(), - format!("http://127.0.0.1:{}", first.port()) - ); - assert_eq!(observed.api_key(), "first-key"); - assert_eq!(observed.identity.as_ref(), Some(&first)); -} diff --git a/crates/workshop/gateway/src/binding/tests/publication.rs b/crates/workshop/gateway/src/binding/tests/publication.rs deleted file mode 100644 index 78ebe2c9c..000000000 --- a/crates/workshop/gateway/src/binding/tests/publication.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! Binding publication: cancellation wakes contenders and close is a permanent linearization point. - -use super::*; - -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, PoisonError}; -use std::time::{Duration, Instant}; - -#[test] -fn cancellation_wakes_a_replacement_contending_on_the_publication_lock() { - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let original = binding.snapshot(); - let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); - let validated = - validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); - let held = binding - .publication - .lock() - .unwrap_or_else(PoisonError::into_inner); - let worker_binding = binding.clone(); - let cancellation = crate::discovery::CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let (entered, blocked) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - let base_url = format!("http://127.0.0.1:{}", validated.port()); - let api_key = validated.api_key().to_owned(); - worker_binding.replace_with_identity_cancellable_with_wait( - &base_url, - &api_key, - validated, - &worker_cancellation, - |cancellation, delay| { - entered - .send(()) - .expect("announce publication lock contention"); - cancellation.wait_timeout(delay) - }, - ) - }); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("replacement blocks inside the real publication boundary"); - - let started = Instant::now(); - cancellation.cancel(); - let published = worker - .join() - .expect("replacement worker joins") - .expect("replacement build succeeds"); - drop(held); - - assert!( - started.elapsed() < Duration::from_millis(250), - "cancellation wakes publication lock contention" - ); - assert!(!published, "the canceled replacement is not published"); - assert_eq!( - binding.snapshot().generation(), - original.generation(), - "the authoritative snapshot remains the pre-cancel generation" - ); -} - -#[test] -fn publication_close_is_permanent_for_every_updater_clone() { - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let original = binding.snapshot(); - let changed = binding.subscribe(); - let updater = binding.updater(); - let clone = updater.clone(); - let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); - let validated = - validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); - - updater.close_publication(); - for updater in [updater, clone, binding.updater()] { - let error = updater - .replace_sidecar(&validated) - .expect_err("a closed binding rejects every updater clone"); - assert!(matches!(error, GatewayPublicationError::PublicationClosed)); - } - let cancellation = crate::discovery::CancellationToken::new(); - cancellation.cancel(); - assert!(matches!( - binding - .updater() - .replace_sidecar_cancellable(&validated, &cancellation), - Err(GatewayPublicationError::PublicationClosed) - )); - - assert_eq!( - binding.snapshot().generation(), - original.generation(), - "rejected publication does not advance or store a generation" - ); - assert!( - !changed - .has_changed() - .expect("the watch channel remains live"), - "rejected publication does not notify subscribers" - ); -} - -#[test] -fn concurrent_publishers_cannot_cross_the_permanent_close_linearization_point() { - let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); - let validated = Arc::new(validated_connection( - &gateway, - "new-key", - 1_778_000_001, - "2026-09-07T18:00:01Z", - )); - let closed = Arc::new(AtomicBool::new(false)); - - std::thread::scope(|scope| { - for _ in 0..8 { - let updater = binding.updater(); - let validated = Arc::clone(&validated); - let closed = Arc::clone(&closed); - scope.spawn(move || { - while !closed.load(Ordering::SeqCst) { - let _ = updater.replace_sidecar(&validated); - } - for _ in 0..32 { - assert!(matches!( - updater.replace_sidecar(&validated), - Err(GatewayPublicationError::PublicationClosed) - )); - } - }); - } - binding.updater().close_publication(); - let closed_generation = binding.generation(); - closed.store(true, Ordering::SeqCst); - std::thread::yield_now(); - assert_eq!( - binding.generation(), - closed_generation, - "no publisher advances the generation after close returns" - ); - }); -} diff --git a/crates/workshop/gateway/src/binding/tests/shutdown.rs b/crates/workshop/gateway/src/binding/tests/shutdown.rs deleted file mode 100644 index 374b7ec7b..000000000 --- a/crates/workshop/gateway/src/binding/tests/shutdown.rs +++ /dev/null @@ -1,179 +0,0 @@ -//! Binding shutdown authority: which identity a quit targets and who may post the shutdown. - -use super::*; - -use std::{sync::mpsc, time::Duration}; - -const FIXTURE_PHASE_TIMEOUT: Duration = Duration::from_secs(10); - -#[derive(Clone, Copy)] -enum PublicationOrder { - QuitFirst, - ReplacementFirst, -} - -fn assert_shutdown_target(order: PublicationOrder) { - let mut original_gateway = crate::test_gateway::ValidatedGateway::spawn("original-key"); - let mut replacement_gateway = crate::test_gateway::ValidatedGateway::spawn("replacement-key"); - let original = validated_connection( - &original_gateway, - "original-key", - 1_778_000_001, - "2026-09-07T18:00:01Z", - ); - let replacement = validated_connection( - &replacement_gateway, - "replacement-key", - 1_778_000_002, - "2026-09-07T18:00:02Z", - ); - let binding = GatewayBinding::new_with_identity( - &format!("http://127.0.0.1:{}", original.port()), - original.api_key(), - Some(original.clone()), - ) - .expect("binding builds"); - let updater = binding.updater(); - let (publish, await_publish) = mpsc::sync_channel(0); - let (completed, await_completion) = mpsc::sync_channel(0); - let (quit, await_quit) = mpsc::sync_channel(0); - - let shutdown_requested = std::thread::scope(|scope| { - let publish_updater = updater.clone(); - let worker = scope.spawn(move || { - await_publish.recv().expect("publication is released"); - publish_updater - .replace_sidecar(&replacement) - .expect("the replacement publishes"); - completed.send(()).expect("publication is observed"); - }); - let shutdown_updater = updater; - let shutdown = scope.spawn(move || { - await_quit.recv().expect("quit is released"); - shutdown_updater - .request_shutdown() - .expect("one coherent generation accepts shutdown") - }); - - let result = match order { - PublicationOrder::QuitFirst => { - quit.send(()).expect("release quit"); - let result = shutdown.join().expect("shutdown does not panic"); - publish.send(()).expect("release publication"); - await_completion.recv().expect("observe publication"); - result - } - PublicationOrder::ReplacementFirst => { - publish.send(()).expect("release publication"); - await_completion.recv().expect("observe publication"); - quit.send(()).expect("release quit"); - shutdown.join().expect("shutdown does not panic") - } - }; - worker.join().expect("publisher does not panic"); - result - }); - - let original_hit = original_gateway.received_shutdown(FIXTURE_PHASE_TIMEOUT); - let replacement_hit = replacement_gateway.received_shutdown(FIXTURE_PHASE_TIMEOUT); - let expect_original = matches!(order, PublicationOrder::QuitFirst); - assert_eq!( - (shutdown_requested, original_hit, replacement_hit), - (true, expect_original, !expect_original), - "quit targets only the identity current at its controlled snapshot load" - ); -} - -#[test] -fn quit_before_replacement_publication_targets_original_identity() { - assert_shutdown_target(PublicationOrder::QuitFirst); -} - -#[test] -fn quit_after_replacement_publication_targets_replacement_identity() { - assert_shutdown_target(PublicationOrder::ReplacementFirst); -} - -#[test] -fn configured_gateway_has_no_shutdown_authority() { - let binding = - GatewayBinding::new("http://192.0.2.10:8080", "configured-key").expect("binding builds"); - - assert!( - !binding - .updater() - .request_shutdown() - .expect("a configured Gateway is an intentional no-op"), - "the absence of validated local identity denies shutdown authority" - ); -} - -#[test] -fn a_sidecar_snapshot_posts_the_authenticated_shutdown_itself() { - let mut gateway = crate::test_gateway::ValidatedGateway::spawn("snapshot-key"); - let identity = validated_connection( - &gateway, - "snapshot-key", - 1_778_000_001, - "2026-09-07T18:00:01Z", - ); - let base_url = format!("http://127.0.0.1:{}", identity.port()); - let binding = GatewayBinding::new_with_identity(&base_url, "snapshot-key", Some(identity)) - .expect("binding builds"); - let snapshot = binding.snapshot(); - - assert!(snapshot.is_sidecar()); - assert!( - snapshot - .request_shutdown() - .expect("the authenticated shutdown is accepted"), - "a sidecar snapshot reports the shutdown it sent" - ); - assert!( - gateway.received_shutdown(FIXTURE_PHASE_TIMEOUT), - "the shutdown reaches the gateway the snapshot's identity names" - ); -} - -#[test] -fn a_configured_snapshot_sends_no_shutdown_and_reports_false() { - let binding = - GatewayBinding::new("http://192.0.2.10:8080", "configured-key").expect("binding builds"); - let snapshot = binding.snapshot(); - - assert!(!snapshot.is_sidecar()); - assert!( - !snapshot - .request_shutdown() - .expect("a configured Gateway is an intentional no-op"), - "without a validated identity there is nothing to shut down" - ); -} - -#[test] -fn publication_close_preserves_shutdown_authority_for_the_current_snapshot() { - let mut gateway = crate::test_gateway::ValidatedGateway::spawn("current-key"); - let current = validated_connection( - &gateway, - "current-key", - 1_778_000_001, - "2026-09-07T18:00:01Z", - ); - let base_url = format!("http://127.0.0.1:{}", current.port()); - let api_key = current.api_key().to_owned(); - let binding = GatewayBinding::new_with_identity(&base_url, &api_key, Some(current)) - .expect("binding builds"); - let updater = binding.updater(); - - updater.close_publication(); - - assert!( - updater - .request_shutdown() - .expect("the current authenticated shutdown is accepted") - ); - assert!( - gateway.received_shutdown(FIXTURE_PHASE_TIMEOUT), - "closure revokes future publication without tearing current shutdown authority" - ); -} diff --git a/crates/workshop/gateway/src/client.rs b/crates/workshop/gateway/src/client.rs deleted file mode 100644 index 66615b312..000000000 --- a/crates/workshop/gateway/src/client.rs +++ /dev/null @@ -1,372 +0,0 @@ -//! HTTP client for the Runvil gateway's OpenAI-compatible API. -//! -//! [`GatewayClient`] wraps `reqwest` with bearer authentication and returns -//! responses as raw bytes so the workshop routes can relay them to the -//! caller byte-for-byte. A non-success status from the gateway is *not* an -//! error here: it is part of the relayed response. The progress -//! subscription is decoded from SSE into a [`ProgressStream`] of decoded -//! snapshots. - -use std::fmt; -use std::time::Duration; - -mod events; -mod progress_stream; -mod socket; -mod sse; - -pub use events::{ForwardedResponse, GatewayResponse, SwitchProfileBody, SwitchResponse}; -pub(crate) use progress_stream::ProgressStream; -pub use socket::GatewayRealtimeSocket; -use sse::read; - -/// Default bound on a single `GET /health` probe: a gateway that accepts -/// the connection but never answers must still read as unreachable, and two -/// seconds keeps the probe well under the heartbeat interval it serves. -pub(crate) const HEALTH_PROBE_TIMEOUT: Duration = Duration::from_secs(2); - -/// TCP connect timeout applied to every request. A gateway that is down or -/// unreachable should fail fast rather than hanging for the OS default (~21 s -/// on Linux, ~75 s on Windows). -const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); - -/// Default whole-request timeout for non-streaming operations: the model -/// catalog fetch and the profile selection. Forwarded requests and the -/// progress subscription can legitimately run for minutes, so the same -/// bound covers only their header phase (see `send_bounded`) and the -/// body stays open-ended. -pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); - -/// A gateway request failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum GatewayError { - /// The HTTP client could not be built. - #[non_exhaustive] - #[error("build gateway http client")] - Build(#[source] Box), - - /// The request could not be sent or no response arrived (connect - /// refused, DNS, TLS, timeout). - #[non_exhaustive] - #[error("gateway transport error")] - Transport(#[source] Box), - - /// The response body could not be read to completion. - #[non_exhaustive] - #[error("read gateway response body")] - ReadBody(#[source] Box), - - /// The gateway answered a streaming-only request with a non-success - /// status (for example 401 on a rejected token). The body is bounded - /// and control-escaped. - #[non_exhaustive] - #[error("gateway answered status {status}: {body}")] - Status { - /// The gateway's status code. - status: reqwest::StatusCode, - /// The gateway's error body, bounded and control-escaped. - body: String, - }, - - /// A gateway event stream contained a block that could not be decoded, - /// one that grew past its size bound without terminating, or a - /// success answer whose body was not the documented JSON shape. - #[non_exhaustive] - #[error("malformed gateway answer: {message}")] - Malformed { - /// What was wrong with the block or body. - message: String, - /// The decode failure, when the block or body was undecodable. - #[source] - source: Option>, - }, -} - -impl GatewayError { - /// A transport failure manufactured by a test, for the server's - /// error-mapping fixtures. - #[cfg(feature = "test-fixtures")] - #[must_use] - pub fn transport_for_test(source: Box) -> Self { - Self::Transport(source) - } -} - -/// Bearer-authenticated client for the gateway's OpenAI-compatible -/// endpoints. An empty API key omits the `Authorization` header entirely, -/// for gateways running with authentication disabled. -#[derive(Clone)] -pub struct GatewayClient { - http: reqwest::Client, - pub(crate) base_url: String, - pub(crate) api_key: String, - /// Whole-request bound for buffered calls; header-phase bound for - /// streaming calls. - request_timeout: Duration, - /// Whole-request bound for the health probe. - health_timeout: Duration, -} - -// Manual so the bearer key is never written to logs. -impl fmt::Debug for GatewayClient { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("GatewayClient") - .field("base_url", &self.base_url) - .field("api_key", &"") - .finish_non_exhaustive() - } -} - -impl GatewayClient { - /// Builds a client for `base_url` authenticating with `api_key`. - /// - /// A trailing slash on `base_url` is trimmed so route joins stay clean. - /// An empty `api_key` disables authentication: requests then omit the - /// `Authorization` header. - /// - /// # Errors - /// Returns [`GatewayError::Build`] if the TLS backend cannot initialize. - pub fn new(base_url: &str, api_key: &str) -> Result { - let http = reqwest::Client::builder() - .connect_timeout(CONNECT_TIMEOUT) - .build() - .map_err(|source| GatewayError::Build(Box::new(source)))?; - Ok(Self { - http, - base_url: base_url.trim_end_matches('/').to_string(), - api_key: api_key.to_string(), - request_timeout: REQUEST_TIMEOUT, - health_timeout: HEALTH_PROBE_TIMEOUT, - }) - } - - /// Overrides the request and probe bounds, so tests can trip them - /// without waiting out the production values. - #[cfg(test)] - pub(crate) fn with_timeouts_for_test(mut self, request: Duration, health: Duration) -> Self { - self.request_timeout = request; - self.health_timeout = health; - self - } - - /// Sends `request`, bounding the wait for the response headers on the - /// client's request timeout. - /// - /// The streaming calls use this instead of a whole-request timeout: a - /// gateway that accepts the connection and then stalls must fail the - /// call rather than hang its caller, but an accepted stream may - /// legitimately run for minutes, so only the header phase is bounded - /// and the body stream stays open-ended. - async fn send_bounded( - &self, - request: reqwest::RequestBuilder, - ) -> Result { - match tokio::time::timeout(self.request_timeout, request.send()).await { - Ok(Ok(response)) => Ok(response), - Ok(Err(source)) => Err(GatewayError::Transport(Box::new(source))), - Err(elapsed) => Err(GatewayError::Transport(Box::new(elapsed))), - } - } - - /// Applies bearer authentication to `request`, unless the client was - /// built with an empty API key, in which case the request goes out with - /// no `Authorization` header. - fn authorize(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - if self.api_key.is_empty() { - request - } else { - request.bearer_auth(&self.api_key) - } - } - - /// The gateway's base URL as configured, trailing slash trimmed - - /// also the origin the config-panel iframe loads from. - #[must_use] - pub fn base_url(&self) -> &str { - &self.base_url - } - - /// Forwards one request to the gateway: `method` on - /// `path_and_query`, with an optional JSON `body`, authenticated - /// with the client's bearer key. Only the wait for the response - /// headers is bounded - a forwarded cache download or config apply - /// legitimately runs for minutes - and the whole body is buffered - /// for relay. A non-success status is relayed in the returned - /// [`ForwardedResponse`]. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed (the header bound elapsing included) and - /// [`GatewayError::ReadBody`] if the response body cannot be read. - pub async fn forward( - &self, - method: reqwest::Method, - path_and_query: &str, - body: Option>, - ) -> Result { - let mut request = self.authorize( - self.http - .request(method, format!("{}{}", self.base_url, path_and_query)), - ); - if let Some(bytes) = body { - request = request - .header(reqwest::header::CONTENT_TYPE, "application/json") - .body(bytes); - } - let response = self.send_bounded(request).await?; - let status = response.status(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(str::to_string); - let body = response - .bytes() - .await - .map_err(|source| GatewayError::ReadBody(Box::new(source)))? - .to_vec(); - Ok(ForwardedResponse { - status, - content_type, - body, - }) - } - - /// Probes the gateway's liveness endpoint, `GET /health`. - /// - /// Returns `true` only when the gateway answers with a success status: - /// a transport failure, a probe timeout, or a non-success answer all - /// read as unreachable. The request never includes the client's API key - /// (the endpoint is unauthenticated by design) and is capped at the - /// probe bound (`HEALTH_PROBE_TIMEOUT` by default). - pub async fn health(&self) -> bool { - let probe = self - .http - .get(format!("{}/health", self.base_url)) - .timeout(self.health_timeout); - match probe.send().await { - Ok(response) => response.status().is_success(), - Err(_) => false, - } - } - - /// Fetches the gateway's model catalog from `GET /v1/models`. - /// - /// A non-success status is relayed in the returned [`GatewayResponse`]. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed and [`GatewayError::ReadBody`] if the response body cannot - /// be read. - pub async fn list_models(&self) -> Result { - self.get_json("/v1/models").await - } - - /// Fetches the gateway's profile list from `GET /admin/profiles`. - /// - /// A non-success status is relayed in the returned [`GatewayResponse`]. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed and [`GatewayError::ReadBody`] if the response body cannot - /// be read. - pub async fn list_profiles(&self) -> Result { - self.get_json("/admin/profiles").await - } - - /// Fetches the gateway's live status from `GET /admin/status`, which - /// reports the active profile's name. - /// - /// A non-success status is relayed in the returned [`GatewayResponse`]. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed and [`GatewayError::ReadBody`] if the response body cannot - /// be read. - pub async fn profile_status(&self) -> Result { - self.get_json("/admin/status").await - } - - /// Sends an authorized `GET` for `path` under the whole-request bound - /// and buffers the answer, non-success status included. - async fn get_json(&self, path: &str) -> Result { - let response = self - .authorize(self.http.get(format!("{}{path}", self.base_url))) - .timeout(self.request_timeout) - .send() - .await - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - read(response).await - } - - /// Posts a profile selection to `POST /admin/switch-profile`: `name` - /// selects a defined profile, `None` selects no profile (`null` on - /// the wire). - /// - /// The gateway persists the selection and answers one JSON document, - /// returned as [`SwitchResponse::Selected`], reporting whether it - /// must restart to serve the selection; the caller decides how that - /// restart happens. A non-success answer is buffered and returned. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed, [`GatewayError::ReadBody`] if the answer's body cannot - /// be read, and [`GatewayError::Malformed`] when a success answer is - /// not the documented outcome shape. - pub async fn switch_profile(&self, name: Option<&str>) -> Result { - let response = self - .authorize( - self.http - .post(format!("{}/admin/switch-profile", self.base_url)), - ) - .timeout(self.request_timeout) - .json(&serde_json::json!({ "name": name })) - .send() - .await - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - let answer = read(response).await?; - if !answer.status.is_success() { - return Ok(SwitchResponse::Buffered(answer)); - } - serde_json::from_slice::(&answer.body) - .map(SwitchResponse::Selected) - .map_err(|source| GatewayError::Malformed { - message: "the switch-profile answer is not the outcome document".to_owned(), - source: Some(Box::new(source)), - }) - } - - /// Subscribes to the gateway's `GET /admin/progress` event stream. - /// - /// The returned stream yields every progress event the gateway - /// sends, beginning with the snapshot replay of the operations live - /// at connect time. Heartbeat comment lines and other non-`data:` - /// lines are skipped. Intermediate events are lossy at the source, - /// so the stream can drop samples; detect completion only from - /// `Finished` events, never from a fraction reaching 1.0. Only the - /// wait for the response headers is bounded: the subscription is - /// long-lived by design, so the stream itself runs without a deadline. - /// The stream ends when the gateway closes the body; whether to - /// resubscribe is the caller's decision. - /// - /// The endpoint answers only an event stream on success, so a - /// non-success status (for example 401 on a rejected token) is a - /// [`GatewayError::Status`], not a relayed response. Decode failures - /// surface as per-item errors instead. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the request cannot be - /// completed (the header bound elapsing included), - /// [`GatewayError::Status`] on a non-success status, and - /// [`GatewayError::ReadBody`] when that answer's body cannot be - /// read. - pub async fn subscribe_progress(&self) -> Result { - let request = self.authorize(self.http.get(format!("{}/admin/progress", self.base_url))); - let response = self.send_bounded(request).await?; - progress_stream::subscribe(response).await - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/workshop/gateway/src/client/events.rs b/crates/workshop/gateway/src/client/events.rs deleted file mode 100644 index 056cef5be..000000000 --- a/crates/workshop/gateway/src/client/events.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! The gateway client's wire types: the buffered relay response, the -//! forwarded config-panel response, and the profile-selection outcome. - -use serde::Deserialize; - -/// A gateway HTTP response captured for verbatim relay. -#[derive(Debug)] -pub struct GatewayResponse { - /// The gateway's status code, relayed unchanged. - pub status: reqwest::StatusCode, - /// The gateway's response body, relayed byte-for-byte. - pub body: Vec, -} - -/// A gateway response captured for the config-panel proxy: the relay -/// keeps the content type alongside the status and body, because the -/// config UI distinguishes a buffered JSON answer from an SSE stream by -/// it. -#[derive(Debug)] -pub struct ForwardedResponse { - /// The gateway's status code, relayed unchanged. - pub status: reqwest::StatusCode, - /// The gateway's `Content-Type`, when it sent one. - pub content_type: Option, - /// The gateway's response body, relayed byte-for-byte. - pub body: Vec, -} - -/// The gateway's answer to a profile selection, `POST /admin/switch-profile`. -/// -/// An accepted selection answers one JSON document decoding as -/// [`SwitchProfileBody`]: the gateway persisted the selection and reports -/// whether it must restart to load it. A refusal (bad auth, a malformed or -/// undefined name) is buffered rather than reported as an error, matching -/// the relay contract of the other client methods. -#[derive(Debug)] -#[non_exhaustive] -pub enum SwitchResponse { - /// The gateway accepted and persisted the selection. - Selected(SwitchProfileBody), - - /// A refusal, buffered: the gateway's error envelope. - Buffered(GatewayResponse), -} - -/// The body of an accepted profile selection. -#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] -pub struct SwitchProfileBody { - /// The selection now persisted: a profile name, or `None` for no - /// profile. - pub profile: Option, - /// Whether the gateway must restart before the selection is served; - /// `false` when the selection already matches the running profile. - pub restart_required: bool, -} diff --git a/crates/workshop/gateway/src/client/progress_stream.rs b/crates/workshop/gateway/src/client/progress_stream.rs deleted file mode 100644 index bf6f9cf65..000000000 --- a/crates/workshop/gateway/src/client/progress_stream.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! The `GET /admin/progress` subscription: a long-lived SSE stream of -//! [`Progress`] snapshots, decoded block-by-block under a hard size -//! bound. -//! -//! Unlike a profile switch, which answers a single JSON document, a -//! progress subscription never terminates on its own and yields -//! snapshots the workshop renders verbatim, so the decode keeps the -//! stricter posture the subscriber always had: only -//! blank-line-terminated blocks dispatch (an incomplete trailing block -//! is discarded), and a block that grows past `MAX_EVENT_BLOCK` without -//! its terminator is refused rather than buffered unbounded. - -use std::pin::Pin; - -use super::super::progress::Progress; -use futures_util::Stream; - -use super::GatewayError; - -/// The largest single SSE event block buffered before the stream refuses -/// it, in bytes. A peer that never sends a blank-line terminator would -/// otherwise grow the reassembly buffer unbounded; sized well above any -/// realistic progress event. -pub(super) const MAX_EVENT_BLOCK: usize = 1024 * 1024; - -/// The largest error body kept for a subscription diagnostic, in bytes. -const MAX_ERROR_BODY: usize = 2000; - -/// A stream of decoded [`Progress`] snapshots from the gateway, in -/// arrival order. -/// -/// A `data:` block that does not decode is yielded as one error item -/// without ending the stream; a read failure or an event block oversized -/// beyond `MAX_EVENT_BLOCK` is yielded as one error item that ends the -/// stream. The stream ends when the gateway closes the body; whether to -/// resubscribe is the caller's decision. -pub(crate) type ProgressStream = Pin> + Send>>; - -/// Turns an answered `GET /admin/progress` request into the snapshot -/// stream. -/// -/// The endpoint answers only an event stream on success, so a -/// non-success status is [`GatewayError::Status`] with a bounded, -/// control-escaped body rather than a relayed response. -pub(super) async fn subscribe(response: reqwest::Response) -> Result { - let status = response.status(); - if !status.is_success() { - return Err(GatewayError::Status { - status, - body: error_body(response).await?, - }); - } - Ok(decode(response)) -} - -/// Reads at most [`MAX_ERROR_BODY`] bytes of a non-success response -/// body, escaping control characters so a hostile body cannot forge log -/// lines or smuggle terminal control sequences into a diagnostic. -async fn error_body(mut response: reqwest::Response) -> Result { - let mut buffer: Vec = Vec::new(); - while buffer.len() < MAX_ERROR_BODY { - match response.chunk().await { - Ok(Some(chunk)) => { - let take = (MAX_ERROR_BODY - buffer.len()).min(chunk.len()); - buffer.extend_from_slice(&chunk[..take]); - if take < chunk.len() { - break; - } - } - Ok(None) => break, - Err(source) => return Err(GatewayError::ReadBody(Box::new(source))), - } - } - if buffer.is_empty() { - return Ok("(empty body)".to_owned()); - } - let lossy = String::from_utf8_lossy(&buffer); - let mut escaped = String::with_capacity(lossy.len()); - for ch in lossy.chars() { - if ch.is_control() { - escaped.extend(ch.escape_default()); - } else { - escaped.push(ch); - } - } - Ok(escaped) -} - -/// Decodes an SSE body into a snapshot stream: chunks are buffered -/// until a blank line terminates an event block (CRLF, LF, or CR line -/// endings alike), comment-only blocks (heartbeats) are skipped, and an -/// undecodable block becomes one error item rather than killing the -/// stream. A mid-stream read failure likewise surfaces as one error -/// item, after which the stream ends. A block that grows past -/// [`MAX_EVENT_BLOCK`] without a terminator is refused as one error -/// item, after which the stream ends, so a peer cannot buffer the client -/// unbounded. -fn decode(response: reqwest::Response) -> ProgressStream { - let events = futures_util::stream::unfold( - (response, Vec::new(), false), - |(mut response, mut buffer, mut failed)| async move { - loop { - if let Some(item) = next_buffered_event(&mut buffer) { - return Some((item, (response, buffer, failed))); - } - if failed { - return None; - } - match response.chunk().await { - Ok(Some(chunk)) => { - if buffer.len() + chunk.len() > MAX_EVENT_BLOCK { - failed = true; - let item = Err(GatewayError::Malformed { - message: format!( - "progress event block exceeds the {MAX_EVENT_BLOCK}-byte limit" - ), - source: None, - }); - return Some((item, (response, buffer, failed))); - } - buffer.extend_from_slice(&chunk); - } - // An incomplete trailing block is discarded, matching - // the SSE rule that only blank-line-terminated blocks - // dispatch. - Ok(None) => return None, - Err(source) => { - failed = true; - let item = Err(GatewayError::ReadBody(Box::new(source))); - return Some((item, (response, buffer, failed))); - } - } - } - }, - ); - Box::pin(events) -} - -/// Pops the next decodable snapshot out of `buffer`, or `None` when no -/// complete block is buffered yet. Comment-only blocks (heartbeats) are -/// consumed and skipped. -fn next_buffered_event(buffer: &mut Vec) -> Option> { - loop { - let end = block_end(buffer)?; - let block: Vec = buffer.drain(..end).collect(); - if let Some(item) = parse_event_block(&block) { - return Some(item); - } - } -} - -/// The end (terminator included) of the first complete event block: a -/// blank line, whether the peer terminates its lines with CRLF, LF, CR, -/// or a mix. -/// -/// A CR that ends the buffer counts as a whole terminator, so a CR-only -/// peer dispatches without waiting for its next chunk. When that CR was -/// half of a split CRLF, the orphaned LF opens the next block as an empty -/// line, which carries no payload and is skipped. -fn block_end(buffer: &[u8]) -> Option { - let mut start = 0; - loop { - let (length, next) = first_line(buffer.get(start..)?)?; - if length == 0 { - return Some(start + next); - } - start += next; - } -} - -/// The length of the first line in `bytes` and the offset just past its -/// CRLF, LF, or CR terminator, or `None` when no terminator is buffered. -fn first_line(bytes: &[u8]) -> Option<(usize, usize)> { - let length = bytes - .iter() - .position(|byte| matches!(byte, b'\r' | b'\n'))?; - let terminator = if bytes[length..].starts_with(b"\r\n") { - 2 - } else { - 1 - }; - Some((length, length + terminator)) -} - -/// Decodes one SSE event block: `data:` lines join into the payload, -/// comment lines and unrecognized fields are ignored, and a block with -/// no payload (a heartbeat) yields `None`. -fn parse_event_block(block: &[u8]) -> Option> { - let mut data: Vec = Vec::new(); - let mut unread = block; - while let Some((length, next)) = first_line(unread) { - let line = &unread[..length]; - unread = &unread[next..]; - if let Some(rest) = line.strip_prefix(b"data:") { - if !data.is_empty() { - data.push(b'\n'); - } - data.extend_from_slice(rest.strip_prefix(b" ").unwrap_or(rest)); - } - } - if data.is_empty() { - return None; - } - Some( - serde_json::from_slice(&data).map_err(|source| GatewayError::Malformed { - message: "progress snapshot was not a valid {busy, text} JSON object".to_owned(), - source: Some(Box::new(source)), - }), - ) -} diff --git a/crates/workshop/gateway/src/client/socket.rs b/crates/workshop/gateway/src/client/socket.rs deleted file mode 100644 index f093e785d..000000000 --- a/crates/workshop/gateway/src/client/socket.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Authenticated WebSocket connections from the workshop to the gateway. - -use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; - -use super::{GatewayClient, GatewayError}; - -/// An authenticated WebSocket connection to the gateway's Realtime transcription. -pub type GatewayRealtimeSocket = - tokio_tungstenite::WebSocketStream>; - -impl GatewayClient { - /// Opens the gateway's authenticated Realtime transcription socket. - /// - /// The target is fixed to `/v1/realtime?intent=transcription`; browser - /// query parameters and handshake policy headers never cross the relay. - /// - /// # Errors - /// Returns [`GatewayError::Transport`] if the socket cannot be - /// connected (the header bound elapsing included). - pub async fn connect_realtime(&self) -> Result { - let mut request = realtime_url(&self.base_url)? - .as_str() - .into_client_request() - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - if !self.api_key.is_empty() { - let value = format!("Bearer {}", self.api_key) - .parse() - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - request.headers_mut().insert( - tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, - value, - ); - } - match tokio::time::timeout( - self.request_timeout, - tokio_tungstenite::connect_async(request), - ) - .await - { - Ok(Ok((socket, _response))) => Ok(socket), - Ok(Err(source)) => Err(GatewayError::Transport(Box::new(source))), - Err(elapsed) => Err(GatewayError::Transport(Box::new(elapsed))), - } - } -} - -/// The Realtime transcription URL under `base_url`, upgraded to its -/// WebSocket scheme: http to ws and https to wss. -pub(super) fn realtime_url(base_url: &str) -> Result { - let mut url = - url::Url::parse(base_url).map_err(|source| GatewayError::Transport(Box::new(source)))?; - let scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - scheme => { - return Err(GatewayError::Transport(Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("gateway URL scheme {scheme:?} cannot be upgraded to a WebSocket"), - )))); - } - }; - url.set_scheme(scheme).map_err(|()| { - GatewayError::Transport(Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "gateway URL scheme cannot be converted to WebSocket", - ))) - })?; - let path = format!("{}/v1/realtime", url.path().trim_end_matches('/')); - url.set_path(&path); - url.set_query(Some("intent=transcription")); - url.set_fragment(None); - Ok(url) -} diff --git a/crates/workshop/gateway/src/client/sse.rs b/crates/workshop/gateway/src/client/sse.rs deleted file mode 100644 index c09e2bf15..000000000 --- a/crates/workshop/gateway/src/client/sse.rs +++ /dev/null @@ -1,14 +0,0 @@ -//! Response-body capture for the gateway client's buffered calls. - -use super::{GatewayError, GatewayResponse}; - -/// Captures the status and raw body of a gateway response. -pub(super) async fn read(response: reqwest::Response) -> Result { - let status = response.status(); - let body = response - .bytes() - .await - .map_err(|source| GatewayError::ReadBody(Box::new(source)))? - .to_vec(); - Ok(GatewayResponse { status, body }) -} diff --git a/crates/workshop/gateway/src/client/tests.rs b/crates/workshop/gateway/src/client/tests.rs deleted file mode 100644 index 527976c63..000000000 --- a/crates/workshop/gateway/src/client/tests.rs +++ /dev/null @@ -1,62 +0,0 @@ -//! Tests for the gateway client: the client basics live here beside the -//! shared mock-server helper; the progress, socket, switch, and timeout -//! areas each have their own submodule. - -use super::*; - -mod progress; -mod socket; -mod switch; -mod timeouts; - -/// Binds `app` on a free loopback port and returns its base URL. -async fn serve(app: axum::Router) -> String { - let (addr, _handle) = workshop_support::fixtures::serve(app).await; - format!("http://{addr}") -} - -#[test] -fn trailing_slash_is_trimmed_from_base_url() { - let client = GatewayClient::new("http://127.0.0.1:8081/", "k").expect("client builds"); - assert_eq!(client.base_url, "http://127.0.0.1:8081"); -} - -#[test] -fn debug_redacts_the_api_key() { - let client = GatewayClient::new("http://127.0.0.1:8081", "secret-key").expect("client"); - let rendered = format!("{client:?}"); - assert!(!rendered.contains("secret-key"), "key leaked: {rendered}"); -} - -#[tokio::test] -async fn each_catalog_read_sends_the_bearer_to_its_own_route() { - fn echo(route: &'static str) -> axum::routing::MethodRouter { - axum::routing::get(move |headers: axum::http::HeaderMap| async move { - let auth = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .unwrap_or_default() - .to_owned(); - format!("{route} {auth}") - }) - } - let app = axum::Router::new() - .route("/v1/models", echo("models")) - .route("/admin/profiles", echo("profiles")) - .route("/admin/status", echo("status")); - let client = GatewayClient::new(&serve(app).await, "tok").expect("client builds in tests"); - - for (read, expected) in [ - (client.list_models().await, "models Bearer tok"), - (client.list_profiles().await, "profiles Bearer tok"), - (client.profile_status().await, "status Bearer tok"), - ] { - let response = read.expect("the read completes"); - assert!( - response.status.is_success(), - "{expected}: {}", - response.status - ); - assert_eq!(String::from_utf8_lossy(&response.body), expected); - } -} diff --git a/crates/workshop/gateway/src/client/tests/progress.rs b/crates/workshop/gateway/src/client/tests/progress.rs deleted file mode 100644 index a325cfbab..000000000 --- a/crates/workshop/gateway/src/client/tests/progress.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Progress-subscription tests: the `GET /admin/progress` event stream -//! decodes block-by-block, heartbeat comments are skipped, a malformed -//! block degrades to one error item without ending the stream, a read -//! failure or an oversized block ends it, and a non-success status is an -//! error rather than a stream. - -use super::*; - -use futures_util::StreamExt as _; - -use crate::client::progress_stream::MAX_EVENT_BLOCK; -use crate::progress::Progress; - -/// Serializes a wire-format progress snapshot by hand, so the tests pin -/// the JSON shape the gateway emits rather than the types crate's -/// serializer. -fn snapshot_json(busy: bool, text: &str) -> String { - serde_json::json!({"busy": busy, "text": text}).to_string() -} - -/// The decoded snapshot as a `(busy, text)` pair. -fn decoded(item: &Result) -> (bool, String) { - let snapshot = item.as_ref().expect("every item decodes"); - (snapshot.busy, snapshot.text.clone()) -} - -/// A mock `GET /admin/progress` that requires the bearer token and -/// answers with `body` as the verbatim SSE payload. -fn mock_progress(body: String) -> axum::Router { - use axum::Router; - use axum::response::IntoResponse; - use axum::routing::get; - - Router::new().route( - "/admin/progress", - get(move |headers: axum::http::HeaderMap| { - let body = body.clone(); - async move { - let auth = headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()); - if auth != Some("Bearer tok") { - return (axum::http::StatusCode::UNAUTHORIZED, "bad token").into_response(); - } - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - body, - ) - .into_response() - } - }), - ) -} - -/// A mock `GET /admin/progress` that answers with each of `chunks` as its -/// own body chunk, so a test controls where the SSE payload splits. -fn mock_progress_chunks(chunks: Vec) -> axum::Router { - axum::Router::new().route( - "/admin/progress", - axum::routing::get(move || { - let chunks: Vec> = - chunks.iter().cloned().map(Ok).collect(); - async move { - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - axum::body::Body::from_stream(futures_util::stream::iter(chunks)), - ) - } - }), - ) -} - -/// Subscribes against `app` and collects the whole event stream. -async fn collect_events(app: axum::Router) -> Vec> { - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "tok").expect("client builds in tests"); - let events = client - .subscribe_progress() - .await - .expect("a well-formed stream subscribes"); - events.collect().await -} - -#[tokio::test] -async fn subscribe_progress_decodes_snapshots_and_skips_heartbeat_comments() { - let begun = snapshot_json(true, "Downloading qwen3-8b.gguf"); - let updated = snapshot_json(true, "Downloading qwen3-8b.gguf 45%"); - let idle = snapshot_json(false, ""); - let body = format!( - ": heartbeat\n\ndata: {begun}\n\ndata: {updated}\n\n: heartbeat\n\ndata: {idle}\n\n" - ); - - let events = collect_events(mock_progress(body)).await; - - let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); - assert_eq!( - snapshots, - vec![ - (true, "Downloading qwen3-8b.gguf".to_owned()), - (true, "Downloading qwen3-8b.gguf 45%".to_owned()), - (false, String::new()), - ], - "each data block decodes to one busy/text snapshot in arrival order" - ); -} - -#[tokio::test] -async fn subscribe_progress_classifies_a_non_success_status() { - let base_url = serve(mock_progress(String::new())).await; - let client = GatewayClient::new(&base_url, "wrong-token").expect("client builds in tests"); - - let Err(error) = client.subscribe_progress().await else { - panic!("a 401 response must surface as an error"); - }; - let GatewayError::Status { status, body } = &error else { - panic!("a 401 response is a status error, got {error}"); - }; - assert_eq!(status.as_u16(), 401); - assert_eq!(body, "bad token"); -} - -#[tokio::test] -async fn subscribe_progress_yields_one_error_per_bad_event_and_continues() { - let begun = snapshot_json(true, "Loading profile"); - let finished = snapshot_json(false, ""); - let body = format!("data: {begun}\n\ndata: {{not json\n\ndata: {finished}\n\n"); - - let events = collect_events(mock_progress(body)).await; - - assert_eq!(events.len(), 3, "one item per data block"); - assert!(events[0].is_ok(), "the leading event decodes"); - let error = events[1] - .as_ref() - .expect_err("the undecodable line is one error item"); - assert!( - matches!(error, GatewayError::Malformed { .. }), - "an undecodable block is a malformed error, got {error}" - ); - assert!( - events[2].is_ok(), - "a bad line must not end the stream: the trailing event still arrives" - ); -} - -#[tokio::test] -async fn subscribe_progress_reassembles_an_event_split_across_chunks() { - let begun = snapshot_json(true, "Loading profile"); - let wire = format!("data: {begun}\n\n"); - let (head, tail) = wire.split_at(wire.len() / 2); - let app = mock_progress_chunks(vec![head.to_owned(), tail.to_owned()]); - - let events = collect_events(app).await; - - assert_eq!(events.len(), 1, "the split block decodes as one event"); - assert!(events[0].is_ok(), "the reassembled event decodes"); -} - -#[tokio::test] -async fn subscribe_progress_skips_the_orphaned_lf_of_a_crlf_split_across_chunks() { - // The first chunk ends on the blank line's CR, so its block dispatches - // at once, and the LF that opens the next chunk is an empty line. - let begun = snapshot_json(true, "Loading profile"); - let finished = snapshot_json(false, ""); - let head = format!("data: {begun}\r\n\r"); - let tail = format!("\ndata: {finished}\r\n\r\n"); - - let events = collect_events(mock_progress_chunks(vec![head, tail])).await; - - let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); - assert_eq!( - snapshots, - vec![(true, "Loading profile".to_owned()), (false, String::new())], - "both events decode and the orphaned LF yields nothing" - ); -} - -#[tokio::test] -async fn subscribe_progress_yields_one_error_on_a_mid_stream_read_failure_then_ends() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - // The server promises a large body, delivers one complete event, then - // drops the connection: the read failure must surface as one error - // item that ends the stream, not as a hang or a silent close. - let begun = snapshot_json(true, "Loading profile"); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - if let Ok((mut sock, _)) = listener.accept().await { - let mut buf = [0u8; 1024]; - let _ = sock.read(&mut buf).await; - let header = "HTTP/1.1 200 OK\r\n\ - Content-Type: text/event-stream\r\n\ - Content-Length: 1000000\r\n\r\n"; - let _ = sock.write_all(header.as_bytes()).await; - let _ = sock - .write_all(format!("data: {begun}\n\n").as_bytes()) - .await; - // Socket drops here: the promised body never completes. - } - }); - let client = GatewayClient::new(&format!("http://{addr}"), "tok").expect("client builds"); - let events = client - .subscribe_progress() - .await - .expect("a well-formed stream subscribes") - .collect::>() - .await; - - assert_eq!(events.len(), 2, "the decoded event, then one error item"); - assert!(events[0].is_ok(), "the leading event decodes"); - let error = events[1] - .as_ref() - .expect_err("the truncated body is one error item"); - assert!( - matches!(error, GatewayError::ReadBody(_)), - "a mid-stream read failure is a body-read error, got {error}" - ); -} - -#[tokio::test] -async fn subscribe_progress_bounds_an_event_block_that_never_terminates() { - let body = "x".repeat(MAX_EVENT_BLOCK + 1); - - let events = collect_events(mock_progress(body)).await; - - assert_eq!(events.len(), 1, "the oversized block is one error item"); - let error = events[0] - .as_ref() - .expect_err("an unterminated oversized block must be refused"); - assert!( - matches!(error, GatewayError::Malformed { .. }), - "an oversized block is a malformed error, got {error}" - ); - assert!( - error.to_string().contains("exceeds"), - "the bound must report the size limit, got {error}" - ); -} - -#[tokio::test] -async fn subscribe_progress_decodes_crlf_terminated_blocks() { - // A peer that terminates its lines with CRLF still dispatches: the - // blank-line terminator is `\r\n\r\n`, which contains no `\n\n`. - let begun = snapshot_json(true, "Loading profile"); - let finished = snapshot_json(false, ""); - let body = format!("data: {begun}\r\n\r\ndata: {finished}\r\n\r\n"); - - let events = collect_events(mock_progress(body)).await; - - let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); - assert_eq!( - snapshots, - vec![(true, "Loading profile".to_owned()), (false, String::new())] - ); -} - -#[tokio::test] -async fn subscribe_progress_decodes_cr_terminated_blocks() { - // A peer that terminates its lines with a bare CR still dispatches, - // and a comment line ended by CR alone stays apart from its data line. - let begun = snapshot_json(true, "Loading profile"); - let finished = snapshot_json(false, ""); - let body = format!("data: {begun}\r\r: heartbeat\rdata: {finished}\r\r"); - - let events = collect_events(mock_progress(body)).await; - - let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); - assert_eq!( - snapshots, - vec![(true, "Loading profile".to_owned()), (false, String::new())] - ); -} - -#[tokio::test] -async fn subscribe_progress_decodes_blocks_with_mixed_line_terminators() { - // Each blank line pairs two different terminators, and one block - // ends its comment line with CR and its data line with CRLF. - let downloading = snapshot_json(true, "Downloading"); - let loading = snapshot_json(true, "Loading"); - let warming = snapshot_json(true, "Warming"); - let idle = snapshot_json(false, ""); - let body = format!( - "data: {downloading}\n\r\n\ - data: {loading}\r\n\n\ - : heartbeat\rdata: {warming}\r\n\r\ - data: {idle}\n\r" - ); - - let events = collect_events(mock_progress(body)).await; - - let snapshots: Vec<(bool, String)> = events.iter().map(decoded).collect(); - assert_eq!( - snapshots, - vec![ - (true, "Downloading".to_owned()), - (true, "Loading".to_owned()), - (true, "Warming".to_owned()), - (false, String::new()), - ] - ); -} - -#[tokio::test] -async fn subscribe_progress_discards_an_incomplete_trailing_block() { - // The body ends mid-block: only blank-line-terminated blocks - // dispatch, so the partial event is dropped and the stream ends. - let begun = snapshot_json(true, "Loading profile"); - let finished = snapshot_json(false, ""); - let body = format!("data: {begun}\n\ndata: {finished}"); - - let events = collect_events(mock_progress(body)).await; - - assert_eq!( - events.len(), - 1, - "the unterminated trailing block is discarded" - ); - assert!(events[0].is_ok(), "the complete event decodes"); -} diff --git a/crates/workshop/gateway/src/client/tests/socket.rs b/crates/workshop/gateway/src/client/tests/socket.rs deleted file mode 100644 index fabc3331f..000000000 --- a/crates/workshop/gateway/src/client/tests/socket.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! Realtime socket tests: the gateway base URL upgrades to its matching -//! WebSocket scheme, and any other scheme is refused before connecting. - -use super::*; - -use crate::client::socket::realtime_url; - -#[test] -fn an_http_gateway_upgrades_to_ws() { - let url = realtime_url("http://127.0.0.1:8081").expect("http upgrades"); - assert_eq!( - url.as_str(), - "ws://127.0.0.1:8081/v1/realtime?intent=transcription" - ); -} - -#[test] -fn an_https_gateway_upgrades_to_wss() { - let url = realtime_url("https://gateway.example/prefix").expect("https upgrades"); - assert_eq!( - url.as_str(), - "wss://gateway.example/prefix/v1/realtime?intent=transcription" - ); -} - -#[test] -fn a_gateway_on_any_other_scheme_is_refused() { - for (base_url, scheme) in [ - ("ftp://gateway.example", "ftp"), - ("ws://127.0.0.1:8081", "ws"), - ] { - let Err(error) = realtime_url(base_url) else { - panic!("{base_url} must not upgrade"); - }; - let GatewayError::Transport(source) = &error else { - panic!("a refused scheme is a transport error, got {error:?}"); - }; - assert!( - source.to_string().contains(&format!("{scheme:?}")), - "the refusal names the scheme, got {source}" - ); - } -} diff --git a/crates/workshop/gateway/src/client/tests/switch.rs b/crates/workshop/gateway/src/client/tests/switch.rs deleted file mode 100644 index fb0ca603e..000000000 --- a/crates/workshop/gateway/src/client/tests/switch.rs +++ /dev/null @@ -1,131 +0,0 @@ -//! Switch-profile tests: a named selection and the no-profile selection -//! each reach the wire as the gateway expects, the JSON answer decodes -//! with an optional profile, a declined switch is buffered rather than -//! an error, and an undecodable success is a malformed answer. - -use super::*; - -use std::sync::{Arc, Mutex}; - -/// A mock switch route that records every request body it receives and -/// answers `body`. -fn recording_switch_route( - answer: &'static str, -) -> (axum::Router, Arc>>) { - let received = Arc::new(Mutex::new(Vec::new())); - let sink = Arc::clone(&received); - let app = axum::Router::new().route( - "/admin/switch-profile", - axum::routing::post(move |axum::Json(body): axum::Json| { - let sink = Arc::clone(&sink); - async move { - sink.lock() - .expect("the recorder lock is healthy") - .push(body); - ( - [(axum::http::header::CONTENT_TYPE, "application/json")], - answer, - ) - } - }), - ); - (app, received) -} - -#[tokio::test] -async fn a_named_selection_posts_the_name_and_decodes_the_outcome() { - let (app, received) = recording_switch_route(r#"{"profile":"beta","restart_required":true}"#); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .switch_profile(Some("beta")) - .await - .expect("the request completes"); - let SwitchResponse::Selected(outcome) = response else { - panic!("an accepted selection decodes, got {response:?}"); - }; - assert_eq!( - outcome, - SwitchProfileBody { - profile: Some("beta".to_string()), - restart_required: true, - } - ); - assert_eq!( - received - .lock() - .expect("the recorder lock is healthy") - .as_slice(), - [serde_json::json!({"name": "beta"})], - "the selection reaches the gateway as its name" - ); -} - -#[tokio::test] -async fn the_no_profile_selection_posts_null_and_decodes_a_null_profile() { - let (app, received) = recording_switch_route(r#"{"profile":null,"restart_required":false}"#); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .switch_profile(None) - .await - .expect("the request completes"); - let SwitchResponse::Selected(outcome) = response else { - panic!("an accepted selection decodes, got {response:?}"); - }; - assert_eq!( - outcome, - SwitchProfileBody { - profile: None, - restart_required: false, - } - ); - assert_eq!( - received - .lock() - .expect("the recorder lock is healthy") - .as_slice(), - [serde_json::json!({"name": null})], - "no profile reaches the gateway as an explicit null" - ); -} - -#[tokio::test] -async fn a_declined_switch_is_buffered_not_an_error() { - let app = axum::Router::new().route( - "/admin/switch-profile", - axum::routing::post(|| async { - ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": {"message": "bad name", "code": "switch_failed"} - })), - ) - }), - ); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let response = client - .switch_profile(Some("../escape")) - .await - .expect("a declined request still completes"); - let SwitchResponse::Buffered(answer) = response else { - panic!("a declined switch is buffered, got {response:?}"); - }; - assert_eq!(answer.status, reqwest::StatusCode::BAD_REQUEST); -} - -#[tokio::test] -async fn an_undecodable_success_is_a_malformed_answer() { - let (app, _received) = recording_switch_route("data: {\"stage\":\"loading-profile\"}\n\n"); - let base_url = serve(app).await; - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); - let error = client - .switch_profile(Some("beta")) - .await - .expect_err("a success body that is not the outcome JSON is refused"); - assert!( - matches!(error, GatewayError::Malformed { .. }), - "expected Malformed, got {error:?}" - ); -} diff --git a/crates/workshop/gateway/src/client/tests/timeouts.rs b/crates/workshop/gateway/src/client/tests/timeouts.rs deleted file mode 100644 index 148326ff9..000000000 --- a/crates/workshop/gateway/src/client/tests/timeouts.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! Timeout tests: a gateway that accepts and never answers must trip the -//! client's bounds rather than hang its caller. - -use super::*; - -use crate::test_gateway::stalled_gateway; - -/// A client against `base_url` whose bounds are tight enough to trip -/// inside a test. -fn impatient_client(base_url: &str) -> GatewayClient { - GatewayClient::new(base_url, "") - .expect("client builds in tests") - .with_timeouts_for_test(Duration::from_millis(100), Duration::from_millis(100)) -} - -#[tokio::test] -async fn a_stalled_gateway_trips_the_request_timeout() { - let (base_url, _accepted) = stalled_gateway().await; - let error = impatient_client(&base_url) - .list_models() - .await - .expect_err("a gateway that never answers must trip the request timeout"); - assert!( - matches!(error, GatewayError::Transport(_)), - "expected Transport, got {error:?}" - ); -} - -#[tokio::test] -async fn a_stalled_gateway_trips_the_stream_header_bound() { - let (base_url, _accepted) = stalled_gateway().await; - let Err(error) = impatient_client(&base_url).subscribe_progress().await else { - panic!("a gateway that never sends headers must trip the header bound"); - }; - assert!( - matches!(error, GatewayError::Transport(_)), - "expected Transport, got {error:?}" - ); -} - -#[tokio::test] -async fn a_stalled_gateway_probe_reads_unreachable() { - let (base_url, _accepted) = stalled_gateway().await; - assert!( - !impatient_client(&base_url).health().await, - "a gateway that accepts but never answers must read unreachable" - ); -} diff --git a/crates/workshop/gateway/src/handles.rs b/crates/workshop/gateway/src/handles.rs deleted file mode 100644 index cbe64d4da..000000000 --- a/crates/workshop/gateway/src/handles.rs +++ /dev/null @@ -1,103 +0,0 @@ -//! The gateway subsystem's registration and any handle types. It -//! registers [`GatewayHandles`] - the replaceable endpoint binding and -//! the reachability flag - as its state handle set, and its background -//! tasks - the reachability heartbeat and the gateway progress -//! subscriber. - -use std::sync::Arc; - -use workshop_registry::{BackgroundTaskAdapter, Registration, Registry, ShutdownHandle}; -use workshop_support::ReconnectBackoff; - -use crate::binding::GatewayBinding; -use crate::heartbeat::{self, GatewayHealth}; -use crate::progress; - -/// The gateway subsystem's shared handles: the atomically replaceable -/// endpoint binding every gateway call snapshots, and the heartbeat's -/// reachability flag the gateway-dependent routes short-circuit on. -#[derive(Debug, Clone)] -pub struct GatewayHandles { - binding: GatewayBinding, - health: GatewayHealth, -} - -impl GatewayHandles { - /// Bundles the binding and the health flag for registration. - #[must_use] - pub fn new(binding: GatewayBinding, health: GatewayHealth) -> Self { - Self { binding, health } - } - - /// The replaceable endpoint binding. - #[must_use] - pub fn binding(&self) -> &GatewayBinding { - &self.binding - } - - /// The heartbeat's shared reachability flag. - #[must_use] - pub fn health(&self) -> &GatewayHealth { - &self.health - } -} - -/// Registers the gateway subsystem's state handles into the registry. -/// The returned guard keeps the registration alive; the composition -/// root holds it for the process lifetime. -pub fn register(registry: &Registry, handles: GatewayHandles) -> Registration { - registry.register_state::(Arc::new(handles)) -} - -/// The gateway subsystem's background-task registration guards: the -/// reachability heartbeat and the gateway progress subscriber. Dropping -/// them deregisters the tasks. -#[derive(Debug)] -#[must_use = "dropping the registrations deregisters the tasks"] -pub struct GatewayTaskRegistrations { - /// The reachability heartbeat. - pub heartbeat: Registration, - /// The gateway progress subscriber. - pub subscriber: Registration, -} - -/// Registers the gateway subsystem's background tasks: the -/// reachability heartbeat and the gateway progress subscriber, both -/// reporting through the registry's push facade. The tasks spawn when -/// the server starts serving and stop inside the graceful-shutdown -/// signal. The returned guards keep the registrations alive; the -/// composition root holds them for the process lifetime. -pub fn register_tasks( - registry: &Registry, - handles: &GatewayHandles, - backoff: ReconnectBackoff, -) -> GatewayTaskRegistrations { - let heartbeat = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ - let registry = registry.clone(); - let binding = handles.binding().clone(); - let health = handles.health().clone(); - move || { - let task = heartbeat::spawn( - binding.clone(), - registry.push(), - health.clone(), - heartbeat::HEARTBEAT_INTERVAL, - backoff.clone(), - ); - ShutdownHandle::new(move || task.shutdown()) - } - }))); - let subscriber = registry.register_task(Arc::new(BackgroundTaskAdapter::new({ - let registry = registry.clone(); - let binding = handles.binding().clone(); - let health = handles.health().clone(); - move || { - let task = progress::spawn(binding.clone(), registry.push(), health.clone()); - ShutdownHandle::new(move || task.shutdown()) - } - }))); - GatewayTaskRegistrations { - heartbeat, - subscriber, - } -} diff --git a/crates/workshop/gateway/src/heartbeat-tests.rs b/crates/workshop/gateway/src/heartbeat-tests.rs deleted file mode 100644 index 067c9155a..000000000 --- a/crates/workshop/gateway/src/heartbeat-tests.rs +++ /dev/null @@ -1,238 +0,0 @@ -//! Heartbeat unit tests: the join-line recompute, the probe bound, and -//! the stop signal and binding replacement ending an in-flight probe or -//! refresh. The bus-coupled loop behavior (transitions, refreshes, -//! convergence) is pinned by the workshop-server integration tests, which -//! compose the heartbeat with the real status, catalog, and menu buses. - -use super::*; -use crate::client::GatewayClient; -use crate::test_gateway::stalled_gateway; - -use std::sync::Arc; - -use axum::http::StatusCode; -use axum::routing::get; -use tokio::sync::{Notify, broadcast}; - -fn retained(label: &str) -> StatusBarUpdate { - StatusBarUpdate { - label: label.to_owned(), - description: String::new(), - busy: false, - severity: Severity::Info, - activity: Activity::General, - } -} - -/// Binds `app` on a free loopback port and returns its base URL. -async fn serve(app: axum::Router) -> String { - let (addr, _handle) = workshop_support::fixtures::serve(app).await; - format!("http://{addr}") -} - -/// A recording status sink standing in for the status bus: the push the -/// heartbeat reports through, the registration keeping the sink alive, -/// and the receiver the test reads. -fn status_recorder() -> ( - Push, - workshop_registry::Registration, - broadcast::Receiver, -) { - let registry = workshop_registry::Registry::new(); - let (status_tx, rx) = broadcast::channel(16); - let sink = registry.register_sink::(Arc::new( - workshop_registry::StatusSinkAdapter::new(move |update| { - let _ = status_tx.send(update); - }), - )); - (registry.push(), sink, rx) -} - -/// The next status line within a generous deadline. -async fn next_label(rx: &mut broadcast::Receiver) -> String { - tokio::time::timeout(Duration::from_secs(5), rx.recv()) - .await - .expect("a status update arrives within the deadline") - .expect("the recording sink is open") - .label -} - -/// Waits for `notify` within a generous deadline. -async fn notified(notify: &Notify, what: &str) { - tokio::time::timeout(Duration::from_secs(5), notify.notified()) - .await - .unwrap_or_else(|_| panic!("{what} within the deadline")); -} - -#[test] -fn a_join_recomputes_a_stale_connect_announcement_to_the_resting_line() { - let health = GatewayHealth::new(); - let update = join_status(Some(retained(CONNECTED_LABEL)), &health) - .expect("a retained transition still yields a join line"); - assert_eq!(update.label, "Ready"); - assert_eq!(update.severity, Severity::Info); -} - -#[test] -fn a_join_recomputes_a_stale_connect_announcement_during_an_outage() { - let health = GatewayHealth::new(); - health.publish(false); - let update = join_status(Some(retained(CONNECTED_LABEL)), &health) - .expect("a retained transition still yields a join line"); - assert_eq!(update.label, UNREACHABLE_LABEL); - assert_eq!(update.description, UNREACHABLE_DESCRIPTION); -} - -#[test] -fn a_join_keeps_a_retained_outage_while_the_gateway_is_down() { - let health = GatewayHealth::new(); - health.publish(false); - let update = join_status(Some(retained(UNREACHABLE_LABEL)), &health) - .expect("the outage line survives the recompute"); - assert_eq!(update.label, UNREACHABLE_LABEL); -} - -#[test] -fn a_join_replays_a_retained_frame_describing_real_work() { - let health = GatewayHealth::new(); - let working = Some(StatusBarUpdate { - label: "Downloading model".to_owned(), - description: "ggml-large-v3.bin".to_owned(), - busy: true, - severity: Severity::Info, - activity: Activity::General, - }); - let update = join_status(working, &health).expect("the work frame replays as-is"); - assert_eq!(update.label, "Downloading model"); - assert!(update.busy, "the busy flag survives the join recompute"); -} - -#[test] -fn a_join_with_no_retained_frame_sends_nothing() { - let health = GatewayHealth::new(); - assert!(join_status(None, &health).is_none()); -} - -#[test] -fn the_probe_bound_is_shorter_than_the_heartbeat_interval() { - assert!( - crate::client::HEALTH_PROBE_TIMEOUT < HEARTBEAT_INTERVAL, - "a probe outlasting the interval would back the heartbeat up \ - behind a stalled gateway" - ); -} - -#[tokio::test] -async fn a_wait_with_no_reachability_watch_runs_its_future_to_completion() { - let (_stop_tx, mut stop) = oneshot::channel(); - let (_gateway_tx, mut gateway_changed) = watch::channel(0_u64); - let mut signals = Signals { - stop: &mut stop, - reachable: None, - gateway_changed: &mut gateway_changed, - }; - let output = until( - async { - tokio::time::sleep(Duration::from_millis(20)).await; - 7 - }, - &mut signals, - ) - .await; - assert!( - matches!(output, Ok(7)), - "an absent reachability watch never ends the wait" - ); -} - -#[tokio::test] -async fn a_stalled_gateway_reads_unreachable_within_the_probe_bound() { - // Without a bounded probe, the first probe would hang forever and the - // heartbeat would never report at all. - let (stalled, _accepted) = stalled_gateway().await; - let client = GatewayClient::new(&stalled, "") - .expect("client builds in tests") - .with_timeouts_for_test(Duration::from_millis(100), Duration::from_millis(100)); - let (push, _sink, mut rx) = status_recorder(); - let heartbeat = spawn( - GatewayBinding::from_client(client), - push, - GatewayHealth::new(), - Duration::from_millis(25), - ReconnectBackoff::with_schedule( - Duration::from_millis(10), - Duration::from_millis(40), - Duration::from_secs(60), - ), - ); - assert_eq!(next_label(&mut rx).await, "Gateway unreachable"); - heartbeat.shutdown().await; -} - -#[tokio::test] -async fn a_binding_replacement_cancels_a_stalled_probe() { - let (stalled, accepted) = stalled_gateway().await; - let healthy = - serve(axum::Router::new().route("/health", get(|| async { StatusCode::OK }))).await; - // A probe bound far past the test deadline: only the rebind can end - // the stalled probe in time. - let client = GatewayClient::new(&stalled, "") - .expect("client builds in tests") - .with_timeouts_for_test(Duration::from_secs(60), Duration::from_secs(60)); - let gateway = GatewayBinding::from_client(client); - let (push, _sink, mut rx) = status_recorder(); - let heartbeat = spawn( - gateway.clone(), - push, - GatewayHealth::new(), - Duration::from_secs(60), - ReconnectBackoff::new(), - ); - - notified(&accepted, "the first probe reaches the stalled stub").await; - gateway - .replace(&healthy, "") - .expect("the replacement publishes"); - assert_eq!( - next_label(&mut rx).await, - CONNECTED_LABEL, - "the replacement's probe reports without waiting out the stalled one" - ); - heartbeat.shutdown().await; -} - -#[tokio::test] -async fn shutdown_cancels_a_stalled_refresh() { - let refreshing = Arc::new(Notify::new()); - let app = axum::Router::new() - .route("/health", get(|| async { StatusCode::OK })) - .route( - "/v1/models", - get({ - let refreshing = Arc::clone(&refreshing); - move || { - refreshing.notify_one(); - std::future::pending::() - } - }), - ) - .route("/admin/profiles", get(std::future::pending::)) - .route("/admin/status", get(std::future::pending::)); - let base_url = serve(app).await; - let (push, _sink, mut rx) = status_recorder(); - let heartbeat = spawn( - GatewayBinding::new(&base_url, "").expect("the test binding builds"), - push, - GatewayHealth::new(), - Duration::from_secs(60), - ReconnectBackoff::new(), - ); - - assert_eq!(next_label(&mut rx).await, CONNECTED_LABEL); - notified(&refreshing, "the catalog refresh reaches the gateway").await; - // The stalled refresh holds for the client's request timeout, far - // past this deadline, unless the stop signal wins the wait. - tokio::time::timeout(Duration::from_secs(5), heartbeat.shutdown()) - .await - .expect("shutdown does not wait out the stalled refresh"); -} diff --git a/crates/workshop/gateway/src/heartbeat.rs b/crates/workshop/gateway/src/heartbeat.rs deleted file mode 100644 index 24d5a493f..000000000 --- a/crates/workshop/gateway/src/heartbeat.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! The gateway heartbeat: a background task polling the gateway's -//! `GET /health` endpoint and publishing reachability to the rest of the -//! server. -//! -//! One task is spawned with the server ([`spawn`]): while the gateway -//! answers, it probes through [`GatewayClient::health`](crate::client::GatewayClient::health) on the fixed -//! [`HEARTBEAT_INTERVAL`] and publishes the outcome to the shared -//! [`GatewayHealth`] flag the gateway-dependent routes read; while the -//! gateway is unreachable, the next probe instead waits out a delay -//! drawn from the shared [`ReconnectBackoff`] - jittered, escalating, -//! and reset only by useful work elsewhere (a delivered token or a -//! successful completion), never by a probe that merely connects, so a -//! gateway that flaps without delivering keeps escalating. When the -//! backoff's total-delay budget exhausts, the loop reports the give-up -//! on the status bus and stops probing for the life of the process. -//! Status subscribers hear about transitions only - the first probe reports -//! the initial state ("Connected to gateway" or "Gateway unreachable"), -//! and after that a status update fires when the answer changes, so a -//! steady state never spams the status bar. Every transition also feeds -//! the Model menu's reachability (so `chat_ready` flips with the -//! gateway), and a transition to reachable (boot's first probe included) -//! refreshes the gateway's profile state and model catalog into their -//! buses. If simultaneous startup leaves either source empty, later healthy -//! ticks retry each source independently until the profile and a selectable -//! model are both ready, then restore the selection exactly once. -//! -//! The task stops through its [`Heartbeat`] handle: the signal wins the -//! loop's selects, so shutdown never waits out a tick or an in-flight -//! probe. The server runs the shutdown inside its graceful-shutdown future. - -use std::time::Duration; - -use tokio::sync::{oneshot, watch}; - -use workshop_protocol::{Activity, Severity, StatusBarUpdate}; -use workshop_registry::Push; -use workshop_support::ReconnectBackoff; - -use crate::binding::{GatewayBinding, GatewaySnapshot}; -use crate::progress::{Ended, Signals, until}; -use crate::refresh::{refresh_catalog, refresh_profiles}; - -/// The status line announcing that the gateway answers its health probe. -const CONNECTED_LABEL: &str = "Connected to gateway"; -/// The status line announcing that the gateway does not answer. -const UNREACHABLE_LABEL: &str = "Gateway unreachable"; -/// The description sent with the unreachable announcement. -const UNREACHABLE_DESCRIPTION: &str = "the gateway does not answer its health probe"; - -/// The status frame a joining session hears first: the bus's retained -/// frame, unless that frame is one of the heartbeat's transition -/// announcements. A transition describes a past moment, not the current -/// state - the boot-time "Connected to gateway" outlives itself within -/// seconds - so the line is recomputed from the current probe. A retained -/// frame that reports real work (a download's progress, a chat's activity) -/// replays as-is. -#[must_use] -pub fn join_status( - retained: Option, - health: &GatewayHealth, -) -> Option { - let update = retained?; - if update.label != CONNECTED_LABEL && update.label != UNREACHABLE_LABEL { - return Some(update); - } - let reachable = health.is_reachable(); - Some(StatusBarUpdate { - label: if reachable { - "Ready" - } else { - UNREACHABLE_LABEL - } - .to_owned(), - description: if reachable { - "idle".to_owned() - } else { - UNREACHABLE_DESCRIPTION.to_owned() - }, - busy: false, - severity: Severity::Info, - activity: Activity::General, - }) -} - -/// How often the heartbeat probes a reachable gateway. Probes of an -/// unreachable gateway follow the [`ReconnectBackoff`] instead. -pub(crate) const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); - -/// Shared gateway reachability, written by the heartbeat and read by the -/// gateway-dependent routes. -/// -/// The flag starts optimistic (`true`): until the first probe lands, a -/// request flows to the gateway and fails or succeeds on its own merits, -/// which keeps a server running without a heartbeat (every router-only -/// test) behaving as it did before the heartbeat existed. -#[derive(Debug, Clone)] -pub struct GatewayHealth { - reachable: watch::Sender, -} - -impl GatewayHealth { - /// Starts the flag optimistic; see the type docs for why. - #[must_use] - pub fn new() -> Self { - Self { - reachable: watch::channel(true).0, - } - } - - /// Whether the gateway is currently believed reachable. - #[must_use] - pub fn is_reachable(&self) -> bool { - *self.reachable.borrow() - } - - /// Subscribes to reachability changes. The current value is visible - /// immediately through the receiver; each later publish that flips the - /// flag notifies. The progress subscriber waits on this to hold its - /// subscription only while the gateway answers. - #[must_use] - pub fn subscribe(&self) -> watch::Receiver { - self.reachable.subscribe() - } - - /// Publishes one probe outcome. The heartbeat is the only production - /// writer; tests publish directly to pin the degraded paths. - pub fn publish(&self, reachable: bool) { - self.reachable.send_if_modified(|current| { - let changed = *current != reachable; - *current = reachable; - changed - }); - } -} - -impl Default for GatewayHealth { - fn default() -> Self { - Self::new() - } -} - -/// A running heartbeat task. -/// -/// [`Heartbeat::shutdown`] signals the loop to stop and awaits the task. -/// Dropping the handle without shutting down still stops the task at its -/// next select point, because the closed channel resolves the stop branch. -#[derive(Debug)] -pub struct Heartbeat { - stop: Option>, - task: Option>, -} - -impl Heartbeat { - /// Signals the heartbeat to stop and waits for its task to finish. - pub async fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); - } - if let Some(task) = self.task.take() { - let _ = task.await; - } - } -} - -/// Spawns the heartbeat loop against `client`, reporting transitions -/// through `push` and publishing reachability to `health` and to the -/// menu behind `push`, which recomputes `chat_ready` from it. A -/// transition to reachable - boot's first probe included - refreshes the -/// gateway's profile state and model catalog through the same handle, -/// then restores a model selection when none is applied. Healthy ticks -/// repeat each incomplete refresh independently, covering a gateway whose -/// health endpoint becomes ready before its catalog or profile state. The first -/// probe runs immediately; later probes follow `interval` while the -/// gateway answers and draw from `backoff` while it does not, ending the -/// loop when the backoff's budget exhausts. -#[must_use] -pub fn spawn( - gateway: GatewayBinding, - push: Push, - health: GatewayHealth, - interval: Duration, - backoff: ReconnectBackoff, -) -> Heartbeat { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&gateway, &push, &health, interval, &backoff, &mut stopped).await; - }); - Heartbeat { - stop: Some(stop), - task: Some(task), - } -} - -/// Which menu sources have converged since the gateway last became -/// reachable, and whether the selection restore has run. -#[derive(Default)] -struct RefreshState { - profiles_ready: bool, - catalog_ready: bool, - selection_restored: bool, -} - -/// What the probe loop has learned about the current binding: the last -/// probe verdict and which menu sources have converged. A replaced -/// binding starts it over. -#[derive(Default)] -struct BindingState { - last: Option, - refresh: RefreshState, -} - -/// The probe loop: repeats [`cycle`] until the stop signal or an -/// exhausted backoff budget ends it. A replaced binding restarts the -/// cycle from the top with fresh state. -async fn run( - gateway: &GatewayBinding, - push: &Push, - health: &GatewayHealth, - interval: Duration, - backoff: &ReconnectBackoff, - stop: &mut oneshot::Receiver<()>, -) { - let mut gateway_changed = gateway.subscribe(); - let mut signals = Signals { - stop, - reachable: None, - gateway_changed: &mut gateway_changed, - }; - let mut state = BindingState::default(); - loop { - match cycle( - gateway, - push, - health, - interval, - backoff, - &mut signals, - &mut state, - ) - .await - { - Ok(()) => {} - Err(Ended::Stop) => break, - // With no reachability watch armed, `Lost` never arrives. - Err(Ended::Rebind | Ended::Lost) => state = BindingState::default(), - } - } -} - -/// One pass of the probe loop, in named phases: wait out the probe -/// interval, probe the health endpoint, announce a transition, refresh -/// stale menu sources, and restore the selection. Every wait goes through -/// [`until`], so the stop signal and a replaced binding end the pass -/// early. -async fn cycle( - gateway: &GatewayBinding, - push: &Push, - health: &GatewayHealth, - interval: Duration, - backoff: &ReconnectBackoff, - signals: &mut Signals<'_>, - state: &mut BindingState, -) -> Result<(), Ended> { - await_probe_interval(state.last, interval, backoff, push, signals).await?; - let snapshot = gateway.snapshot(); - let generation = snapshot.generation(); - let reachable = until(snapshot.client().health(), signals).await?; - if gateway.generation() != generation { - return Err(Ended::Rebind); - } - announce_transition(push, health, reachable, &mut state.last); - let refresh = &mut state.refresh; - if !reachable { - *refresh = RefreshState::default(); - return Ok(()); - } - if !refresh.profiles_ready || !refresh.catalog_ready { - until( - refresh_incomplete_sources(&snapshot, push, refresh), - signals, - ) - .await?; - } - if refresh.profiles_ready && refresh.catalog_ready && !refresh.selection_restored { - // A fresh boot has no selection, so restore the remembered - // model for the now-known active profile (else the first - // catalog model); a reconnect whose selection survived the - // outage is a no-op. This branch runs exactly once per reachable - // convergence because both readiness facts remain true. - push.menu().restore_selection(); - refresh.selection_restored = true; - } - Ok(()) -} - -/// The wait before a probe: skipped on the first, `interval` while the -/// gateway answered last time (measured from the previous probe's -/// completion, so a slow probe never bunches into a catch-up burst), and -/// the backoff's next delay while it did not, ending the loop when the -/// backoff's budget exhausts. -async fn await_probe_interval( - last: Option, - interval: Duration, - backoff: &ReconnectBackoff, - push: &Push, - signals: &mut Signals<'_>, -) -> Result<(), Ended> { - let Some(reachable) = last else { - return Ok(()); - }; - let wait = if reachable { - interval - } else if let Some(delay) = backoff.next_delay() { - delay - } else { - push.push_failure( - "Gateway reconnect stopped", - "the reconnect budget is exhausted; restart the workshop to retry", - Activity::General, - ); - return Err(Ended::Stop); - }; - until(tokio::time::sleep(wait), signals).await -} - -/// Publishes the probe outcome and, when it differs from the last -/// verdict, reports the transition to the menu and the status bar. -fn announce_transition( - push: &Push, - health: &GatewayHealth, - reachable: bool, - last: &mut Option, -) { - health.publish(reachable); - let transitioned = *last != Some(reachable); - *last = Some(reachable); - if transitioned { - // The menu recomputes chat_ready from reachability, so the - // verdict feeds it before any slower refresh work below. - push.menu().set_gateway_reachable(reachable); - if reachable { - push.push_status_update( - CONNECTED_LABEL, - "the gateway answers its health probe", - Activity::General, - ); - } else { - push.push_status_update( - UNREACHABLE_LABEL, - UNREACHABLE_DESCRIPTION, - Activity::General, - ); - } - } -} - -/// Refreshes only the gateway-owned menu sources that have not converged. -/// All menu state is server-owned and reaches the UI via socket pushes - -/// the UI fetches nothing on boot - so every transition into reachable, -/// boot's first probe included, (re)populates the profile state and the -/// model catalog. Healthy ticks independently repeat either refresh until -/// both sources are populated, because health and one ready source do not -/// imply the other source is ready; the probe interval bounds retries and -/// keeps this from becoming a busy loop. -async fn refresh_incomplete_sources( - snapshot: &GatewaySnapshot, - push: &Push, - refresh: &mut RefreshState, -) { - match (refresh.profiles_ready, refresh.catalog_ready) { - (false, false) => { - (refresh.profiles_ready, refresh.catalog_ready) = tokio::join!( - refresh_profiles(snapshot.client(), push), - refresh_catalog(snapshot.client(), push) - ); - } - (false, true) => refresh.profiles_ready = refresh_profiles(snapshot.client(), push).await, - (true, false) => refresh.catalog_ready = refresh_catalog(snapshot.client(), push).await, - (true, true) => {} - } -} -#[cfg(test)] -#[path = "heartbeat-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway/src/lib.rs b/crates/workshop/gateway/src/lib.rs deleted file mode 100644 index c5d22fe29..000000000 --- a/crates/workshop/gateway/src/lib.rs +++ /dev/null @@ -1,64 +0,0 @@ -//! workshop-gateway - the gateway subsystem: the bearer-authenticated -//! HTTP client for the Runvil gateway's OpenAI-compatible API -//! (`client`), the replaceable endpoint binding (`binding`) and -//! discovery-file resolution (`resolve`), the reachability heartbeat -//! (`heartbeat`), the catalog and profile refresh the heartbeat and the -//! server's profile switch share (`refresh`), and the gateway progress -//! subscriber (`progress`). Every production module is private, and -//! the crate root's re-exports are the one public path. -//! -//! ## Invariants -//! -//! - Tier: service; may depend on: `workshop-protocol`, `workshop-registry`, -//! `workshop-support`. `cargo test -p build-xtask` enforces the product -//! and container boundaries. -//! - No axum type appears in this crate's public API: the domain code -//! speaks `reqwest` statuses and raw bodies, and the server maps them -//! to HTTP responses. -//! - A bearer key is never written to logs or `Debug` output. -//! - Workshop reaches model providers only through the gateway, so no -//! vendor credential passes through this crate or reaches the page or -//! Lua. -//! - User-visible reporting flows through the registry's push facade, and -//! the gateway drives the menu through its -//! [`MenuPush`](workshop_registry::MenuPush) face, so this crate never -//! names another subsystem's bus. -//! - The gateway's progress reaches this crate as one wire type defined in -//! `progress-wire.rs`: no progress machinery is shared with the gateway -//! family, and its field names are pinned by a golden JSON test so drift -//! is caught. - -mod binding; -mod client; -mod handles; -mod heartbeat; -mod progress; -mod refresh; -mod resolve; -#[cfg(any(test, feature = "test-fixtures"))] -pub mod test_gateway; -mod version; - -pub use binding::{GatewayBinding, GatewayPublicationError, GatewaySnapshot, GatewayUpdater}; -pub use client::{ - ForwardedResponse, GatewayClient, GatewayError, GatewayRealtimeSocket, GatewayResponse, - REQUEST_TIMEOUT, SwitchProfileBody, SwitchResponse, -}; -pub use handles::{GatewayHandles, GatewayTaskRegistrations, register, register_tasks}; -pub use heartbeat::{GatewayHealth, Heartbeat, join_status, spawn as spawn_heartbeat}; -pub use refresh::{refresh_catalog, refresh_profiles}; -pub use resolve::{GatewaySource, ResolveError, ResolvedGateway, report, resolve}; -pub use version::{ - MIN_GATEWAY, Version, VersionCheck, announce as announce_version, report as report_version, -}; - -/// The Gateway discovery contract as Workshop reads it, re-exported whole -/// from the fork that owns it. -/// -/// The desktop app may name only `workshop-server-api` among workshop crates, -/// and the server and its facade are re-exports, so this module binding is -/// what carries the contract out to the desktop app: -/// `workshop_server_api::discovery::resolve` and friends. Naming the crate -/// once here rather than listing its twenty items at each level keeps one -/// name for one thing. -pub use workshop_gateway_discovery as discovery; diff --git a/crates/workshop/gateway/src/progress-wire-tests.rs b/crates/workshop/gateway/src/progress-wire-tests.rs deleted file mode 100644 index 9b320b20b..000000000 --- a/crates/workshop/gateway/src/progress-wire-tests.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! The progress wire contract, pinned by golden JSON. -//! -//! Workshop only deserializes `Progress`, so this file pins the field names -//! and their JSON types rather than a round trip. A rename on the Gateway's -//! side, or a type change here, fails one of these assertions instead of -//! silently leaving the status bar stuck. - -use super::*; - -#[test] -fn the_canonical_document_decodes_to_the_two_fields() { - let snapshot: Progress = - serde_json::from_str(r#"{"busy":true,"text":"Downloading qwen3-8b.gguf 45%"}"#) - .expect("the canonical document decodes"); - assert_eq!( - snapshot, - Progress { - busy: true, - text: "Downloading qwen3-8b.gguf 45%".to_owned(), - } - ); -} - -#[test] -fn an_idle_document_decodes_to_an_empty_text() { - let snapshot: Progress = - serde_json::from_str(r#"{"busy":false,"text":""}"#).expect("an idle document decodes"); - assert_eq!(snapshot, Progress::default()); -} - -#[test] -fn the_field_names_and_json_types_are_the_contract() { - // Pins the wire names and types through a decode of each, so a rename - // on either side fails here even if nobody updates a fixture. - // - // Both fields are required: the Gateway writes the whole snapshot on - // every event, so a document missing either one is malformed, and the - // decoder reports it rather than filling in a default. - for document in [r#"{"busy":true}"#, r#"{"text":"Loading"}"#] { - let decoded = serde_json::from_str::(document); - assert!( - decoded.is_err(), - "both fields are required, so {document} must not decode: {decoded:?}" - ); - } - - let wrong_type = serde_json::from_str::(r#"{"busy":"yes","text":""}"#); - assert!( - wrong_type.is_err(), - "busy is a boolean, not a string: {wrong_type:?}" - ); - let wrong_text = serde_json::from_str::(r#"{"busy":false,"text":7}"#); - assert!( - wrong_text.is_err(), - "text is a string, not a number: {wrong_text:?}" - ); -} - -#[test] -fn an_unknown_field_is_tolerated() { - // A newer Gateway adding a field must not break an older Workshop. - let snapshot: Progress = - serde_json::from_str(r#"{"busy":true,"text":"Loading","fraction":0.45}"#) - .expect("an unknown field is ignored"); - assert_eq!(snapshot.text, "Loading"); -} diff --git a/crates/workshop/gateway/src/progress-wire.rs b/crates/workshop/gateway/src/progress-wire.rs deleted file mode 100644 index c4543b284..000000000 --- a/crates/workshop/gateway/src/progress-wire.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! The gateway progress wire type, as Workshop reads it: one live activity, -//! a busy flag and a producer-owned text. -//! -//! [`Progress`] is the shape of one `GET /admin/progress` event and of the -//! progress field in `GET /admin/status`. Workshop only ever deserializes -//! it; the Gateway owns the producing side and the anti-flicker policy -//! lives in [`crate::progress`]. -//! -//! The field names and types are the contract. [`tests`] pins them against -//! a golden JSON document, so a rename or a type change on either side -//! fails here rather than in a status bar that silently stops updating. -//! -//! The text is user-visible, so a producer must never place a bearer key, -//! API key, or other credential in it. - -use serde::Deserialize; - -/// One snapshot of the Gateway's live activity. -/// -/// Deserialization tolerates fields this type does not know, which is what -/// lets a newer Gateway add one without breaking an older Workshop. There -/// is no schema version. -#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)] -pub struct Progress { - /// Whether any activity is live. The UI shows an indeterminate - /// barberpole while this is set. - pub busy: bool, - /// The newest live activity's text, e.g. `"Downloading qwen3-8b.gguf - /// 45%"`; empty when idle. - pub text: String, -} - -#[cfg(test)] -#[path = "progress-wire-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway/src/progress.rs b/crates/workshop/gateway/src/progress.rs deleted file mode 100644 index a9f15752d..000000000 --- a/crates/workshop/gateway/src/progress.rs +++ /dev/null @@ -1,352 +0,0 @@ -//! The gateway progress subscriber: a background task that reads the -//! gateway's `GET /admin/progress` snapshot stream and drives the status -//! bar's busy indicator through the registry's [`Push`] facade, so -//! gateway-side work (model downloads, profile switches) shows as a -//! barberpole plus the gateway's own text. -//! -//! The task follows the heartbeat's lifecycle posture: spawned with the -//! server, stopped through its [`Subscriber`] handle inside the same -//! graceful-shutdown signal, and driven by the shared [`GatewayHealth`] -//! verdict rather than by probes of its own. It subscribes while the -//! gateway reads reachable and idles while it does not; a reconnect -//! resubscribes. Each snapshot passes through the anti-flicker -//! `Presenter`, which decides when the bar shows and when it rests. -//! When the subscription drops - a lost connection or an unreachable -//! verdict - the bar returns to rest, because progress from a gateway -//! the workshop can no longer hear is stale; a bar that only just -//! appeared still waits out its minimum visible time first, so a -//! dropped stream cannot flash it. - -mod presenter; -#[path = "progress-wire.rs"] -mod wire; - -use std::future::Future; -use std::time::Duration; - -use futures_util::StreamExt; -use tokio::sync::{oneshot, watch}; -use tokio::time::Instant; - -use workshop_registry::Push; - -use crate::binding::{GatewayBinding, GatewaySnapshot}; -use crate::client::ProgressStream; -use crate::heartbeat::GatewayHealth; -pub(crate) use wire::Progress; - -#[cfg(test)] -use presenter::{MIN_VISIBLE, SHOW_DELAY}; -use presenter::{Policy, Presenter}; - -/// How long a resubscribe waits when the stream ended while the gateway -/// still reads reachable, so an endpoint that accepts and immediately -/// closes cannot spin the loop. A reachability flip restarts at once; -/// matched to the heartbeat's probe cadence. -const RESUBSCRIBE_DELAY: Duration = Duration::from_secs(5); - -/// The subscriber's durations, injectable so tests can shorten them. -#[derive(Debug, Clone, Copy)] -struct Timing { - /// Wait before resubscribing to a stream that ended while reachable. - resubscribe_delay: Duration, - /// The anti-flicker policy the presenter runs. - policy: Policy, -} - -impl Timing { - /// The production timing. - const DEFAULT: Self = Self { - resubscribe_delay: RESUBSCRIBE_DELAY, - policy: Policy::DEFAULT, - }; -} - -/// A running subscriber task. -/// -/// [`Subscriber::shutdown`] signals the task to stop and awaits it. -/// Dropping the handle without shutting down still stops the task at its -/// next select point, because the closed channel resolves the stop branch. -#[derive(Debug)] -pub(crate) struct Subscriber { - stop: Option>, - task: Option>, -} - -impl Subscriber { - /// Signals the subscriber to stop and waits for its task to finish. - pub(crate) async fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); - } - if let Some(task) = self.task.take() { - let _ = task.await; - } - } -} - -/// Spawns the subscriber task against the gateway behind `gateway`, -/// pushing busy and idle frames through `push` while `health` reads -/// reachable. -#[must_use] -pub(crate) fn spawn(gateway: GatewayBinding, push: Push, health: GatewayHealth) -> Subscriber { - spawn_with_timing(gateway, push, health, Timing::DEFAULT) -} - -/// [`spawn`] with every duration injected, so tests can shorten them. -fn spawn_with_timing( - gateway: GatewayBinding, - push: Push, - health: GatewayHealth, - timing: Timing, -) -> Subscriber { - let (stop, mut stopped) = oneshot::channel(); - let task = tokio::spawn(async move { - run(&gateway, &push, &health, timing, &mut stopped).await; - }); - Subscriber { - stop: Some(stop), - task: Some(task), - } -} - -/// Why one wait ended early. -pub(crate) enum Ended { - /// The stop signal fired, or a control channel closed: the task - /// returns. - Stop, - /// The endpoint binding was replaced: start over against the new one. - Rebind, - /// The gateway's reachability changed, or the stream closed: fall - /// back to the reconnect loop. - Lost, -} - -/// The control signals every wait in a loop listens to beside its own -/// future. The heartbeat writes reachability rather than following it, -/// so it leaves `reachable` empty. -pub(crate) struct Signals<'a> { - pub(crate) stop: &'a mut oneshot::Receiver<()>, - pub(crate) reachable: Option<&'a mut watch::Receiver>, - pub(crate) gateway_changed: &'a mut watch::Receiver, -} - -impl Signals<'_> { - /// The current reachability verdict, marked seen so the next wait - /// wakes only on a later change. Without a watch it reads reachable. - fn reachable_now(&mut self) -> bool { - self.reachable - .as_deref_mut() - .is_none_or(|reachable| *reachable.borrow_and_update()) - } -} - -/// The subscription loop, in named phases: idle while unreachable, -/// subscribe, drive the subscription, and recover from its end. Every -/// wait goes through [`until`] and [`ticking`], so the stop signal wins -/// each select and the presenter's deadlines keep ticking between -/// subscriptions. -async fn run( - gateway: &GatewayBinding, - push: &Push, - health: &GatewayHealth, - timing: Timing, - stop: &mut oneshot::Receiver<()>, -) { - let mut reachable = health.subscribe(); - let mut gateway_changed = gateway.subscribe(); - let mut signals = Signals { - stop, - reachable: Some(&mut reachable), - gateway_changed: &mut gateway_changed, - }; - let mut presenter = Presenter::new(timing.policy); - loop { - if let Err(Ended::Stop) = idle_until_reachable(&mut signals, &mut presenter, push).await { - return; - } - let snapshot = gateway.snapshot(); - let stream = match subscribe(&snapshot, timing, &mut signals, &mut presenter, push).await { - Ok(stream) => stream, - Err(Ended::Stop) => return, - Err(Ended::Lost | Ended::Rebind) => continue, - }; - let ended = drive_stream(stream, &mut signals, &mut presenter, push).await; - if let Err(Ended::Stop) = recover(ended, timing, &mut signals, &mut presenter, push).await { - return; - } - } -} - -/// Idles while the gateway reads unreachable, waking on any control -/// signal. Returns to proceed with a subscription, or on stop. -async fn idle_until_reachable( - signals: &mut Signals<'_>, - presenter: &mut Presenter, - push: &Push, -) -> Result<(), Ended> { - while !signals.reachable_now() { - match until( - ticking(std::future::pending::<()>(), presenter, push), - signals, - ) - .await - { - Err(Ended::Stop) => return Err(Ended::Stop), - Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} - } - } - Ok(()) -} - -/// Opens one progress subscription, waiting out the resubscribe delay and -/// looping around when the endpoint declines. Returns the stream, or an -/// [`Ended`] telling the loop to stop or loop around again. -async fn subscribe( - snapshot: &GatewaySnapshot, - timing: Timing, - signals: &mut Signals<'_>, - presenter: &mut Presenter, - push: &Push, -) -> Result { - match until( - ticking(snapshot.client().subscribe_progress(), presenter, push), - signals, - ) - .await - { - Err(ended) => return Err(ended), - Ok(Ok(stream)) => return Ok(stream), - Ok(Err(error)) => { - tracing::warn!(%error, "gateway progress subscription failed"); - } - } - // A declined subscription waits out the resubscribe delay, then loops - // around to try again. - match until( - ticking( - tokio::time::sleep(timing.resubscribe_delay), - presenter, - push, - ), - signals, - ) - .await - { - Err(Ended::Stop) => Err(Ended::Stop), - Err(Ended::Lost | Ended::Rebind) | Ok(()) => Err(Ended::Lost), - } -} - -/// Drives one subscription: every snapshot reaches the presenter, a -/// malformed snapshot is skipped, and the stream's own end is reported. -async fn drive_stream( - stream: ProgressStream, - signals: &mut Signals<'_>, - presenter: &mut Presenter, - push: &Push, -) -> Ended { - tokio::pin!(stream); - loop { - match until(ticking(stream.next(), presenter, push), signals).await { - Err(ended) => break ended, - Ok(Some(Ok(snapshot))) => presenter.apply(snapshot, Instant::now(), push), - // One malformed snapshot or a terminal read failure; the - // stream itself decides which by continuing or ending. - Ok(Some(Err(error))) => { - tracing::warn!(%error, "gateway progress snapshot skipped"); - } - Ok(None) => break Ended::Lost, - } - } -} - -/// Handles a subscription's end: the bar rests because a subscription the -/// workshop can no longer hear is stale, and - while still reachable - the -/// loop waits out the resubscribe delay before subscribing again. -async fn recover( - ended: Ended, - timing: Timing, - signals: &mut Signals<'_>, - presenter: &mut Presenter, - push: &Push, -) -> Result<(), Ended> { - match ended { - Ended::Stop => return Err(Ended::Stop), - Ended::Rebind => { - presenter.detach(Instant::now(), push); - return Ok(()); - } - Ended::Lost => presenter.detach(Instant::now(), push), - } - if signals.reachable_now() { - match until( - ticking( - tokio::time::sleep(timing.resubscribe_delay), - presenter, - push, - ), - signals, - ) - .await - { - Err(Ended::Stop) => return Err(Ended::Stop), - Err(Ended::Lost | Ended::Rebind) | Ok(()) => {} - } - } - Ok(()) -} - -/// Awaits `future` with the control signals armed beside it. A control -/// signal ends the wait early with its [`Ended`]. Both watch senders live -/// in the registry's [`GatewayHandles`](crate::GatewayHandles) for the -/// process lifetime, so a closed watch means shutdown. -pub(crate) async fn until( - future: F, - signals: &mut Signals<'_>, -) -> Result { - tokio::select! { - _ = &mut *signals.stop => Err(Ended::Stop), - changed = reachability_changed(signals.reachable.as_deref_mut()) => { - Err(if changed.is_err() { Ended::Stop } else { Ended::Lost }) - } - changed = signals.gateway_changed.changed() => { - Err(if changed.is_err() { Ended::Stop } else { Ended::Rebind }) - } - output = future => Ok(output), - } -} - -/// Resolves on the next reachability change, or never without a watch. -async fn reachability_changed( - reachable: Option<&mut watch::Receiver>, -) -> Result<(), watch::error::RecvError> { - match reachable { - Some(reachable) => reachable.changed().await, - None => std::future::pending().await, - } -} - -/// Awaits `future` while ticking the presenter at each of its deadlines, -/// so a minimum-visible hold lapses on time even while the loop is -/// between subscriptions. -async fn ticking(future: F, presenter: &mut Presenter, push: &Push) -> F::Output { - tokio::pin!(future); - loop { - tokio::select! { - () = wake_at(presenter.next_wake()) => presenter.tick(Instant::now(), push), - output = &mut future => return output, - } - } -} - -/// Waits for `at`, or forever when there is no pending deadline. -async fn wake_at(at: Option) { - match at { - Some(at) => tokio::time::sleep_until(at).await, - None => std::future::pending().await, - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/workshop/gateway/src/progress/presenter.rs b/crates/workshop/gateway/src/progress/presenter.rs deleted file mode 100644 index 1c7917869..000000000 --- a/crates/workshop/gateway/src/progress/presenter.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! The anti-flicker policy between the gateway's progress snapshots and -//! the status bar: the barberpole appears only once the gateway has been -//! busy for [`SHOW_DELAY`], stays up at least [`MIN_VISIBLE`] once shown, -//! and follows the newest text while up. The policy lives here, next to -//! the subscriber that feeds it, so the UI stays dumb and the wire type -//! stays a plain busy flag plus text. -//! -//! [`Presenter`] is a pure state machine over explicit instants: the -//! subscriber loop hands it every decoded snapshot and wakes it at -//! [`Presenter::next_wake`], and the tests drive it with instants of -//! their own choosing. - -use std::time::Duration; - -use tokio::time::Instant; - -use workshop_protocol::Activity; -use workshop_registry::Push; - -use super::Progress; - -/// How long the gateway must stay busy before the barberpole appears; -/// work shorter than this never disturbs the status bar. -pub(super) const SHOW_DELAY: Duration = Duration::from_secs(1); - -/// How long the barberpole stays up once shown, so work that ends just -/// past [`SHOW_DELAY`] reads as a completed activity. -pub(super) const MIN_VISIBLE: Duration = Duration::from_millis(500); - -/// The tooltip on every gateway busy frame. -const DESCRIPTION: &str = "gateway activity"; - -/// The two anti-flicker durations, injectable so the subscriber tests -/// run against a live mock gateway without waiting out the production -/// values. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) struct Policy { - /// Busy time before the barberpole appears. - pub(super) show_delay: Duration, - /// Minimum time the barberpole stays up once shown. - pub(super) min_visible: Duration, -} - -impl Policy { - /// The production policy. - pub(super) const DEFAULT: Self = Self { - show_delay: SHOW_DELAY, - min_visible: MIN_VISIBLE, - }; -} - -/// The gateway is busy: since when, and its newest text. -#[derive(Debug)] -struct Live { - since: Instant, - text: String, -} - -/// The barberpole is up: since when, and the text last pushed. -#[derive(Debug)] -struct Shown { - at: Instant, - text: String, -} - -/// The anti-flicker state machine over gateway snapshots. -#[derive(Debug)] -pub(super) struct Presenter { - policy: Policy, - live: Option, - shown: Option, -} - -impl Presenter { - /// A presenter at rest: nothing live, nothing shown. - pub(super) fn new(policy: Policy) -> Self { - Self { - policy, - live: None, - shown: None, - } - } - - /// Records one decoded snapshot at `now` and pushes whatever - /// transition it calls for. A busy snapshot starts the show delay - /// (or replaces the live text); an idle snapshot ends the busy run - /// and starts the minimum-visible hold if the bar is up. - pub(super) fn apply(&mut self, snapshot: Progress, now: Instant, push: &Push) { - if snapshot.busy { - match &mut self.live { - Some(live) => live.text = snapshot.text, - None => { - self.live = Some(Live { - since: now, - text: snapshot.text, - }); - } - } - } else { - self.live = None; - } - self.settle(now, push); - } - - /// Re-evaluates the deadlines at `now` without a new snapshot: the - /// show delay lapsing pushes the bar up, the minimum-visible hold - /// lapsing pushes it idle. - pub(super) fn tick(&mut self, now: Instant, push: &Push) { - self.settle(now, push); - } - - /// The next moment [`Presenter::tick`] can change state without a - /// snapshot: the show deadline while the gateway warms up, or the - /// earliest idle moment once the gateway went idle under a shown bar. - pub(super) fn next_wake(&self) -> Option { - match (&self.live, &self.shown) { - (Some(live), None) => Some(live.since + self.policy.show_delay), - (None, Some(shown)) => Some(shown.at + self.policy.min_visible), - (Some(_), Some(_)) | (None, None) => None, - } - } - - /// The subscription is gone at `now`: progress from a gateway the - /// workshop can no longer hear is stale, so this reads as an idle - /// snapshot. Any pending show is forgotten, and a shown bar rests once - /// its minimum-visible hold has lapsed, the same hold an idle snapshot - /// gets; the subscriber keeps [`Presenter::next_wake`] armed between - /// subscriptions so the hold lapses on time. - pub(super) fn detach(&mut self, now: Instant, push: &Push) { - self.live = None; - self.settle(now, push); - } - - fn settle(&mut self, now: Instant, push: &Push) { - let Some(live) = &self.live else { - if let Some(shown) = &self.shown - && now.duration_since(shown.at) >= self.policy.min_visible - { - self.shown = None; - push.push_idle(); - } - return; - }; - match self.shown.as_mut() { - None => { - if now.duration_since(live.since) >= self.policy.show_delay { - push.push_busy(live.text.clone(), DESCRIPTION, Activity::General); - self.shown = Some(Shown { - at: now, - text: live.text.clone(), - }); - } - } - Some(shown) => { - if shown.text != live.text { - push.push_busy(live.text.clone(), DESCRIPTION, Activity::General); - shown.text.clone_from(&live.text); - } - } - } - } -} diff --git a/crates/workshop/gateway/src/progress/tests-presenter.rs b/crates/workshop/gateway/src/progress/tests-presenter.rs deleted file mode 100644 index f7285ea43..000000000 --- a/crates/workshop/gateway/src/progress/tests-presenter.rs +++ /dev/null @@ -1,259 +0,0 @@ -//! The anti-flicker presenter pinned with explicit instants: a busy -//! snapshot shows only after the show delay, an idle snapshot before the -//! delay pushes nothing, an idle arriving within the minimum visible time -//! is deferred to it, text changes republish while shown, and a detach -//! rests a shown bar under the same hold. - -use super::*; - -/// A presenter under the production policy, its push, and the recorder. -fn wired() -> (Presenter, Recorder) { - (Presenter::new(Policy::DEFAULT), recorder()) -} - -fn busy(text: &str) -> Progress { - Progress { - busy: true, - text: text.to_owned(), - } -} - -fn idle() -> Progress { - Progress::default() -} - -#[tokio::test] -async fn a_busy_snapshot_pushes_only_once_the_show_delay_has_passed() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model"), start, &recorder.push); - assert!( - recorder.pushed().is_empty(), - "a fresh busy snapshot must not flash the bar" - ); - assert_eq!( - presenter.next_wake(), - Some(start + SHOW_DELAY), - "the presenter asks to wake exactly at the show deadline" - ); - - presenter.tick( - start + SHOW_DELAY - Duration::from_millis(1), - &recorder.push, - ); - assert!( - recorder.pushed().is_empty(), - "one millisecond short of the delay is still too soon" - ); - - presenter.tick(start + SHOW_DELAY, &recorder.push); - assert_eq!( - recorder.pushed(), - [("Downloading model".to_owned(), true)], - "the delay lapsing pushes one busy frame holding the gateway text" - ); - assert_eq!( - presenter.next_wake(), - None, - "a shown bar with live work has no deadline of its own" - ); -} - -#[tokio::test] -async fn an_idle_snapshot_before_the_show_delay_pushes_nothing() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Loading profile"), start, &recorder.push); - presenter.apply(idle(), start + Duration::from_millis(300), &recorder.push); - presenter.tick(start + SHOW_DELAY * 2, &recorder.push); - assert!( - recorder.pushed().is_empty(), - "sub-second work never disturbs the status bar" - ); - assert_eq!(presenter.next_wake(), None); -} - -#[tokio::test] -async fn an_idle_snapshot_within_the_minimum_visible_time_is_deferred_to_it() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model"), start, &recorder.push); - let shown = start + SHOW_DELAY; - presenter.tick(shown, &recorder.push); - assert_eq!(recorder.pushed().len(), 1, "the bar is up"); - - let ended = shown + Duration::from_millis(100); - presenter.apply(idle(), ended, &recorder.push); - assert_eq!( - recorder.pushed().len(), - 1, - "an idle arriving 100ms after showing must not flash the bar off" - ); - assert_eq!( - presenter.next_wake(), - Some(shown + MIN_VISIBLE), - "the presenter asks to wake when the minimum visible time lapses" - ); - - presenter.tick( - shown + MIN_VISIBLE - Duration::from_millis(1), - &recorder.push, - ); - assert_eq!(recorder.pushed().len(), 1, "still inside the hold"); - - presenter.tick(shown + MIN_VISIBLE, &recorder.push); - assert_eq!( - recorder.pushed(), - [ - ("Downloading model".to_owned(), true), - ("Ready".to_owned(), false), - ], - "the hold lapsing rests the bar" - ); - assert_eq!(presenter.next_wake(), None); -} - -#[tokio::test] -async fn an_idle_snapshot_after_the_minimum_visible_time_rests_the_bar_at_once() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model"), start, &recorder.push); - let shown = start + SHOW_DELAY; - presenter.tick(shown, &recorder.push); - presenter.apply(idle(), shown + MIN_VISIBLE * 4, &recorder.push); - assert_eq!( - recorder.pushed().last(), - Some(&("Ready".to_owned(), false)), - "an idle past the hold needs no deferral" - ); -} - -#[tokio::test] -async fn a_text_change_while_shown_republishes_and_an_unchanged_text_does_not() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model 10%"), start, &recorder.push); - presenter.apply( - busy("Downloading model 20%"), - start + Duration::from_millis(500), - &recorder.push, - ); - assert!( - recorder.pushed().is_empty(), - "text changes before the delay stay pending" - ); - let shown = start + SHOW_DELAY; - presenter.tick(shown, &recorder.push); - assert_eq!( - recorder.pushed(), - [("Downloading model 20%".to_owned(), true)], - "the delay lapsing pushes the newest text, not the first" - ); - - presenter.apply( - busy("Downloading model 20%"), - shown + Duration::from_millis(10), - &recorder.push, - ); - assert_eq!( - recorder.pushed().len(), - 1, - "a snapshot repeating the shown text is not re-pushed" - ); - - presenter.apply( - busy("Downloading model 30%"), - shown + Duration::from_millis(20), - &recorder.push, - ); - assert_eq!( - recorder.pushed().last(), - Some(&("Downloading model 30%".to_owned(), true)), - "a new text republishes while the bar is up" - ); -} - -#[tokio::test] -async fn work_resuming_during_the_hold_keeps_the_bar_up_without_a_new_delay() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Selecting profile"), start, &recorder.push); - let shown = start + SHOW_DELAY; - presenter.tick(shown, &recorder.push); - presenter.apply(idle(), shown + Duration::from_millis(100), &recorder.push); - presenter.apply( - busy("Starting models"), - shown + Duration::from_millis(200), - &recorder.push, - ); - assert_eq!( - recorder.pushed(), - [ - ("Selecting profile".to_owned(), true), - ("Starting models".to_owned(), true), - ], - "back-to-back work shares one visible run: no idle frame, no second delay" - ); - assert_eq!(presenter.next_wake(), None); -} - -#[tokio::test] -async fn a_detach_within_the_minimum_visible_time_is_deferred_to_it() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model"), start, &recorder.push); - let shown = start + SHOW_DELAY; - presenter.tick(shown, &recorder.push); - presenter.detach(shown + Duration::from_millis(100), &recorder.push); - assert_eq!( - recorder.pushed().len(), - 1, - "a subscription lost 100ms after showing must not flash the bar off" - ); - assert_eq!( - presenter.next_wake(), - Some(shown + MIN_VISIBLE), - "the presenter asks to wake when the minimum visible time lapses" - ); - - presenter.tick(shown + MIN_VISIBLE, &recorder.push); - assert_eq!( - recorder.pushed(), - [ - ("Downloading model".to_owned(), true), - ("Ready".to_owned(), false), - ], - "the hold lapsing rests the bar" - ); - assert_eq!(presenter.next_wake(), None); -} - -#[tokio::test] -async fn a_detach_after_the_minimum_visible_time_rests_the_bar_at_once() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model"), start, &recorder.push); - let shown = start + SHOW_DELAY; - presenter.tick(shown, &recorder.push); - presenter.detach(shown + MIN_VISIBLE, &recorder.push); - assert_eq!( - recorder.pushed().last(), - Some(&("Ready".to_owned(), false)), - "a lost subscription past the hold rests the bar at once" - ); - assert_eq!(presenter.next_wake(), None); -} - -#[tokio::test] -async fn a_detach_before_the_bar_showed_forgets_the_pending_work() { - let (mut presenter, recorder) = wired(); - let start = Instant::now(); - presenter.apply(busy("Downloading model"), start, &recorder.push); - presenter.detach(start + Duration::from_millis(300), &recorder.push); - assert_eq!(presenter.next_wake(), None); - presenter.tick(start + SHOW_DELAY * 2, &recorder.push); - assert!( - recorder.pushed().is_empty(), - "a detach before the bar showed pushes nothing, then or later" - ); -} diff --git a/crates/workshop/gateway/src/progress/tests-recovery.rs b/crates/workshop/gateway/src/progress/tests-recovery.rs deleted file mode 100644 index 874dc639a..000000000 --- a/crates/workshop/gateway/src/progress/tests-recovery.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Progress subscription recovery: an endpoint replacement moves the subscription at once. - -use super::*; - -#[tokio::test] -async fn an_endpoint_replacement_moves_the_progress_subscription_immediately() { - let original = Arc::new(MockProgress::new()); - let original_url = spawn_gateway(Arc::clone(&original).router()).await; - let replacement = Arc::new(MockProgress::new()); - let replacement_url = spawn_gateway(Arc::clone(&replacement).router()).await; - let gateway = binding(&original_url); - let recorder = recorder(); - let subscriber = spawn_with_timing( - gateway.clone(), - recorder.push.clone(), - GatewayHealth::new(), - FAST_TIMING, - ); - - wait_for_connections(&original, 1).await; - original.send(true, "original-download"); - recorder.frames_where(|frames| frames.len() == 1).await; - - gateway - .replace(&replacement_url, "") - .expect("the replacement publishes"); - let pushed = recorder.frames_where(|frames| frames.len() == 2).await; - assert_eq!( - pushed[1], - ("Ready".to_owned(), false), - "the old endpoint's progress is stale the moment the binding moves" - ); - wait_for_connections(&replacement, 1).await; - replacement.send(true, "replacement-download"); - let pushed = recorder.frames_where(|frames| frames.len() == 3).await; - assert_eq!( - pushed[2], - ("replacement-download".to_owned(), true), - "the replacement's snapshots drive the bar" - ); - assert_eq!( - original.connections.load(Ordering::Relaxed), - 1, - "the old endpoint is never retried after publication" - ); - subscriber.shutdown().await; -} diff --git a/crates/workshop/gateway/src/progress/tests.rs b/crates/workshop/gateway/src/progress/tests.rs deleted file mode 100644 index af02b5473..000000000 --- a/crates/workshop/gateway/src/progress/tests.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Subscriber tests against a mock `GET /admin/progress`: snapshots -//! reach the status bar as busy frames under the anti-flicker policy, -//! a malformed snapshot is skipped, a closed stream resubscribes after -//! the delay, an unreachable gateway holds no subscription, and a -//! reconnect rests the bar and resubscribes once. A lost subscription -//! rests the bar under the presenter's minimum-visible hold, which the -//! loop keeps ticking between subscriptions. The presenter's timing -//! rules are pinned separately with explicit instants; here the policy -//! runs at millisecond scale so the mock round trips stay fast. - -use super::*; - -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, PoisonError}; - -use axum::extract::State; -use axum::response::{IntoResponse, Response}; -use tokio::sync::broadcast; - -use workshop_protocol::StatusBarUpdate; -use workshop_registry::{Registration, Registry, StatusSink, StatusSinkAdapter}; - -/// The anti-flicker policy the mock-gateway tests run under: short -/// enough that a test waits milliseconds, long enough that a snapshot -/// still has to outlive the show delay to reach the bar. -const FAST_POLICY: Policy = Policy { - show_delay: Duration::from_millis(40), - min_visible: Duration::from_millis(20), -}; - -/// The production resubscribe delay is seconds; the tests inject this. -const FAST_TIMING: Timing = Timing { - resubscribe_delay: Duration::from_millis(50), - policy: FAST_POLICY, -}; - -/// Binds `app` as a mock gateway on a free loopback port and returns its -/// base URL. -async fn spawn_gateway(app: axum::Router) -> String { - let (addr, _handle) = workshop_support::fixtures::serve(app).await; - format!("http://{addr}") -} - -/// A replaceable binding for one mock gateway. -fn binding(base_url: &str) -> GatewayBinding { - GatewayBinding::new(base_url, "").expect("the test binding builds") -} - -/// A recording status sink behind a [`Push`]: every frame the subscriber -/// pushes lands in `frames`, in order. -struct Recorder { - push: Push, - frames: Arc>>, - _guard: Registration, -} - -fn recorder() -> Recorder { - let registry = Registry::new(); - let frames: Arc>> = Arc::new(Mutex::new(Vec::new())); - let sink = Arc::clone(&frames); - let guard = - registry.register_sink::(Arc::new(StatusSinkAdapter::new(move |update| { - sink.lock() - .unwrap_or_else(PoisonError::into_inner) - .push(update); - }))); - Recorder { - push: registry.push(), - frames, - _guard: guard, - } -} - -impl Recorder { - /// The `(label, busy)` of every frame pushed so far. - fn pushed(&self) -> Vec<(String, bool)> { - self.frames - .lock() - .unwrap_or_else(PoisonError::into_inner) - .iter() - .map(|update| (update.label.clone(), update.busy)) - .collect() - } - - /// Polls until `accept` holds over the pushed frames, within a - /// generous deadline (the heartbeat tests' snapshot_where pattern). - async fn frames_where( - &self, - accept: impl Fn(&[(String, bool)]) -> bool, - ) -> Vec<(String, bool)> { - tokio::time::timeout(Duration::from_secs(5), async { - loop { - let pushed = self.pushed(); - if accept(&pushed) { - return pushed; - } - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("matching frames arrive within the deadline") - } -} - -/// A mock `GET /admin/progress`: every payload published to the feed -/// streams to every connected subscriber as an SSE `data:` frame, and -/// `connections` counts how often the endpoint was hit. The receiver -/// is created before the count increments, so a test that observes a -/// connection can publish without losing the frame. [`close`](Self::close) -/// ends every live stream, so a test can drive the resubscribe path. -struct MockProgress { - connections: AtomicUsize, - feeds: Mutex>, -} - -impl MockProgress { - fn new() -> Self { - Self { - connections: AtomicUsize::new(0), - feeds: Mutex::new(broadcast::channel(16).0), - } - } - - fn router(self: Arc) -> axum::Router { - axum::Router::new() - .route("/admin/progress", axum::routing::get(serve_feed)) - .with_state(self) - } - - /// Publishes one snapshot to every connected subscriber. - fn send(&self, busy: bool, text: &str) { - self.send_raw(serde_json::json!({"busy": busy, "text": text}).to_string()); - } - - /// Publishes one raw payload to every connected subscriber. - fn send_raw(&self, payload: String) { - self.feeds - .lock() - .expect("the feed lock is not poisoned") - .send(payload) - .expect("the mock has a subscriber"); - } - - /// Ends every live stream; later connections subscribe to the - /// fresh feed. - fn close(&self) { - *self.feeds.lock().expect("the feed lock is not poisoned") = broadcast::channel(16).0; - } -} - -async fn serve_feed(State(mock): State>) -> Response { - let rx = mock - .feeds - .lock() - .expect("the feed lock is not poisoned") - .subscribe(); - mock.connections.fetch_add(1, Ordering::Relaxed); - let stream = futures_util::stream::unfold(rx, |mut rx| async move { - loop { - match rx.recv().await { - Ok(payload) => { - return Some(( - Ok::<_, std::convert::Infallible>(format!("data: {payload}\n\n")), - rx, - )); - } - Err(broadcast::error::RecvError::Lagged(_)) => {} - Err(broadcast::error::RecvError::Closed) => return None, - } - } - }); - ( - [(axum::http::header::CONTENT_TYPE, "text/event-stream")], - axum::body::Body::from_stream(stream), - ) - .into_response() -} - -/// Polls the mock's connection count until it reaches `n`. -async fn wait_for_connections(mock: &MockProgress, n: usize) { - tokio::time::timeout(Duration::from_secs(5), async { - while mock.connections.load(Ordering::Relaxed) < n { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("the subscriber connects within the deadline"); -} - -#[tokio::test] -async fn a_busy_snapshot_reaches_the_status_bar_as_a_busy_frame_with_the_gateway_text() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let recorder = recorder(); - // The flag starts optimistic, so the subscriber connects at once. - let subscriber = spawn_with_timing( - binding(&base_url), - recorder.push.clone(), - GatewayHealth::new(), - FAST_TIMING, - ); - - wait_for_connections(&mock, 1).await; - mock.send(true, "Downloading qwen3-8b.gguf 45%"); - - let pushed = recorder.frames_where(|frames| !frames.is_empty()).await; - assert_eq!( - pushed, - [("Downloading qwen3-8b.gguf 45%".to_owned(), true)], - "the gateway's text is the bar's label and the frame is busy" - ); - - mock.send(false, ""); - let pushed = recorder.frames_where(|frames| frames.len() == 2).await; - assert_eq!( - pushed[1], - ("Ready".to_owned(), false), - "the idle snapshot rests the bar once the minimum visible time passes" - ); - subscriber.shutdown().await; -} - -#[tokio::test] -async fn a_malformed_snapshot_is_skipped_and_the_stream_continues() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let recorder = recorder(); - let subscriber = spawn_with_timing( - binding(&base_url), - recorder.push.clone(), - GatewayHealth::new(), - FAST_TIMING, - ); - - wait_for_connections(&mock, 1).await; - // One undecodable `data:` block ahead of a valid snapshot: the - // subscriber warns and continues rather than dropping the stream. - mock.send_raw("{not valid json".to_owned()); - mock.send(true, "Loading profile"); - - let pushed = recorder.frames_where(|frames| !frames.is_empty()).await; - assert_eq!( - pushed, - [("Loading profile".to_owned(), true)], - "the snapshot after the malformed one still reaches the bar" - ); - assert_eq!( - mock.connections.load(Ordering::Relaxed), - 1, - "a malformed snapshot never drops the subscription" - ); - subscriber.shutdown().await; -} - -#[tokio::test] -async fn a_stream_that_ends_while_reachable_rests_the_bar_and_resubscribes_after_the_delay() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let recorder = recorder(); - let subscriber = spawn_with_timing( - binding(&base_url), - recorder.push.clone(), - GatewayHealth::new(), - FAST_TIMING, - ); - - wait_for_connections(&mock, 1).await; - mock.send(true, "Downloading"); - recorder.frames_where(|frames| frames.len() == 1).await; - - // The stream ends while the gateway still reads reachable: the bar - // rests once its minimum visible time lapses, which falls inside the - // resubscribe wait, and a fresh subscription follows the delay. - let closed = std::time::Instant::now(); - mock.close(); - let pushed = recorder.frames_where(|frames| frames.len() == 2).await; - assert_eq!( - pushed[1], - ("Ready".to_owned(), false), - "a lost subscription rests the bar: its progress is stale" - ); - wait_for_connections(&mock, 2).await; - assert!( - closed.elapsed() >= FAST_TIMING.resubscribe_delay, - "the resubscribe waits out the delay rather than spinning" - ); - subscriber.shutdown().await; -} - -#[tokio::test] -async fn an_unreachable_gateway_holds_no_subscription_and_pushes_nothing() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let recorder = recorder(); - let health = GatewayHealth::new(); - health.publish(false); - let subscriber = spawn_with_timing( - binding(&base_url), - recorder.push.clone(), - health.clone(), - FAST_TIMING, - ); - - let quiet = tokio::time::timeout(Duration::from_millis(200), async { - wait_for_connections(&mock, 1).await; - }) - .await; - assert!( - quiet.is_err(), - "an unreachable gateway must not be subscribed" - ); - assert!( - recorder.pushed().is_empty(), - "a subscriber that never connected pushes nothing" - ); - - health.publish(true); - wait_for_connections(&mock, 1).await; - subscriber.shutdown().await; -} - -#[tokio::test] -async fn a_reconnect_rests_the_bar_and_resubscribes_once() { - let mock = Arc::new(MockProgress::new()); - let base_url = spawn_gateway(Arc::clone(&mock).router()).await; - let recorder = recorder(); - let health = GatewayHealth::new(); - let subscriber = spawn_with_timing( - binding(&base_url), - recorder.push.clone(), - health.clone(), - FAST_TIMING, - ); - - wait_for_connections(&mock, 1).await; - mock.send(true, "Downloading"); - recorder.frames_where(|frames| frames.len() == 1).await; - - health.publish(false); - let pushed = recorder.frames_where(|frames| frames.len() == 2).await; - assert_eq!( - pushed[1], - ("Ready".to_owned(), false), - "an unreachable verdict rests the bar" - ); - - health.publish(true); - wait_for_connections(&mock, 2).await; - mock.send(true, "Starting models"); - let pushed = recorder.frames_where(|frames| frames.len() == 3).await; - assert_eq!( - pushed[2], - ("Starting models".to_owned(), true), - "the fresh subscription's snapshots reach the bar" - ); - assert_eq!( - mock.connections.load(Ordering::Relaxed), - 2, - "the reconnect resubscribes exactly once" - ); - subscriber.shutdown().await; -} - -#[path = "tests-presenter.rs"] -mod presenter; -#[path = "tests-recovery.rs"] -mod recovery; diff --git a/crates/workshop/gateway/src/refresh.rs b/crates/workshop/gateway/src/refresh.rs deleted file mode 100644 index 0af53d673..000000000 --- a/crates/workshop/gateway/src/refresh.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! Gateway profile and model-catalog refresh after reachability. - -use workshop_protocol::is_chat_capable; -use workshop_registry::Push; - -use crate::client::GatewayClient; - -/// Re-fetches the gateway's model catalog and pushes it to every session. -/// -/// A failed, declined, or malformed catalog is logged and skipped rather -/// than pushed: pushing a bad snapshot would clear pickers that still hold -/// a usable list. -pub async fn refresh_catalog(client: &GatewayClient, push: &Push) -> bool { - let response = match client.list_models().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "catalog refresh failed"); - return false; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "catalog refresh was declined"); - return false; - } - let body: serde_json::Value = match serde_json::from_slice(&response.body) { - Ok(body) => body, - Err(error) => { - tracing::warn!(%error, "catalog refresh was not JSON"); - return false; - } - }; - let Some(models) = body.get("data").and_then(serde_json::Value::as_array) else { - tracing::warn!("catalog refresh is missing its data array"); - return false; - }; - let selectable = models.iter().any(is_chat_capable); - push.push_models_catalog(models.clone()); - selectable -} - -/// The decoded body of `GET /admin/profiles`. -#[derive(serde::Deserialize)] -struct ProfileList { - /// Every profile name the gateway can serve, in gateway order. - profiles: Vec, -} - -/// The decoded body of `GET /admin/status`, reduced to the one field the -/// menu needs. -#[derive(serde::Deserialize)] -struct ProfileStatus { - /// The profile the gateway is serving. - profile: Option, -} - -/// Fetches the gateway's profile list and active profile and publishes -/// them into the workbench snapshot. -/// -/// A failed or declined profile fetch is a state, not an error: it -/// degrades its half to empty, as a malformed answer does, so the menu -/// shows no profiles rather than stale names. -pub async fn refresh_profiles(client: &GatewayClient, push: &Push) -> bool { - let (profiles, active) = tokio::join!(fetch_profile_list(client), fetch_active_profile(client)); - let ready = profiles.as_ref().is_some_and(|profiles| { - !profiles.is_empty() - && active - .as_ref() - .is_some_and(|active| profiles.contains(active)) - }); - push.menu() - .set_profiles(profiles.unwrap_or_default(), active); - ready -} - -/// The gateway's profile names, or `None` on a failed response. -async fn fetch_profile_list(client: &GatewayClient) -> Option> { - let response = match client.list_profiles().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "profile list fetch failed"); - return None; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "profile list was declined"); - return None; - } - match serde_json::from_slice::(&response.body) { - Ok(list) => Some(list.profiles), - Err(error) => { - tracing::warn!(%error, "profile list was not the expected JSON"); - None - } - } -} - -/// The active profile name, or `None` on a failed response. -async fn fetch_active_profile(client: &GatewayClient) -> Option { - let response = match client.profile_status().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "profile status fetch failed"); - return None; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "profile status was declined"); - return None; - } - match serde_json::from_slice::(&response.body) { - Ok(status) => status.profile, - Err(error) => { - tracing::warn!(%error, "profile status was not the expected JSON"); - None - } - } -} diff --git a/crates/workshop/gateway/src/resolve-tests.rs b/crates/workshop/gateway/src/resolve-tests.rs deleted file mode 100644 index 7df81781c..000000000 --- a/crates/workshop/gateway/src/resolve-tests.rs +++ /dev/null @@ -1,343 +0,0 @@ -//! Gateway resolution tests: discovery file versus explicit config, stale files, and probe failures. - -use super::*; - -use std::io::{Read, Write as _}; -use std::net::TcpListener; - -use crate::discovery::GatewayDiscoveryFile; - -/// The test process's own image name, so the probe's pid and image -/// checks pass and the test reaches the liveness probes. -fn own_image_name() -> String { - std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned() -} - -/// A probe running the real liveness gauntlet against the test -/// binary's own image. -fn probe_own_image(run_dir: &Path) -> Result { - crate::discovery::resolve_for_test(run_dir, &own_image_name()) -} - -/// A gateway discovery file pointing at the test process itself. -fn live_file(port: u16, api_key: &str) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port, - api_key: api_key.to_owned(), - pid: std::process::id(), - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } -} - -/// A pid guaranteed dead: a short-lived child, reaped and dropped so -/// no handle keeps the process object alive. -fn dead_pid() -> u32 { - let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) - .arg("--list") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn a short-lived child"); - let pid = child.id(); - child.wait().expect("the child exits"); - drop(child); - pid -} - -/// A fixture gateway: answers `GET /health` with 200 and the key -/// probe with 200 only when the bearer matches `expected_key`. -fn fixture_gateway(expected_key: &'static str) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); - let port = listener.local_addr().expect("fixture address").port(); - std::thread::spawn(move || { - while let Ok((mut stream, _)) = listener.accept() { - for _ in 0..2 { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - break; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let accepted = request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); - let response = if accepted { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - if stream.write_all(response).is_err() { - break; - } - } - } - }); - port -} - -/// An explicit gateway config: a LAN URL, never the built-in default. -fn explicit_config() -> GatewayConfig { - GatewayConfig { - base_url: "http://gateway.lan:9999".to_owned(), - api_key: "config-key".to_owned(), - } -} - -#[test] -fn a_live_gateway_discovery_file_wins_over_explicit_config() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let gateway = crate::test_gateway::ValidatedGateway::spawn("file-key"); - let port = gateway.port(); - gateway - .gateway_discovery_file("file-key", 1_757_000_000, "2026-09-03T12:00:00Z") - .write_to(dir.path()) - .expect("write"); - - let resolved = - resolve_with(Some(dir.path()), &explicit_config(), probe).expect("a live file resolves"); - assert_eq!(resolved.source(), GatewaySource::GatewayDiscoveryFile); - assert_eq!(resolved.base_url(), format!("http://127.0.0.1:{port}")); - assert_eq!(resolved.api_key(), "file-key"); - assert_eq!( - resolved.identity().map(ValidatedConnection::port), - Some(port), - "the winning sidecar retains its validated identity for the initial snapshot" - ); - assert_eq!(resolved.stale(), None); -} - -#[test] -fn a_stale_file_is_cleaned_and_explicit_config_wins() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "k") - }; - file.write_to(dir.path()).expect("write"); - - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config is the fallback"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.base_url(), "http://gateway.lan:9999"); - assert_eq!(resolved.api_key(), "config-key"); - assert_eq!(resolved.stale(), Some(StaleReason::ProcessDead)); - assert!( - !crate::discovery::gateway_discovery_file_path(dir.path()).exists(), - "the stale file was removed" - ); -} - -#[test] -fn a_wrong_key_is_reported_distinctly_from_a_dead_pid() { - // Wrong key: the pid, image, and health checks pass; the key - // probe rejects. - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - live_file(port, "wrong") - .write_to(dir.path()) - .expect("write"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config is the fallback"); - assert_eq!(resolved.stale(), Some(StaleReason::KeyRejected)); - - // Dead pid: the process check fails before any probe runs. - let dir = tempfile::TempDir::new().expect("tempdir"); - let file = GatewayDiscoveryFile { - pid: dead_pid(), - ..live_file(1, "k") - }; - file.write_to(dir.path()).expect("write"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config is the fallback"); - assert_eq!(resolved.stale(), Some(StaleReason::ProcessDead)); -} - -#[test] -fn no_file_and_no_explicit_config_is_the_plain_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(Some(dir.path()), &config, probe_own_image) - .expect_err("an empty base_url is not explicit config"); - assert!( - error - .to_string() - .contains("no gateway configured or running"), - "the error says it plainly: {error}" - ); - assert_eq!(error.stale(), None, "no file existed to remove"); -} - -#[test] -fn an_explicitly_configured_default_url_is_honored() { - // The well-known default URL written by hand is explicit config: - // it names a gateway discovery cannot see (an SSH-tunneled remote, - // a gateway whose discovery-file write failed), so it must - // resolve, not read as an unset value. - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = GatewayConfig { - base_url: "http://127.0.0.1:8081".to_owned(), - api_key: "config-key".to_owned(), - }; - let resolved = resolve_with(Some(dir.path()), &config, probe_own_image) - .expect("an explicitly configured URL resolves"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.base_url(), "http://127.0.0.1:8081"); - assert_eq!(resolved.api_key(), "config-key"); -} - -#[test] -fn a_stale_file_with_no_explicit_config_names_the_reason_in_the_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("right"); - live_file(port, "wrong") - .write_to(dir.path()) - .expect("write"); - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(Some(dir.path()), &config, probe_own_image) - .expect_err("no explicit config remains"); - assert_eq!(error.stale(), Some(StaleReason::KeyRejected)); - let message = error.to_string(); - assert!( - message.contains("no gateway configured or running"), - "the error says it plainly: {message}" - ); - assert!( - message.contains("key was rejected"), - "the removed file's reason is named: {message}" - ); -} - -#[test] -fn no_file_and_explicit_config_uses_the_config() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) - .expect("explicit config resolves"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.stale(), None); -} - -/// A probe whose discovery-file read fails: a directory sits where -/// `gateway.json` belongs, so the read errors instead of answering. -fn probe_read_failure(run_dir: &Path) -> Result { - std::fs::create_dir(run_dir.join("gateway.json")).expect("the unreadable file plants"); - crate::discovery::resolve_for_test(run_dir, &own_image_name()) -} - -#[test] -fn a_probe_io_failure_degrades_to_the_config_fallback() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_read_failure) - .expect("a probe failure never fails startup on its own"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.base_url(), "http://gateway.lan:9999"); - assert_eq!(resolved.stale(), None, "nothing was removed"); -} - -#[test] -fn a_probe_io_failure_with_no_explicit_config_is_the_plain_error() { - let dir = tempfile::TempDir::new().expect("tempdir"); - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(Some(dir.path()), &config, probe_read_failure) - .expect_err("no explicit config remains after a probe failure"); - assert!( - error - .to_string() - .contains("no gateway configured or running"), - "the error says it plainly: {error}" - ); - assert_eq!( - error.stale(), - None, - "a probe failure does not remove the file" - ); -} - -#[test] -fn no_run_directory_skips_discovery() { - // The production probe stands in: with no run directory it is - // never called, so resolution is the config fallback or the plain - // error, and the real run directory is never consulted. - let resolved = resolve_with(None, &explicit_config(), probe) - .expect("explicit config resolves without a run directory"); - assert_eq!(resolved.source(), GatewaySource::Config); - assert_eq!(resolved.stale(), None); - - let config = GatewayConfig { - base_url: String::new(), - api_key: String::new(), - }; - let error = resolve_with(None, &config, probe) - .expect_err("no run directory and no explicit config is the plain error"); - assert!( - error - .to_string() - .contains("no gateway configured or running"), - "the error says it plainly: {error}" - ); -} - -#[test] -fn the_report_names_the_winning_source_and_a_removed_file() { - // A recording status sink stands in for the status bus: the report's - // frames land on it through the registry's push facade. - let registry = workshop_registry::Registry::new(); - let (status_tx, mut receiver) = tokio::sync::broadcast::channel(16); - let _sink = registry.register_sink::(std::sync::Arc::new( - workshop_registry::StatusSinkAdapter::new(move |update| { - let _ = status_tx.send(update); - }), - )); - let push = registry.push(); - - let resolved = ResolvedGateway { - base_url: "http://127.0.0.1:4000".to_owned(), - api_key: "k".to_owned(), - identity: None, - source: GatewaySource::Config, - stale: Some(StaleReason::KeyRejected), - }; - report(&resolved, &push); - - let stale_frame = receiver.try_recv().expect("the stale note is reported"); - assert_eq!(stale_frame.label, "Stale gateway discovery file"); - assert!( - stale_frame.description.contains("key was rejected"), - "the stale reason is named: {}", - stale_frame.description - ); - let connecting = receiver.try_recv().expect("the winning source is reported"); - assert_eq!(connecting.label, "Connecting to gateway"); - assert!( - connecting.description.contains("http://127.0.0.1:4000") - && connecting.description.contains("workshop.toml"), - "the endpoint and its source are named: {}", - connecting.description - ); -} - -#[test] -fn debug_redacts_the_api_key() { - let resolved = ResolvedGateway { - base_url: "http://127.0.0.1:4000".to_owned(), - api_key: "secret-key".to_owned(), - identity: None, - source: GatewaySource::Config, - stale: None, - }; - let rendered = format!("{resolved:?}"); - assert!(!rendered.contains("secret-key"), "key leaked: {rendered}"); -} diff --git a/crates/workshop/gateway/src/resolve.rs b/crates/workshop/gateway/src/resolve.rs deleted file mode 100644 index ca2ca4d05..000000000 --- a/crates/workshop/gateway/src/resolve.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Gateway endpoint resolution: a live gateway discovery file in the run -//! directory first, explicit `[gateway]` config second. -//! -//! The Gateway writes `gateway.json` after a successful bind (its own -//! `runvil-gateway` repository owns the writer), so a workshop that finds a -//! live file attaches to that gateway - loopback, WSL, or LAN become one -//! topology. A stale file -//! is removed by the probe with its reason reported, and explicit config -//! takes over; with no live file and no explicit config there is nothing -//! to connect to, which is the plain [`ResolveError`]. - -use std::fmt; -use std::path::Path; - -use crate::discovery::{Resolution, SidecarError, StaleReason, ValidatedConnection}; - -use workshop_protocol::Activity; -use workshop_registry::Push; -use workshop_support::GatewayConfig; - -/// The gateway endpoint state construction connects to, and how it was -/// found. -#[derive(Clone, PartialEq, Eq)] -pub struct ResolvedGateway { - base_url: String, - api_key: String, - identity: Option, - source: GatewaySource, - stale: Option, -} - -// Manual so the bearer key is never written to logs. -impl fmt::Debug for ResolvedGateway { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ResolvedGateway") - .field("base_url", &self.base_url) - .field("api_key", &"") - .field("identity", &self.identity) - .field("source", &self.source) - .field("stale", &self.stale) - .finish() - } -} - -/// Which source won gateway endpoint resolution. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[non_exhaustive] -pub enum GatewaySource { - /// A live `gateway.json` gateway discovery file in the run directory. - GatewayDiscoveryFile, - /// Explicit `[gateway]` settings from `workshop.toml`. - Config, -} - -impl ResolvedGateway { - /// The endpoint explicit config names, with no discovery: the bypass - /// for an embedding binary that already holds its gateway endpoint, or - /// a test fixture. - #[must_use] - pub fn from_config(config: &GatewayConfig) -> Self { - Self { - base_url: config.base_url.clone(), - api_key: config.api_key.clone(), - identity: None, - source: GatewaySource::Config, - stale: None, - } - } - - /// The endpoint a validated local gateway boot published, for a test - /// fixture holding its own validated identity. - #[cfg(feature = "test-fixtures")] - #[must_use] - pub fn from_validated(identity: ValidatedConnection) -> Self { - Self { - base_url: format!("http://127.0.0.1:{}", identity.port()), - api_key: identity.api_key().to_owned(), - identity: Some(identity), - source: GatewaySource::GatewayDiscoveryFile, - stale: None, - } - } - - /// The same resolution with its API served from `base_url` instead of - /// the identity's own port: a test setup whose shutdown authority (the - /// validated fixture process) and API surface (a mock router) are two - /// processes. - #[cfg(feature = "test-fixtures")] - #[must_use] - pub fn with_base_url(self, base_url: &str) -> Self { - Self { - base_url: base_url.trim_end_matches('/').to_owned(), - ..self - } - } - - /// The resolved base URL, for example `http://127.0.0.1:8081`. - #[must_use] - pub fn base_url(&self) -> &str { - &self.base_url - } - - /// The resolved bearer key. - #[must_use] - pub fn api_key(&self) -> &str { - &self.api_key - } - - /// The validated local gateway boot, when discovery won. - #[must_use] - pub fn identity(&self) -> Option<&ValidatedConnection> { - self.identity.as_ref() - } - - /// Which source won the resolution. - #[cfg(test)] - #[must_use] - fn source(&self) -> GatewaySource { - self.source - } - - /// Why a gateway discovery file was removed on the way to the config - /// fallback, when one was. - #[must_use] - fn stale(&self) -> Option { - self.stale - } - - /// The winning source rendered for the status bar and the log. - #[must_use] - fn source_label(&self) -> &'static str { - match self.source { - GatewaySource::GatewayDiscoveryFile => "gateway discovery file", - GatewaySource::Config => "workshop.toml", - } - } -} - -/// Gateway endpoint resolution found nothing to connect to. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[non_exhaustive] -#[error("no gateway configured or running{detail}")] -pub struct ResolveError { - /// The rendered suffix: the stale-file note and the remedy. - detail: String, - /// Why the gateway discovery file was removed, when one was. - stale: Option, -} - -impl ResolveError { - /// The failure with the stale-file note rendered in, when a file was - /// removed on the way. - fn new(stale: Option) -> Self { - let note = stale - .map(|reason| { - format!( - " (removed a stale gateway discovery file: {})", - stale_clause(reason) - ) - }) - .unwrap_or_default(); - Self { - detail: format!( - "{note}; start runvil-gateway or set [gateway] base_url and api_key in workshop.toml" - ), - stale, - } - } - - /// Why the gateway discovery file was removed, when one was: a wrong key, - /// a dead pid, and a foreign image are different problems for the - /// operator. - #[must_use] - pub fn stale(&self) -> Option { - self.stale - } -} - -/// Resolves the gateway endpoint for a loaded config: the live gateway -/// discovery file in the default run directory first, explicit `[gateway]` -/// config second. -/// -/// # Errors -/// Returns [`ResolveError`] when no live gateway discovery file exists and the -/// config names no explicit gateway. -pub fn resolve(config: &GatewayConfig) -> Result { - resolve_with( - crate::discovery::default_run_dir().as_deref(), - config, - probe, - ) -} - -/// The production probe: the fork's stale-detecting resolve. -fn probe(run_dir: &Path) -> Result { - crate::discovery::resolve(run_dir) -} - -/// `resolve` against an explicit run directory and probe, so tests point -/// at a tempdir and at a probe that accepts the test binary's own image. -fn resolve_with( - run_dir: Option<&Path>, - config: &GatewayConfig, - probe: fn(&Path) -> Result, -) -> Result { - let mut stale = None; - if let Some(run_dir) = run_dir { - match probe(run_dir) { - Ok(Resolution::Attach(file)) => match validate_resolved(file) { - Ok(identity) => { - return Ok(ResolvedGateway { - base_url: format!("http://127.0.0.1:{}", identity.port()), - api_key: identity.api_key().to_owned(), - identity: Some(identity), - source: GatewaySource::GatewayDiscoveryFile, - stale: None, - }); - } - Err(reason) => stale = Some(reason), - }, - Ok(Resolution::Stale(reason)) => { - tracing::warn!( - reason = stale_clause(reason), - "removed a stale gateway discovery file" - ); - stale = Some(reason); - } - // A read or cleanup I/O failure degrades discovery to the - // config fallback; it never fails startup on its own. - Err(error) => { - tracing::warn!("could not resolve the gateway discovery file: {error}"); - } - // Absent, or any other resolution: nothing to attach to. - _ => {} - } - } - if is_explicit(config) { - return Ok(ResolvedGateway { - base_url: config.base_url.clone(), - api_key: config.api_key.clone(), - identity: None, - source: GatewaySource::Config, - stale, - }); - } - Err(ResolveError::new(stale)) -} - -/// Reifies the shared resolver's live result as the capability stored in -/// the workshop's immutable gateway snapshot. -fn validate_resolved( - file: crate::discovery::GatewayDiscoveryFile, -) -> Result { - ValidatedConnection::validate(file) -} - -/// Reports the resolution outcome where the server surfaces startup state: -/// a removed file's reason and the winning source on the status bus, -/// the same facts in the log. -pub fn report(gateway: &ResolvedGateway, push: &Push) { - if let Some(reason) = gateway.stale() { - push.push_status_update( - "Stale gateway discovery file", - format!("{}; attaching from workshop.toml", stale_clause(reason)), - Activity::General, - ); - } - push.push_status_update( - "Connecting to gateway", - format!( - "base URL {} ({})", - gateway.base_url(), - gateway.source_label() - ), - Activity::General, - ); - tracing::info!( - base_url = %gateway.base_url(), - source = gateway.source_label(), - "gateway endpoint resolved" - ); -} - -/// Whether the config names a gateway itself: a non-empty `base_url` is -/// explicit, an empty one (unset, or an unset `${RUNVIL_GATEWAY_URL}` -/// interpolation) is not. The explicit fallback exists for the gateways -/// discovery cannot see - a LAN gateway; a local gateway writes a -/// gateway discovery file, which discovery finds first. -fn is_explicit(config: &GatewayConfig) -> bool { - !config.base_url.is_empty() -} - -/// Renders a stale reason as a user-facing clause: a wrong key, a dead -/// pid, and a foreign image are different problems for the operator. -fn stale_clause(reason: StaleReason) -> &'static str { - match reason { - StaleReason::Invalid => "the file was not valid", - StaleReason::ProcessDead => "the recorded gateway process is dead", - StaleReason::ImageMismatch => "the recorded pid belongs to another program", - StaleReason::HealthFailed => "the recorded gateway does not answer", - StaleReason::KeyRejected => "the file's key was rejected", - _ => "the file is stale", - } -} - -#[cfg(test)] -#[path = "resolve-tests.rs"] -mod tests; diff --git a/crates/workshop/gateway/src/test_gateway-process.rs b/crates/workshop/gateway/src/test_gateway-process.rs deleted file mode 100644 index bba435cd9..000000000 --- a/crates/workshop/gateway/src/test_gateway-process.rs +++ /dev/null @@ -1,127 +0,0 @@ -//! Child-process protocol for the named local gateway fixture. - -use std::io::{Read as _, Write as _}; -use std::net::{TcpListener, TcpStream}; - -use super::{CONTROL_ADDRESS_ENV, EXPECTED_KEY_ENV, INSTANCE_LEASE_RUN_DIR_ENV}; - -/// Serves health, bearer, and shutdown requests inside the named child. -#[expect( - clippy::expect_used, - reason = "the isolated fixture process fails immediately with the failed invariant" -)] -pub(super) fn run() { - let Ok(control_address) = std::env::var(CONTROL_ADDRESS_ENV) else { - return; - }; - let _instance_lease = std::env::var_os(INSTANCE_LEASE_RUN_DIR_ENV).map(|run_dir| { - crate::discovery::GatewayInstanceLease::try_acquire(std::path::Path::new(&run_dir)) - .expect("acquire named fixture process lease") - .expect("the named fixture is the process lease owner") - }); - let expected_key = - std::env::var(EXPECTED_KEY_ENV).expect("the fixture child receives an expected key"); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind named fixture Gateway"); - let port = listener - .local_addr() - .expect("read named fixture address") - .port(); - let mut control = TcpStream::connect(control_address).expect("connect fixture control"); - control - .write_all(&port.to_be_bytes()) - .expect("announce named fixture readiness"); - - for stream in listener.incoming() { - let mut stream = stream.expect("accept named fixture request"); - let expected_key = expected_key.clone(); - let mut connection_control = control - .try_clone() - .expect("clone the fixture control connection"); - std::thread::spawn(move || { - while let Some(shutdown) = answer_request(&mut stream, expected_key.as_bytes()) { - if shutdown { - connection_control - .write_all(&[1]) - .expect("report the accepted shutdown request"); - } - } - }); - } -} - -fn answer_request(stream: &mut TcpStream, expected_key: &[u8]) -> Option { - let mut buffer = [0_u8; 4096]; - let Ok(read) = stream.read(&mut buffer) else { - return None; - }; - if read == 0 { - return None; - } - let request = &buffer[..read]; - let accepted = request.starts_with(b"GET /health ") || has_bearer(request, expected_key); - let response = if accepted { - "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" - } else { - "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n" - }; - stream - .write_all(response.as_bytes()) - .is_ok() - .then(|| accepted && request.starts_with(b"POST /shutdown ")) -} - -fn has_bearer(request: &[u8], expected_key: &[u8]) -> bool { - request.split(|byte| *byte == b'\n').any(|line| { - let line = line.strip_suffix(b"\r").unwrap_or(line); - let Some(colon) = line.iter().position(|byte| *byte == b':') else { - return false; - }; - if !line[..colon].eq_ignore_ascii_case(b"authorization") { - return false; - } - let value = line[colon + 1..].trim_ascii_start(); - let Some(space) = value.iter().position(|byte| *byte == b' ') else { - return false; - }; - value[..space].eq_ignore_ascii_case(b"bearer") && &value[space + 1..] == expected_key - }) -} - -#[test] -fn bearer_credentials_are_byte_exact_and_case_sensitive() { - let expected = b"CaseSensitive-Key"; - assert!(has_bearer( - b"GET / HTTP/1.1\r\naUtHoRiZaTiOn: bEaReR CaseSensitive-Key\r\n\r\n", - expected - )); - - let listener = TcpListener::bind("127.0.0.1:0").expect("bind credential regression"); - let address = listener.local_addr().expect("read regression address"); - let client = std::thread::spawn(move || { - let mut stream = TcpStream::connect(address).expect("connect credential regression"); - stream - .write_all( - b"GET /protected HTTP/1.1\r\nAuthorization: Bearer casesensitive-key\r\n\r\n", - ) - .expect("send wrong-case credential"); - stream - .shutdown(std::net::Shutdown::Write) - .expect("finish fixture request"); - let mut response = String::new(); - stream - .read_to_string(&mut response) - .expect("read fixture response"); - response - }); - let (mut server, _) = listener.accept().expect("accept credential regression"); - assert!( - !answer_request(&mut server, expected).expect("the fixture answers the request"), - "an unauthorized request cannot report shutdown" - ); - drop(server); - let response = client.join().expect("credential regression client joins"); - assert!( - response.starts_with("HTTP/1.1 401 Unauthorized"), - "the shared fixture rejects a bearer that differs only by case: {response}" - ); -} diff --git a/crates/workshop/gateway/src/test_gateway.rs b/crates/workshop/gateway/src/test_gateway.rs deleted file mode 100644 index f8a72091b..000000000 --- a/crates/workshop/gateway/src/test_gateway.rs +++ /dev/null @@ -1,335 +0,0 @@ -//! Gateway test fixtures: the named local gateway process shared by -//! capability and supervision tests, and a stalled TCP stub for the -//! timeout tests. - -use std::io::Read; -use std::net::{TcpListener, TcpStream}; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; - -use crate::discovery::{GatewayDiscoveryFile, ValidatedConnection}; - -#[path = "test_gateway-process.rs"] -mod process; - -#[cfg(windows)] -const GATEWAY_EXE_NAME: &str = "runvil-gateway.exe"; -#[cfg(not(windows))] -const GATEWAY_EXE_NAME: &str = "runvil-gateway"; - -const CONTROL_ADDRESS_ENV: &str = "RUNVIL_TEST_GATEWAY_CONTROL_ADDRESS"; -const EXPECTED_KEY_ENV: &str = "RUNVIL_TEST_GATEWAY_EXPECTED_KEY"; -const INSTANCE_LEASE_RUN_DIR_ENV: &str = "RUNVIL_TEST_GATEWAY_INSTANCE_LEASE_RUN_DIR"; - -/// A named child gateway that passes production process-image validation. -#[derive(Debug)] -pub struct ValidatedGateway { - child: Child, - port: u16, - control: TcpStream, - _directory: tempfile::TempDir, -} - -impl ValidatedGateway { - #[cfg(test)] - pub(crate) fn spawn(expected_key: &str) -> Self { - Self::spawn_in_with_lease( - expected_key, - "test_gateway::validated_gateway_fixture_process", - None, - ) - } - - #[cfg(test)] - fn spawn_with_instance_lease(expected_key: &str, run_dir: &std::path::Path) -> Self { - Self::spawn_in_with_lease( - expected_key, - "test_gateway::validated_gateway_fixture_process", - Some(run_dir), - ) - } - - /// Starts the fixture in the current test binary through `fixture_test`. - /// - /// The named ignored test must call - /// [`run_validated_gateway_fixture_process`]. - /// - /// # Panics - /// Panics when the fixture listener, copied test image, child process, or - /// readiness handshake cannot be created. - #[must_use] - pub fn spawn_in(expected_key: &str, fixture_test: &str) -> Self { - Self::spawn_in_with_lease(expected_key, fixture_test, None) - } - - #[expect( - clippy::expect_used, - reason = "test fixture setup fails immediately with the failed invariant" - )] - fn spawn_in_with_lease( - expected_key: &str, - fixture_test: &str, - lease_run_dir: Option<&std::path::Path>, - ) -> Self { - let control = TcpListener::bind("127.0.0.1:0").expect("bind fixture control"); - control - .set_nonblocking(true) - .expect("make fixture control nonblocking"); - let directory = tempfile::TempDir::new().expect("create fixture executable directory"); - let executable = directory.path().join(GATEWAY_EXE_NAME); - std::fs::copy( - std::env::current_exe().expect("locate test executable"), - &executable, - ) - .expect("copy test executable under the Gateway image name"); - let mut command = Command::new(&executable); - command - .args(["--exact", fixture_test, "--ignored"]) - .env( - CONTROL_ADDRESS_ENV, - control - .local_addr() - .expect("read fixture control address") - .to_string(), - ) - .env(EXPECTED_KEY_ENV, expected_key) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - if let Some(run_dir) = lease_run_dir { - command.env(INSTANCE_LEASE_RUN_DIR_ENV, run_dir); - } - let mut child = command - .spawn() - .expect("start the named Gateway fixture process"); - let deadline = Instant::now() + Duration::from_secs(10); - let mut stream = loop { - match control.accept() { - Ok((stream, _)) => break stream, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - child.try_wait().expect("observe fixture process").is_none(), - "the named Gateway fixture exited before becoming ready" - ); - assert!( - Instant::now() < deadline, - "the named Gateway fixture did not become ready" - ); - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("accept fixture control connection: {error}"), - } - }; - stream - .set_nonblocking(false) - .expect("make accepted fixture control blocking"); - let mut port = [0_u8; 2]; - stream - .read_exact(&mut port) - .expect("read fixture Gateway port"); - Self { - child, - port: u16::from_be_bytes(port), - control: stream, - _directory: directory, - } - } - - /// Returns the fixture's loopback port. - #[must_use] - pub const fn port(&self) -> u16 { - self.port - } - - /// Produces a production-validated capability for this child. - /// - /// # Panics - /// Panics when the supplied bearer or boot identity does not validate - /// against the running fixture. - #[must_use] - #[expect( - clippy::expect_used, - reason = "test fixture validation fails immediately with the failed invariant" - )] - pub fn validate(&self, api_key: &str, epoch: u64, started_at: &str) -> ValidatedConnection { - ValidatedConnection::validate(self.gateway_discovery_file(api_key, epoch, started_at)) - .expect("the named local Gateway validates") - } - - /// Builds a gateway discovery file naming this child and the supplied boot data. - #[must_use] - pub fn gateway_discovery_file( - &self, - api_key: &str, - epoch: u64, - started_at: &str, - ) -> GatewayDiscoveryFile { - GatewayDiscoveryFile { - port: self.port, - api_key: api_key.to_owned(), - pid: self.child.id(), - epoch, - version: "test".to_owned(), - started_at: started_at.to_owned(), - } - } - - /// Waits up to `timeout` for an authenticated shutdown request. - /// - /// # Panics - /// Panics when the control socket cannot be configured or read, or when - /// the child sends an invalid control marker. - #[expect( - clippy::expect_used, - reason = "test fixture observation fails immediately with the failed invariant" - )] - pub fn received_shutdown(&mut self, timeout: Duration) -> bool { - self.control - .set_read_timeout(Some(timeout)) - .expect("set fixture control timeout"); - let mut marker = [0_u8; 1]; - match self.control.read_exact(&mut marker) { - Ok(()) => { - assert_eq!(marker, [1], "the fixture reports only shutdown requests"); - true - } - Err(error) - if matches!( - error.kind(), - std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut - ) => - { - false - } - Err(error) => panic!("read fixture shutdown marker: {error}"), - } - } - - #[cfg(test)] - fn terminate_within(&mut self, timeout: Duration) { - if self - .child - .try_wait() - .expect("observe named Gateway fixture") - .is_none() - { - let _ = self.child.kill(); - } - let deadline = Instant::now() + timeout; - loop { - if self - .child - .try_wait() - .expect("observe named Gateway fixture") - .is_some() - { - return; - } - assert!( - Instant::now() < deadline, - "the named Gateway fixture did not stop within {timeout:?}" - ); - std::thread::sleep(Duration::from_millis(10)); - } - } -} - -impl Drop for ValidatedGateway { - fn drop(&mut self) { - if self.child.try_wait().ok().flatten().is_none() { - let _ = self.child.kill(); - } - let deadline = Instant::now() + Duration::from_secs(5); - while self.child.try_wait().ok().flatten().is_none() && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(10)); - } - } -} - -/// Binds a loopback stub that completes TCP handshakes and never answers, -/// modeling a gateway that is up but wedged. Returns its base URL and a -/// handle notified as the stub takes each connection. -#[cfg(test)] -pub(crate) async fn stalled_gateway() -> (String, std::sync::Arc) { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind stalled stub"); - let addr = listener.local_addr().expect("stalled stub address"); - let accepted = std::sync::Arc::new(tokio::sync::Notify::new()); - let notify = std::sync::Arc::clone(&accepted); - tokio::spawn(async move { - // Sockets are held open and never answered until the test's - // runtime tears the task down. - let mut held = Vec::new(); - while let Ok((socket, _)) = listener.accept().await { - held.push(socket); - notify.notify_one(); - } - }); - (format!("http://{addr}"), accepted) -} - -#[cfg(test)] -#[test] -#[ignore = "runs only as a named child process"] -fn validated_gateway_fixture_process() { - run_validated_gateway_fixture_process(); -} - -#[cfg(test)] -#[test] -fn received_shutdown_waits_for_a_delayed_marker() { - let mut gateway = ValidatedGateway::spawn("fixture-key"); - let validated = gateway.validate("fixture-key", 1_778_000_001, "2026-09-07T18:00:01Z"); - let delay = Duration::from_millis(100); - let started = Instant::now(); - - std::thread::scope(|scope| { - scope.spawn(move || { - std::thread::sleep(delay); - crate::discovery::request_shutdown(&validated) - .expect("the delayed authenticated shutdown is accepted"); - }); - assert!( - gateway.received_shutdown(Duration::from_secs(2)), - "the control stream waits for the delayed shutdown marker" - ); - }); - - assert!( - started.elapsed() >= delay, - "the delayed marker cannot be reported before it is sent" - ); -} - -#[cfg(test)] -#[test] -fn a_named_fixture_releases_its_process_lease_when_terminated() { - let run_dir = tempfile::TempDir::new().expect("create lease run directory"); - let mut gateway = ValidatedGateway::spawn_with_instance_lease("fixture-key", run_dir.path()); - assert!( - crate::discovery::GatewayInstanceLease::try_acquire(run_dir.path()) - .expect("contend for the named fixture lease") - .is_none(), - "the live fixture owns the process lease" - ); - - gateway.terminate_within(Duration::from_secs(5)); - assert!( - crate::discovery::GatewayInstanceLease::try_acquire(run_dir.path()) - .expect("recover the named fixture lease") - .is_some(), - "the operating system releases the lease when the named fixture dies" - ); -} - -/// Runs the child half of [`ValidatedGateway`] inside an ignored test. -/// -/// This function never returns during a successful fixture run. -/// -/// # Panics -/// Panics when required fixture environment, listener, control connection, -/// request acceptance, or shutdown reporting cannot be established. -pub fn run_validated_gateway_fixture_process() { - process::run(); -} diff --git a/crates/workshop/gateway/src/version-tests.rs b/crates/workshop/gateway/src/version-tests.rs deleted file mode 100644 index 41a7c80da..000000000 --- a/crates/workshop/gateway/src/version-tests.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! Unit tests for the Gateway version comparison. - -use super::*; - -use std::sync::Arc; - -use tokio::sync::broadcast; - -/// A recording status sink standing in for the status bus, and the push -/// facade the report writes through. -fn status_recorder() -> ( - Push, - broadcast::Receiver, - workshop_registry::Registration, -) { - let registry = workshop_registry::Registry::new(); - let (tx, rx) = broadcast::channel(16); - let sink = registry.register_sink::(Arc::new( - workshop_registry::StatusSinkAdapter::new(move |update| { - let _ = tx.send(update); - }), - )); - (registry.push(), rx, sink) -} - -#[test] -fn parses_a_plain_three_part_version() { - assert_eq!( - Version::parse("0.4.1"), - Some(Version { - major: 0, - minor: 4, - patch: 1 - }) - ); -} - -#[test] -fn parses_a_leading_v_and_a_prerelease_suffix() { - let expected = Version::parse("1.2.3"); - assert_eq!(Version::parse("v1.2.3"), expected); - assert_eq!(Version::parse("1.2.3-rc.1"), expected); - assert_eq!(Version::parse("1.2.3+build.7"), expected); -} - -#[test] -fn a_two_part_version_treats_the_missing_patch_as_zero() { - assert_eq!(Version::parse("2.5"), Version::parse("2.5.0")); -} - -#[test] -fn a_version_that_is_not_numbers_is_unreadable() { - for text in ["", " ", "unknown", "1.x.0", "1.2.3.4", "-1.0.0"] { - assert_eq!(Version::parse(text), None, "{text}"); - } -} - -#[test] -fn versions_compare_numerically_not_as_strings() { - let older = Version::parse("0.9.0").expect("a version"); - let newer = Version::parse("0.10.0").expect("a version"); - assert!(newer > older, "0.10.0 is newer than 0.9.0"); - assert!(newer.is_at_least(older)); - assert!(!older.is_at_least(newer)); - assert_eq!(older.to_string(), "0.9.0"); -} - -#[test] -fn an_equal_version_satisfies_the_minimum() { - assert_eq!( - VersionCheck::compare("0.4.0", "0.4.0"), - VersionCheck::Satisfied { - running: Version::parse("0.4.0").expect("a version"), - minimum: Version::parse("0.4.0").expect("a version"), - } - ); -} - -#[test] -fn an_older_gateway_is_reported_too_old() { - let check = VersionCheck::compare("0.3.9", "0.4.0"); - assert!(matches!(check, VersionCheck::TooOld { .. }), "{check:?}"); - assert!(check.is_problem()); -} - -#[test] -fn a_newer_gateway_satisfies_the_minimum() { - assert!(!VersionCheck::compare("2.0.0", "0.4.0").is_problem()); -} - -#[test] -fn an_empty_minimum_means_the_pin_recorded_none() { - let check = VersionCheck::compare("0.1.0", ""); - assert_eq!(check, VersionCheck::NoMinimumRecorded); - assert!( - !check.is_problem(), - "a placeholder pin must not warn about every Gateway" - ); -} - -#[test] -fn an_unreadable_version_is_reported_rather_than_assumed() { - let check = VersionCheck::compare("nightly", "0.4.0"); - assert!( - matches!(check, VersionCheck::Unreadable { .. }), - "an unreadable running version is a problem, not a pass: {check:?}" - ); - assert!(check.is_problem()); - assert!(VersionCheck::compare("0.4.0", "not a version").is_problem()); -} - -#[test] -fn a_too_old_gateway_warns_on_the_status_bar_and_names_both_versions() { - let (push, mut rx, _sink) = status_recorder(); - announce(&VersionCheck::compare("0.3.9", "0.4.0"), &push); - let frame = rx.try_recv().expect("the warning is pushed"); - assert_eq!(frame.label, "Gateway too old"); - assert!( - frame.description.contains("0.3.9") && frame.description.contains("0.4.0"), - "both versions are named: {}", - frame.description - ); -} - -#[test] -fn an_unreadable_version_warns_on_the_status_bar() { - let (push, mut rx, _sink) = status_recorder(); - announce(&VersionCheck::compare("nightly", "0.4.0"), &push); - let frame = rx.try_recv().expect("the warning is pushed"); - assert_eq!(frame.label, "Gateway version unknown"); - assert!( - frame.description.contains("nightly"), - "{}", - frame.description - ); -} - -#[test] -fn a_satisfied_check_says_nothing_on_the_status_bar() { - let (push, mut rx, _sink) = status_recorder(); - announce(&VersionCheck::compare("0.4.0", "0.4.0"), &push); - assert!( - rx.try_recv().is_err(), - "a healthy boot pushes no status line" - ); -} - -#[test] -fn an_unfilled_pin_warns_on_the_status_bar_in_a_release_build() { - let (push, mut rx, _sink) = status_recorder(); - announce_for(&VersionCheck::NoMinimumRecorded, &push, Build::Release); - let frame = rx.try_recv().expect("the unfilled pin is pushed"); - assert_eq!(frame.label, "Gateway version unchecked"); - assert!( - frame.description.contains("no minimum version"), - "the description names the cause: {}", - frame.description - ); - assert!( - frame.description.contains("nothing checks"), - "the description names the consequence: {}", - frame.description - ); -} - -#[test] -fn an_unfilled_pin_stays_off_the_status_bar_in_a_debug_build() { - let (push, mut rx, _sink) = status_recorder(); - announce_for(&VersionCheck::NoMinimumRecorded, &push, Build::Debug); - assert!( - rx.try_recv().is_err(), - "a developer's checkout must not repeat this line on every launch" - ); -} - -#[test] -fn the_public_entry_point_follows_the_build_it_was_compiled_into() { - let (push, mut rx, _sink) = status_recorder(); - announce(&VersionCheck::NoMinimumRecorded, &push); - if cfg!(debug_assertions) { - assert!( - rx.try_recv().is_err(), - "this test build is a debug build, so the pin reaches the log only" - ); - } else { - let frame = rx - .try_recv() - .expect("a release build shows the unfilled pin"); - assert_eq!(frame.label, "Gateway version unchecked"); - } -} - -#[test] -fn the_pin_minimum_is_the_compiled_default() { - // Whatever the pin holds, the constant is its value, never a guess. A - // placeholder becomes an empty minimum, which the comparison reads as - // "no minimum recorded" rather than as a version to enforce. - assert!( - MIN_GATEWAY.is_empty() || Version::parse(MIN_GATEWAY).is_some(), - "the compiled minimum is empty or a version: {MIN_GATEWAY}" - ); - assert!( - !MIN_GATEWAY.contains("PLACEHOLDER"), - "a placeholder must never become a minimum to compare against" - ); - assert_eq!( - VersionCheck::against_pin("0.0.1"), - VersionCheck::compare("0.0.1", MIN_GATEWAY) - ); -} diff --git a/crates/workshop/gateway/src/version.rs b/crates/workshop/gateway/src/version.rs deleted file mode 100644 index 70553924d..000000000 --- a/crates/workshop/gateway/src/version.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! The Gateway version check: does the Gateway Workshop attached to meet -//! the lowest release the pin allows? -//! -//! Workshop and the Gateway ship as separate releases, so a running -//! Gateway may be older than the pin. Nothing else proves the API surface -//! matches: the pin's `min_gateway` is the lowest release with every -//! operation Workshop calls, so a Gateway below it is missing calls that -//! exist only in later releases. -//! -//! Both versions are logged, and a Gateway below the minimum is announced -//! on the status bar through the same push facade every other startup -//! phase uses. The versions are compared as dotted numbers, never as -//! strings: `0.10.0` is newer than `0.9.0`, and a string compare says the -//! opposite. -//! -//! A pin that records no minimum turns the check off, which is a problem -//! with the build rather than with the Gateway. A release build says so on -//! the status bar: nothing then guards the Gateway it ships. A debug build -//! says so in the log only, because it reaches that state on every launch -//! while the pin is still a placeholder, and a status line for it would be -//! noise a developer learns to ignore. - -use std::cmp::Ordering; -use std::fmt; - -use workshop_protocol::Activity; -use workshop_registry::Push; - -/// The `min_gateway` value the pin recorded when Workshop was built, or -/// an empty string when the pin was still a placeholder. -pub const MIN_GATEWAY: &str = env!("RUNVIL_MIN_GATEWAY"); - -/// A dotted version: a major, a minor, and a patch number. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Version { - major: u64, - minor: u64, - patch: u64, -} - -impl Version { - /// Parses `major.minor.patch`, tolerating a leading `v` and ignoring a - /// prerelease or build suffix. Returns `None` for anything else, so a - /// version nobody can read is never silently treated as satisfying a - /// minimum. - #[must_use] - pub fn parse(text: &str) -> Option { - let trimmed = text.trim().trim_start_matches('v'); - let core = trimmed - .split(['-', '+']) - .next() - .filter(|core| !core.is_empty())?; - let mut parts = core.split('.'); - let major = parts.next()?.parse().ok()?; - let minor = parts.next()?.parse().ok()?; - let patch = parts.next().unwrap_or("0").parse().ok()?; - if parts.next().is_some() { - return None; - } - Some(Self { - major, - minor, - patch, - }) - } - - /// Whether this version is at least `floor`. - #[must_use] - pub fn is_at_least(self, floor: Self) -> bool { - self >= floor - } -} - -impl Ord for Version { - fn cmp(&self, other: &Self) -> Ordering { - (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) - } -} - -impl PartialOrd for Version { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl fmt::Display for Version { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}.{}.{}", self.major, self.minor, self.patch) - } -} - -/// What the check found. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VersionCheck { - /// The pin recorded no minimum, because it is still a placeholder. - NoMinimumRecorded, - /// The running Gateway meets the pin's minimum. - Satisfied { - /// The Gateway's version, as the discovery file reported it. - running: Version, - /// The pin's `min_gateway`. - minimum: Version, - }, - /// The running Gateway is older than the pin's minimum. - TooOld { - /// The Gateway's version, as the discovery file reported it. - running: Version, - /// The pin's `min_gateway`. - minimum: Version, - }, - /// Either version could not be read as numbers. Reported rather than - /// assumed either way. - Unreadable { - /// The Gateway's version, verbatim. - running: String, - /// The pin's `min_gateway`, verbatim. - minimum: String, - }, -} - -impl VersionCheck { - /// Compares a running Gateway's version against a minimum, both as - /// text. An empty `minimum` means the pin recorded none. - #[must_use] - pub fn compare(running: &str, minimum: &str) -> Self { - if minimum.trim().is_empty() { - return Self::NoMinimumRecorded; - } - match (Version::parse(running), Version::parse(minimum)) { - (Some(running), Some(minimum)) if running.is_at_least(minimum) => { - Self::Satisfied { running, minimum } - } - (Some(running), Some(minimum)) => Self::TooOld { running, minimum }, - _ => Self::Unreadable { - running: running.to_owned(), - minimum: minimum.to_owned(), - }, - } - } - - /// The check against the pin's recorded minimum. - #[must_use] - pub fn against_pin(running: &str) -> Self { - Self::compare(running, MIN_GATEWAY) - } - - /// Whether this check should be shown to the user. - #[must_use] - pub fn is_problem(&self) -> bool { - matches!(self, Self::TooOld { .. } | Self::Unreadable { .. }) - } -} - -/// Logs both versions and, when the running Gateway is below the pin's -/// minimum or its version cannot be read, warns on the status bar. A -/// satisfied check logs and says nothing: every healthy boot looks like -/// that, and a status line for it would be noise. -pub fn report(running: &str, push: &Push) { - announce(&VersionCheck::against_pin(running), push); -} - -/// Logs `check` and warns on the status bar when it is a problem. Split -/// from [`report`] so a test can drive any outcome, including one the -/// committed pin cannot currently produce. -/// -/// An unfilled pin reaches the status bar in a release build only; the -/// build split lives in `announce_for`. -pub fn announce(check: &VersionCheck, push: &Push) { - announce_for(check, push, Build::current()); -} - -/// Which kind of build is asking. An unfilled pin is a defect in a -/// shipped build and a normal state in a developer's checkout, so the -/// status bar is told about the first and not the second. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum Build { - /// A debug build: the developer's own `cargo build` or `cargo test`. - Debug, - /// A release build, which is what an installer ships. - Release, -} - -impl Build { - /// The build this code was compiled into. - fn current() -> Self { - if cfg!(debug_assertions) { - Self::Debug - } else { - Self::Release - } - } -} - -/// Logs `check` and warns on the status bar when it is a problem. `build` -/// selects whether an unfilled pin also reaches the status bar. -fn announce_for(check: &VersionCheck, push: &Push, build: Build) { - match check { - VersionCheck::NoMinimumRecorded => { - tracing::warn!("the Gateway pin records no minimum version; the version check is off"); - // A release build is what an installer ships. An unfilled pin - // there leaves the app with no version guard at all, so the - // user sees it. A debug build is a developer's own checkout, - // where the pin stays a placeholder until a Gateway release - // exists, so it stays in the log. - if build == Build::Release { - push.push_status_update( - "Gateway version unchecked", - "this Workshop was built from a pin with no minimum version, so nothing \ - checks the Gateway it attaches to", - Activity::General, - ); - } - } - VersionCheck::Satisfied { running, minimum } => { - tracing::info!( - gateway_version = %running, - minimum_gateway = %minimum, - "the Gateway meets the pin's minimum version" - ); - } - VersionCheck::TooOld { running, minimum } => { - tracing::warn!( - gateway_version = %running, - minimum_gateway = %minimum, - "the Gateway is older than the pin's minimum version" - ); - push.push_status_update( - "Gateway too old", - format!( - "the running Gateway is {running} but this Workshop needs at least {minimum}; \ - install a newer Gateway" - ), - Activity::General, - ); - } - VersionCheck::Unreadable { running, minimum } => { - tracing::warn!( - gateway_version = running, - minimum_gateway = minimum, - "a Gateway version could not be compared with the pin's minimum" - ); - push.push_status_update( - "Gateway version unknown", - format!( - "could not compare the running Gateway's version ({running}) with the \ - required {minimum}" - ), - Activity::General, - ); - } - } -} - -#[cfg(test)] -#[path = "version-tests.rs"] -mod tests; diff --git a/crates/workshop/look/AGENTS.md b/crates/workshop/look/AGENTS.md deleted file mode 100644 index 127fd3ffe..000000000 --- a/crates/workshop/look/AGENTS.md +++ /dev/null @@ -1,7 +0,0 @@ -# look - -`@workshop/look`, the Workshop family's visual layer. It was forked from `crates/shared-ui` on 2026-09-27 and has no sync obligation: changes here never need to reach `shared-ui`, and `shared-ui` changes never need to reach here. - -- Components stay app-agnostic. They own no timer or document listener that outlives their element, and consumers own integration lifecycle. -- Focus follows Cursor: list rows, tree rows, menu rows, and inputs draw a 1px `--cursor-stroke-focused` outline at offset -1px (`--focus-outline`), and everything else uses the matching state background or opacity behavior. Do not invent a ring or a focus-like shadow. -- Keep third-party derivation notices and their source comments intact. diff --git a/crates/workshop/look/THIRD_PARTY_NOTICES.md b/crates/workshop/look/THIRD_PARTY_NOTICES.md deleted file mode 100644 index 1307f3fca..000000000 --- a/crates/workshop/look/THIRD_PARTY_NOTICES.md +++ /dev/null @@ -1,44 +0,0 @@ -# Third-Party Notices - -Source in this directory that derives from another project, with the notice its license requires. - -## Codicons - -`icons.ts` holds the artwork of 31 icons from the Visual Studio Code codicon set - add, agent, arrow-up, ask, bug, check, checklist, chevron-down, chevron-right, close, copy, ellipsis, error, file, file-code, file-media, file-text, folder, gear, globe, info, layers, layout-sidebar-left, layout-sidebar-right, link, mic, new-folder, stop-circle, symbol-keyword, trash, and warning - as inline SVG strings. Each string is the icon's `src/icons/.svg` file from `@vscode/codicons` with only the `width` and `height` attributes changed. `@vscode/codicons` is a `devDependency` that only `test/icons.mjs` reads, to check every string against its source file. - -- Project: -- Package: `@vscode/codicons` 0.0.46-24 -- Copyright: (c) Microsoft Corporation -- License: Creative Commons Attribution 4.0 International (CC-BY-4.0), . The codicon font and icon artwork are licensed CC-BY-4.0; the package's code is MIT. -- Changes: the `width` and `height` attributes of each string are set to the size the consumer needs. The paths are unmodified. - -## murm-ui - -`dropdown.ts` and `dropdown.css` are ported from the `components/dropdown.ts` and `styles/dropdown.css` files of murm-ui 0.2.0 (commit `336ff7db79d928373e83c3672db6041a0adbc868`), cut to the shared action-menu's needs and restyled onto the Cursor Dark tokens. (Moved here from the workshop UI package, now `crates/workshop/ui/src/parts/`, where the port first landed.) - -- Project: -- License: MIT - -```text -MIT License - -Copyright (c) 2026 Lev Morozov - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -``` diff --git a/crates/workshop/look/controls.css b/crates/workshop/look/controls.css deleted file mode 100644 index 6639d3dfe..000000000 --- a/crates/workshop/look/controls.css +++ /dev/null @@ -1,229 +0,0 @@ -/* Button, input, select, and switch base classes for the Workshop family's - UIs, on the Cursor Dark tokens from tokens.css. The rules live in the - components cascade layer: a consuming UI that declares its own layer - order keeps its overrides winning on specificity, and a consuming UI - with no layers (the workshop) lets its unlayered rules override these. - The menu scrollbar rules at the end are the one exception. - - Focus follows Cursor: inputs, selects, list rows, and tree rows draw the - 1px --focus-outline inside their box (offset -1px); every other control - uses its state background. */ - -@layer components { - /* Buttons: 24px tall, 8px padding, 6px radius, 13px weight 400. -sm is - 20px tall with a 4px radius. A variant class picks the fill. */ - .button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--space-1); - height: var(--height-sm); - padding-inline: var(--space-2); - border: 1px solid transparent; - border-radius: var(--radius); - background: transparent; - color: var(--cursor-text-primary); - font-size: var(--font-size-base); - font-weight: 400; - white-space: nowrap; - cursor: pointer; - transition: background-color var(--duration-normal) ease-out, color var(--duration-normal) ease-out, - border-color var(--duration-normal) ease-out; - } - .button:disabled { - border-color: transparent; - background: var(--cursor-control-disabled-bg); - color: var(--cursor-control-disabled-text); - pointer-events: none; - } - .button-sm { - height: var(--height-xs); - border-radius: var(--radius-sm); - } - /* Dark label on Cursor's accent; hover mixes 10% white into it. */ - .button-primary { - background: var(--cursor-accent); - color: var(--cursor-text-invert); - } - .button-primary:hover { - background: var(--cursor-accent-hover); - } - .button-secondary { - background: var(--cursor-bg-tertiary); - } - .button-secondary:hover { - background: var(--cursor-bg-secondary); - } - .button-ghost { - background: transparent; - } - .button-ghost:hover { - background: var(--cursor-bg-tertiary); - } - .button-outline { - border-color: var(--cursor-stroke-secondary); - background: transparent; - } - .button-outline:hover { - background: var(--cursor-bg-quaternary); - } - .button-danger { - border-color: var(--danger); - background: color-mix(in srgb, var(--danger) 10%, transparent); - color: var(--danger-text); - } - .button-danger:hover { - background: color-mix(in srgb, var(--danger) 15%, transparent); - } - - /* Text input: 24px tall, 4px 5px padding, a 1px border at 12% that rises - to 20% on focus, a 6px radius, a 3% fill, 13px/18px text, 150ms - transitions. */ - .input { - height: var(--height-sm); - padding: var(--space-1) 5px; - border: 1px solid var(--cursor-stroke-secondary); - border-radius: var(--radius); - background: var(--cursor-bg-input-field); - color: var(--cursor-text-primary); - font-size: var(--font-size-base); - line-height: var(--line-height-base); - transition: border-color var(--duration-normal) ease, background-color var(--duration-normal) ease; - } - .input::placeholder { - color: var(--input-placeholder); - } - input::-ms-reveal { - display: none; - } - .input:focus { - border-color: var(--cursor-stroke-primary); - } - .input:disabled { - background: var(--cursor-control-disabled-bg); - color: var(--cursor-control-disabled-text); - } - textarea.input { - height: auto; - } - - /* Select trigger: 12px text, a 12% border, a 6px radius, and room on the - right for the caret. */ - .select { - display: inline-flex; - align-items: center; - justify-content: space-between; - gap: var(--space-2); - padding: 3px 2px 3px 6px; - border: 1px solid var(--cursor-stroke-secondary); - border-radius: var(--radius); - background: transparent; - color: var(--cursor-text-primary); - font-size: var(--font-size-sm); - cursor: pointer; - transition: border-color var(--duration-normal) ease, background-color var(--duration-normal) ease; - } - .select::after { - content: ""; - width: 0.4rem; - height: 0.4rem; - margin-inline-end: var(--space-1); - border-inline-end: 1px solid currentcolor; - border-block-end: 1px solid currentcolor; - transform: translateY(-0.125rem) rotate(45deg); - } - .select:hover { - background: var(--cursor-bg-quaternary); - } - .select:disabled { - background: var(--cursor-control-disabled-bg); - color: var(--cursor-control-disabled-text); - cursor: not-allowed; - } - - /* Switch, on a