diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 8191870..2d16add 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -4,13 +4,13 @@ on: workflow_call: inputs: tag: - description: "Release tag to publish (for example: v0.0.1-rc.1)" + description: "Release tag to publish (for example: v0.0.1-rc.2)" required: true type: string workflow_dispatch: inputs: tag: - description: "Release tag to publish (for example: v0.0.1-rc.1)" + description: "Release tag to publish (for example: v0.0.1-rc.2)" required: true type: string @@ -54,8 +54,6 @@ jobs: --pattern "openbitdo-${TAG}-linux-aarch64.tar.gz" \ --pattern "openbitdo-${TAG}-macos-arm64.tar.gz" \ --dir /tmp/release-input - gh api "repos/${GITHUB_REPOSITORY}/tarball/${TAG}" \ - > "/tmp/release-input/openbitdo-${TAG}-source.tar.gz" bash packaging/scripts/render_release_metadata.sh \ "$TAG" \ "$GITHUB_REPOSITORY" \ @@ -64,8 +62,6 @@ jobs: useradd -m builder chown -R builder:builder /tmp/release-metadata su builder -s /bin/bash -c "set -euo pipefail; \ - cd /tmp/release-metadata/aur/openbitdo; \ - makepkg --printsrcinfo > .SRCINFO; \ cd /tmp/release-metadata/aur/openbitdo-bin; \ makepkg --printsrcinfo > .SRCINFO" @@ -74,8 +70,6 @@ jobs: with: name: aur-rendered-metadata-${{ inputs.tag }} path: | - /tmp/release-metadata/aur/openbitdo/PKGBUILD - /tmp/release-metadata/aur/openbitdo/.SRCINFO /tmp/release-metadata/aur/openbitdo-bin/PKGBUILD /tmp/release-metadata/aur/openbitdo-bin/.SRCINFO /tmp/release-metadata/checksums.env @@ -96,7 +90,7 @@ jobs: ssh-keyscan -H aur.archlinux.org >> "$HOME/.ssh/known_hosts" chmod 644 "$HOME/.ssh/known_hosts" - - name: Publish openbitdo and openbitdo-bin + - name: Publish openbitdo-bin env: GIT_SSH_COMMAND: ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o UserKnownHostsFile=$HOME/.ssh/known_hosts -o StrictHostKeyChecking=accept-new run: | @@ -141,5 +135,4 @@ jobs: fi } - publish_pkg openbitdo publish_pkg openbitdo-bin diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be50129..4c01881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,14 +37,12 @@ jobs: run: | set -euo pipefail if grep -nE 'SKIP|:no_check' \ - packaging/aur/openbitdo/PKGBUILD \ packaging/aur/openbitdo-bin/PKGBUILD \ packaging/homebrew/Formula/openbitdo.rb; then echo "Found placeholder checksum markers; release metadata must be pinned." >&2 exit 1 fi test -f packaging/scripts/render_release_metadata.sh - test -f packaging/aur/openbitdo/PKGBUILD.tmpl test -f packaging/aur/openbitdo-bin/PKGBUILD.tmpl test -f packaging/homebrew/Formula/openbitdo.rb.tmpl - name: Validate PKGBUILD and .SRCINFO @@ -52,9 +50,6 @@ jobs: useradd -m builder chown -R builder:builder "$GITHUB_WORKSPACE" su builder -s /bin/bash -c "set -euo pipefail; \ - cd '$GITHUB_WORKSPACE/packaging/aur/openbitdo'; \ - makepkg --printsrcinfo > /tmp/openbitdo.srcinfo; \ - diff -u .SRCINFO /tmp/openbitdo.srcinfo; \ cd '$GITHUB_WORKSPACE/packaging/aur/openbitdo-bin'; \ makepkg --printsrcinfo > /tmp/openbitdo-bin.srcinfo; \ diff -u .SRCINFO /tmp/openbitdo-bin.srcinfo" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20ba7a9..c2cb0f8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -297,8 +297,6 @@ jobs: --pattern "openbitdo-${GITHUB_REF_NAME}-linux-aarch64.tar.gz" \ --pattern "openbitdo-${GITHUB_REF_NAME}-macos-arm64.tar.gz" \ --dir /tmp/release-input - gh api "repos/${GITHUB_REPOSITORY}/tarball/${GITHUB_REF_NAME}" \ - > "/tmp/release-input/openbitdo-${GITHUB_REF_NAME}-source.tar.gz" bash packaging/scripts/render_release_metadata.sh \ "${GITHUB_REF_NAME}" \ "$GITHUB_REPOSITORY" \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d486f5..f52bd3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. +## Unreleased + +### Changed +- CLI contract reverted to single-command interactive launch: + - `openbitdo [--mock]` + - subcommand forms `openbitdo ui ...` and `openbitdo run ...` are rejected. +- Headless automation output remains available in the `bitdo_tui` Rust API (human and line-delimited JSON records). +- AUR packaging/publish flow now targets `openbitdo-bin` only. +- Settings schema is now documented as v2: + - `schema_version` + - `advanced_mode` + - `report_save_mode` + - `device_filter_text` + - `dashboard_layout_mode` + - `last_panel_focus` + ## v0.0.1-rc.1 ### Added diff --git a/MIGRATION.md b/MIGRATION.md index c5adfc8..cf1d6f4 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,54 +1,57 @@ # OpenBitdo Migration Notes +## Scope +This migration restores the single-command `openbitdo` CLI contract and removes the `ui`/`run` subcommand surface from user-facing usage. + ## What changed - `bitdoctl` was removed. - `openbitdo cmd ...` was removed. -- JSON report/output flags were removed from user-facing flows. -- OpenBitdo now focuses on a single beginner entrypoint: `openbitdo`. +- `openbitdo` now launches interactive TUI directly (with optional `--mock`). +- subcommand forms `openbitdo ui ...` and `openbitdo run ...` are rejected (historical). +- headless output modes remain available through the Rust API, not the CLI. +- Settings schema moved to v2 fields while keeping compatibility defaults for v1 files. + +## Command mapping +| Prior command form | Current command | +| --- | --- | +| `cargo run -p openbitdo --` | `cargo run -p openbitdo --` | +| `cargo run -p openbitdo -- --mock` | `cargo run -p openbitdo -- --mock` | +| `openbitdo ui --mock` (historical) | `openbitdo --mock` | +| `openbitdo run ...` (historical) | Not supported in CLI | ## New usage From `/Users/brooklyn/data/8bitdo/cleanroom/sdk`: +Interactive dashboard: + ```bash cargo run -p openbitdo -- -``` - -Optional mock mode: - -```bash cargo run -p openbitdo -- --mock ``` -## Beginner flow -1. Launch `openbitdo`. -2. Select a detected device. -3. Click or choose an action: -- `Update` (guided firmware flow) -- `Diagnose` (quick readiness checks) -- `Refresh` -- `Quit` -4. Confirm with a simple `y`/`yes` prompt before firmware transfer. +## Historical note +The temporary subcommand surface (`openbitdo ui` / `openbitdo run`) is historical (historical) and should not be used for current workflows. -## Firmware behavior -- OpenBitdo first attempts a recommended firmware download. -- If download or verification fails, it immediately asks for a local firmware file (`.bin`/`.fw`). -- Detect-only devices remain blocked from firmware write operations with a clear reason. +## Headless library API +Headless automation remains available to Rust callers through `bitdo_tui`: -## New device-specific wizards -- JP108 (`0x5209`/`0x520a`): - - Dedicated button mapping for `A`, `B`, and `K1-K8` - - Auto-backup before write - - One-click restore if needed - - Guided button test text after apply -- Ultimate2 (`0x6012`/`0x6013`): - - Slot + mode + core map editing - - Auto-backup and rollback path - - Guided button test text after apply +```bash +run_headless(core, RunLaunchOptions { output_mode: HeadlessOutputMode::Json, ..Default::default() }) +``` -## CI changes -- Hardware CI split into per-family jobs: -- `hardware-dinput` (required) -- `hardware-standard64` (required) -- `hardware-ultimate2` (required) -- `hardware-108jp` (required) -- `hardware-jphandshake` (gated until fixture availability) +## Settings schema migration +Current schema is `schema_version = 2` with fields: +- `advanced_mode` +- `report_save_mode` +- `device_filter_text` +- `dashboard_layout_mode` +- `last_panel_focus` + +Compatibility behavior: +- v1 settings files load with defaults for missing v2 fields. +- if `advanced_mode = false`, `report_save_mode = off` is normalized to `failure_only`. + +## CI note +The CLI smoke coverage now validates: +- `openbitdo --help` exposes single-command option usage. +- `openbitdo ui ...` and `openbitdo run ...` fail as unsupported forms (historical). diff --git a/RC_CHECKLIST.md b/RC_CHECKLIST.md index f0a3ca5..62daff8 100644 --- a/RC_CHECKLIST.md +++ b/RC_CHECKLIST.md @@ -1,9 +1,9 @@ -# OpenBitdo RC Checklist (`v0.0.1-rc.1`) +# OpenBitdo RC Checklist (`v0.0.1-rc.2`) -This checklist defines release-candidate readiness for the first public RC tag. +This checklist defines release-candidate readiness for the `v0.0.1-rc.2` public RC tag. ## Candidate Policy -- Tag format: `v*` (for this RC: `v0.0.1-rc.1`) +- Tag format: `v*` (for this RC: `v0.0.1-rc.2`) - Tag source: `main` only - Release trigger: tag push - RC gate: all required CI checks + manual smoke validation @@ -22,9 +22,11 @@ Daily review cadence: - run once per day until RC tag: - `gh issue list -R bybrooklyn/openbitdo --label release-blocker --state open --limit 200` - release remains blocked while this list is non-empty. +- AUR auth troubleshooting runbook: + - [aur_publish_troubleshooting.md](/Users/brooklyn/data/8bitdo/cleanroom/process/aur_publish_troubleshooting.md) ## Scope-Completeness Gate ("Good Point") -Before tagging `v0.0.1-rc.1`, RC scope must match the locked contract: +Before tagging `v0.0.1-rc.2`, RC scope must match the locked contract: - JP108 mapping supports dedicated keys only (`A/B/K1-K8`) for RC. - Ultimate2 expanded mapping supports RC-required fields only: - remappable slots `A/B/K1-K8` @@ -71,42 +73,45 @@ Tag preflight must fail early if any required secret is missing: ## Artifact Expectations Release assets must include: -- `openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz` -- `openbitdo-v0.0.1-rc.1-linux-x86_64` -- `openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz` -- `openbitdo-v0.0.1-rc.1-linux-aarch64` -- `openbitdo-v0.0.1-rc.1-macos-arm64.tar.gz` -- `openbitdo-v0.0.1-rc.1-macos-arm64` -- `openbitdo-v0.0.1-rc.1-macos-arm64.pkg` +- `openbitdo-v0.0.1-rc.2-linux-x86_64.tar.gz` +- `openbitdo-v0.0.1-rc.2-linux-x86_64` +- `openbitdo-v0.0.1-rc.2-linux-aarch64.tar.gz` +- `openbitdo-v0.0.1-rc.2-linux-aarch64` +- `openbitdo-v0.0.1-rc.2-macos-arm64.tar.gz` +- `openbitdo-v0.0.1-rc.2-macos-arm64` +- `openbitdo-v0.0.1-rc.2-macos-arm64.pkg` - corresponding `.sha256` files for every artifact above ## Verify Checksums Run from release asset directory: ```bash -shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-x86_64.tar.gz.sha256 -shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-x86_64.sha256 -shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-aarch64.tar.gz.sha256 -shasum -a 256 -c openbitdo-v0.0.1-rc.1-linux-aarch64.sha256 -shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.tar.gz.sha256 -shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.sha256 -shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.pkg.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-x86_64.tar.gz.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-x86_64.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-aarch64.tar.gz.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-linux-aarch64.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-macos-arm64.tar.gz.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-macos-arm64.sha256 +shasum -a 256 -c openbitdo-v0.0.1-rc.2-macos-arm64.pkg.sha256 ``` ## Manual Smoke Matrix 1. Linux `x86_64` - Extract tarball, run `./bin/openbitdo --mock` -- Confirm waiting/home flow renders -- Confirm About page opens (`a` and mouse click) +- Confirm dashboard renders (device list, quick actions, event panel) +- Confirm settings screen opens and returns to dashboard 2. Linux `aarch64` - Extract tarball, run `./bin/openbitdo --mock` -- Confirm main navigation and update preflight render +- Confirm dashboard navigation and preflight/task render 3. macOS arm64 - Run standalone binary `openbitdo --mock` - Install `.pkg`, then run `/opt/homebrew/bin/openbitdo --mock` -- Confirm launch and About page behavior +- Confirm launch and settings view behavior + +## Help Surface Verification +- `openbitdo --help` shows single-command usage with `--mock`. ## Distribution Readiness Notes - Homebrew publication runs after release asset publish when `HOMEBREW_PUBLISH_ENABLED=1`. @@ -123,6 +128,8 @@ shasum -a 256 -c openbitdo-v0.0.1-rc.1-macos-arm64.pkg.sha256 | RC release allowed | Fail | `No` yet: AUR SSH auth still returns `Permission denied (publickey)`. | ## RC Execution Log +Historical note: entries below may mention older command forms from prior milestones and are preserved as-is for audit history. + - 2026-03-02T20:54:31Z: governance preflight complete; release blocker remains open by policy. - 2026-03-02T21:38:17Z: set `HOMEBREW_TAP_REPO=bybrooklyn/homebrew-openbitdo`; repository and tap visibility switched to public. - 2026-03-02T21:40:00Z: bootstrapped tap repo `bybrooklyn/homebrew-openbitdo` with initial `Formula/openbitdo.rb`. diff --git a/README.md b/README.md index 901525c..4e17528 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Optional mock mode: cargo run -p openbitdo -- --mock ``` -Beginner flow is always `openbitdo` only. No extra command surface is required. +`openbitdo` launches the interactive dashboard directly. ## Install (Public RC) @@ -30,11 +30,10 @@ brew install openbitdo Homebrew Core inclusion (`brew install openbitdo` without tapping) is not an RC blocker and may land later. AUR package names: -- `openbitdo` (source build) - `openbitdo-bin` (prebuilt binary) macOS RC caveat: -- `.pkg` installers are unsigned/ad-hoc for `v0.0.1-rc.1`. +- `.pkg` installers are unsigned/ad-hoc for `v0.0.1-rc.2`. - notarization is required starting with `v0.1.0`. ## UI Language Support @@ -182,5 +181,5 @@ Support is implemented to our best current knowledge. Coverage and confidence ar - Dirty-room source index + official web cross-check URLs: [device_name_sources.md](/Users/brooklyn/data/8bitdo/cleanroom/process/device_name_sources.md) ## Public RC Gate -`v0.0.1-rc.1` remains blocked until release-blocker issues are zero and all required checks are green. +The next RC remains blocked until release-blocker issues are zero and all required checks are green. RC gating is checklist-based (see [RC_CHECKLIST.md](/Users/brooklyn/data/8bitdo/cleanroom/RC_CHECKLIST.md)). diff --git a/packaging/aur/README.md b/packaging/aur/README.md index 11f3c6f..e04897c 100644 --- a/packaging/aur/README.md +++ b/packaging/aur/README.md @@ -1,7 +1,6 @@ # AUR Packaging This directory contains AUR package sources for: -- `openbitdo` (source build) - `openbitdo-bin` (prebuilt release assets) Publishing is automated by `.github/workflows/aur-publish.yml` and remains gated: @@ -12,8 +11,7 @@ Publish flow: 1. wait for release assets from a `v*` tag 2. compute authoritative SHA256 values from released artifacts 3. render `PKGBUILD`/`.SRCINFO` with pinned hashes -4. push updates to AUR repos +4. push updates to AUR repo Template files used for release rendering: -- `openbitdo/PKGBUILD.tmpl` - `openbitdo-bin/PKGBUILD.tmpl` diff --git a/packaging/aur/openbitdo/.SRCINFO b/packaging/aur/openbitdo/.SRCINFO deleted file mode 100644 index 1a608a7..0000000 --- a/packaging/aur/openbitdo/.SRCINFO +++ /dev/null @@ -1,14 +0,0 @@ -pkgbase = openbitdo - pkgdesc = Beginner-first clean-room 8BitDo utility - pkgver = 0.0.1rc1 - pkgrel = 1 - url = https://github.com/bybrooklyn/openbitdo - arch = x86_64 - arch = aarch64 - license = BSD-3-Clause - makedepends = cargo - depends = hidapi - source = openbitdo-v0.0.1-rc.1.tar.gz::https://github.com/bybrooklyn/openbitdo/archive/refs/tags/v0.0.1-rc.1.tar.gz - sha256sums = 0000000000000000000000000000000000000000000000000000000000000000 - -pkgname = openbitdo diff --git a/packaging/aur/openbitdo/PKGBUILD b/packaging/aur/openbitdo/PKGBUILD deleted file mode 100644 index 51e03b6..0000000 --- a/packaging/aur/openbitdo/PKGBUILD +++ /dev/null @@ -1,24 +0,0 @@ -pkgname=openbitdo -pkgver=0.0.1rc1 -_upstream_tag=v0.0.1-rc.1 -pkgrel=1 -pkgdesc="Beginner-first clean-room 8BitDo utility" -arch=('x86_64' 'aarch64') -url="https://github.com/bybrooklyn/openbitdo" -license=('BSD-3-Clause') -depends=('hidapi') -makedepends=('cargo') -source=("${pkgname}-${_upstream_tag}.tar.gz::${url}/archive/refs/tags/${_upstream_tag}.tar.gz") -sha256sums=('0000000000000000000000000000000000000000000000000000000000000000') - -build() { - cd "${srcdir}/openbitdo-${_upstream_tag#v}/sdk" - cargo build --release -p openbitdo -} - -package() { - cd "${srcdir}/openbitdo-${_upstream_tag#v}" - install -Dm755 "sdk/target/release/openbitdo" "${pkgdir}/usr/bin/openbitdo" - install -Dm644 "README.md" "${pkgdir}/usr/share/doc/openbitdo/README.md" - install -Dm644 "LICENSE" "${pkgdir}/usr/share/licenses/openbitdo/LICENSE" -} diff --git a/packaging/aur/openbitdo/PKGBUILD.tmpl b/packaging/aur/openbitdo/PKGBUILD.tmpl deleted file mode 100644 index 122c7a8..0000000 --- a/packaging/aur/openbitdo/PKGBUILD.tmpl +++ /dev/null @@ -1,24 +0,0 @@ -pkgname=openbitdo -pkgver=@AUR_PKGVER@ -_upstream_tag=@UPSTREAM_TAG@ -pkgrel=1 -pkgdesc="Beginner-first clean-room 8BitDo utility" -arch=('x86_64' 'aarch64') -url="https://github.com/@REPOSITORY@" -license=('BSD-3-Clause') -depends=('hidapi') -makedepends=('cargo') -source=("${pkgname}-${_upstream_tag}.tar.gz::${url}/archive/refs/tags/${_upstream_tag}.tar.gz") -sha256sums=('@SOURCE_SHA256@') - -build() { - cd "${srcdir}/openbitdo-${_upstream_tag#v}/sdk" - cargo build --release -p openbitdo -} - -package() { - cd "${srcdir}/openbitdo-${_upstream_tag#v}" - install -Dm755 "sdk/target/release/openbitdo" "${pkgdir}/usr/bin/openbitdo" - install -Dm644 "README.md" "${pkgdir}/usr/share/doc/openbitdo/README.md" - install -Dm644 "LICENSE" "${pkgdir}/usr/share/licenses/openbitdo/LICENSE" -} diff --git a/packaging/scripts/render_release_metadata.sh b/packaging/scripts/render_release_metadata.sh index e87aefb..d20a945 100755 --- a/packaging/scripts/render_release_metadata.sh +++ b/packaging/scripts/render_release_metadata.sh @@ -7,7 +7,6 @@ Usage: render_release_metadata.sh Inputs expected in : - openbitdo--source.tar.gz openbitdo--linux-x86_64.tar.gz openbitdo--linux-aarch64.tar.gz openbitdo--macos-arm64.tar.gz @@ -46,13 +45,11 @@ aur_pkgver_from_tag() { VERSION="${TAG#v}" AUR_PKGVER="$(aur_pkgver_from_tag "$TAG")" -SOURCE_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-source.tar.gz" LINUX_X86_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-x86_64.tar.gz" LINUX_AARCH64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-linux-aarch64.tar.gz" MACOS_ARM64_ARCHIVE="${INPUT_DIR}/openbitdo-${TAG}-macos-arm64.tar.gz" for required in \ - "$SOURCE_ARCHIVE" \ "$LINUX_X86_ARCHIVE" \ "$LINUX_AARCH64_ARCHIVE" \ "$MACOS_ARM64_ARCHIVE"; do @@ -62,13 +59,11 @@ for required in \ fi done -SOURCE_SHA256="$(sha256 "$SOURCE_ARCHIVE")" LINUX_X86_SHA256="$(sha256 "$LINUX_X86_ARCHIVE")" LINUX_AARCH64_SHA256="$(sha256 "$LINUX_AARCH64_ARCHIVE")" MACOS_ARM64_SHA256="$(sha256 "$MACOS_ARM64_ARCHIVE")" mkdir -p \ - "${OUTPUT_DIR}/aur/openbitdo" \ "${OUTPUT_DIR}/aur/openbitdo-bin" \ "${OUTPUT_DIR}/homebrew/Formula" @@ -80,16 +75,12 @@ render() { -e "s|@UPSTREAM_TAG@|${TAG}|g" \ -e "s|@VERSION@|${VERSION}|g" \ -e "s|@REPOSITORY@|${REPOSITORY}|g" \ - -e "s|@SOURCE_SHA256@|${SOURCE_SHA256}|g" \ -e "s|@LINUX_X86_64_SHA256@|${LINUX_X86_SHA256}|g" \ -e "s|@LINUX_AARCH64_SHA256@|${LINUX_AARCH64_SHA256}|g" \ -e "s|@MACOS_ARM64_SHA256@|${MACOS_ARM64_SHA256}|g" \ "$template" > "$destination" } -render \ - "${ROOT}/packaging/aur/openbitdo/PKGBUILD.tmpl" \ - "${OUTPUT_DIR}/aur/openbitdo/PKGBUILD" render \ "${ROOT}/packaging/aur/openbitdo-bin/PKGBUILD.tmpl" \ "${OUTPUT_DIR}/aur/openbitdo-bin/PKGBUILD" @@ -102,7 +93,6 @@ TAG=${TAG} VERSION=${VERSION} AUR_PKGVER=${AUR_PKGVER} REPOSITORY=${REPOSITORY} -SOURCE_SHA256=${SOURCE_SHA256} LINUX_X86_64_SHA256=${LINUX_X86_SHA256} LINUX_AARCH64_SHA256=${LINUX_AARCH64_SHA256} MACOS_ARM64_SHA256=${MACOS_ARM64_SHA256} diff --git a/process/aur_publish_troubleshooting.md b/process/aur_publish_troubleshooting.md new file mode 100644 index 0000000..d36531d --- /dev/null +++ b/process/aur_publish_troubleshooting.md @@ -0,0 +1,70 @@ +# AUR Publish SSH Troubleshooting + +This runbook focuses on resolving AUR publish failures such as `Permission denied (publickey)` in release workflows. + +## Preconditions +- `AUR_USERNAME` secret exists. +- `AUR_SSH_PRIVATE_KEY` secret exists and contains the full private key block. +- Runner can reach `aur.archlinux.org:22`. + +## 1) Key format and permissions checks +Run on a secure local shell before updating secrets: + +```bash +mkdir -p /tmp/aur-debug && cd /tmp/aur-debug +cat > aur_key <<'KEY' + +KEY +chmod 600 aur_key +ssh-keygen -y -f aur_key >/tmp/aur_key.pub +``` + +Expected: +- `ssh-keygen -y` succeeds. +- no passphrase prompt for CI use. + +## 2) Known hosts and host verification + +```bash +mkdir -p ~/.ssh && chmod 700 ~/.ssh +ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts +chmod 600 ~/.ssh/known_hosts +``` + +Expected: +- `aur.archlinux.org` host key is present in `known_hosts`. + +## 3) SSH dry-run authentication + +```bash +ssh -i /tmp/aur-debug/aur_key \ + -o IdentitiesOnly=yes \ + -o StrictHostKeyChecking=yes \ + ${AUR_USERNAME}@aur.archlinux.org +``` + +Expected success signature: +- authentication accepted (AUR may close session after auth; that still proves key acceptance). + +Expected failure signatures: +- `Permission denied (publickey)` means wrong key/user pairing. +- `Host key verification failed` means known_hosts mismatch/missing. + +## 4) Repo-level publish dry run +For package repo: + +```bash +git ls-remote ssh://${AUR_USERNAME}@aur.archlinux.org/openbitdo-bin.git +``` + +Expected: +- command returns refs without auth failures. + +## 5) CI secret update checklist +- Store private key in `AUR_SSH_PRIVATE_KEY` exactly as multiline PEM/OpenSSH block. +- Store account name in `AUR_USERNAME`. +- Re-run release workflow preflight job. + +## 6) Post-fix validation +- Confirm release preflight no longer fails on SSH auth. +- Confirm `publish-aur` job pushes `openbitdo-bin` metadata repo. diff --git a/sdk/Cargo.lock b/sdk/Cargo.lock index 489ac3c..df17560 100644 --- a/sdk/Cargo.lock +++ b/sdk/Cargo.lock @@ -161,10 +161,14 @@ dependencies = [ "bitdo_proto", "chrono", "crossterm 0.29.0", + "fuzzy-matcher", + "insta", "ratatui", "serde", + "serde_json", "tokio", "toml", + "unicode-width 0.2.0", ] [[package]] @@ -316,6 +320,18 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -558,6 +574,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -574,6 +596,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -643,6 +671,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -994,6 +1031,18 @@ dependencies = [ "rustversion", ] +[[package]] +name = "insta" +version = "1.46.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + [[package]] name = "instability" version = "0.3.11" @@ -1196,6 +1245,7 @@ dependencies = [ "anyhow", "assert_cmd", "bitdo_app_core", + "bitdo_proto", "bitdo_tui", "clap", "predicates", @@ -1785,6 +1835,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "slab" version = "0.4.12" @@ -1894,6 +1950,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" diff --git a/sdk/Cargo.toml b/sdk/Cargo.toml index a0f5112..5edf83a 100644 --- a/sdk/Cargo.toml +++ b/sdk/Cargo.toml @@ -9,7 +9,7 @@ resolver = "2" [workspace.package] edition = "2021" -version = "0.1.0" +version = "0.0.1-rc.2" license = "BSD-3-Clause" [workspace.dependencies] @@ -31,3 +31,6 @@ ratatui = "0.29" crossterm = "0.29" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } +fuzzy-matcher = "0.3" +unicode-width = "0.2" +insta = "1.43" diff --git a/sdk/README.md b/sdk/README.md index 354fcf2..a51212d 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -31,50 +31,54 @@ cargo test --workspace --all-targets cargo run -p openbitdo -- --mock ``` -## Beginner-first behavior -- launch with no subcommands -- if no device is connected, OpenBitdo starts in a waiting screen with `Refresh`, `Help`, and `Quit` -- if one device is connected, it is auto-selected and ready for action -- choose `Recommended Update` or `Diagnose` from large clickable actions -- for JP108 devices (`0x5209`/`0x520a`), `Recommended Update` enters a dedicated-button wizard: - - edit `A/B/K1-K8` - - backup + apply - - guided button test -- for Ultimate2 devices (`0x6012`/`0x6013`), `Recommended Update` enters a core-profile wizard: - - choose slot (`Slot1/2/3`) - - set mode - - edit RC mapping slots (`A/B/K1-K8`) with known controller-button targets - - view/edit L2/R2 analog values when capability supports writes - - backup + apply - - guided button test -- firmware path defaults to verified recommended download; local file fallback is prompted if unavailable -- update transfer requires one plain-language `I Understand` confirmation -- detect-only PIDs stay read/diagnostic-only with a clear block reason -- mouse support: - - left click for primary actions - - right click on device rows for context menu actions - - scroll wheel to navigate device list/detail panes -- support reports are TOML only - - beginner mode: `failure_only` (default) or `always` - - advanced mode: `failure_only`, `always`, or `off` (with warning) -- advanced mode is toggled from About (`t` or click) and persisted to OS config TOML -- advanced report hotkeys after a failure report exists: - - `c` copy report path - - `o` open report file - - `f` open report folder -- open About from home (`a` key or click `About`) to view: - - app version - - git commit short and full hash - - build date (UTC) - - compile target triple - - runtime OS/arch - - firmware signing-key fingerprint (short with full-view toggle, plus next-key short) +## CLI surface +- `openbitdo [--mock]`: interactive dashboard flow (mouse-primary, minimal keyboard). + +## Interactive behavior (`openbitdo`) +- dashboard starts with: + - searchable device list (left) + - quick actions (center) + - persistent event panel (right) +- primary quick actions: + - `Refresh` + - `Diagnose` + - `Recommended Update` + - `Edit Mapping` (capability-gated) + - `Settings` + - `Quit` +- firmware transfer path: + - preflight generation + - explicit confirm/cancel action + - updating progress and final result screen +- mapping editors are draft-first with: + - apply + - undo + - reset + - restore backup +- recovery lock behavior is preserved when rollback fails. + +## Headless library API +- headless automation remains available as a Rust API in `bitdo_tui`: + - `run_headless` + - `RunLaunchOptions` + - `HeadlessOutputMode` +- `openbitdo` CLI does not expose a headless command surface. + +## Config schema (v2) +- persisted fields: + - `schema_version` + - `advanced_mode` + - `report_save_mode` + - `device_filter_text` + - `dashboard_layout_mode` + - `last_panel_focus` +- v1 files are read with compatibility defaults and normalized to v2 fields at load time. ## Packaging ```bash -./scripts/package-linux.sh v0.0.1-rc.1 x86_64 -./scripts/package-linux.sh v0.0.1-rc.1 aarch64 -./scripts/package-macos.sh v0.0.1-rc.1 arm64 aarch64-apple-darwin +./scripts/package-linux.sh v0.0.1-rc.2 x86_64 +./scripts/package-linux.sh v0.0.1-rc.2 aarch64 +./scripts/package-macos.sh v0.0.1-rc.2 arm64 aarch64-apple-darwin ``` Packaging outputs use: @@ -87,7 +91,7 @@ Packaging outputs use: - CI checks remain in `.github/workflows/ci.yml`. - Tag-based release workflow is in `.github/workflows/release.yml`. - Release tags must originate from `main`. -- `v0.0.1-rc.1` style tags publish GitHub pre-releases. +- `v0.0.1-rc.2` style tags publish GitHub pre-releases. - Release notes are sourced from `/Users/brooklyn/data/8bitdo/cleanroom/CHANGELOG.md`. - Package-manager publish runs only after release assets are published. @@ -102,15 +106,13 @@ Packaging outputs use: - Homebrew install path for public RC: - `brew tap bybrooklyn/openbitdo` - `brew install openbitdo` -- Homebrew Core inclusion is not required for `v0.0.1-rc.1`. +- Homebrew Core inclusion is not required for `v0.0.1-rc.2`. - Homebrew formula scaffold: `/Users/brooklyn/data/8bitdo/cleanroom/packaging/homebrew/Formula/openbitdo.rb` - Homebrew tap sync script (disabled by default): `/Users/brooklyn/data/8bitdo/cleanroom/packaging/homebrew/sync_tap.sh` - Tap repository: `bybrooklyn/homebrew-openbitdo` - AUR package sources: - - `/Users/brooklyn/data/8bitdo/cleanroom/packaging/aur/openbitdo` - `/Users/brooklyn/data/8bitdo/cleanroom/packaging/aur/openbitdo-bin` - AUR package names: - - `openbitdo` - `openbitdo-bin` - Release metadata renderer: - `/Users/brooklyn/data/8bitdo/cleanroom/packaging/scripts/render_release_metadata.sh` @@ -121,7 +123,7 @@ Packaging outputs use: - `release.yml` renders checksum-pinned formula and runs `sync_tap.sh` - gated by `HOMEBREW_PUBLISH_ENABLED=1` - macOS `.pkg` caveat: - - unsigned/ad-hoc is accepted for RC + - unsigned/ad-hoc is accepted for `v0.0.1-rc.2` - notarization required for `v0.1.0` ## CI Gates diff --git a/sdk/crates/bitdo_app_core/src/lib.rs b/sdk/crates/bitdo_app_core/src/lib.rs index debc5f0..867ad9d 100644 --- a/sdk/crates/bitdo_app_core/src/lib.rs +++ b/sdk/crates/bitdo_app_core/src/lib.rs @@ -1,14 +1,14 @@ use base64::Engine; use bitdo_proto::{ - device_profile_for, enumerate_hid_devices, BitdoErrorCode, DeviceSession, DiagProbeResult, - DiagSeverity, HidTransport, PidCapability, ProtocolFamily, SessionConfig, SupportEvidence, - SupportLevel, SupportTier, VidPid, + device_profile_for, enumerate_hid_devices, find_command, BitdoErrorCode, CommandId, + DeviceSession, DiagProbeResult, DiagSeverity, HidTransport, PidCapability, ProtocolFamily, + ResponseStatus, SessionConfig, SupportEvidence, SupportLevel, SupportTier, VidPid, }; use chrono::{DateTime, Utc}; use ed25519_dalek::{Signature, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -348,6 +348,11 @@ impl OpenBitdoCore { pub fn beginner_diag_summary(&self, device: &AppDevice, diag: &DiagProbeResult) -> String { let passed = diag.command_checks.iter().filter(|c| c.ok).count(); let total = diag.command_checks.len(); + let issue_total = diag + .command_checks + .iter() + .filter(|c| !c.ok || c.severity != DiagSeverity::Ok) + .count(); let experimental_total = diag .command_checks .iter() @@ -381,23 +386,28 @@ impl OpenBitdoCore { } }; - let status_hint = if needs_attention > 0 { - format!("Needs attention: {needs_attention} safety-critical diagnostic signal(s).") + let status_hint = if issue_total > 0 { + format!("Issues: {issue_total} total, {needs_attention} need attention.") } else { - "Needs attention: none.".to_owned() + "Issues: none.".to_owned() }; let experimental_hint = format!("Experimental checks: {experimental_ok}/{experimental_total} passed."); + let transport_hint = if diag.transport_ready { + "Transport ready: yes." + } else { + "Transport ready: no successful safe-read responses yet." + }; match device.support_tier { SupportTier::Full => format!( - "{passed}/{total} checks passed. {experimental_hint} {status_hint} {family_hint} This device is full-support." + "{passed}/{total} checks passed. {experimental_hint} {status_hint} {transport_hint} {family_hint} This device is full-support." ), SupportTier::CandidateReadOnly => format!( - "{passed}/{total} checks passed. {experimental_hint} {status_hint} {family_hint} This device is candidate-readonly: update and mapping stay blocked until runtime + hardware confirmation." + "{passed}/{total} checks passed. {experimental_hint} {status_hint} {transport_hint} {family_hint} This device is candidate-readonly: update and mapping stay blocked until runtime + hardware confirmation." ), SupportTier::DetectOnly => format!( - "{passed}/{total} checks passed. {experimental_hint} {status_hint} {family_hint} This device is detect-only: use diagnostics only." + "{passed}/{total} checks passed. {experimental_hint} {status_hint} {transport_hint} {family_hint} This device is detect-only: use diagnostics only." ), } } @@ -1790,6 +1800,30 @@ fn mock_device(vid_pid: VidPid, full: bool) -> AppDevice { fn mock_diag_probe(target: VidPid) -> DiagProbeResult { let profile = device_profile_for(target); + let command_checks = mock_diag_commands_for(&profile, target) + .into_iter() + .map(|command| { + let parsed_facts = mock_diag_parsed_facts(command, target); + let detail = mock_diag_detail(command, &parsed_facts); + let row = find_command(command).expect("mock diag command exists"); + bitdo_proto::DiagCommandStatus { + command, + ok: true, + confidence: row.evidence_confidence(), + is_experimental: row.experimental_default, + severity: bitdo_proto::DiagSeverity::Ok, + attempts: 1, + validator: format!("mock:{command:?}"), + response_status: ResponseStatus::Ok, + bytes_written: row.request.len(), + bytes_read: 64, + error_code: None, + detail, + parsed_facts, + } + }) + .collect::>(); + DiagProbeResult { target, profile_name: profile.name, @@ -1799,35 +1833,203 @@ fn mock_diag_probe(target: VidPid) -> DiagProbeResult { capability: profile.capability, evidence: profile.evidence, transport_ready: true, - command_checks: vec![ - bitdo_proto::DiagCommandStatus { - command: bitdo_proto::CommandId::GetPid, - ok: true, - confidence: bitdo_proto::EvidenceConfidence::Confirmed, - is_experimental: false, - severity: bitdo_proto::DiagSeverity::Ok, - error_code: None, - detail: "ok".to_owned(), - }, - bitdo_proto::DiagCommandStatus { - command: bitdo_proto::CommandId::GetControllerVersion, - ok: true, - confidence: bitdo_proto::EvidenceConfidence::Confirmed, - is_experimental: false, - severity: bitdo_proto::DiagSeverity::Ok, - error_code: None, - detail: "ok".to_owned(), - }, - bitdo_proto::DiagCommandStatus { - command: bitdo_proto::CommandId::GetSuperButton, - ok: true, - confidence: bitdo_proto::EvidenceConfidence::Inferred, - is_experimental: true, - severity: bitdo_proto::DiagSeverity::Ok, - error_code: None, - detail: "ok".to_owned(), - }, - ], + command_checks, + } +} + +fn mock_diag_commands_for(profile: &bitdo_proto::DeviceProfile, target: VidPid) -> Vec { + const SAFE_READ_ORDER: &[CommandId] = &[ + CommandId::GetPid, + CommandId::GetReportRevision, + CommandId::GetMode, + CommandId::GetModeAlt, + CommandId::GetControllerVersion, + CommandId::GetSuperButton, + CommandId::Idle, + CommandId::Version, + CommandId::ReadProfile, + CommandId::Jp108ReadDedicatedMappings, + CommandId::Jp108ReadFeatureFlags, + CommandId::Jp108ReadVoice, + CommandId::U2GetCurrentSlot, + CommandId::U2ReadConfigSlot, + CommandId::U2ReadButtonMap, + ]; + + SAFE_READ_ORDER + .iter() + .copied() + .filter(|command| mock_diag_command_allowed(profile, target, *command)) + .collect() +} + +fn mock_diag_command_allowed( + profile: &bitdo_proto::DeviceProfile, + target: VidPid, + command: CommandId, +) -> bool { + let Some(row) = find_command(command) else { + return false; + }; + if !row.applies_to.is_empty() && !row.applies_to.contains(&target.pid) { + return false; + } + if !mock_diag_family_allowed(profile.protocol_family, command) { + return false; + } + if !mock_diag_capability_allowed(profile.capability, command) { + return false; + } + if profile.support_tier == SupportTier::CandidateReadOnly + && !mock_diag_candidate_allowed(target.pid, command) + { + return false; + } + + true +} + +fn mock_diag_family_allowed(family: ProtocolFamily, command: CommandId) -> bool { + match family { + ProtocolFamily::Unknown => matches!( + command, + CommandId::GetPid + | CommandId::GetReportRevision + | CommandId::GetControllerVersion + | CommandId::Version + | CommandId::Idle + ), + ProtocolFamily::JpHandshake => !matches!( + command, + CommandId::GetMode + | CommandId::GetModeAlt + | CommandId::ReadProfile + | CommandId::U2GetCurrentSlot + | CommandId::U2ReadConfigSlot + | CommandId::U2ReadButtonMap + ), + ProtocolFamily::DS4Boot => matches!( + command, + CommandId::GetPid + | CommandId::GetReportRevision + | CommandId::GetControllerVersion + | CommandId::Version + | CommandId::Idle + ), + ProtocolFamily::Standard64 | ProtocolFamily::DInput => !matches!( + command, + CommandId::Jp108ReadDedicatedMappings + | CommandId::Jp108ReadFeatureFlags + | CommandId::Jp108ReadVoice + ), + } +} + +fn mock_diag_capability_allowed(capability: PidCapability, command: CommandId) -> bool { + match command { + CommandId::GetPid + | CommandId::GetReportRevision + | CommandId::GetControllerVersion + | CommandId::GetSuperButton + | CommandId::Idle + | CommandId::Version => true, + CommandId::GetMode | CommandId::GetModeAlt => capability.supports_mode, + CommandId::ReadProfile => capability.supports_profile_rw, + CommandId::Jp108ReadDedicatedMappings + | CommandId::Jp108ReadFeatureFlags + | CommandId::Jp108ReadVoice => capability.supports_jp108_dedicated_map, + CommandId::U2GetCurrentSlot | CommandId::U2ReadConfigSlot => { + capability.supports_u2_slot_config + } + CommandId::U2ReadButtonMap => capability.supports_u2_button_map, + _ => false, + } +} + +fn mock_diag_candidate_allowed(pid: u16, command: CommandId) -> bool { + const BASE_DIAG_READS: &[CommandId] = &[ + CommandId::GetPid, + CommandId::GetReportRevision, + CommandId::GetControllerVersion, + CommandId::Version, + CommandId::Idle, + ]; + const STANDARD_CANDIDATE_PIDS: &[u16] = &[ + 0x6002, 0x6003, 0x3010, 0x3011, 0x3012, 0x3013, 0x3004, 0x3019, 0x3100, 0x3105, 0x2100, + 0x2101, 0x901a, 0x6006, 0x5203, 0x5204, 0x301a, 0x9028, 0x3026, 0x3027, + ]; + const JP_CANDIDATE_PIDS: &[u16] = &[0x5200, 0x5201, 0x203a, 0x2049, 0x2028, 0x202e]; + + if BASE_DIAG_READS.contains(&command) { + return STANDARD_CANDIDATE_PIDS.contains(&pid) || JP_CANDIDATE_PIDS.contains(&pid); + } + + if STANDARD_CANDIDATE_PIDS.contains(&pid) { + return matches!( + command, + CommandId::GetMode | CommandId::GetModeAlt | CommandId::ReadProfile + ); + } + + false +} + +fn mock_diag_parsed_facts(command: CommandId, target: VidPid) -> BTreeMap { + let mut facts = BTreeMap::new(); + match command { + CommandId::GetPid => { + facts.insert("detected_pid".to_owned(), target.pid as u32); + } + CommandId::GetReportRevision => { + facts.insert("revision".to_owned(), 1); + } + CommandId::GetMode | CommandId::GetModeAlt => { + facts.insert("mode".to_owned(), 2); + } + CommandId::GetControllerVersion | CommandId::Version => { + facts.insert("version_x100".to_owned(), 4200); + facts.insert("beta".to_owned(), 0); + } + CommandId::U2GetCurrentSlot => { + facts.insert("slot".to_owned(), 1); + } + _ => {} + } + facts +} + +fn mock_diag_detail(command: CommandId, parsed_facts: &BTreeMap) -> String { + match command { + CommandId::GetPid => parsed_facts + .get("detected_pid") + .map(|pid| format!("detected pid {pid:#06x}")) + .unwrap_or_else(|| "ok".to_owned()), + CommandId::GetReportRevision => parsed_facts + .get("revision") + .map(|revision| format!("report revision {revision}")) + .unwrap_or_else(|| "ok".to_owned()), + CommandId::GetMode | CommandId::GetModeAlt => parsed_facts + .get("mode") + .map(|mode| format!("mode {mode}")) + .unwrap_or_else(|| "ok".to_owned()), + CommandId::GetControllerVersion | CommandId::Version => { + let version_x100 = parsed_facts.get("version_x100").copied(); + let beta = parsed_facts.get("beta").copied().unwrap_or(0); + version_x100 + .map(|version| { + format!( + "firmware {}.{:02} beta={beta}", + version / 100, + version % 100 + ) + }) + .unwrap_or_else(|| "ok".to_owned()) + } + CommandId::U2GetCurrentSlot => parsed_facts + .get("slot") + .map(|slot| format!("current slot {slot}")) + .unwrap_or_else(|| "ok".to_owned()), + _ => "ok".to_owned(), } } diff --git a/sdk/crates/bitdo_proto/src/session.rs b/sdk/crates/bitdo_proto/src/session.rs index b025c8f..d46eda5 100644 --- a/sdk/crates/bitdo_proto/src/session.rs +++ b/sdk/crates/bitdo_proto/src/session.rs @@ -88,8 +88,14 @@ pub struct DiagCommandStatus { pub confidence: EvidenceConfidence, pub is_experimental: bool, pub severity: DiagSeverity, + pub attempts: u8, + pub validator: String, + pub response_status: ResponseStatus, + pub bytes_written: usize, + pub bytes_read: usize, pub error_code: Option, pub detail: String, + pub parsed_facts: BTreeMap, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] @@ -210,60 +216,12 @@ impl DeviceSession { } pub fn diag_probe(&mut self) -> DiagProbeResult { - let target_pid = self.target.pid; - let checks_to_run = [ - CommandId::GetPid, - CommandId::GetReportRevision, - CommandId::GetMode, - CommandId::GetControllerVersion, - // Inferred safe reads are intentionally included in diagnostics so - // users always see signal quality, but results are labeled - // experimental and only strict safety conditions escalate. - CommandId::GetSuperButton, - CommandId::ReadProfile, - ] - .iter() - .filter_map(|cmd| { - let row = find_command(*cmd)?; - if row.safety_class != SafetyClass::SafeRead { - return None; - } - if !command_applies_to_pid(row, target_pid) { - return None; - } - Some((*cmd, row.runtime_policy(), row.evidence_confidence())) - }) - .collect::>(); - + let checks_to_run = self.diag_commands_to_run(); let mut checks = Vec::with_capacity(checks_to_run.len()); - for (cmd, runtime_policy, confidence) in checks_to_run { - match self.send_command(cmd, None) { - Ok(_) => checks.push(DiagCommandStatus { - command: cmd, - ok: true, - confidence, - is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate, - severity: DiagSeverity::Ok, - error_code: None, - detail: "ok".to_owned(), - }), - Err(err) => checks.push(DiagCommandStatus { - command: cmd, - ok: false, - confidence, - is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate, - severity: classify_diag_failure( - cmd, - runtime_policy, - confidence, - err.code(), - self.target.pid, - ), - error_code: Some(err.code()), - detail: err.to_string(), - }), - } + for (command, runtime_policy, confidence) in checks_to_run { + checks.push(self.run_diag_check(command, runtime_policy, confidence)); } + let transport_ready = checks.iter().any(|check| check.ok); DiagProbeResult { target: self.target, @@ -273,11 +231,193 @@ impl DeviceSession { protocol_family: self.profile.protocol_family, capability: self.profile.capability, evidence: self.profile.evidence, - transport_ready: true, + transport_ready, command_checks: checks, } } + fn diag_commands_to_run(&self) -> Vec<(CommandId, CommandRuntimePolicy, EvidenceConfidence)> { + CommandId::all() + .iter() + .filter_map(|command| { + let row = find_command(*command)?; + if row.safety_class != SafetyClass::SafeRead { + return None; + } + if !command_applies_to_pid(row, self.target.pid) { + return None; + } + if !is_command_allowed_by_family(self.profile.protocol_family, *command) + || !is_command_allowed_by_capability(self.profile.capability, *command) + { + return None; + } + if self.profile.support_tier == SupportTier::CandidateReadOnly + && !is_command_allowed_for_candidate_pid( + self.target.pid, + *command, + row.safety_class, + ) + { + return None; + } + + Some((*command, row.runtime_policy(), row.evidence_confidence())) + }) + .collect() + } + + fn run_diag_check( + &mut self, + command: CommandId, + runtime_policy: CommandRuntimePolicy, + confidence: EvidenceConfidence, + ) -> DiagCommandStatus { + if command == CommandId::GetMode { + return self.run_diag_mode_check(runtime_policy, confidence); + } + + match self.send_command(command, None) { + Ok(response) => { + let detail = diag_success_detail(command, &response.parsed_fields); + self.diag_success_status( + command, + runtime_policy, + confidence, + response.parsed_fields, + self.last_execution.clone(), + detail, + ) + } + Err(err) => self.diag_failure_status( + command, + runtime_policy, + confidence, + err, + self.last_execution.clone(), + None, + ), + } + } + + fn run_diag_mode_check( + &mut self, + runtime_policy: CommandRuntimePolicy, + confidence: EvidenceConfidence, + ) -> DiagCommandStatus { + match self.send_command(CommandId::GetMode, None) { + Ok(response) => { + let detail = diag_success_detail(CommandId::GetMode, &response.parsed_fields); + self.diag_success_status( + CommandId::GetMode, + runtime_policy, + confidence, + response.parsed_fields, + self.last_execution.clone(), + detail, + ) + } + Err(primary_err) => { + let primary_detail = primary_err.to_string(); + let primary_execution = self.last_execution.clone(); + match self.send_command(CommandId::GetModeAlt, None) { + Ok(response) => self.diag_success_status( + CommandId::GetMode, + runtime_policy, + confidence, + response.parsed_fields, + self.last_execution.clone().or(primary_execution), + format!("ok via GetModeAlt fallback ({primary_detail})"), + ), + Err(fallback_err) => self.diag_failure_status( + CommandId::GetMode, + runtime_policy, + confidence, + fallback_err, + self.last_execution.clone().or(primary_execution), + Some(format!( + "GetMode failed ({primary_detail}); GetModeAlt failed" + )), + ), + } + } + } + } + + fn diag_success_status( + &self, + command: CommandId, + runtime_policy: CommandRuntimePolicy, + confidence: EvidenceConfidence, + parsed_facts: BTreeMap, + execution: Option, + detail: String, + ) -> DiagCommandStatus { + let metadata = execution.as_ref(); + DiagCommandStatus { + command, + ok: true, + confidence, + is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate, + severity: DiagSeverity::Ok, + attempts: metadata.map(|report| report.attempts).unwrap_or(0), + validator: metadata + .map(|report| report.validator.clone()) + .unwrap_or_else(|| "unknown".to_owned()), + response_status: metadata + .map(|report| report.status.clone()) + .unwrap_or(ResponseStatus::Ok), + bytes_written: metadata.map(|report| report.bytes_written).unwrap_or(0), + bytes_read: metadata.map(|report| report.bytes_read).unwrap_or(0), + error_code: None, + detail, + parsed_facts, + } + } + + fn diag_failure_status( + &self, + command: CommandId, + runtime_policy: CommandRuntimePolicy, + confidence: EvidenceConfidence, + err: BitdoError, + execution: Option, + detail_prefix: Option, + ) -> DiagCommandStatus { + let metadata = execution.as_ref(); + let error_code = err.code(); + let detail = if let Some(prefix) = detail_prefix { + format!("{prefix} ({err})") + } else { + err.to_string() + }; + DiagCommandStatus { + command, + ok: false, + confidence, + is_experimental: runtime_policy == CommandRuntimePolicy::ExperimentalGate, + severity: classify_diag_failure( + command, + runtime_policy, + confidence, + error_code, + self.target.pid, + ), + attempts: metadata.map(|report| report.attempts).unwrap_or(0), + validator: metadata + .map(|report| report.validator.clone()) + .unwrap_or_else(|| "unknown".to_owned()), + response_status: metadata + .map(|report| report.status.clone()) + .unwrap_or(ResponseStatus::Malformed), + bytes_written: metadata.map(|report| report.bytes_written).unwrap_or(0), + bytes_read: metadata.map(|report| report.bytes_read).unwrap_or(0), + error_code: Some(error_code), + detail, + parsed_facts: BTreeMap::new(), + } + } + pub fn get_mode(&mut self) -> Result { let resp = self.send_command(CommandId::GetMode, None)?; if let Some(mode) = resp.parsed_fields.get("mode").copied() { @@ -1015,6 +1155,9 @@ fn parse_fields(command: CommandId, response: &[u8]) -> BTreeMap { let pid = u16::from_le_bytes([response[22], response[23]]); parsed.insert("detected_pid".to_owned(), pid as u32); } + CommandId::GetReportRevision if response.len() >= 6 => { + parsed.insert("revision".to_owned(), response[5] as u32); + } CommandId::GetMode | CommandId::GetModeAlt if response.len() >= 6 => { parsed.insert("mode".to_owned(), response[5] as u32); } @@ -1047,3 +1190,40 @@ fn parse_indexed_u16_table(raw: &[u8], expected_items: usize) -> Vec<(u8, u16)> out } + +fn diag_success_detail(command: CommandId, parsed_facts: &BTreeMap) -> String { + match command { + CommandId::GetPid => parsed_facts + .get("detected_pid") + .map(|pid| format!("detected pid {pid:#06x}")) + .unwrap_or_else(|| "ok".to_owned()), + CommandId::GetReportRevision => parsed_facts + .get("revision") + .map(|revision| format!("report revision {revision}")) + .unwrap_or_else(|| "ok".to_owned()), + CommandId::GetMode | CommandId::GetModeAlt => parsed_facts + .get("mode") + .map(|mode| format!("mode {mode}")) + .unwrap_or_else(|| "ok".to_owned()), + CommandId::GetControllerVersion | CommandId::Version => { + let version = parsed_facts.get("version_x100").copied(); + let beta = parsed_facts.get("beta").copied(); + match (version, beta) { + (Some(version_x100), Some(beta)) => format!( + "firmware {}.{:02} beta={beta}", + version_x100 / 100, + version_x100 % 100 + ), + (Some(version_x100), None) => { + format!("firmware {}.{:02}", version_x100 / 100, version_x100 % 100) + } + _ => "ok".to_owned(), + } + } + CommandId::U2GetCurrentSlot => parsed_facts + .get("slot") + .map(|slot| format!("current slot {slot}")) + .unwrap_or_else(|| "ok".to_owned()), + _ => "ok".to_owned(), + } +} diff --git a/sdk/crates/bitdo_tui/Cargo.toml b/sdk/crates/bitdo_tui/Cargo.toml index 421694b..8ca62fe 100644 --- a/sdk/crates/bitdo_tui/Cargo.toml +++ b/sdk/crates/bitdo_tui/Cargo.toml @@ -9,11 +9,15 @@ anyhow = { workspace = true } ratatui = { workspace = true } crossterm = { workspace = true } serde = { workspace = true } +serde_json = { workspace = true } tokio = { workspace = true } chrono = { workspace = true } toml = { workspace = true } +fuzzy-matcher = { workspace = true } +unicode-width = { workspace = true } bitdo_proto = { path = "../bitdo_proto" } bitdo_app_core = { path = "../bitdo_app_core" } [dev-dependencies] tokio = { workspace = true, features = ["macros", "rt", "time"] } +insta = { workspace = true } diff --git a/sdk/crates/bitdo_tui/src/app/action.rs b/sdk/crates/bitdo_tui/src/app/action.rs new file mode 100644 index 0000000..d587a3f --- /dev/null +++ b/sdk/crates/bitdo_tui/src/app/action.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum QuickAction { + Refresh, + Diagnose, + RunAgain, + SaveReport, + RecommendedUpdate, + EditMappings, + Settings, + Quit, + Confirm, + Cancel, + ApplyDraft, + UndoDraft, + ResetDraft, + RestoreBackup, + Firmware, + Back, +} + +impl QuickAction { + pub fn label(self) -> &'static str { + match self { + QuickAction::Refresh => "Refresh", + QuickAction::Diagnose => "Diagnose", + QuickAction::RunAgain => "Run Again", + QuickAction::SaveReport => "Save Report", + QuickAction::RecommendedUpdate => "Recommended Update", + QuickAction::EditMappings => "Edit Mapping", + QuickAction::Settings => "Settings", + QuickAction::Quit => "Quit", + QuickAction::Confirm => "Confirm", + QuickAction::Cancel => "Cancel", + QuickAction::ApplyDraft => "Apply", + QuickAction::UndoDraft => "Undo", + QuickAction::ResetDraft => "Reset", + QuickAction::RestoreBackup => "Restore Backup", + QuickAction::Firmware => "Firmware", + QuickAction::Back => "Back", + } + } +} diff --git a/sdk/crates/bitdo_tui/src/app/effect.rs b/sdk/crates/bitdo_tui/src/app/effect.rs new file mode 100644 index 0000000..0bb9711 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/app/effect.rs @@ -0,0 +1,69 @@ +use crate::{DashboardLayoutMode, PanelFocus, ReportSaveMode}; +use bitdo_app_core::{ + ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareUpdateSessionId, + U2CoreProfile, +}; +use bitdo_proto::DiagProbeResult; +use bitdo_proto::VidPid; +use std::path::PathBuf; + +#[derive(Clone, Debug)] +pub enum MappingApplyDraft { + Jp108(Vec), + Ultimate2(U2CoreProfile), +} + +#[derive(Clone, Debug)] +pub enum Effect { + RefreshDevices, + RunDiagnostics { + vid_pid: VidPid, + }, + LoadMappings { + vid_pid: VidPid, + }, + ApplyMappings { + vid_pid: VidPid, + draft: MappingApplyDraft, + }, + RestoreBackup { + backup_id: ConfigBackupId, + }, + PreparePreflight { + vid_pid: VidPid, + firmware_path_override: Option, + allow_unsafe: bool, + brick_risk_ack: bool, + experimental: bool, + chunk_size: Option, + }, + StartFirmware { + session_id: FirmwareUpdateSessionId, + acknowledged_risk: bool, + }, + CancelFirmware { + session_id: FirmwareUpdateSessionId, + }, + PollFirmwareReport { + session_id: FirmwareUpdateSessionId, + }, + DeleteTempFile { + path: PathBuf, + }, + PersistSettings { + path: PathBuf, + advanced_mode: bool, + report_save_mode: ReportSaveMode, + device_filter_text: String, + dashboard_layout_mode: DashboardLayoutMode, + last_panel_focus: PanelFocus, + }, + PersistSupportReport { + operation: String, + vid_pid: Option, + status: String, + message: String, + diag: Option, + firmware: Option, + }, +} diff --git a/sdk/crates/bitdo_tui/src/app/event.rs b/sdk/crates/bitdo_tui/src/app/event.rs new file mode 100644 index 0000000..87656f8 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/app/event.rs @@ -0,0 +1,85 @@ +use crate::AppDevice; +use bitdo_app_core::{ + ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareProgressEvent, + FirmwareUpdatePlan, U2CoreProfile, +}; +use bitdo_proto::{DiagProbeResult, VidPid}; +use std::path::PathBuf; + +use super::action::QuickAction; +use super::state::DiagnosticsFilter; + +#[derive(Clone, Debug)] +pub enum AppEvent { + Init, + Tick, + DeviceFilterSet(String), + DeviceFilterInput(char), + DeviceFilterBackspace, + SelectFilteredDevice(usize), + SelectNextDevice, + SelectPrevDevice, + SelectNextAction, + SelectPrevAction, + DiagnosticsSelectCheck(usize), + DiagnosticsSelectNextCheck, + DiagnosticsSelectPrevCheck, + DiagnosticsShiftFilter(i32), + DiagnosticsSetFilter(DiagnosticsFilter), + TriggerAction(QuickAction), + ConfirmPrimary, + Back, + Quit, + ToggleAdvancedMode, + CycleReportSaveMode, + MappingAdjust(i32), + MappingMoveSelection(i32), + DevicesLoaded(Vec), + DevicesLoadFailed(String), + DiagnosticsCompleted { + vid_pid: VidPid, + result: DiagProbeResult, + summary: String, + }, + DiagnosticsFailed { + vid_pid: VidPid, + error: String, + }, + MappingsLoadedJp108 { + vid_pid: VidPid, + mappings: Vec, + }, + MappingsLoadedUltimate2 { + vid_pid: VidPid, + profile: U2CoreProfile, + }, + MappingLoadFailed(String), + MappingApplied { + backup_id: Option, + message: String, + recovery_lock: bool, + }, + MappingApplyFailed(String), + BackupRestoreCompleted(String), + BackupRestoreFailed(String), + PreflightReady { + vid_pid: VidPid, + firmware_path: PathBuf, + source: String, + version: String, + plan: FirmwareUpdatePlan, + downloaded_firmware_path: Option, + }, + PreflightBlocked(String), + UpdateStarted { + session_id: String, + source: String, + version: String, + }, + UpdateProgress(FirmwareProgressEvent), + UpdateFinished(FirmwareFinalReport), + UpdateFailed(String), + SettingsPersisted, + SupportReportSaved(PathBuf), + Error(String), +} diff --git a/sdk/crates/bitdo_tui/src/app/mod.rs b/sdk/crates/bitdo_tui/src/app/mod.rs new file mode 100644 index 0000000..ea54426 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/app/mod.rs @@ -0,0 +1,5 @@ +pub mod action; +pub mod effect; +pub mod event; +pub mod reducer; +pub mod state; diff --git a/sdk/crates/bitdo_tui/src/app/reducer.rs b/sdk/crates/bitdo_tui/src/app/reducer.rs new file mode 100644 index 0000000..4d4ea17 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/app/reducer.rs @@ -0,0 +1,882 @@ +use super::action::QuickAction; +use super::effect::{Effect, MappingApplyDraft}; +use super::event::AppEvent; +use super::state::{ + AppState, DiagnosticsFilter, DiagnosticsState, EventLevel, MappingDraftState, PanelFocus, + Screen, TaskMode, TaskState, +}; + +pub fn reduce(state: &mut AppState, event: AppEvent) -> Vec { + let mut effects = Vec::new(); + + match event { + AppEvent::Init => { + state.append_event(EventLevel::Info, "Initializing dashboard"); + effects.push(Effect::RefreshDevices); + } + AppEvent::Tick => { + if let Some(task) = state.task_state.as_ref() { + if matches!(task.mode, TaskMode::Updating) { + if let Some(plan) = task.plan.as_ref() { + effects.push(Effect::PollFirmwareReport { + session_id: plan.session_id.clone(), + }); + } + } + } + } + AppEvent::DeviceFilterSet(next) => { + state.device_filter = next; + state.selected_filtered_index = 0; + state.last_panel_focus = PanelFocus::Devices; + state.select_filtered_index(0); + if let Some(effect) = persist_settings_effect(state) { + effects.push(effect); + } + } + AppEvent::DeviceFilterInput(ch) => { + state.device_filter.push(ch); + state.selected_filtered_index = 0; + state.select_filtered_index(0); + if let Some(effect) = persist_settings_effect(state) { + effects.push(effect); + } + } + AppEvent::DeviceFilterBackspace => { + state.device_filter.pop(); + state.selected_filtered_index = 0; + state.select_filtered_index(0); + if let Some(effect) = persist_settings_effect(state) { + effects.push(effect); + } + } + AppEvent::SelectFilteredDevice(index) => { + state.select_filtered_index(index); + state.last_panel_focus = PanelFocus::Devices; + state.recompute_quick_actions(); + } + AppEvent::SelectNextDevice => { + state.select_next_device(); + } + AppEvent::SelectPrevDevice => { + state.select_prev_device(); + } + AppEvent::SelectNextAction => { + state.select_next_action(); + state.last_panel_focus = PanelFocus::QuickActions; + } + AppEvent::SelectPrevAction => { + state.select_prev_action(); + state.last_panel_focus = PanelFocus::QuickActions; + } + AppEvent::DiagnosticsSelectCheck(index) => { + state.select_diagnostics_filtered_index(index); + } + AppEvent::DiagnosticsSelectNextCheck => { + state.select_next_diagnostics_check(); + } + AppEvent::DiagnosticsSelectPrevCheck => { + state.select_prev_diagnostics_check(); + } + AppEvent::DiagnosticsShiftFilter(delta) => { + state.shift_diagnostics_filter(delta); + } + AppEvent::DiagnosticsSetFilter(filter) => { + state.set_diagnostics_filter(filter); + } + AppEvent::TriggerAction(action) => { + effects.extend(handle_action(state, action)); + } + AppEvent::ConfirmPrimary => { + if let Some(action) = state.selected_action() { + effects.extend(handle_action(state, action)); + } + } + AppEvent::Back => { + let keep_task_state = state + .task_state + .as_ref() + .map(|task| task.mode == TaskMode::Updating) + .unwrap_or(false); + if let Some(path) = take_cleanup_path_for_navigation(state) { + effects.push(Effect::DeleteTempFile { path }); + } + state.screen = Screen::Dashboard; + if !keep_task_state { + state.task_state = None; + } else { + state.set_status("Firmware update continues in background"); + } + state.diagnostics_state = None; + state.mapping_draft_state = None; + state.recompute_quick_actions(); + } + AppEvent::Quit => { + state.quit_requested = true; + } + AppEvent::ToggleAdvancedMode => { + state.advanced_mode = !state.advanced_mode; + if !state.advanced_mode && state.report_save_mode == crate::ReportSaveMode::Off { + state.report_save_mode = crate::ReportSaveMode::FailureOnly; + } + state.append_event( + EventLevel::Info, + if state.advanced_mode { + "Advanced mode enabled" + } else { + "Advanced mode disabled" + }, + ); + if let Some(effect) = persist_settings_effect(state) { + effects.push(effect); + } + state.recompute_quick_actions(); + } + AppEvent::CycleReportSaveMode => { + state.report_save_mode = state.report_save_mode.next(state.advanced_mode); + state.append_event( + EventLevel::Info, + format!( + "Report save mode changed to {}", + state.report_save_mode.as_str() + ), + ); + if let Some(effect) = persist_settings_effect(state) { + effects.push(effect); + } + } + AppEvent::MappingMoveSelection(delta) => { + if let Some(mapping) = state.mapping_draft_state.as_mut() { + match mapping { + MappingDraftState::Jp108 { + selected_row, + current, + .. + } => { + if current.is_empty() { + return effects; + } + let len = current.len() as i32; + let mut idx = *selected_row as i32 + delta; + while idx < 0 { + idx += len; + } + *selected_row = (idx % len) as usize; + } + MappingDraftState::Ultimate2 { + selected_row, + current, + .. + } => { + if current.mappings.is_empty() { + return effects; + } + let len = current.mappings.len() as i32; + let mut idx = *selected_row as i32 + delta; + while idx < 0 { + idx += len; + } + *selected_row = (idx % len) as usize; + } + } + } + state.recompute_quick_actions(); + } + AppEvent::MappingAdjust(delta) => { + adjust_mapping(state, delta); + state.recompute_quick_actions(); + } + AppEvent::DevicesLoaded(devices) => { + state.devices = devices; + let filtered = state.filtered_device_indices(); + if filtered.is_empty() { + state.selected_device_id = None; + state.selected_filtered_index = 0; + state.set_status("No controller detected"); + } else { + let selected = filtered + .get(state.selected_filtered_index) + .copied() + .unwrap_or(filtered[0]); + state.selected_filtered_index = + state.selected_filtered_index.min(filtered.len() - 1); + state.selected_device_id = Some(state.devices[selected].vid_pid); + state.set_status("Controllers refreshed"); + } + state.append_event( + EventLevel::Info, + format!("Device refresh complete ({} found)", state.devices.len()), + ); + state.recompute_quick_actions(); + } + AppEvent::DevicesLoadFailed(err) => { + state.set_status(format!("Refresh failed: {err}")); + state.append_event(EventLevel::Error, format!("Refresh failed: {err}")); + } + AppEvent::DiagnosticsCompleted { + vid_pid, + result, + summary, + } => { + let check_count = result.command_checks.len(); + state.screen = Screen::Diagnostics; + state.task_state = None; + state.diagnostics_state = Some(DiagnosticsState { + result: result.clone(), + summary: summary.clone(), + selected_check_index: 0, + active_filter: DiagnosticsFilter::All, + latest_report_path: None, + }); + state.ensure_diagnostics_selection(); + state.set_status("Diagnostics complete"); + state.append_event( + EventLevel::Info, + format!("Diagnostics complete for {vid_pid} ({check_count} checks)"), + ); + if crate::should_save_support_report(state.report_save_mode, false) { + effects.push(Effect::PersistSupportReport { + operation: "diag-probe".to_owned(), + vid_pid: Some(vid_pid), + status: "ok".to_owned(), + message: summary, + diag: Some(result), + firmware: None, + }); + } + state.recompute_quick_actions(); + } + AppEvent::DiagnosticsFailed { vid_pid, error } => { + state.screen = Screen::Task; + state.diagnostics_state = None; + state.task_state = Some(TaskState { + mode: TaskMode::Final, + plan: None, + progress: 100, + status: format!("Diagnostics failed for {vid_pid}: {error}"), + final_report: None, + downloaded_firmware_path: None, + }); + state.set_status("Diagnostics failed"); + state.append_event( + EventLevel::Error, + format!("Diagnostics failed for {vid_pid}: {error}"), + ); + if crate::should_save_support_report(state.report_save_mode, true) { + effects.push(Effect::PersistSupportReport { + operation: "diag-probe".to_owned(), + vid_pid: Some(vid_pid), + status: "failed".to_owned(), + message: error, + diag: None, + firmware: None, + }); + } + state.recompute_quick_actions(); + } + AppEvent::MappingsLoadedJp108 { vid_pid, mappings } => { + state.screen = Screen::MappingEditor; + state.mapping_draft_state = Some(MappingDraftState::Jp108 { + loaded: mappings.clone(), + current: mappings, + undo_stack: Vec::new(), + selected_row: 0, + }); + state.append_event( + EventLevel::Info, + format!("Loaded JP108 mappings for {vid_pid}"), + ); + state.set_status("Mapping draft loaded"); + state.recompute_quick_actions(); + } + AppEvent::MappingsLoadedUltimate2 { vid_pid, profile } => { + state.screen = Screen::MappingEditor; + state.mapping_draft_state = Some(MappingDraftState::Ultimate2 { + loaded: profile.clone(), + current: profile, + undo_stack: Vec::new(), + selected_row: 0, + }); + state.append_event( + EventLevel::Info, + format!("Loaded Ultimate2 profile mapping for {vid_pid}"), + ); + state.set_status("Mapping draft loaded"); + state.recompute_quick_actions(); + } + AppEvent::MappingLoadFailed(err) => { + state.set_status(format!("Mapping load failed: {err}")); + state.append_event(EventLevel::Error, format!("Mapping load failed: {err}")); + } + AppEvent::MappingApplied { + backup_id, + message, + recovery_lock, + } => { + state.latest_backup = backup_id; + if recovery_lock { + state.write_lock_until_restart = true; + state.screen = Screen::Recovery; + state.set_status("Write lock enabled until restart"); + state.append_event(EventLevel::Error, message); + } else { + state.set_status("Mapping applied"); + state.append_event(EventLevel::Info, message); + } + state.recompute_quick_actions(); + } + AppEvent::MappingApplyFailed(err) => { + state.set_status(format!("Apply failed: {err}")); + state.append_event(EventLevel::Error, format!("Apply failed: {err}")); + } + AppEvent::BackupRestoreCompleted(message) => { + state.set_status("Backup restored"); + state.append_event(EventLevel::Info, message); + } + AppEvent::BackupRestoreFailed(message) => { + state.set_status("Backup restore failed"); + state.append_event(EventLevel::Error, message); + } + AppEvent::PreflightReady { + vid_pid, + firmware_path, + source, + version, + plan, + downloaded_firmware_path, + } => { + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Preflight, + plan: Some(plan.clone()), + progress: 0, + status: format!( + "Ready to update {vid_pid} to {version} from {}", + firmware_path.display() + ), + final_report: None, + downloaded_firmware_path, + }); + state.append_event( + EventLevel::Info, + format!("Preflight passed ({source}, {version})"), + ); + state.set_status("Preflight ready, confirm to acknowledge risk and start"); + state.recompute_quick_actions(); + } + AppEvent::PreflightBlocked(reason) => { + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Final, + plan: None, + progress: 100, + status: format!("Preflight blocked: {reason}"), + final_report: None, + downloaded_firmware_path: None, + }); + state.set_status("Preflight blocked"); + state.append_event(EventLevel::Warning, reason); + state.recompute_quick_actions(); + } + AppEvent::UpdateStarted { + session_id, + source, + version, + } => { + if let Some(task) = state.task_state.as_mut() { + task.mode = TaskMode::Updating; + task.progress = 1; + task.status = + format!("Session {session_id}: updating from {source} (target {version})"); + } else { + state.task_state = Some(TaskState { + mode: TaskMode::Updating, + plan: None, + progress: 1, + status: format!("Session {session_id}: update started"), + final_report: None, + downloaded_firmware_path: None, + }); + } + state.screen = Screen::Task; + state.append_event(EventLevel::Info, "Firmware transfer started"); + state.set_status("Firmware update in progress"); + state.recompute_quick_actions(); + } + AppEvent::UpdateProgress(progress_event) => { + if let Some(task) = state.task_state.as_mut() { + task.mode = TaskMode::Updating; + task.progress = progress_event.progress; + task.status = format!("{}: {}", progress_event.stage, progress_event.message); + } + state.append_event( + EventLevel::Info, + format!( + "{}% {}: {}", + progress_event.progress, progress_event.stage, progress_event.message + ), + ); + } + AppEvent::UpdateFinished(report) => { + let downloaded_firmware_path = take_any_downloaded_firmware_path(state); + let failed = report.status != bitdo_app_core::FirmwareOutcome::Completed; + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Final, + plan: None, + progress: 100, + status: format!("Update {:?}: {}", report.status, report.message), + final_report: Some(report.clone()), + downloaded_firmware_path: None, + }); + state.set_status(format!("Update {:?}", report.status)); + state.append_event( + if failed { + EventLevel::Error + } else { + EventLevel::Info + }, + format!( + "Update {:?} (chunks {}/{})", + report.status, report.chunks_sent, report.chunks_total + ), + ); + if crate::should_save_support_report(state.report_save_mode, failed) { + effects.push(Effect::PersistSupportReport { + operation: "fw-write".to_owned(), + vid_pid: state.selected_device().map(|d| d.vid_pid), + status: if failed { + "failed".to_owned() + } else { + "completed".to_owned() + }, + message: report.message.clone(), + diag: None, + firmware: Some(report), + }); + } + if let Some(path) = downloaded_firmware_path { + effects.push(Effect::DeleteTempFile { path }); + } + state.recompute_quick_actions(); + } + AppEvent::UpdateFailed(err) => { + let downloaded_firmware_path = take_any_downloaded_firmware_path(state); + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Final, + plan: None, + progress: 100, + status: format!("Update failed: {err}"), + final_report: None, + downloaded_firmware_path: None, + }); + state.set_status("Update failed"); + state.append_event(EventLevel::Error, format!("Update failed: {err}")); + if let Some(path) = downloaded_firmware_path { + effects.push(Effect::DeleteTempFile { path }); + } + state.recompute_quick_actions(); + } + AppEvent::SettingsPersisted => { + state.append_event(EventLevel::Info, "Settings saved"); + } + AppEvent::SupportReportSaved(path) => { + state.latest_report_path = Some(path.clone()); + if let Some(diagnostics) = state.diagnostics_state.as_mut() { + diagnostics.latest_report_path = Some(path.clone()); + } + state.append_event( + EventLevel::Info, + format!("Support report saved: {}", path.display()), + ); + } + AppEvent::Error(message) => { + state.set_status(message.clone()); + state.append_event(EventLevel::Error, message); + } + } + + effects +} + +fn handle_action(state: &mut AppState, action: QuickAction) -> Vec { + let mut effects = Vec::new(); + match state.screen { + Screen::Dashboard => match action { + QuickAction::Refresh => { + effects.push(Effect::RefreshDevices); + } + QuickAction::Diagnose => { + if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) { + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Diagnostics, + plan: None, + progress: 5, + status: format!("Running diagnostics for {vid_pid}"), + final_report: None, + downloaded_firmware_path: None, + }); + state.diagnostics_state = None; + effects.push(Effect::RunDiagnostics { vid_pid }); + } + } + QuickAction::RecommendedUpdate => { + if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) { + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Preflight, + plan: None, + progress: 0, + status: format!("Preparing preflight for {vid_pid}"), + final_report: None, + downloaded_firmware_path: None, + }); + effects.push(Effect::PreparePreflight { + vid_pid, + firmware_path_override: state.firmware_path_override.clone(), + allow_unsafe: true, + brick_risk_ack: true, + experimental: state.experimental, + chunk_size: state.chunk_size, + }); + } + } + QuickAction::EditMappings => { + if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) { + effects.push(Effect::LoadMappings { vid_pid }); + } + } + QuickAction::Settings => { + state.screen = Screen::Settings; + } + QuickAction::Quit => state.quit_requested = true, + _ => {} + }, + Screen::Task => match action { + QuickAction::Confirm => { + if let Some(task) = state.task_state.as_ref() { + if let Some(plan) = task.plan.as_ref() { + effects.push(Effect::StartFirmware { + session_id: plan.session_id.clone(), + acknowledged_risk: true, + }); + } + } + } + QuickAction::Cancel => { + if let Some(task) = state.task_state.as_ref() { + if task.mode == TaskMode::Updating { + if let Some(plan) = task.plan.as_ref() { + effects.push(Effect::CancelFirmware { + session_id: plan.session_id.clone(), + }); + } else { + state.screen = Screen::Dashboard; + state.task_state = None; + } + } else { + if let Some(path) = take_cleanup_path_for_navigation(state) { + effects.push(Effect::DeleteTempFile { path }); + } + state.screen = Screen::Dashboard; + state.task_state = None; + } + } else { + state.screen = Screen::Dashboard; + } + } + QuickAction::Back => { + state.screen = Screen::Dashboard; + if state + .task_state + .as_ref() + .map(|task| task.mode == TaskMode::Updating) + .unwrap_or(false) + { + state.set_status("Firmware update continues in background"); + } else { + if let Some(path) = take_cleanup_path_for_navigation(state) { + effects.push(Effect::DeleteTempFile { path }); + } + state.task_state = None; + } + } + QuickAction::Quit => state.quit_requested = true, + _ => {} + }, + Screen::Diagnostics => match action { + QuickAction::RunAgain => { + let vid_pid = state + .diagnostics_state + .as_ref() + .map(|diagnostics| diagnostics.result.target) + .or_else(|| state.selected_device().map(|device| device.vid_pid)); + if let Some(vid_pid) = vid_pid { + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Diagnostics, + plan: None, + progress: 5, + status: format!("Running diagnostics for {vid_pid}"), + final_report: None, + downloaded_firmware_path: None, + }); + state.diagnostics_state = None; + effects.push(Effect::RunDiagnostics { vid_pid }); + } + } + QuickAction::SaveReport => { + if let Some(diagnostics) = state.diagnostics_state.as_ref() { + let has_issues = + diagnostics.result.command_checks.iter().any(|check| { + !check.ok || check.severity != bitdo_proto::DiagSeverity::Ok + }); + let target = diagnostics.result.target; + let result = diagnostics.result.clone(); + let summary = diagnostics.summary.clone(); + state.set_status("Saving diagnostics report"); + effects.push(Effect::PersistSupportReport { + operation: "diag-probe".to_owned(), + vid_pid: Some(target), + status: if has_issues { + "attention".to_owned() + } else { + "ok".to_owned() + }, + message: summary, + diag: Some(result), + firmware: None, + }); + } + } + QuickAction::Back => { + state.screen = Screen::Dashboard; + state.task_state = None; + state.diagnostics_state = None; + } + QuickAction::Quit => state.quit_requested = true, + _ => {} + }, + Screen::MappingEditor => match action { + QuickAction::ApplyDraft => { + if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) { + if let Some(draft) = state.mapping_draft_state.as_ref() { + let payload = match draft { + MappingDraftState::Jp108 { current, .. } => { + MappingApplyDraft::Jp108(current.clone()) + } + MappingDraftState::Ultimate2 { current, .. } => { + MappingApplyDraft::Ultimate2(current.clone()) + } + }; + effects.push(Effect::ApplyMappings { + vid_pid, + draft: payload, + }); + } + } + } + QuickAction::UndoDraft => { + mapping_undo(state); + } + QuickAction::ResetDraft => { + mapping_reset(state); + } + QuickAction::RestoreBackup => { + if let Some(backup) = state.latest_backup.clone() { + effects.push(Effect::RestoreBackup { backup_id: backup }); + } + } + QuickAction::Firmware => { + if let Some(vid_pid) = state.selected_device().map(|d| d.vid_pid) { + state.screen = Screen::Task; + state.task_state = Some(TaskState { + mode: TaskMode::Preflight, + plan: None, + progress: 0, + status: format!("Preparing preflight for {vid_pid}"), + final_report: None, + downloaded_firmware_path: None, + }); + effects.push(Effect::PreparePreflight { + vid_pid, + firmware_path_override: state.firmware_path_override.clone(), + allow_unsafe: true, + brick_risk_ack: true, + experimental: state.experimental, + chunk_size: state.chunk_size, + }); + } + } + QuickAction::Back => { + state.screen = Screen::Dashboard; + state.mapping_draft_state = None; + } + QuickAction::Quit => state.quit_requested = true, + _ => {} + }, + Screen::Recovery => match action { + QuickAction::RestoreBackup => { + if let Some(backup) = state.latest_backup.clone() { + effects.push(Effect::RestoreBackup { backup_id: backup }); + } + } + QuickAction::Back => { + state.screen = Screen::Dashboard; + } + QuickAction::Quit => state.quit_requested = true, + _ => {} + }, + Screen::Settings => match action { + QuickAction::Back => state.screen = Screen::Dashboard, + QuickAction::Quit => state.quit_requested = true, + _ => {} + }, + } + + state.recompute_quick_actions(); + effects +} + +fn persist_settings_effect(state: &AppState) -> Option { + state + .settings_path + .clone() + .map(|path| Effect::PersistSettings { + path, + advanced_mode: state.advanced_mode, + report_save_mode: state.report_save_mode, + device_filter_text: state.device_filter.clone(), + dashboard_layout_mode: state.dashboard_layout_mode, + last_panel_focus: state.last_panel_focus, + }) +} + +fn mapping_undo(state: &mut AppState) { + match state.mapping_draft_state.as_mut() { + Some(MappingDraftState::Jp108 { + current, + undo_stack, + .. + }) => { + if let Some(previous) = undo_stack.pop() { + *current = previous; + } + } + Some(MappingDraftState::Ultimate2 { + current, + undo_stack, + .. + }) => { + if let Some(previous) = undo_stack.pop() { + *current = previous; + } + } + None => {} + } +} + +fn mapping_reset(state: &mut AppState) { + match state.mapping_draft_state.as_mut() { + Some(MappingDraftState::Jp108 { + loaded, + current, + undo_stack, + .. + }) => { + undo_stack.push(current.clone()); + *current = loaded.clone(); + } + Some(MappingDraftState::Ultimate2 { + loaded, + current, + undo_stack, + .. + }) => { + undo_stack.push(current.clone()); + *current = loaded.clone(); + } + None => {} + } +} + +fn adjust_mapping(state: &mut AppState, delta: i32) { + match state.mapping_draft_state.as_mut() { + Some(MappingDraftState::Jp108 { + current, + undo_stack, + selected_row, + .. + }) => { + if *selected_row < current.len() { + undo_stack.push(current.clone()); + let entry = &mut current[*selected_row]; + entry.target_hid_usage = cycle_jp108(entry.target_hid_usage, delta); + } + } + Some(MappingDraftState::Ultimate2 { + current, + undo_stack, + selected_row, + .. + }) => { + if *selected_row < current.mappings.len() { + undo_stack.push(current.clone()); + let entry = &mut current.mappings[*selected_row]; + entry.target_hid_usage = cycle_u2(entry.target_hid_usage, delta); + } + } + None => {} + } +} + +fn take_any_downloaded_firmware_path(state: &mut AppState) -> Option { + state + .task_state + .as_mut() + .and_then(|task| task.downloaded_firmware_path.take()) +} + +fn take_cleanup_path_for_navigation(state: &mut AppState) -> Option { + let should_cleanup = state + .task_state + .as_ref() + .map(|task| task.mode != TaskMode::Updating) + .unwrap_or(false); + + if should_cleanup { + take_any_downloaded_firmware_path(state) + } else { + None + } +} + +const JP108_PRESETS: [u16; 16] = [ + 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x0028, 0x0029, 0x002c, 0x003a, + 0x003b, 0x003c, 0x00e0, 0x00e1, +]; + +const U2_PRESETS: [u16; 17] = [ + 0x0100, 0x0101, 0x0102, 0x0103, 0x0104, 0x0105, 0x0106, 0x0107, 0x0108, 0x0109, 0x010a, 0x010b, + 0x010c, 0x010d, 0x010e, 0x010f, 0x0110, +]; + +fn cycle_jp108(current: u16, delta: i32) -> u16 { + cycle_from_table(&JP108_PRESETS, current, delta) +} + +fn cycle_u2(current: u16, delta: i32) -> u16 { + cycle_from_table(&U2_PRESETS, current, delta) +} + +fn cycle_from_table(table: &[u16], current: u16, delta: i32) -> u16 { + let pos = table.iter().position(|item| *item == current).unwrap_or(0) as i32; + let len = table.len() as i32; + let mut next = pos + delta; + while next < 0 { + next += len; + } + table[(next as usize) % table.len()] +} diff --git a/sdk/crates/bitdo_tui/src/app/state.rs b/sdk/crates/bitdo_tui/src/app/state.rs new file mode 100644 index 0000000..b2e3502 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/app/state.rs @@ -0,0 +1,634 @@ +use crate::{AppDevice, BuildInfo, ReportSaveMode, UiLaunchOptions}; +use bitdo_app_core::{ + ConfigBackupId, DedicatedButtonMapping, FirmwareFinalReport, FirmwareUpdatePlan, U2CoreProfile, +}; +use bitdo_proto::{DiagCommandStatus, DiagProbeResult, DiagSeverity, SupportTier, VidPid}; +use chrono::Utc; +use fuzzy_matcher::skim::SkimMatcherV2; +use fuzzy_matcher::FuzzyMatcher; +use serde::{Deserialize, Serialize}; +use std::collections::VecDeque; +use std::path::PathBuf; + +use super::action::QuickAction; + +pub const EVENT_LOG_CAPACITY: usize = 200; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Screen { + Dashboard, + Task, + Diagnostics, + MappingEditor, + Recovery, + Settings, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum DashboardLayoutMode { + #[default] + Wide, + Compact, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PanelFocus { + #[default] + Devices, + QuickActions, + EventLog, + Status, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EventLevel { + Info, + Warning, + Error, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct EventEntry { + pub timestamp_utc: String, + pub level: EventLevel, + pub message: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskMode { + Diagnostics, + Preflight, + Updating, + Final, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct TaskState { + pub mode: TaskMode, + pub plan: Option, + pub progress: u8, + pub status: String, + pub final_report: Option, + pub downloaded_firmware_path: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticsFilter { + #[default] + All, + Issues, + Experimental, +} + +impl DiagnosticsFilter { + pub const ALL: [DiagnosticsFilter; 3] = [ + DiagnosticsFilter::All, + DiagnosticsFilter::Issues, + DiagnosticsFilter::Experimental, + ]; + + pub fn label(self) -> &'static str { + match self { + DiagnosticsFilter::All => "All", + DiagnosticsFilter::Issues => "Issues", + DiagnosticsFilter::Experimental => "Experimental", + } + } + + pub fn matches(self, check: &DiagCommandStatus) -> bool { + match self { + DiagnosticsFilter::All => true, + DiagnosticsFilter::Issues => !check.ok || check.severity != DiagSeverity::Ok, + DiagnosticsFilter::Experimental => check.is_experimental, + } + } + + pub fn shift(self, delta: i32) -> Self { + let current = Self::ALL + .iter() + .position(|candidate| *candidate == self) + .unwrap_or(0) as i32; + let len = Self::ALL.len() as i32; + let mut next = current + delta; + while next < 0 { + next += len; + } + Self::ALL[(next as usize) % Self::ALL.len()] + } +} + +#[derive(Clone, Debug)] +pub struct DiagnosticsState { + pub result: DiagProbeResult, + pub summary: String, + pub selected_check_index: usize, + pub active_filter: DiagnosticsFilter, + pub latest_report_path: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MappingEditorKind { + Jp108, + Ultimate2, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum MappingDraftState { + Jp108 { + loaded: Vec, + current: Vec, + undo_stack: Vec>, + selected_row: usize, + }, + Ultimate2 { + loaded: U2CoreProfile, + current: U2CoreProfile, + undo_stack: Vec, + selected_row: usize, + }, +} + +#[derive(Clone, Debug)] +pub struct QuickActionState { + pub action: QuickAction, + pub enabled: bool, + pub reason: Option, +} + +#[derive(Clone, Debug)] +pub struct AppState { + pub screen: Screen, + pub build_info: BuildInfo, + pub advanced_mode: bool, + pub report_save_mode: ReportSaveMode, + pub settings_path: Option, + pub dashboard_layout_mode: DashboardLayoutMode, + pub last_panel_focus: PanelFocus, + pub devices: Vec, + pub selected_device_id: Option, + pub selected_filtered_index: usize, + pub device_filter: String, + pub quick_actions: Vec, + pub selected_action_index: usize, + pub event_log: VecDeque, + pub task_state: Option, + pub diagnostics_state: Option, + pub mapping_draft_state: Option, + pub latest_backup: Option, + pub write_lock_until_restart: bool, + pub latest_report_path: Option, + pub status_line: String, + pub firmware_path_override: Option, + pub allow_unsafe: bool, + pub brick_risk_ack: bool, + pub experimental: bool, + pub chunk_size: Option, + pub quit_requested: bool, +} + +impl AppState { + pub fn new(opts: &UiLaunchOptions) -> Self { + let mut state = Self { + screen: Screen::Dashboard, + build_info: opts.build_info.clone(), + advanced_mode: opts.advanced_mode, + report_save_mode: if !opts.advanced_mode && opts.report_save_mode == ReportSaveMode::Off + { + ReportSaveMode::FailureOnly + } else { + opts.report_save_mode + }, + settings_path: opts.settings_path.clone(), + dashboard_layout_mode: DashboardLayoutMode::Wide, + last_panel_focus: PanelFocus::Devices, + devices: Vec::new(), + selected_device_id: None, + selected_filtered_index: 0, + device_filter: String::new(), + quick_actions: Vec::new(), + selected_action_index: 0, + event_log: VecDeque::with_capacity(EVENT_LOG_CAPACITY), + task_state: None, + diagnostics_state: None, + mapping_draft_state: None, + latest_backup: None, + write_lock_until_restart: false, + latest_report_path: None, + status_line: "OpenBitdo ready".to_owned(), + firmware_path_override: opts.firmware_path.clone(), + allow_unsafe: opts.allow_unsafe, + brick_risk_ack: opts.brick_risk_ack, + experimental: opts.experimental, + chunk_size: opts.chunk_size, + quit_requested: false, + }; + state.recompute_quick_actions(); + state + } + + pub fn set_layout_from_size(&mut self, width: u16, height: u16) { + self.dashboard_layout_mode = if width < 80 || height < 24 { + DashboardLayoutMode::Compact + } else { + DashboardLayoutMode::Wide + }; + } + + pub fn filtered_device_indices(&self) -> Vec { + if self.device_filter.trim().is_empty() { + return (0..self.devices.len()).collect(); + } + + let query = self.device_filter.to_lowercase(); + let matcher = SkimMatcherV2::default(); + let mut scored: Vec<(i64, usize)> = self + .devices + .iter() + .enumerate() + .filter_map(|(idx, dev)| { + let text = format!( + "{:04x}:{:04x} {}", + dev.vid_pid.vid, + dev.vid_pid.pid, + dev.name.to_lowercase() + ); + matcher.fuzzy_match(&text, &query).map(|score| (score, idx)) + }) + .collect(); + scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + scored.into_iter().map(|(_, idx)| idx).collect() + } + + pub fn selected_device(&self) -> Option<&AppDevice> { + self.selected_device_id + .and_then(|id| self.devices.iter().find(|d| d.vid_pid == id)) + .or_else(|| self.devices.first()) + } + + pub fn select_filtered_index(&mut self, index: usize) { + let filtered = self.filtered_device_indices(); + if let Some(device_idx) = filtered.get(index).copied() { + self.selected_filtered_index = index; + self.selected_device_id = Some(self.devices[device_idx].vid_pid); + self.recompute_quick_actions(); + } + } + + pub fn select_next_device(&mut self) { + let filtered = self.filtered_device_indices(); + if filtered.is_empty() { + return; + } + self.selected_filtered_index = (self.selected_filtered_index + 1) % filtered.len(); + self.select_filtered_index(self.selected_filtered_index); + } + + pub fn select_prev_device(&mut self) { + let filtered = self.filtered_device_indices(); + if filtered.is_empty() { + return; + } + if self.selected_filtered_index == 0 { + self.selected_filtered_index = filtered.len().saturating_sub(1); + } else { + self.selected_filtered_index -= 1; + } + self.select_filtered_index(self.selected_filtered_index); + } + + pub fn append_event(&mut self, level: EventLevel, message: impl Into) { + if self.event_log.len() >= EVENT_LOG_CAPACITY { + self.event_log.pop_front(); + } + self.event_log.push_back(EventEntry { + timestamp_utc: Utc::now().format("%H:%M:%S").to_string(), + level, + message: message.into(), + }); + } + + pub fn set_status(&mut self, message: impl Into) { + self.status_line = message.into(); + } + + pub fn select_next_action(&mut self) { + if self.quick_actions.is_empty() { + return; + } + self.selected_action_index = (self.selected_action_index + 1) % self.quick_actions.len(); + } + + pub fn select_prev_action(&mut self) { + if self.quick_actions.is_empty() { + return; + } + if self.selected_action_index == 0 { + self.selected_action_index = self.quick_actions.len().saturating_sub(1); + } else { + self.selected_action_index -= 1; + } + } + + pub fn selected_action(&self) -> Option { + self.quick_actions + .get(self.selected_action_index) + .filter(|a| a.enabled) + .map(|a| a.action) + } + + pub fn diagnostics_filtered_indices(&self) -> Vec { + let Some(diagnostics) = self.diagnostics_state.as_ref() else { + return Vec::new(); + }; + + diagnostics + .result + .command_checks + .iter() + .enumerate() + .filter_map(|(idx, check)| diagnostics.active_filter.matches(check).then_some(idx)) + .collect() + } + + pub fn selected_diagnostics_check(&self) -> Option<&DiagCommandStatus> { + let diagnostics = self.diagnostics_state.as_ref()?; + diagnostics + .result + .command_checks + .get(diagnostics.selected_check_index) + } + + pub fn select_diagnostics_filtered_index(&mut self, filtered_index: usize) { + let filtered = self.diagnostics_filtered_indices(); + if let Some(check_index) = filtered.get(filtered_index).copied() { + if let Some(diagnostics) = self.diagnostics_state.as_mut() { + diagnostics.selected_check_index = check_index; + } + } + } + + pub fn select_next_diagnostics_check(&mut self) { + let filtered = self.diagnostics_filtered_indices(); + if filtered.is_empty() { + return; + } + + let current = self + .diagnostics_state + .as_ref() + .and_then(|diagnostics| { + filtered + .iter() + .position(|idx| *idx == diagnostics.selected_check_index) + }) + .unwrap_or(0); + let next = (current + 1) % filtered.len(); + self.select_diagnostics_filtered_index(next); + } + + pub fn select_prev_diagnostics_check(&mut self) { + let filtered = self.diagnostics_filtered_indices(); + if filtered.is_empty() { + return; + } + + let current = self + .diagnostics_state + .as_ref() + .and_then(|diagnostics| { + filtered + .iter() + .position(|idx| *idx == diagnostics.selected_check_index) + }) + .unwrap_or(0); + let next = if current == 0 { + filtered.len().saturating_sub(1) + } else { + current - 1 + }; + self.select_diagnostics_filtered_index(next); + } + + pub fn set_diagnostics_filter(&mut self, filter: DiagnosticsFilter) { + if let Some(diagnostics) = self.diagnostics_state.as_mut() { + diagnostics.active_filter = filter; + } + self.ensure_diagnostics_selection(); + } + + pub fn shift_diagnostics_filter(&mut self, delta: i32) { + if let Some(diagnostics) = self.diagnostics_state.as_mut() { + diagnostics.active_filter = diagnostics.active_filter.shift(delta); + } + self.ensure_diagnostics_selection(); + } + + pub fn ensure_diagnostics_selection(&mut self) { + let filtered = self.diagnostics_filtered_indices(); + let Some(diagnostics) = self.diagnostics_state.as_mut() else { + return; + }; + + if filtered.is_empty() { + diagnostics.selected_check_index = 0; + } else if !filtered.contains(&diagnostics.selected_check_index) { + diagnostics.selected_check_index = filtered[0]; + } + } + + pub fn recompute_quick_actions(&mut self) { + self.quick_actions = if matches!(self.screen, Screen::Dashboard) { + let actions = vec![ + QuickActionState { + action: QuickAction::Refresh, + enabled: true, + reason: None, + }, + QuickActionState { + action: QuickAction::Diagnose, + enabled: self.selected_device().is_some(), + reason: None, + }, + QuickActionState { + action: QuickAction::RecommendedUpdate, + enabled: self + .selected_device() + .map(|d| d.support_tier == SupportTier::Full) + .unwrap_or(false) + && !self.write_lock_until_restart, + reason: self.selected_device().and_then(|d| { + if d.support_tier != SupportTier::Full { + Some("Read-only until hardware confirmation".to_owned()) + } else if self.write_lock_until_restart { + Some("Write locked until restart".to_owned()) + } else { + None + } + }), + }, + QuickActionState { + action: QuickAction::EditMappings, + enabled: self + .selected_device() + .map(|d| { + (d.capability.supports_jp108_dedicated_map + || (d.capability.supports_u2_button_map + && d.capability.supports_u2_slot_config)) + && d.support_tier == SupportTier::Full + && !self.write_lock_until_restart + }) + .unwrap_or(false), + reason: None, + }, + QuickActionState { + action: QuickAction::Settings, + enabled: true, + reason: None, + }, + QuickActionState { + action: QuickAction::Quit, + enabled: true, + reason: None, + }, + ]; + if self.selected_action_index >= actions.len() { + self.selected_action_index = 0; + } + actions + } else if matches!(self.screen, Screen::Task) { + vec![ + QuickActionState { + action: QuickAction::Confirm, + enabled: self + .task_state + .as_ref() + .map(|task| matches!(task.mode, TaskMode::Preflight)) + .unwrap_or(false), + reason: None, + }, + QuickActionState { + action: QuickAction::Cancel, + enabled: true, + reason: None, + }, + QuickActionState { + action: QuickAction::Back, + enabled: true, + reason: None, + }, + ] + } else if matches!(self.screen, Screen::Diagnostics) { + vec![ + QuickActionState { + action: QuickAction::RunAgain, + enabled: self.selected_device().is_some(), + reason: None, + }, + QuickActionState { + action: QuickAction::SaveReport, + enabled: self.diagnostics_state.is_some(), + reason: None, + }, + QuickActionState { + action: QuickAction::Back, + enabled: true, + reason: None, + }, + ] + } else if matches!(self.screen, Screen::MappingEditor) { + vec![ + QuickActionState { + action: QuickAction::ApplyDraft, + enabled: !self.write_lock_until_restart, + reason: None, + }, + QuickActionState { + action: QuickAction::UndoDraft, + enabled: self.mapping_can_undo(), + reason: None, + }, + QuickActionState { + action: QuickAction::ResetDraft, + enabled: self.mapping_has_changes(), + reason: None, + }, + QuickActionState { + action: QuickAction::RestoreBackup, + enabled: self.latest_backup.is_some(), + reason: None, + }, + QuickActionState { + action: QuickAction::Firmware, + enabled: !self.write_lock_until_restart, + reason: None, + }, + QuickActionState { + action: QuickAction::Back, + enabled: true, + reason: None, + }, + ] + } else if matches!(self.screen, Screen::Recovery) { + vec![ + QuickActionState { + action: QuickAction::RestoreBackup, + enabled: self.latest_backup.is_some(), + reason: None, + }, + QuickActionState { + action: QuickAction::Back, + enabled: true, + reason: None, + }, + QuickActionState { + action: QuickAction::Quit, + enabled: true, + reason: None, + }, + ] + } else { + vec![ + QuickActionState { + action: QuickAction::Back, + enabled: true, + reason: None, + }, + QuickActionState { + action: QuickAction::Quit, + enabled: true, + reason: None, + }, + ] + }; + if self.selected_action_index >= self.quick_actions.len() { + self.selected_action_index = 0; + } + } + + pub fn mapping_can_undo(&self) -> bool { + match self.mapping_draft_state.as_ref() { + Some(MappingDraftState::Jp108 { undo_stack, .. }) => !undo_stack.is_empty(), + Some(MappingDraftState::Ultimate2 { undo_stack, .. }) => !undo_stack.is_empty(), + None => false, + } + } + + pub fn mapping_has_changes(&self) -> bool { + match self.mapping_draft_state.as_ref() { + Some(MappingDraftState::Jp108 { + loaded, current, .. + }) => loaded != current, + Some(MappingDraftState::Ultimate2 { + loaded, current, .. + }) => loaded != current, + None => false, + } + } +} diff --git a/sdk/crates/bitdo_tui/src/headless/mod.rs b/sdk/crates/bitdo_tui/src/headless/mod.rs new file mode 100644 index 0000000..1a6da42 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/headless/mod.rs @@ -0,0 +1,301 @@ +use crate::support_report::persist_support_report; +use crate::{should_save_support_report, HeadlessOutputMode, RunLaunchOptions}; +use anyhow::{anyhow, Result}; +use bitdo_app_core::{ + FirmwareCancelRequest, FirmwareConfirmRequest, FirmwareFinalReport, FirmwareOutcome, + FirmwarePreflightRequest, FirmwareStartRequest, OpenBitdoCore, +}; +use serde::Serialize; +use std::path::Path; +use tokio::time::{sleep, Duration}; + +#[derive(Serialize)] +struct JsonProgress<'a> { + r#type: &'static str, + session_id: &'a str, + sequence: u64, + stage: &'a str, + progress: u8, + message: &'a str, + timestamp: String, +} + +#[derive(Serialize)] +struct JsonFinal<'a> { + r#type: &'static str, + session_id: &'a str, + status: &'a str, + chunks_sent: usize, + chunks_total: usize, + message: &'a str, + error_code: Option, +} + +pub async fn run_headless( + core: OpenBitdoCore, + opts: RunLaunchOptions, +) -> Result { + let downloaded_firmware = opts.firmware_path.is_none() && opts.use_recommended; + let firmware_path = if let Some(path) = opts.firmware_path.clone() { + path + } else if opts.use_recommended { + core.download_recommended_firmware(opts.vid_pid) + .await + .map(|d| d.firmware_path) + .map_err(|err| anyhow!("recommended firmware unavailable: {err}"))? + } else { + return Err(anyhow!( + "firmware path is required when --recommended is not used" + )); + }; + + let preflight = match core + .preflight_firmware(FirmwarePreflightRequest { + vid_pid: opts.vid_pid, + firmware_path: firmware_path.clone(), + allow_unsafe: opts.allow_unsafe, + brick_risk_ack: opts.brick_risk_ack, + experimental: opts.experimental, + chunk_size: opts.chunk_size, + }) + .await + { + Ok(preflight) => preflight, + Err(err) => { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(err.into()); + } + }; + + if !preflight.gate.allowed { + let message = preflight + .gate + .message + .unwrap_or_else(|| "policy denied".to_owned()); + emit_failed_final(&opts, "preflight", &message); + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(anyhow!("preflight denied: {message}")); + } + + let plan = match preflight.plan { + Some(plan) => plan, + None => { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(anyhow!("preflight allowed without transfer plan")); + } + }; + + if let Err(err) = core + .start_firmware(FirmwareStartRequest { + session_id: plan.session_id.clone(), + }) + .await + { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(err.into()); + } + + if let Err(err) = core + .confirm_firmware(FirmwareConfirmRequest { + session_id: plan.session_id.clone(), + acknowledged_risk: opts.acknowledged_risk, + }) + .await + { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(err.into()); + } + + let mut events = match core.subscribe_events(&plan.session_id.0).await { + Ok(events) => events, + Err(err) => { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(err.into()); + } + }; + + loop { + tokio::select! { + evt = events.recv() => { + if let Ok(evt) = evt { + if opts.emit_events { + emit_progress(&opts, &evt.session_id.0, evt.sequence, &evt.stage, evt.progress, &evt.message, evt.timestamp.to_rfc3339()); + } + if evt.terminal { + break; + } + } + } + _ = sleep(Duration::from_millis(10)) => { + let report = match core.firmware_report(&plan.session_id.0).await { + Ok(report) => report, + Err(err) => { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path) + .await; + return Err(err.into()); + } + }; + if let Some(report) = report { + emit_final(&opts, &report); + maybe_persist_report(&core, &opts, &report).await; + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Ok(report); + } + } + } + } + + let report = match core.firmware_report(&plan.session_id.0).await { + Ok(report) => report.unwrap_or(FirmwareFinalReport { + session_id: plan.session_id, + status: FirmwareOutcome::Failed, + started_at: None, + completed_at: None, + bytes_total: 0, + chunks_total: 0, + chunks_sent: 0, + error_code: None, + message: "missing final report".to_owned(), + }), + Err(err) => { + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + return Err(err.into()); + } + }; + + emit_final(&opts, &report); + maybe_persist_report(&core, &opts, &report).await; + maybe_cleanup_downloaded_firmware(downloaded_firmware, &firmware_path).await; + Ok(report) +} + +pub async fn cancel_headless( + core: &OpenBitdoCore, + session_id: &str, +) -> Result { + core.cancel_firmware(FirmwareCancelRequest { + session_id: bitdo_app_core::FirmwareUpdateSessionId(session_id.to_owned()), + }) + .await + .map_err(Into::into) +} + +fn emit_progress( + opts: &RunLaunchOptions, + session_id: &str, + sequence: u64, + stage: &str, + progress: u8, + message: &str, + timestamp: String, +) { + match opts.output_mode { + HeadlessOutputMode::Human => { + println!("[{progress:>3}%] {stage}: {message}"); + } + HeadlessOutputMode::Json => { + let payload = JsonProgress { + r#type: "progress", + session_id, + sequence, + stage, + progress, + message, + timestamp, + }; + if let Ok(json) = serde_json::to_string(&payload) { + println!("{json}"); + } + } + } +} + +fn emit_final(opts: &RunLaunchOptions, report: &FirmwareFinalReport) { + match opts.output_mode { + HeadlessOutputMode::Human => { + println!( + "final: {:?} chunks={}/{} message={}", + report.status, report.chunks_sent, report.chunks_total, report.message + ); + } + HeadlessOutputMode::Json => { + let payload = JsonFinal { + r#type: "final", + session_id: &report.session_id.0, + status: match report.status { + FirmwareOutcome::Completed => "Completed", + FirmwareOutcome::Cancelled => "Cancelled", + FirmwareOutcome::Failed => "Failed", + }, + chunks_sent: report.chunks_sent, + chunks_total: report.chunks_total, + message: &report.message, + error_code: report.error_code.map(|err| format!("{err:?}")), + }; + if let Ok(json) = serde_json::to_string(&payload) { + println!("{json}"); + } + } + } +} + +fn emit_failed_final(opts: &RunLaunchOptions, session_id: &str, message: &str) { + match opts.output_mode { + HeadlessOutputMode::Human => { + println!("final: Failed message={message}"); + } + HeadlessOutputMode::Json => { + let payload = JsonFinal { + r#type: "final", + session_id, + status: "Failed", + chunks_sent: 0, + chunks_total: 0, + message, + error_code: None, + }; + if let Ok(json) = serde_json::to_string(&payload) { + println!("{json}"); + } + } + } +} + +async fn maybe_persist_report( + core: &OpenBitdoCore, + opts: &RunLaunchOptions, + report: &FirmwareFinalReport, +) { + let is_failure = report.status != FirmwareOutcome::Completed; + if !should_save_support_report(opts.report_save_mode, is_failure) { + return; + } + + let devices = core.list_devices().await.unwrap_or_default(); + let selected = devices.iter().find(|d| d.vid_pid == opts.vid_pid); + let status = if is_failure { "failed" } else { "completed" }; + + let _ = persist_support_report( + "fw-write", + selected, + status, + report.message.clone(), + None, + Some(report), + ) + .await; +} + +async fn maybe_cleanup_downloaded_firmware(downloaded_firmware: bool, firmware_path: &Path) { + if downloaded_firmware { + let _ = cleanup_temp_file(firmware_path).await; + } +} + +async fn cleanup_temp_file(path: &Path) -> std::io::Result<()> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} diff --git a/sdk/crates/bitdo_tui/src/lib.rs b/sdk/crates/bitdo_tui/src/lib.rs index ec44fc0..e2e6e84 100644 --- a/sdk/crates/bitdo_tui/src/lib.rs +++ b/sdk/crates/bitdo_tui/src/lib.rs @@ -1,49 +1,21 @@ -use anyhow::{anyhow, Result}; -use bitdo_app_core::{ - AppDevice, ConfigBackupId, DedicatedButtonMapping, DeviceKind, FirmwareCancelRequest, - FirmwareConfirmRequest, FirmwareFinalReport, FirmwareOutcome, FirmwarePreflightRequest, - FirmwareProgressEvent, FirmwareStartRequest, FirmwareUpdatePlan, FirmwareUpdateSessionId, - OpenBitdoCore, U2CoreProfile, U2SlotId, UserSupportStatus, WriteRecoveryReport, -}; -use bitdo_proto::{SupportTier, VidPid}; -use crossterm::event::{self, Event as CEvent, KeyCode, MouseButton, MouseEvent, MouseEventKind}; -use crossterm::execute; -use crossterm::terminal::{ - disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, -}; -use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, Paragraph}; -use ratatui::{backend::CrosstermBackend, Frame, Terminal}; +use anyhow::Result; +use bitdo_app_core::{FirmwareFinalReport, OpenBitdoCore}; +use bitdo_proto::VidPid; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::io::{self, Stdout, Write}; -use std::path::{Path, PathBuf}; -use tokio::sync::broadcast; -use tokio::time::{sleep, Duration}; +use std::path::PathBuf; + +pub type AppDevice = bitdo_app_core::AppDevice; + +pub mod app; +pub mod headless; +pub mod persistence; +pub mod runtime; +pub mod ui; -mod desktop_io; -mod settings; mod support_report; -use desktop_io::{copy_text_to_clipboard, open_path_with_default_app}; -use settings::persist_user_settings; -use support_report::{persist_support_report, prune_reports_on_startup}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] -pub enum TuiWorkflowState { - WaitForDevice, - Home, - HelpOverlay, - Jp108Mapping, - U2CoreProfile, - Recovery, - Preflight, - Updating, - FinalReport, - About, -} +pub use app::action::QuickAction; +pub use app::state::{DashboardLayoutMode, PanelFocus, Screen}; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct BuildInfo { @@ -92,7 +64,7 @@ impl ReportSaveMode { } } - fn next(self, advanced_mode: bool) -> Self { + pub fn next(self, advanced_mode: bool) -> Self { match (self, advanced_mode) { (ReportSaveMode::FailureOnly, false) => ReportSaveMode::Always, (ReportSaveMode::Always, false) => ReportSaveMode::FailureOnly, @@ -104,2213 +76,88 @@ impl ReportSaveMode { } } -#[derive(Clone, Debug)] -struct PendingUpdate { - target: AppDevice, - firmware_path: PathBuf, - firmware_source: String, - firmware_version: String, - plan: FirmwareUpdatePlan, -} - -#[derive(Clone, Copy, Debug)] -struct MouseContextMenu { - anchor_col: u16, - anchor_row: u16, - hovered_index: Option, +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum HeadlessOutputMode { + #[default] + Human, + Json, } #[derive(Clone, Debug)] -pub struct TuiApp { - pub state: TuiWorkflowState, - pub devices: Vec, - pub selected_index: usize, - pub selected: Option, - pub session_id: Option, - pub progress: u8, - pub last_message: String, - pub final_report: Option, +pub struct UiLaunchOptions { pub build_info: BuildInfo, pub advanced_mode: bool, pub report_save_mode: ReportSaveMode, pub settings_path: Option, - detail_scroll: u16, - hovered_action: Option, - about_toggle_hovered: bool, - about_report_mode_hovered: bool, - about_fingerprint_hovered: bool, - about_show_full_fingerprint: bool, - context_menu: Option, - pending_update: Option, - jp108_mappings: Vec, - jp108_selected: usize, - u2_profile: Option, - u2_selected: usize, - latest_backup: Option, - latest_report_path: Option, - write_lock_until_restart: bool, - recovery_report: Option, -} - -impl Default for TuiApp { - fn default() -> Self { - Self { - state: TuiWorkflowState::WaitForDevice, - devices: Vec::new(), - selected_index: 0, - selected: None, - session_id: None, - progress: 0, - last_message: "Plug in your controller, then choose Refresh.".to_owned(), - final_report: None, - build_info: BuildInfo::default(), - advanced_mode: false, - report_save_mode: ReportSaveMode::FailureOnly, - settings_path: None, - detail_scroll: 0, - hovered_action: None, - about_toggle_hovered: false, - about_report_mode_hovered: false, - about_fingerprint_hovered: false, - about_show_full_fingerprint: false, - context_menu: None, - pending_update: None, - jp108_mappings: Vec::new(), - jp108_selected: 0, - u2_profile: None, - u2_selected: 0, - latest_backup: None, - latest_report_path: None, - write_lock_until_restart: false, - recovery_report: None, - } - } -} - -impl TuiApp { - pub fn refresh_devices(&mut self, mut devices: Vec) { - devices.sort_by_key(|d| (d.vid_pid.vid, d.vid_pid.pid)); - self.devices = devices; - - if self.devices.is_empty() { - self.selected_index = 0; - self.selected = None; - if !self.is_overlay_state() { - self.state = TuiWorkflowState::WaitForDevice; - } - return; - } - - if self.devices.len() == 1 || self.selected_index >= self.devices.len() { - self.selected_index = 0; - } - - self.selected = Some(self.devices[self.selected_index].vid_pid); - - if !matches!( - self.state, - TuiWorkflowState::About - | TuiWorkflowState::HelpOverlay - | TuiWorkflowState::Recovery - | TuiWorkflowState::Preflight - | TuiWorkflowState::Updating - | TuiWorkflowState::FinalReport - ) { - self.state = TuiWorkflowState::Home; - } - } - - pub fn selected_device(&self) -> Option<&AppDevice> { - self.devices.get(self.selected_index) - } - - pub fn select_next(&mut self) { - if self.devices.is_empty() { - return; - } - self.selected_index = (self.selected_index + 1) % self.devices.len(); - self.selected = Some(self.devices[self.selected_index].vid_pid); - self.context_menu = None; - } - - pub fn select_prev(&mut self) { - if self.devices.is_empty() { - return; - } - if self.selected_index == 0 { - self.selected_index = self.devices.len() - 1; - } else { - self.selected_index -= 1; - } - self.selected = Some(self.devices[self.selected_index].vid_pid); - self.context_menu = None; - } - - pub fn select_index(&mut self, idx: usize) { - if idx < self.devices.len() { - self.selected_index = idx; - self.selected = Some(self.devices[idx].vid_pid); - self.context_menu = None; - } - } - - pub fn set_home_message(&mut self, message: impl Into) { - self.progress = 0; - self.session_id = None; - self.pending_update = None; - self.context_menu = None; - self.detail_scroll = 0; - self.last_message = message.into(); - self.state = if self.devices.is_empty() { - TuiWorkflowState::WaitForDevice - } else { - TuiWorkflowState::Home - }; - } - - pub fn open_about(&mut self) { - self.state = TuiWorkflowState::About; - self.context_menu = None; - self.about_toggle_hovered = false; - self.about_report_mode_hovered = false; - self.about_fingerprint_hovered = false; - self.last_message = "OpenBitdo build details and settings.".to_owned(); - } - - pub fn open_help(&mut self) { - self.state = TuiWorkflowState::HelpOverlay; - self.context_menu = None; - } - - pub fn close_overlay(&mut self) { - self.about_toggle_hovered = false; - self.about_report_mode_hovered = false; - self.about_fingerprint_hovered = false; - self.about_show_full_fingerprint = false; - if self.devices.is_empty() { - self.state = TuiWorkflowState::WaitForDevice; - } else { - self.state = TuiWorkflowState::Home; - } - } - - fn begin_preflight(&mut self, pending: PendingUpdate) { - self.pending_update = Some(pending); - self.state = TuiWorkflowState::Preflight; - self.progress = 0; - self.session_id = None; - self.final_report = None; - self.context_menu = None; - self.last_message = "Review preflight details and confirm.".to_owned(); - } - - fn begin_jp108_mapping(&mut self, mappings: Vec) { - self.jp108_mappings = mappings; - self.jp108_selected = 0; - self.state = TuiWorkflowState::Jp108Mapping; - self.pending_update = None; - self.context_menu = None; - self.last_message = - "Edit dedicated buttons, then click Backup + Apply. Firmware remains available." - .to_owned(); - } - - fn begin_u2_profile(&mut self, profile: U2CoreProfile) { - self.u2_profile = Some(profile); - self.u2_selected = 0; - self.state = TuiWorkflowState::U2CoreProfile; - self.pending_update = None; - self.context_menu = None; - self.last_message = - "Choose slot/mode and core button mappings, then click Backup + Apply.".to_owned(); - } - - pub fn set_session(&mut self, id: FirmwareUpdateSessionId) { - self.session_id = Some(id); - self.state = TuiWorkflowState::Updating; - self.context_menu = None; - self.pending_update = None; - } - - pub fn apply_progress(&mut self, progress: u8, message: String) { - self.progress = progress; - self.last_message = message; - } - - pub fn complete(&mut self, report: FirmwareFinalReport) { - self.progress = 100; - self.state = TuiWorkflowState::FinalReport; - self.last_message = format!("final status: {:?}", report.status); - self.final_report = Some(report); - self.session_id = None; - self.pending_update = None; - self.context_menu = None; - } - - pub fn open_context_menu(&mut self, col: u16, row: u16) { - self.context_menu = Some(MouseContextMenu { - anchor_col: col, - anchor_row: row, - hovered_index: None, - }); - } - - pub fn close_context_menu(&mut self) { - self.context_menu = None; - } - - fn set_advanced_mode(&mut self, core: &OpenBitdoCore, enabled: bool) -> Result<()> { - self.advanced_mode = enabled; - core.set_advanced_mode(enabled); - if !enabled && self.report_save_mode == ReportSaveMode::Off { - self.report_save_mode = ReportSaveMode::FailureOnly; - } - if let Some(path) = self.settings_path.as_deref() { - persist_user_settings(path, self.advanced_mode, self.report_save_mode)?; - } - self.last_message = if enabled { - "Advanced mode enabled: inferred read diagnostics are available.".to_owned() - } else { - "Advanced mode disabled: beginner-safe defaults restored.".to_owned() - }; - Ok(()) - } - - fn toggle_advanced_mode(&mut self, core: &OpenBitdoCore) -> Result<()> { - self.set_advanced_mode(core, !self.advanced_mode) - } - - fn cycle_report_save_mode(&mut self) -> Result<()> { - self.report_save_mode = self.report_save_mode.next(self.advanced_mode); - if let Some(path) = self.settings_path.as_deref() { - persist_user_settings(path, self.advanced_mode, self.report_save_mode)?; - } - self.last_message = if self.report_save_mode == ReportSaveMode::Off { - "Report save mode set to off. Disabling reports may make support impossible.".to_owned() - } else { - format!( - "Report save mode set to {}.", - self.report_save_mode.as_str() - ) - }; - Ok(()) - } - - fn toggle_fingerprint_view(&mut self) { - self.about_show_full_fingerprint = !self.about_show_full_fingerprint; - self.last_message = if self.about_show_full_fingerprint { - "Showing full signing-key fingerprint.".to_owned() - } else { - "Showing short signing-key fingerprint.".to_owned() - }; - } - - fn enter_recovery(&mut self, report: WriteRecoveryReport) { - self.write_lock_until_restart = true; - self.recovery_report = Some(report); - self.state = TuiWorkflowState::Recovery; - self.pending_update = None; - self.session_id = None; - self.progress = 0; - self.last_message = - "Apply failed and rollback also failed. Write actions are locked until restart." - .to_owned(); - } - - fn remember_report_path(&mut self, path: PathBuf) { - self.latest_report_path = Some(path); - } - - fn is_overlay_state(&self) -> bool { - matches!( - self.state, - TuiWorkflowState::About | TuiWorkflowState::HelpOverlay - ) - } -} - -#[derive(Clone, Debug)] -pub struct TuiRunRequest { - pub vid_pid: VidPid, - pub firmware_path: PathBuf, - pub allow_unsafe: bool, - pub brick_risk_ack: bool, - pub experimental: bool, - pub chunk_size: Option, - pub acknowledged_risk: bool, - pub no_ui: bool, -} - -#[derive(Clone, Debug)] -pub struct TuiLaunchOptions { - pub no_ui: bool, - pub selected_vid_pid: Option, pub firmware_path: Option, pub allow_unsafe: bool, pub brick_risk_ack: bool, pub experimental: bool, pub chunk_size: Option, - pub build_info: BuildInfo, - pub advanced_mode: bool, - pub report_save_mode: ReportSaveMode, - pub settings_path: Option, } -impl Default for TuiLaunchOptions { +impl Default for UiLaunchOptions { fn default() -> Self { Self { - no_ui: false, - selected_vid_pid: None, - firmware_path: None, - allow_unsafe: true, - brick_risk_ack: true, - experimental: false, - chunk_size: None, build_info: BuildInfo::default(), advanced_mode: false, report_save_mode: ReportSaveMode::FailureOnly, settings_path: None, + firmware_path: None, + allow_unsafe: false, + brick_risk_ack: false, + experimental: false, + chunk_size: None, } } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum HomeAction { - Update, - Diagnose, - Refresh, - About, - Help, - Quit, +#[derive(Clone, Debug)] +pub struct RunLaunchOptions { + pub vid_pid: VidPid, + pub firmware_path: Option, + pub use_recommended: bool, + pub allow_unsafe: bool, + pub brick_risk_ack: bool, + pub experimental: bool, + pub chunk_size: Option, + pub acknowledged_risk: bool, + pub output_mode: HeadlessOutputMode, + pub emit_events: bool, + pub report_save_mode: ReportSaveMode, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum DeviceFlowAction { - BackupApply, - RetryRead, - RestoreBackup, - GuidedTest, - Firmware, - Done, -} - -const HOME_ACTIONS: [HomeAction; 5] = [ - HomeAction::Update, - HomeAction::Diagnose, - HomeAction::Refresh, - HomeAction::About, - HomeAction::Quit, -]; - -const WAIT_ACTIONS: [HomeAction; 3] = [HomeAction::Refresh, HomeAction::Help, HomeAction::Quit]; - -const CONTEXT_ACTIONS: [HomeAction; 3] = - [HomeAction::Diagnose, HomeAction::About, HomeAction::Refresh]; - -const DEVICE_FLOW_ACTIONS: [DeviceFlowAction; 6] = [ - DeviceFlowAction::BackupApply, - DeviceFlowAction::RetryRead, - DeviceFlowAction::RestoreBackup, - DeviceFlowAction::GuidedTest, - DeviceFlowAction::Firmware, - DeviceFlowAction::Done, -]; - -impl HomeAction { - fn label(self) -> &'static str { - match self { - HomeAction::Update => "Recommended Update", - HomeAction::Diagnose => "Diagnose", - HomeAction::Refresh => "Refresh", - HomeAction::About => "About", - HomeAction::Help => "Help", - HomeAction::Quit => "Quit", +impl Default for RunLaunchOptions { + fn default() -> Self { + Self { + vid_pid: VidPid::new(0x2dc8, 0x6009), + firmware_path: None, + use_recommended: true, + allow_unsafe: false, + brick_risk_ack: false, + experimental: false, + chunk_size: None, + acknowledged_risk: false, + output_mode: HeadlessOutputMode::Human, + emit_events: true, + report_save_mode: ReportSaveMode::FailureOnly, } } } -impl DeviceFlowAction { - fn label(self) -> &'static str { - match self { - DeviceFlowAction::BackupApply => "Backup + Apply", - DeviceFlowAction::RetryRead => "Retry Read", - DeviceFlowAction::RestoreBackup => "Restore", - DeviceFlowAction::GuidedTest => "Button Test", - DeviceFlowAction::Firmware => "Firmware", - DeviceFlowAction::Done => "Done", - } - } +pub async fn run_ui(core: OpenBitdoCore, opts: UiLaunchOptions) -> Result<()> { + runtime::r#loop::run_ui_loop(core, opts).await } -pub async fn run_tui_app(core: OpenBitdoCore, opts: TuiLaunchOptions) -> Result<()> { - let _ = prune_reports_on_startup().await; - let initial_report_mode = if !opts.advanced_mode && opts.report_save_mode == ReportSaveMode::Off - { - ReportSaveMode::FailureOnly - } else { - opts.report_save_mode - }; - - let mut app = TuiApp { - build_info: opts.build_info.clone(), - advanced_mode: opts.advanced_mode, - report_save_mode: initial_report_mode, - settings_path: opts.settings_path.clone(), - ..Default::default() - }; - core.set_advanced_mode(opts.advanced_mode); - - let devices = core.list_devices().await?; - app.refresh_devices(devices); - match app.devices.len() { - 0 => { - app.last_message = - "No controller detected. Plug one in, then choose Refresh.".to_owned() - } - 1 => { - app.last_message = - "Controller detected and auto-selected. Choose Recommended Update or Diagnose." - .to_owned() - } - _ => { - app.last_message = - "Select a controller, then choose Recommended Update or Diagnose.".to_owned() - } - } - - if opts.no_ui { - let selected = opts - .selected_vid_pid - .or_else(|| { - app.devices - .iter() - .find(|d| d.support_tier == SupportTier::Full) - .map(|d| d.vid_pid) - }) - .or_else(|| app.devices.first().map(|d| d.vid_pid)) - .ok_or_else(|| anyhow!("no devices detected"))?; - - let firmware_path = match opts.firmware_path.clone() { - Some(path) => path, - None => core - .download_recommended_firmware(selected) - .await - .map(|d| d.firmware_path)?, - }; - - run_tui_flow( - core, - TuiRunRequest { - vid_pid: selected, - firmware_path, - allow_unsafe: opts.allow_unsafe, - brick_risk_ack: opts.brick_risk_ack, - experimental: opts.experimental, - chunk_size: opts.chunk_size, - acknowledged_risk: true, - no_ui: true, - }, - ) - .await?; - - return Ok(()); - } - - let mut terminal = Some(init_terminal()?); - let mut firmware_events: Option> = None; - - if app.devices.len() == 1 { - let action = if app - .selected_device() - .map(|d| d.support_tier == SupportTier::Full) - .unwrap_or(false) - { - HomeAction::Update - } else { - HomeAction::Diagnose - }; - let _ = execute_home_action( - &core, - &mut terminal, - &mut app, - &opts, - &mut firmware_events, - action, - ) - .await?; - } - - loop { - poll_firmware_progress(&core, &mut app, &mut firmware_events).await?; - render_if_needed(&mut terminal, &app)?; - - if !event::poll(Duration::from_millis(120))? { - continue; - } - - match event::read()? { - CEvent::Key(key) => { - if handle_key_event( - &core, - &mut terminal, - &mut app, - &opts, - &mut firmware_events, - key.code, - ) - .await? - { - teardown_terminal(&mut terminal)?; - return Ok(()); - } - } - CEvent::Mouse(mouse) => { - if handle_mouse_event( - &core, - &mut terminal, - &mut app, - &opts, - &mut firmware_events, - mouse, - ) - .await? - { - teardown_terminal(&mut terminal)?; - return Ok(()); - } - } - _ => {} - } - } +pub async fn run_headless( + core: OpenBitdoCore, + opts: RunLaunchOptions, +) -> Result { + headless::run_headless(core, opts).await } -async fn handle_key_event( - core: &OpenBitdoCore, - terminal: &mut Option>>, - app: &mut TuiApp, - opts: &TuiLaunchOptions, - firmware_events: &mut Option>, - key: KeyCode, -) -> Result { - if app.advanced_mode && handle_report_hotkey(app, key)? { - return Ok(false); - } - - match app.state { - TuiWorkflowState::About => match key { - KeyCode::Esc | KeyCode::Enter => app.close_overlay(), - KeyCode::Char('t') => { - app.toggle_advanced_mode(core)?; - } - KeyCode::Char('r') => { - app.cycle_report_save_mode()?; - } - KeyCode::Char('v') => app.toggle_fingerprint_view(), - KeyCode::Char('q') => return Ok(true), - _ => {} - }, - TuiWorkflowState::HelpOverlay => match key { - KeyCode::Esc | KeyCode::Enter => app.close_overlay(), - KeyCode::Char('q') => return Ok(true), - _ => {} - }, - TuiWorkflowState::WaitForDevice => { - let action = match key { - KeyCode::Enter | KeyCode::Char('r') => Some(HomeAction::Refresh), - KeyCode::Char('?') => Some(HomeAction::Help), - KeyCode::Char('q') | KeyCode::Esc => Some(HomeAction::Quit), - _ => None, - }; - - if let Some(action) = action { - return execute_home_action(core, terminal, app, opts, firmware_events, action) - .await; - } - } - TuiWorkflowState::Home => { - let action = match key { - KeyCode::Char('q') | KeyCode::Esc => Some(HomeAction::Quit), - KeyCode::Down | KeyCode::Char('j') => { - app.select_next(); - None - } - KeyCode::Up | KeyCode::Char('k') => { - app.select_prev(); - None - } - KeyCode::Char('d') => Some(HomeAction::Diagnose), - KeyCode::Char('r') => Some(HomeAction::Refresh), - KeyCode::Char('a') => Some(HomeAction::About), - KeyCode::Char('?') => Some(HomeAction::Help), - KeyCode::Enter | KeyCode::Char('u') => Some(HomeAction::Update), - _ => None, - }; - - if let Some(action) = action { - return execute_home_action(core, terminal, app, opts, firmware_events, action) - .await; - } - } - TuiWorkflowState::Jp108Mapping => { - let action = match key { - KeyCode::Down | KeyCode::Char('j') => { - if !app.jp108_mappings.is_empty() { - app.jp108_selected = (app.jp108_selected + 1) % app.jp108_mappings.len(); - } - None - } - KeyCode::Up | KeyCode::Char('k') => { - if !app.jp108_mappings.is_empty() { - if app.jp108_selected == 0 { - app.jp108_selected = app.jp108_mappings.len().saturating_sub(1); - } else { - app.jp108_selected -= 1; - } - } - None - } - KeyCode::Left => { - jp108_adjust_selected_usage(app, -1); - None - } - KeyCode::Right => { - jp108_adjust_selected_usage(app, 1); - None - } - KeyCode::Enter => Some(DeviceFlowAction::BackupApply), - KeyCode::Char('b') => Some(DeviceFlowAction::BackupApply), - KeyCode::Char('r') => Some(DeviceFlowAction::RetryRead), - KeyCode::Char('s') => Some(DeviceFlowAction::RestoreBackup), - KeyCode::Char('t') => Some(DeviceFlowAction::GuidedTest), - KeyCode::Char('f') => Some(DeviceFlowAction::Firmware), - KeyCode::Esc | KeyCode::Char('q') => Some(DeviceFlowAction::Done), - _ => None, - }; - - if let Some(action) = action { - return execute_device_flow_action( - core, - terminal, - app, - opts, - firmware_events, - action, - ) - .await; - } - } - TuiWorkflowState::U2CoreProfile => { - let action = match key { - KeyCode::Down | KeyCode::Char('j') => { - if let Some(profile) = app.u2_profile.as_ref() { - if !profile.mappings.is_empty() { - app.u2_selected = (app.u2_selected + 1) % profile.mappings.len(); - } - } - None - } - KeyCode::Up | KeyCode::Char('k') => { - if let Some(profile) = app.u2_profile.as_ref() { - if !profile.mappings.is_empty() { - if app.u2_selected == 0 { - app.u2_selected = profile.mappings.len().saturating_sub(1); - } else { - app.u2_selected -= 1; - } - } - } - None - } - KeyCode::Left => { - u2_adjust_selected_usage(app, -1); - None - } - KeyCode::Right => { - u2_adjust_selected_usage(app, 1); - None - } - KeyCode::Char('1') => { - if let Some(profile) = app.u2_profile.as_mut() { - profile.slot = U2SlotId::Slot1; - } - None - } - KeyCode::Char('2') => { - if let Some(profile) = app.u2_profile.as_mut() { - profile.slot = U2SlotId::Slot2; - } - None - } - KeyCode::Char('3') => { - if let Some(profile) = app.u2_profile.as_mut() { - profile.slot = U2SlotId::Slot3; - } - None - } - KeyCode::Char('m') => { - if let Some(profile) = app.u2_profile.as_mut() { - profile.mode = (profile.mode + 1) % 4; - } - None - } - KeyCode::Char('[') => { - if let Some(profile) = app.u2_profile.as_mut() { - if profile.supports_trigger_write { - profile.l2_analog = (profile.l2_analog - 0.05).clamp(0.0, 1.0); - } - } - None - } - KeyCode::Char(']') => { - if let Some(profile) = app.u2_profile.as_mut() { - if profile.supports_trigger_write { - profile.l2_analog = (profile.l2_analog + 0.05).clamp(0.0, 1.0); - } - } - None - } - KeyCode::Char(';') => { - if let Some(profile) = app.u2_profile.as_mut() { - if profile.supports_trigger_write { - profile.r2_analog = (profile.r2_analog - 0.05).clamp(0.0, 1.0); - } - } - None - } - KeyCode::Char('\'') => { - if let Some(profile) = app.u2_profile.as_mut() { - if profile.supports_trigger_write { - profile.r2_analog = (profile.r2_analog + 0.05).clamp(0.0, 1.0); - } - } - None - } - KeyCode::Enter => Some(DeviceFlowAction::BackupApply), - KeyCode::Char('b') => Some(DeviceFlowAction::BackupApply), - KeyCode::Char('r') => Some(DeviceFlowAction::RetryRead), - KeyCode::Char('s') => Some(DeviceFlowAction::RestoreBackup), - KeyCode::Char('t') => Some(DeviceFlowAction::GuidedTest), - KeyCode::Char('f') => Some(DeviceFlowAction::Firmware), - KeyCode::Esc | KeyCode::Char('q') => Some(DeviceFlowAction::Done), - _ => None, - }; - - if let Some(action) = action { - return execute_device_flow_action( - core, - terminal, - app, - opts, - firmware_events, - action, - ) - .await; - } - } - TuiWorkflowState::Recovery => { - match key { - KeyCode::Char('r') => { - if let Some(backup) = app.latest_backup.clone() { - match core.restore_backup(backup).await { - Ok(_) => { - app.last_message = "Recovery restore succeeded. Restart OpenBitdo before attempting writes again.".to_owned(); - } - Err(err) => { - app.last_message = format!("Recovery restore failed: {err}"); - } - } - } else { - app.last_message = "No backup available to restore. Use diagnostics and restart OpenBitdo.".to_owned(); - } - } - KeyCode::Enter | KeyCode::Esc => { - app.set_home_message( - "Recovery mode exited. Write actions remain locked until restart.", - ); - } - KeyCode::Char('q') => return Ok(true), - _ => {} - } - } - TuiWorkflowState::Preflight => match key { - KeyCode::Enter | KeyCode::Char('y') => { - start_pending_update(core, app, firmware_events).await?; - } - KeyCode::Esc | KeyCode::Char('c') => { - app.set_home_message("Update cancelled before transfer."); - } - KeyCode::Char('q') => return Ok(true), - _ => {} - }, - TuiWorkflowState::Updating => match key { - KeyCode::Esc | KeyCode::Char('c') => { - cancel_running_update(core, app, firmware_events).await?; - } - KeyCode::Char('q') => { - cancel_running_update(core, app, firmware_events).await?; - return Ok(true); - } - _ => {} - }, - TuiWorkflowState::FinalReport => match key { - KeyCode::Enter | KeyCode::Esc => app.set_home_message("Ready for next action."), - KeyCode::Char('q') => return Ok(true), - KeyCode::Char('a') => app.open_about(), - _ => {} - }, - } - - Ok(false) -} - -async fn handle_mouse_event( - core: &OpenBitdoCore, - terminal: &mut Option>>, - app: &mut TuiApp, - opts: &TuiLaunchOptions, - firmware_events: &mut Option>, - mouse: MouseEvent, -) -> Result { - let Some(size) = terminal.as_ref().map(|t| t.size()).transpose()? else { - return Ok(false); - }; - - let area = Rect::new(0, 0, size.width, size.height); - - match app.state { - TuiWorkflowState::About | TuiWorkflowState::HelpOverlay => { - if app.state == TuiWorkflowState::About { - let (toggle_rect, report_mode_rect, fingerprint_rect) = about_buttons_rects(area); - if matches!(mouse.kind, MouseEventKind::Moved) { - app.about_toggle_hovered = point_in_rect(mouse.column, mouse.row, toggle_rect); - app.about_report_mode_hovered = - point_in_rect(mouse.column, mouse.row, report_mode_rect); - app.about_fingerprint_hovered = - point_in_rect(mouse.column, mouse.row, fingerprint_rect); - } - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if point_in_rect(mouse.column, mouse.row, toggle_rect) { - app.toggle_advanced_mode(core)?; - } else if point_in_rect(mouse.column, mouse.row, report_mode_rect) { - app.cycle_report_save_mode()?; - } else if point_in_rect(mouse.column, mouse.row, fingerprint_rect) { - app.toggle_fingerprint_view(); - } else { - app.close_overlay(); - } - } - } else if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - app.close_overlay(); - } - } - TuiWorkflowState::WaitForDevice => { - let layout = waiting_layout(area); - let buttons = action_buttons(layout.actions, &WAIT_ACTIONS); - if matches!(mouse.kind, MouseEventKind::Moved) { - app.hovered_action = button_hit(mouse.column, mouse.row, &buttons); - } - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = button_hit(mouse.column, mouse.row, &buttons) { - app.hovered_action = Some(action); - return execute_home_action(core, terminal, app, opts, firmware_events, action) - .await; - } - } - } - TuiWorkflowState::Home => { - let layout = home_layout(area); - let buttons = action_buttons(layout.actions, &HOME_ACTIONS); - - if let Some(menu) = app.context_menu.as_mut() { - if matches!(mouse.kind, MouseEventKind::Moved) { - menu.hovered_index = context_menu_item_at(area, *menu, mouse.column, mouse.row) - .map(action_index); - } - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = context_menu_item_at(area, *menu, mouse.column, mouse.row) - { - app.close_context_menu(); - return execute_home_action( - core, - terminal, - app, - opts, - firmware_events, - action, - ) - .await; - } - app.close_context_menu(); - } - } - - match mouse.kind { - MouseEventKind::Moved => { - app.hovered_action = button_hit(mouse.column, mouse.row, &buttons); - } - MouseEventKind::Down(MouseButton::Left) => { - if let Some(action) = button_hit(mouse.column, mouse.row, &buttons) { - app.hovered_action = Some(action); - return execute_home_action( - core, - terminal, - app, - opts, - firmware_events, - action, - ) - .await; - } - - if let Some(idx) = device_row_at(app, layout.devices, mouse.row) { - app.select_index(idx); - } - } - MouseEventKind::Down(MouseButton::Right) => { - if let Some(idx) = device_row_at(app, layout.devices, mouse.row) { - app.select_index(idx); - app.open_context_menu(mouse.column, mouse.row); - } - } - MouseEventKind::ScrollDown => { - if point_in_rect(mouse.column, mouse.row, layout.devices) { - app.select_next(); - } else if point_in_rect(mouse.column, mouse.row, layout.detail) { - app.detail_scroll = app.detail_scroll.saturating_add(1); - } - } - MouseEventKind::ScrollUp => { - if point_in_rect(mouse.column, mouse.row, layout.devices) { - app.select_prev(); - } else if point_in_rect(mouse.column, mouse.row, layout.detail) { - app.detail_scroll = app.detail_scroll.saturating_sub(1); - } - } - _ => {} - } - } - TuiWorkflowState::Jp108Mapping => { - let layout = simple_action_layout(area); - let buttons = flow_buttons(layout.actions, &DEVICE_FLOW_ACTIONS); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = flow_button_hit(mouse.column, mouse.row, &buttons) { - return execute_device_flow_action( - core, - terminal, - app, - opts, - firmware_events, - action, - ) - .await; - } - if let Some(row_idx) = - mapping_row_hit(layout.body, mouse.row, app.jp108_mappings.len()) - { - app.jp108_selected = row_idx; - } - } - match mouse.kind { - MouseEventKind::ScrollDown => jp108_adjust_selected_usage(app, 1), - MouseEventKind::ScrollUp => jp108_adjust_selected_usage(app, -1), - _ => {} - } - } - TuiWorkflowState::U2CoreProfile => { - let layout = simple_action_layout(area); - let buttons = flow_buttons(layout.actions, &DEVICE_FLOW_ACTIONS); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - if let Some(action) = flow_button_hit(mouse.column, mouse.row, &buttons) { - return execute_device_flow_action( - core, - terminal, - app, - opts, - firmware_events, - action, - ) - .await; - } - if let Some(profile) = app.u2_profile.as_ref() { - if let Some(row_idx) = - mapping_row_hit(layout.body, mouse.row, profile.mappings.len()) - { - app.u2_selected = row_idx; - } - } - } - match mouse.kind { - MouseEventKind::ScrollDown => u2_adjust_selected_usage(app, 1), - MouseEventKind::ScrollUp => u2_adjust_selected_usage(app, -1), - _ => {} - } - } - TuiWorkflowState::Recovery => { - let layout = simple_action_layout(area); - let buttons = action_buttons( - layout.actions, - &[HomeAction::Refresh, HomeAction::About, HomeAction::Quit], - ); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - match button_hit(mouse.column, mouse.row, &buttons) { - Some(HomeAction::Refresh) => { - if let Some(backup) = app.latest_backup.clone() { - match core.restore_backup(backup).await { - Ok(_) => { - app.last_message = "Recovery restore succeeded. Restart OpenBitdo before attempting writes again.".to_owned(); - } - Err(err) => { - app.last_message = format!("Recovery restore failed: {err}"); - } - } - } else { - app.last_message = "No backup available to restore.".to_owned(); - } - } - Some(HomeAction::About) => { - app.set_home_message( - "Recovery mode exited. Write actions remain locked until restart.", - ); - } - Some(HomeAction::Quit) => return Ok(true), - _ => {} - } - } - } - TuiWorkflowState::Preflight => { - let layout = simple_action_layout(area); - let buttons = action_buttons(layout.actions, &[HomeAction::Update, HomeAction::Quit]); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - match button_hit(mouse.column, mouse.row, &buttons) { - Some(HomeAction::Update) => { - start_pending_update(core, app, firmware_events).await? - } - Some(HomeAction::Quit) => { - app.set_home_message("Update cancelled before transfer.") - } - _ => {} - } - } - } - TuiWorkflowState::Updating => { - let layout = simple_action_layout(area); - let buttons = action_buttons(layout.actions, &[HomeAction::Quit]); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) - && button_hit(mouse.column, mouse.row, &buttons) == Some(HomeAction::Quit) - { - cancel_running_update(core, app, firmware_events).await?; - } - } - TuiWorkflowState::FinalReport => { - let layout = simple_action_layout(area); - let buttons = action_buttons(layout.actions, &[HomeAction::Refresh, HomeAction::Quit]); - if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) { - match button_hit(mouse.column, mouse.row, &buttons) { - Some(HomeAction::Refresh) => app.set_home_message("Ready for next action."), - Some(HomeAction::Quit) => return Ok(true), - _ => {} - } - } - } - } - - Ok(false) -} - -async fn execute_home_action( - core: &OpenBitdoCore, - terminal: &mut Option>>, - app: &mut TuiApp, - opts: &TuiLaunchOptions, - firmware_events: &mut Option>, - action: HomeAction, -) -> Result { - match action { - HomeAction::About => app.open_about(), - HomeAction::Help => app.open_help(), - HomeAction::Quit => return Ok(true), - HomeAction::Refresh => match core.list_devices().await { - Ok(devices) => { - app.refresh_devices(devices); - app.last_message = match app.devices.len() { - 0 => { - "Still waiting for a controller. Plug one in and choose Refresh.".to_owned() - } - 1 => "Controller detected and auto-selected.".to_owned(), - count => format!("Detected {count} controllers. Select one to continue."), - }; - } - Err(err) => app.last_message = format!("Refresh failed: {err}"), - }, - HomeAction::Diagnose => { - if let Some(selected) = app.selected_device().cloned() { - match core.diag_probe(selected.vid_pid).await { - Ok(diag) => { - let confirmed_total = diag - .command_checks - .iter() - .filter(|c| !c.is_experimental) - .count(); - let confirmed_ok = diag - .command_checks - .iter() - .filter(|c| !c.is_experimental && c.ok) - .count(); - let experimental_total = diag - .command_checks - .iter() - .filter(|c| c.is_experimental) - .count(); - let experimental_ok = diag - .command_checks - .iter() - .filter(|c| c.is_experimental && c.ok) - .count(); - let needs_attention = diag - .command_checks - .iter() - .filter(|c| c.severity == bitdo_proto::DiagSeverity::NeedsAttention) - .count(); - app.last_message = format!( - "Diagnostics for {}\nConfirmed checks: {}/{}\nExperimental checks: {}/{}\nNeeds attention: {}\n{}", - selected.vid_pid, - confirmed_ok, - confirmed_total, - experimental_ok, - experimental_total, - needs_attention, - core.beginner_diag_summary(&selected, &diag) - ); - if should_save_support_report(app.report_save_mode, false) { - if let Ok(path) = persist_support_report( - "diag-probe", - Some(&selected), - "ok", - app.last_message.clone(), - Some(&diag), - None, - ) - .await - { - app.remember_report_path(path.clone()); - app.last_message = format!( - "Diagnostics complete. Support file saved: {}", - path.to_string_lossy() - ); - } - } - } - Err(err) => { - app.last_message = format!("Diagnostics failed: {err}"); - if should_save_support_report(app.report_save_mode, true) { - if let Ok(path) = persist_support_report( - "diag-probe", - Some(&selected), - "failed", - app.last_message.clone(), - None, - None, - ) - .await - { - app.remember_report_path(path.clone()); - if app.advanced_mode { - app.last_message = format!( - "Diagnostics failed. Report saved: {} (c=copy o=open f=folder)", - path.to_string_lossy() - ); - } else { - app.last_message = format!( - "Diagnostics failed. A support file was saved: {}", - path.to_string_lossy() - ); - } - } - } - } - } - } else { - app.last_message = "No device selected.".to_owned(); - } - } - HomeAction::Update => { - let Some(selected) = app.selected_device().cloned() else { - app.last_message = "No device selected.".to_owned(); - return Ok(false); - }; - - if app.write_lock_until_restart { - app.state = TuiWorkflowState::Recovery; - app.last_message = - "Write actions are locked until restart due to a failed rollback.".to_owned(); - return Ok(false); - } - - if selected.support_tier != SupportTier::Full { - app.last_message = format!( - "Recommended Update is coming soon for {} ({}). This device is currently read-only in OpenBitdo. Use Diagnose for now.", - selected.name, selected.vid_pid - ); - return Ok(false); - } - - if selected.capability.supports_jp108_dedicated_map { - match core.jp108_read_dedicated_mapping(selected.vid_pid).await { - Ok(mappings) => app.begin_jp108_mapping(mappings), - Err(err) => app.last_message = format!("JP108 mapping read failed: {err}"), - } - } else if selected.capability.supports_u2_button_map - && selected.capability.supports_u2_slot_config - { - match core - .u2_read_core_profile(selected.vid_pid, U2SlotId::Slot1) - .await - { - Ok(profile) => app.begin_u2_profile(profile), - Err(err) => app.last_message = format!("Ultimate2 profile read failed: {err}"), - } - } else { - prepare_update_preflight(core, terminal, app, opts, firmware_events).await?; - } - } - } - - Ok(false) -} - -async fn execute_device_flow_action( - core: &OpenBitdoCore, - terminal: &mut Option>>, - app: &mut TuiApp, - opts: &TuiLaunchOptions, - firmware_events: &mut Option>, - action: DeviceFlowAction, -) -> Result { - let Some(selected) = app.selected_device().cloned() else { - app.last_message = "No device selected.".to_owned(); - return Ok(false); - }; - - if app.write_lock_until_restart - && matches!( - action, - DeviceFlowAction::BackupApply | DeviceFlowAction::Firmware - ) - { - app.state = TuiWorkflowState::Recovery; - app.last_message = - "Write actions are locked until restart because recovery has not completed.".to_owned(); - return Ok(false); - } - - match action { - DeviceFlowAction::Done => app.set_home_message("Ready for next action."), - DeviceFlowAction::Firmware => { - prepare_update_preflight(core, terminal, app, opts, firmware_events).await?; - } - DeviceFlowAction::RetryRead => { - if app.state == TuiWorkflowState::Jp108Mapping { - match core.jp108_read_dedicated_mapping(selected.vid_pid).await { - Ok(mappings) => app.begin_jp108_mapping(mappings), - Err(err) => app.last_message = format!("Reload failed: {err}"), - } - } else if app.state == TuiWorkflowState::U2CoreProfile { - let slot = app - .u2_profile - .as_ref() - .map(|p| p.slot) - .unwrap_or(U2SlotId::Slot1); - match core.u2_read_core_profile(selected.vid_pid, slot).await { - Ok(profile) => app.begin_u2_profile(profile), - Err(err) => app.last_message = format!("Reload failed: {err}"), - } - } - } - DeviceFlowAction::BackupApply => { - if app.state == TuiWorkflowState::Jp108Mapping { - let warnings = jp108_mapping_warnings(&app.jp108_mappings); - match core - .jp108_apply_dedicated_mapping_with_recovery( - selected.vid_pid, - app.jp108_mappings.clone(), - true, - ) - .await - { - Ok(result) => { - if result.write_applied { - app.latest_backup = result.backup_id; - if warnings.is_empty() { - app.last_message = - "JP108 mapping applied. Run Button Test or continue to Firmware." - .to_owned(); - } else { - app.last_message = format!( - "JP108 mapping applied with warnings (allowed): {}", - warnings.join(" ") - ); - } - } else if result.rollback_failed() { - app.latest_backup = result.backup_id.clone(); - app.enter_recovery(result); - } else { - app.latest_backup = result.backup_id; - app.last_message = - "Apply failed, but rollback restored your previous mapping safely." - .to_owned(); - } - } - Err(err) => { - app.last_message = format!("Apply failed: {err}"); - } - } - } else if app.state == TuiWorkflowState::U2CoreProfile { - if let Some(profile) = app.u2_profile.clone() { - let warnings = u2_mapping_warnings(&profile.mappings); - match core - .u2_apply_core_profile_with_recovery( - selected.vid_pid, - profile.slot, - profile.mode, - profile.mappings, - profile.l2_analog, - profile.r2_analog, - true, - ) - .await - { - Ok(result) => { - if result.write_applied { - app.latest_backup = result.backup_id; - if warnings.is_empty() { - app.last_message = "Ultimate2 profile applied. Run Button Test or continue to Firmware." - .to_owned(); - } else { - app.last_message = format!( - "Ultimate2 profile applied with warnings (allowed): {}", - warnings.join(" ") - ); - } - } else if result.rollback_failed() { - app.latest_backup = result.backup_id.clone(); - app.enter_recovery(result); - } else { - app.latest_backup = result.backup_id; - app.last_message = "Apply failed, but rollback restored your previous Ultimate2 profile safely." - .to_owned(); - } - } - Err(err) => { - app.last_message = format!("Apply failed: {err}"); - } - } - } - } - } - DeviceFlowAction::RestoreBackup => { - if let Some(backup) = app.latest_backup.clone() { - match core.restore_backup(backup).await { - Ok(_) => app.last_message = "Backup restored successfully.".to_owned(), - Err(err) => app.last_message = format!("Restore failed: {err}"), - } - } else { - app.last_message = "No backup available yet. Use Backup + Apply first.".to_owned(); - } - } - DeviceFlowAction::GuidedTest => { - let result = if app.state == TuiWorkflowState::Jp108Mapping { - core.guided_button_test( - DeviceKind::Jp108, - app.jp108_mappings - .iter() - .map(|entry| { - format!("{:?} -> 0x{:04x}", entry.button, entry.target_hid_usage) - }) - .collect(), - ) - .await - } else { - let expected = app - .u2_profile - .as_ref() - .map(|profile| { - profile - .mappings - .iter() - .map(|entry| { - format!( - "{:?} -> {} (0x{:04x})", - entry.button, - u2_target_label(entry.target_hid_usage), - entry.target_hid_usage - ) - }) - .collect::>() - }) - .unwrap_or_default(); - core.guided_button_test(DeviceKind::Ultimate2, expected) - .await - }; - - match result { - Ok(report) => app.last_message = report.guidance, - Err(err) => app.last_message = format!("Guided test failed: {err}"), - } - } - } - - Ok(false) -} - -async fn prepare_update_preflight( - core: &OpenBitdoCore, - terminal: &mut Option>>, - app: &mut TuiApp, - opts: &TuiLaunchOptions, - firmware_events: &mut Option>, -) -> Result<()> { - *firmware_events = None; - - let Some(selected) = app.selected_device().cloned() else { - app.last_message = "No device selected.".to_owned(); - return Ok(()); - }; - - if selected.support_tier != SupportTier::Full { - app.last_message = format!( - "Firmware update is blocked for {} until hardware confirmation is complete. You can still run diagnostics.", - selected.vid_pid - ); - return Ok(()); - } - - let (firmware_path, source_label, version_label) = match opts.firmware_path.clone() { - Some(path) => (path, "local file".to_owned(), "manual".to_owned()), - None => match core.download_recommended_firmware(selected.vid_pid).await { - Ok(download) => ( - download.firmware_path, - "recommended verified download".to_owned(), - download.version, - ), - Err(err) => { - let prompt = format!( - "Recommended firmware unavailable ({err}). Enter local firmware path: " - ); - let input = prompt_line(terminal, &prompt)?; - if input.trim().is_empty() { - app.last_message = "Update cancelled: no firmware file selected.".to_owned(); - return Ok(()); - } - ( - PathBuf::from(input), - "local file fallback".to_owned(), - "manual".to_owned(), - ) - } - }, - }; - - let preflight = core - .preflight_firmware(FirmwarePreflightRequest { - vid_pid: selected.vid_pid, - firmware_path: firmware_path.clone(), - allow_unsafe: opts.allow_unsafe, - brick_risk_ack: opts.brick_risk_ack, - experimental: opts.experimental, - chunk_size: opts.chunk_size, - }) - .await?; - - if !preflight.gate.allowed { - let reason = preflight - .gate - .message - .unwrap_or_else(|| "Update is not allowed for this device.".to_owned()); - app.last_message = format!("Preflight blocked: {reason}"); - return Ok(()); - } - - let plan = preflight - .plan - .ok_or_else(|| anyhow!("missing preflight plan for allowed request"))?; - - app.begin_preflight(PendingUpdate { - target: selected, - firmware_path, - firmware_source: source_label, - firmware_version: version_label, - plan, - }); - - Ok(()) -} - -async fn start_pending_update( - core: &OpenBitdoCore, - app: &mut TuiApp, - firmware_events: &mut Option>, -) -> Result<()> { - let Some(pending) = app.pending_update.clone() else { - app.set_home_message("No preflight plan found. Start from Home."); - return Ok(()); - }; - - core.start_firmware(FirmwareStartRequest { - session_id: pending.plan.session_id.clone(), - }) - .await?; - - core.confirm_firmware(FirmwareConfirmRequest { - session_id: pending.plan.session_id.clone(), - acknowledged_risk: true, - }) - .await?; - - *firmware_events = Some(core.subscribe_events(&pending.plan.session_id.0).await?); - app.set_session(pending.plan.session_id.clone()); - app.last_message = format!( - "Transferring firmware {} from {}. Press Esc or click Cancel to stop.", - pending.firmware_version, pending.firmware_source - ); - - Ok(()) -} - -async fn cancel_running_update( - core: &OpenBitdoCore, - app: &mut TuiApp, - firmware_events: &mut Option>, -) -> Result<()> { - let Some(session_id) = app.session_id.clone() else { - return Ok(()); - }; - - let report = core - .cancel_firmware(FirmwareCancelRequest { session_id }) - .await?; - app.complete(report.clone()); - *firmware_events = None; - - if report.status != FirmwareOutcome::Completed - && should_save_support_report(app.report_save_mode, true) - { - let selected = app.selected_device().cloned(); - if let Ok(path) = persist_support_report( - "fw-write", - selected.as_ref(), - "cancelled", - report.message.clone(), - None, - Some(&report), - ) - .await - { - app.remember_report_path(path.clone()); - if app.advanced_mode { - app.last_message = format!( - "Update cancelled. Report saved: {} (c=copy o=open f=folder)", - path.to_string_lossy() - ); - } else { - app.last_message = format!( - "Update cancelled. A support file was saved: {}", - path.to_string_lossy() - ); - } - } - } - - Ok(()) -} - -async fn poll_firmware_progress( - core: &OpenBitdoCore, - app: &mut TuiApp, - firmware_events: &mut Option>, -) -> Result<()> { - if app.state != TuiWorkflowState::Updating { - return Ok(()); - } - - if let Some(receiver) = firmware_events.as_mut() { - loop { - match receiver.try_recv() { - Ok(evt) => { - app.apply_progress(evt.progress, format!("{}: {}", evt.stage, evt.message)); - } - Err(broadcast::error::TryRecvError::Empty) => break, - Err(broadcast::error::TryRecvError::Lagged(_)) => continue, - Err(broadcast::error::TryRecvError::Closed) => { - *firmware_events = None; - break; - } - } - } - } - - if let Some(session_id) = app.session_id.as_ref() { - if let Some(report) = core.firmware_report(&session_id.0).await? { - app.complete(report.clone()); - *firmware_events = None; - match report.status { - FirmwareOutcome::Completed => { - app.last_message = - "Firmware update completed. Press Enter to continue.".to_owned(); - if should_save_support_report(app.report_save_mode, false) { - let selected = app.selected_device().cloned(); - if let Ok(path) = persist_support_report( - "fw-write", - selected.as_ref(), - "completed", - report.message.clone(), - None, - Some(&report), - ) - .await - { - app.remember_report_path(path.clone()); - app.last_message = format!( - "Firmware update completed. Support file saved: {}", - path.to_string_lossy() - ); - } - } - } - FirmwareOutcome::Cancelled | FirmwareOutcome::Failed => { - app.last_message = - "Firmware update did not complete. Press Enter to return Home.".to_owned(); - if should_save_support_report(app.report_save_mode, true) { - let selected = app.selected_device().cloned(); - if let Ok(path) = persist_support_report( - "fw-write", - selected.as_ref(), - "failed", - report.message.clone(), - None, - Some(&report), - ) - .await - { - app.remember_report_path(path.clone()); - if app.advanced_mode { - app.last_message = format!( - "Firmware update failed. Report saved: {} (c=copy o=open f=folder)", - path.to_string_lossy() - ); - } else { - app.last_message = format!( - "Firmware update failed. A support file was saved: {}", - path.to_string_lossy() - ); - } - } - } - } - } - } - } - - Ok(()) -} - -fn home_layout(area: Rect) -> HomeLayout { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(10), - Constraint::Length(5), - Constraint::Length(3), - Constraint::Min(6), - ]) - .split(area); - - let detail_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(4), Constraint::Length(5)]) - .split(chunks[4]); - - HomeLayout { - title: chunks[0], - devices: chunks[1], - actions: chunks[2], - progress: chunks[3], - detail: detail_chunks[0], - blocked: detail_chunks[1], - } -} - -fn waiting_layout(area: Rect) -> WaitingLayout { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(8), - Constraint::Length(5), - Constraint::Length(3), - ]) - .split(area); - - WaitingLayout { - header: chunks[0], - body: chunks[1], - actions: chunks[2], - footer: chunks[3], - } -} - -fn simple_action_layout(area: Rect) -> SimpleActionLayout { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(10), - Constraint::Length(5), - Constraint::Length(3), - ]) - .split(area); - - SimpleActionLayout { - body: chunks[0], - actions: chunks[1], - footer: chunks[2], - } -} - -#[derive(Clone, Copy)] -struct HomeLayout { - title: Rect, - devices: Rect, - actions: Rect, - progress: Rect, - detail: Rect, - blocked: Rect, -} - -#[derive(Clone, Copy)] -struct WaitingLayout { - header: Rect, - body: Rect, - actions: Rect, - footer: Rect, -} - -#[derive(Clone, Copy)] -struct SimpleActionLayout { - body: Rect, - actions: Rect, - footer: Rect, -} - -fn action_buttons(area: Rect, actions: &[HomeAction]) -> Vec<(Rect, HomeAction)> { - if actions.is_empty() { - return Vec::new(); - } - - let constraints = - vec![Constraint::Percentage((100 / actions.len()).max(1) as u16); actions.len()]; - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints(constraints) - .split(area); - - chunks - .iter() - .copied() - .zip(actions.iter().copied()) - .collect::>() -} - -fn flow_buttons(area: Rect, actions: &[DeviceFlowAction]) -> Vec<(Rect, DeviceFlowAction)> { - if actions.is_empty() { - return Vec::new(); - } - - let constraints = - vec![Constraint::Percentage((100 / actions.len()).max(1) as u16); actions.len()]; - let chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints(constraints) - .split(area); - - chunks - .iter() - .copied() - .zip(actions.iter().copied()) - .collect::>() -} - -fn button_hit(column: u16, row: u16, buttons: &[(Rect, HomeAction)]) -> Option { - buttons - .iter() - .find(|(rect, _)| point_in_rect(column, row, *rect)) - .map(|(_, action)| *action) -} - -fn flow_button_hit( - column: u16, - row: u16, - buttons: &[(Rect, DeviceFlowAction)], -) -> Option { - buttons - .iter() - .find(|(rect, _)| point_in_rect(column, row, *rect)) - .map(|(_, action)| *action) -} - -fn mapping_row_hit(body_rect: Rect, row: u16, total_rows: usize) -> Option { - let start = body_rect.y.saturating_add(2); - if row < start { - return None; - } - let idx = row.saturating_sub(start) as usize; - if idx < total_rows { - Some(idx) - } else { - None - } -} - -const HID_USAGE_PRESETS: [u16; 16] = [ - 0x0004, 0x0005, 0x0006, 0x0007, 0x0008, 0x0009, 0x000a, 0x000b, 0x0028, 0x0029, 0x002c, 0x003a, - 0x003b, 0x003c, 0x00e0, 0x00e1, -]; - -// Ultimate2 target set is intentionally restricted to known controller-button -// codes for RC safety/readability. -const U2_TARGET_PRESETS: [u16; 17] = [ - 0x0100, // A - 0x0101, // B - 0x0102, // X - 0x0103, // Y - 0x0104, // L1 - 0x0105, // R1 - 0x0106, // L2 - 0x0107, // R2 - 0x0108, // L3 - 0x0109, // R3 - 0x010a, // Select - 0x010b, // Start - 0x010c, // Home - 0x010d, // DPadUp - 0x010e, // DPadDown - 0x010f, // DPadLeft - 0x0110, // DPadRight -]; - -fn cycle_usage(current: u16, delta: i32) -> u16 { - let pos = HID_USAGE_PRESETS - .iter() - .position(|value| *value == current) - .unwrap_or(0) as i32; - let len = HID_USAGE_PRESETS.len() as i32; - let next = (pos + delta).rem_euclid(len) as usize; - HID_USAGE_PRESETS[next] -} - -fn cycle_u2_target(current: u16, delta: i32) -> u16 { - let pos = U2_TARGET_PRESETS - .iter() - .position(|value| *value == current) - .unwrap_or(0) as i32; - let len = U2_TARGET_PRESETS.len() as i32; - let next = (pos + delta).rem_euclid(len) as usize; - U2_TARGET_PRESETS[next] -} - -fn u2_target_label(target: u16) -> &'static str { - match target { - 0x0100 => "A", - 0x0101 => "B", - 0x0102 => "X", - 0x0103 => "Y", - 0x0104 => "L1", - 0x0105 => "R1", - 0x0106 => "L2", - 0x0107 => "R2", - 0x0108 => "L3", - 0x0109 => "R3", - 0x010a => "Select", - 0x010b => "Start", - 0x010c => "Home", - 0x010d => "DPadUp", - 0x010e => "DPadDown", - 0x010f => "DPadLeft", - 0x0110 => "DPadRight", - _ => "Unknown", - } -} - -fn u2_default_target_for_slot(slot: &bitdo_app_core::U2ButtonId) -> u16 { - match slot { - bitdo_app_core::U2ButtonId::A => 0x0100, - bitdo_app_core::U2ButtonId::B => 0x0101, - bitdo_app_core::U2ButtonId::K1 => 0x0102, - bitdo_app_core::U2ButtonId::K2 => 0x0103, - bitdo_app_core::U2ButtonId::K3 => 0x0104, - bitdo_app_core::U2ButtonId::K4 => 0x0105, - bitdo_app_core::U2ButtonId::K5 => 0x0106, - bitdo_app_core::U2ButtonId::K6 => 0x0107, - bitdo_app_core::U2ButtonId::K7 => 0x0108, - bitdo_app_core::U2ButtonId::K8 => 0x0109, - } -} - -fn u2_mapping_warnings(mappings: &[bitdo_app_core::U2ButtonMapping]) -> Vec { - let mut warnings = Vec::new(); - let mut target_counts: HashMap = HashMap::new(); - for mapping in mappings { - *target_counts.entry(mapping.target_hid_usage).or_insert(0) += 1; - } - - for (target, count) in target_counts { - if count > 1 { - warnings.push(format!( - "Duplicate target {} (0x{:04x}) appears {} times.", - u2_target_label(target), - target, - count - )); - } - } - - for mapping in mappings { - if mapping.target_hid_usage == u2_default_target_for_slot(&mapping.button) { - warnings.push(format!( - "Identity mapping kept for {:?} -> {} (0x{:04x}).", - mapping.button, - u2_target_label(mapping.target_hid_usage), - mapping.target_hid_usage - )); - } - } - - warnings -} - -fn jp108_default_target_for_button(button: &bitdo_app_core::DedicatedButtonId) -> u16 { - match button { - bitdo_app_core::DedicatedButtonId::A => 0x0004, - bitdo_app_core::DedicatedButtonId::B => 0x0005, - bitdo_app_core::DedicatedButtonId::K1 => 0x0006, - bitdo_app_core::DedicatedButtonId::K2 => 0x0007, - bitdo_app_core::DedicatedButtonId::K3 => 0x0008, - bitdo_app_core::DedicatedButtonId::K4 => 0x0009, - bitdo_app_core::DedicatedButtonId::K5 => 0x000a, - bitdo_app_core::DedicatedButtonId::K6 => 0x000b, - bitdo_app_core::DedicatedButtonId::K7 => 0x0028, - bitdo_app_core::DedicatedButtonId::K8 => 0x0029, - } -} - -fn jp108_mapping_warnings(mappings: &[bitdo_app_core::DedicatedButtonMapping]) -> Vec { - let mut warnings = Vec::new(); - let mut target_counts: HashMap = HashMap::new(); - for mapping in mappings { - *target_counts.entry(mapping.target_hid_usage).or_insert(0) += 1; - } - - for (target, count) in target_counts { - if count > 1 { - warnings.push(format!( - "Duplicate target 0x{:04x} appears {} times.", - target, count - )); - } - } - - for mapping in mappings { - if mapping.target_hid_usage == jp108_default_target_for_button(&mapping.button) { - warnings.push(format!( - "Identity mapping kept for {:?} -> 0x{:04x}.", - mapping.button, mapping.target_hid_usage - )); - } - } - - warnings -} - -fn jp108_adjust_selected_usage(app: &mut TuiApp, delta: i32) { - if let Some(current) = app.jp108_mappings.get_mut(app.jp108_selected) { - current.target_hid_usage = cycle_usage(current.target_hid_usage, delta); - } -} - -fn u2_adjust_selected_usage(app: &mut TuiApp, delta: i32) { - if let Some(profile) = app.u2_profile.as_mut() { - if let Some(current) = profile.mappings.get_mut(app.u2_selected) { - current.target_hid_usage = cycle_u2_target(current.target_hid_usage, delta); - } - } -} - -fn slot_label(slot: U2SlotId) -> &'static str { - match slot { - U2SlotId::Slot1 => "Slot1", - U2SlotId::Slot2 => "Slot2", - U2SlotId::Slot3 => "Slot3", - } -} - -fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool { - x >= rect.x - && y >= rect.y - && x < rect.x.saturating_add(rect.width) - && y < rect.y.saturating_add(rect.height) -} - -fn device_row_at(app: &TuiApp, devices_rect: Rect, row: u16) -> Option { - let start = devices_rect.y.saturating_add(1); - let end = devices_rect - .y - .saturating_add(devices_rect.height.saturating_sub(1)); - - if row < start || row >= end { - return None; - } - - let visible_rows = devices_rect.height.saturating_sub(2) as usize; - if visible_rows == 0 { - return None; - } - - let window_start = if app.selected_index >= visible_rows { - app.selected_index + 1 - visible_rows - } else { - 0 - }; - - let offset = row.saturating_sub(start) as usize; - let idx = window_start + offset; - if idx < app.devices.len() { - Some(idx) - } else { - None - } -} - -fn context_menu_rect(area: Rect, menu: MouseContextMenu) -> Rect { - let width: u16 = 30; - let height: u16 = CONTEXT_ACTIONS.len() as u16 + 2; - - let max_x = area.x.saturating_add(area.width.saturating_sub(width)); - let max_y = area.y.saturating_add(area.height.saturating_sub(height)); - - let x = menu.anchor_col.min(max_x); - let y = menu.anchor_row.min(max_y); - - Rect::new(x, y, width, height) -} - -fn context_menu_item_at( - area: Rect, - menu: MouseContextMenu, - column: u16, - row: u16, -) -> Option { - let rect = context_menu_rect(area, menu); - if !point_in_rect(column, row, rect) { - return None; - } - - let inner_y = row.saturating_sub(rect.y.saturating_add(1)); - CONTEXT_ACTIONS.get(inner_y as usize).copied() -} - -fn action_index(action: HomeAction) -> usize { - match action { - HomeAction::Update => 0, - HomeAction::Diagnose => 1, - HomeAction::Refresh => 2, - HomeAction::About => 3, - HomeAction::Help => 4, - HomeAction::Quit => 5, - } -} - -fn action_tooltip(action: HomeAction, advanced_mode: bool) -> &'static str { - match (action, advanced_mode) { - (HomeAction::Update, false) => "Recommended update starts the safest guided flow.", - (HomeAction::Update, true) => { - "Recommended update runs preflight, signature checks, and explicit confirmation." - } - (HomeAction::Diagnose, false) => "Diagnose checks device readiness without risky writes.", - (HomeAction::Diagnose, true) => { - "Diagnose includes inferred read-only checks while advanced mode is enabled." - } - (HomeAction::Refresh, _) => "Refresh rescans connected 8BitDo devices.", - (HomeAction::About, false) => { - "About shows version/build details and advanced mode setting." - } - (HomeAction::About, true) => "About also controls advanced mode and report hotkey details.", - (HomeAction::Help, _) => "Help shows the beginner flow and optional shortcuts.", - (HomeAction::Quit, _) => "Quit closes OpenBitdo safely.", - } -} - -fn available_actions_summary(device: &AppDevice) -> &'static str { - match device.support_status() { - UserSupportStatus::Supported => { - if device.capability.supports_jp108_dedicated_map { - "JP108 dedicated mapping (A/B/K1-K8), diagnostics, and firmware update" - } else if device.capability.supports_u2_button_map - && device.capability.supports_u2_slot_config - { - "Ultimate2 mode/slot/core mapping, diagnostics, and firmware update" - } else { - "Diagnostics and firmware update" - } - } - UserSupportStatus::InProgress => { - "Diagnostics only (mapping/update blocked in beginner mode)" - } - UserSupportStatus::Planned => "Detection and diagnostics only", - UserSupportStatus::Blocked => "No actions available", - } -} - -fn display_device_name(device: &AppDevice) -> String { - if device.name == "PID_UNKNOWN" - || device.protocol_family == bitdo_proto::ProtocolFamily::Unknown - { - format!( - "Unknown 8BitDo Device ({:04x}:{:04x})", - device.vid_pid.vid, device.vid_pid.pid - ) - } else { - device.name.clone() - } -} - -fn beginner_status_label(device: &AppDevice) -> &'static str { - match device.support_status() { - UserSupportStatus::InProgress => UserSupportStatus::Blocked.as_str(), - other => other.as_str(), - } -} - -fn blocked_action_panel_text(device: &AppDevice) -> String { - match device.support_status() { - UserSupportStatus::Supported => { - "Blocked Actions: none. This device is fully supported in the current build." - .to_owned() - } - UserSupportStatus::InProgress => format!( - "Status shown as Blocked for {} in beginner mode.\nRecommended Update is visible with a Coming soon badge.\nDiagnostics are available, but mapping and firmware writes are blocked until hardware confirmation is complete.", - device.name - ), - UserSupportStatus::Planned => format!( - "Recommended Update is blocked for {} and marked Coming soon because support is still Planned.\nYou can run diagnostics now, and full actions unlock after confirmation work.", - device.name - ), - UserSupportStatus::Blocked => format!( - "This action is currently blocked for {} by policy. Use Diagnose for a safe check.", - device.name - ), - } -} - -fn should_save_support_report(mode: ReportSaveMode, is_failure: bool) -> bool { +pub(crate) fn should_save_support_report(mode: ReportSaveMode, is_failure: bool) -> bool { match mode { ReportSaveMode::Off => false, ReportSaveMode::Always => true, @@ -2318,834 +165,5 @@ fn should_save_support_report(mode: ReportSaveMode, is_failure: bool) -> bool { } } -fn about_buttons_rects(area: Rect) -> (Rect, Rect, Rect) { - let width: u16 = 30; - let height: u16 = 3; - let spacing: u16 = 1; - let total_height = height * 3 + spacing * 2; - let x = area.x.saturating_add(area.width.saturating_sub(width) / 2); - let y = area - .y - .saturating_add(area.height.saturating_sub(total_height + 2)); - let first = Rect::new(x, y, width.min(area.width), height); - let second = Rect::new( - x, - y.saturating_add(height + spacing), - width.min(area.width), - height, - ); - let third = Rect::new( - x, - y.saturating_add((height + spacing) * 2), - width.min(area.width), - height, - ); - (first, second, third) -} - -fn render_button(frame: &mut Frame<'_>, rect: Rect, label: &str, active: bool) { - let style = if active { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - let button = Paragraph::new(Line::from(Span::styled(label, style))).block( - Block::default().borders(Borders::ALL).style(if active { - Style::default().fg(Color::Cyan) - } else { - Style::default().fg(Color::Gray) - }), - ); - frame.render_widget(button, rect); -} - -fn render_if_needed( - terminal: &mut Option>>, - app: &TuiApp, -) -> Result<()> { - let Some(terminal) = terminal else { - return Ok(()); - }; - - terminal.draw(|frame| match app.state { - TuiWorkflowState::About => render_about(frame, app), - TuiWorkflowState::HelpOverlay => render_help(frame, app.advanced_mode), - TuiWorkflowState::WaitForDevice => render_waiting(frame, app), - TuiWorkflowState::Home => render_home(frame, app), - TuiWorkflowState::Jp108Mapping => render_jp108_mapping(frame, app), - TuiWorkflowState::U2CoreProfile => render_u2_profile(frame, app), - TuiWorkflowState::Recovery => render_recovery(frame, app), - TuiWorkflowState::Preflight => render_preflight(frame, app), - TuiWorkflowState::Updating => render_updating(frame, app), - TuiWorkflowState::FinalReport => render_final_report(frame, app), - })?; - - Ok(()) -} - -fn render_about(frame: &mut Frame<'_>, app: &TuiApp) { - let block = Block::default() - .borders(Borders::ALL) - .title("About OpenBitdo"); - let toggle_label = if app.advanced_mode { - "Advanced Mode: ON" - } else { - "Advanced Mode: OFF" - }; - let report_label = format!("Report Saving: {}", app.report_save_mode.as_str()); - let fingerprint_toggle_label = if app.about_show_full_fingerprint { - "Fingerprint: full" - } else { - "Fingerprint: short" - }; - let key_line = if app.about_show_full_fingerprint { - app.build_info.signing_key_fingerprint_full.clone() - } else { - app.build_info.signing_key_fingerprint_short.clone() - }; - let lines = vec![ - Line::from(format!("App version: {}", app.build_info.app_version)), - Line::from(format!( - "Git commit (short): {}", - app.build_info.git_commit_short - )), - Line::from(format!( - "Git commit (full): {}", - app.build_info.git_commit_full - )), - Line::from(format!( - "Build date (UTC): {}", - app.build_info.build_date_utc - )), - Line::from(format!("Platform target: {}", app.build_info.target_triple)), - Line::from(format!( - "Runtime platform: {}", - app.build_info.runtime_platform - )), - Line::from(format!("Signing key (active): {key_line}")), - Line::from(format!( - "Signing key (next, short): {}", - app.build_info.signing_key_next_fingerprint_short - )), - Line::from(""), - Line::from(format!( - "{toggle_label} (press 't' or click button to toggle)" - )), - Line::from(format!( - "{} (press 'r' or click button to cycle)", - report_label - )), - Line::from("Press 'v' or click Fingerprint to toggle short/full view."), - Line::from("Esc/Enter/click outside to return"), - ]; - - let paragraph = Paragraph::new(lines).block(block); - frame.render_widget(paragraph, frame.area()); - - let (toggle_rect, report_rect, fingerprint_rect) = about_buttons_rects(frame.area()); - render_button(frame, toggle_rect, toggle_label, app.about_toggle_hovered); - render_button( - frame, - report_rect, - report_label.as_str(), - app.about_report_mode_hovered, - ); - render_button( - frame, - fingerprint_rect, - fingerprint_toggle_label, - app.about_fingerprint_hovered, - ); -} - -fn render_help(frame: &mut Frame<'_>, advanced_mode: bool) { - let mut lines = vec![ - Line::from("Beginner flow:"), - Line::from("1) Select your controller"), - Line::from("2) Choose Recommended Update or Diagnose"), - Line::from("3) Follow the on-screen confirmation"), - Line::from(""), - Line::from("Optional shortcuts:"), - Line::from("u=update d=diagnose r=refresh a=about q=quit"), - ]; - if advanced_mode { - lines.push(Line::from("")); - lines.push(Line::from( - "Advanced hotkeys (reports): c=copy path, o=open report, f=open folder", - )); - } - lines.push(Line::from("Esc/Enter/click to return")); - let paragraph = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Help")); - frame.render_widget(paragraph, frame.area()); -} - -fn render_waiting(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = waiting_layout(frame.area()); - - let header = Paragraph::new(Line::from(vec![ - Span::styled("OpenBitdo", Style::default().fg(Color::Cyan)), - Span::raw(" Beginner wizard"), - ])) - .block(Block::default().borders(Borders::ALL).title("Welcome")); - - let body_lines = vec![ - Line::from("No supported 8BitDo controller is detected yet."), - Line::from(""), - Line::from("Plug in your controller and click Refresh."), - Line::from("If you need help, open Help for a quick walkthrough."), - Line::from(""), - Line::from(app.last_message.clone()), - ]; - let body = Paragraph::new(body_lines).block( - Block::default() - .borders(Borders::ALL) - .title("Waiting for Controller"), - ); - - frame.render_widget(header, layout.header); - frame.render_widget(body, layout.body); - - for (rect, action) in action_buttons(layout.actions, &WAIT_ACTIONS) { - render_button( - frame, - rect, - action.label(), - app.hovered_action == Some(action), - ); - } - - let hover_hint = app - .hovered_action - .map(|action| action_tooltip(action, app.advanced_mode)) - .unwrap_or("Mouse: click buttons. Keyboard: Enter refresh, ? help, q quit"); - let footer = - Paragraph::new(hover_hint).block(Block::default().borders(Borders::ALL).title("Controls")); - frame.render_widget(footer, layout.footer); -} - -fn render_home(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = home_layout(frame.area()); - - let title = Paragraph::new(Line::from(vec![ - Span::styled("OpenBitdo", Style::default().fg(Color::Cyan)), - Span::raw(" Choose a controller and action"), - ])) - .block(Block::default().borders(Borders::ALL).title("Home")); - - let visible_rows = layout.devices.height.saturating_sub(2) as usize; - let window_start = if app.selected_index >= visible_rows && visible_rows > 0 { - app.selected_index + 1 - visible_rows - } else { - 0 - }; - let window_end = (window_start + visible_rows).min(app.devices.len()); - - let mut device_items = Vec::new(); - if app.devices.is_empty() { - device_items.push(ListItem::new("No controllers detected")); - } else { - for (idx, dev) in app.devices[window_start..window_end].iter().enumerate() { - let absolute_idx = window_start + idx; - let status = beginner_status_label(dev); - let line = format!( - "{:04x}:{:04x} {} [{}]", - dev.vid_pid.vid, - dev.vid_pid.pid, - display_device_name(dev), - status - ); - let style = if absolute_idx == app.selected_index { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - device_items.push(ListItem::new(line).style(style)); - } - } - - let device_list = - List::new(device_items).block(Block::default().borders(Borders::ALL).title("Controllers")); - - frame.render_widget(title, layout.title); - frame.render_widget(device_list, layout.devices); - - let update_blocked = app - .selected_device() - .map(|d| d.support_tier != SupportTier::Full) - .unwrap_or(true); - - for (rect, action) in action_buttons(layout.actions, &HOME_ACTIONS) { - let label = if action == HomeAction::Update && update_blocked { - "Recommended Update [Coming soon]" - } else { - action.label() - }; - render_button(frame, rect, label, app.hovered_action == Some(action)); - } - - let gauge = Gauge::default() - .block(Block::default().title("Progress").borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Green)) - .percent(app.progress as u16) - .label(format!("{}%", app.progress)); - frame.render_widget(gauge, layout.progress); - - let selected_summary = app - .selected_device() - .map(|d| { - let tooltip = app - .hovered_action - .map(|action| { - if action == HomeAction::Update && update_blocked { - "Recommended update support for this device is coming soon." - } else { - action_tooltip(action, app.advanced_mode) - } - }) - .unwrap_or(""); - let actions = available_actions_summary(d); - format!( - "Selected: {} ({:04x}:{:04x})\nStatus: {}\nCurrent user actions: {}\n{}\n{}", - display_device_name(d), - d.vid_pid.vid, - d.vid_pid.pid, - beginner_status_label(d), - actions, - app.last_message, - tooltip - ) - }) - .unwrap_or_else(|| format!("No controller selected\n{}", app.last_message)); - - let detail = Paragraph::new(selected_summary) - .scroll((app.detail_scroll, 0)) - .block(Block::default().borders(Borders::ALL).title("Guidance")); - frame.render_widget(detail, layout.detail); - - let blocked_text = app - .selected_device() - .map(blocked_action_panel_text) - .unwrap_or_else(|| "Blocked Actions: none".to_owned()); - let blocked = Paragraph::new(blocked_text).block( - Block::default() - .borders(Borders::ALL) - .title("Blocked Actions"), - ); - frame.render_widget(blocked, layout.blocked); - - if let Some(menu) = app.context_menu { - let menu_rect = context_menu_rect(frame.area(), menu); - let items = CONTEXT_ACTIONS - .iter() - .enumerate() - .map(|(idx, action)| { - let style = if menu.hovered_index == Some(idx) { - Style::default() - .fg(Color::Black) - .bg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - ListItem::new(action.label()).style(style) - }) - .collect::>(); - let popup = List::new(items).block(Block::default().borders(Borders::ALL).title("Actions")); - frame.render_widget(popup, menu_rect); - } -} - -fn render_jp108_mapping(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = simple_action_layout(frame.area()); - let mut lines = vec![ - Line::from("JP108 Dedicated Button Mapping"), - Line::from("Use Up/Down to select, Left/Right to change mapped HID usage."), - Line::from(""), - ]; - - for (idx, mapping) in app.jp108_mappings.iter().enumerate() { - let marker = if idx == app.jp108_selected { ">" } else { " " }; - lines.push(Line::from(format!( - "{marker} {:?} -> 0x{:04x}", - mapping.button, mapping.target_hid_usage - ))); - } - - lines.push(Line::from("")); - lines.push(Line::from(app.last_message.clone())); - - let body = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("JP108 Wizard")); - frame.render_widget(body, layout.body); - - for (rect, action) in flow_buttons(layout.actions, &DEVICE_FLOW_ACTIONS) { - render_button(frame, rect, action.label(), false); - } - - let footer = Paragraph::new("b=apply r=reload s=restore t=test f=firmware Esc=done") - .block(Block::default().borders(Borders::ALL).title("Controls")); - frame.render_widget(footer, layout.footer); -} - -fn render_u2_profile(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = simple_action_layout(frame.area()); - let mut lines = vec![ - Line::from("Ultimate2 Core Profile Mapping"), - Line::from("Use Up/Down to select button mapping, Left/Right to adjust usage."), - Line::from("Press 1/2/3 to select slot, m to cycle mode."), - Line::from(""), - ]; - - if let Some(profile) = app.u2_profile.as_ref() { - lines.push(Line::from(format!("Slot: {}", slot_label(profile.slot)))); - lines.push(Line::from(format!("Mode: {}", profile.mode))); - lines.push(Line::from(format!( - "Firmware version: {}", - profile.firmware_version - ))); - lines.push(Line::from(format!( - "L2 analog: {:.2} {}", - profile.l2_analog, - if profile.supports_trigger_write { - "(write-enabled)" - } else { - "(read-only)" - } - ))); - lines.push(Line::from(format!( - "R2 analog: {:.2} {}", - profile.r2_analog, - if profile.supports_trigger_write { - "(write-enabled)" - } else { - "(read-only)" - } - ))); - lines.push(Line::from("")); - - for (idx, mapping) in profile.mappings.iter().enumerate() { - let marker = if idx == app.u2_selected { ">" } else { " " }; - lines.push(Line::from(format!( - "{marker} {:?} -> {} (0x{:04x})", - mapping.button, - u2_target_label(mapping.target_hid_usage), - mapping.target_hid_usage - ))); - } - } else { - lines.push(Line::from("No profile loaded.")); - } - - lines.push(Line::from("")); - lines.push(Line::from(app.last_message.clone())); - - let body = Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .title("Ultimate2 Wizard"), - ); - frame.render_widget(body, layout.body); - - for (rect, action) in flow_buttons(layout.actions, &DEVICE_FLOW_ACTIONS) { - render_button(frame, rect, action.label(), false); - } - - let footer = - Paragraph::new("b=apply r=reload s=restore t=test f=firmware [ ]=L2 ; '=R2 Esc=done") - .block(Block::default().borders(Borders::ALL).title("Controls")); - frame.render_widget(footer, layout.footer); -} - -fn render_recovery(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = simple_action_layout(frame.area()); - let mut lines = vec![ - Line::from("Recovery Wizard"), - Line::from(""), - Line::from("A write operation failed and automatic rollback also failed."), - Line::from("Write actions are now locked until OpenBitdo is restarted."), - Line::from(""), - Line::from("Safe sequence: auto rollback -> verify -> retry -> safe exit."), - Line::from("Current state: auto rollback did not fully recover."), - ]; - - if let Some(report) = app.recovery_report.as_ref() { - if let Some(write_error) = report.write_error.as_deref() { - lines.push(Line::from(format!("Write error: {write_error}"))); - } - if let Some(rollback_error) = report.rollback_error.as_deref() { - lines.push(Line::from(format!("Rollback error: {rollback_error}"))); - } - lines.push(Line::from(format!( - "Rollback attempted: {}", - if report.rollback_attempted { - "yes" - } else { - "no" - } - ))); - } - lines.push(Line::from("")); - lines.push(Line::from(app.last_message.clone())); - - let body = Paragraph::new(lines).block( - Block::default() - .borders(Borders::ALL) - .title("Needs Attention"), - ); - frame.render_widget(body, layout.body); - - let buttons = action_buttons( - layout.actions, - &[HomeAction::Refresh, HomeAction::About, HomeAction::Quit], - ); - for (rect, action) in buttons { - let label = match action { - HomeAction::Refresh => "Try Restore Backup", - HomeAction::About => "Safe Exit to Home", - HomeAction::Quit => "Quit", - _ => action.label(), - }; - render_button(frame, rect, label, false); - } - - let footer = Paragraph::new("r=restore backup Enter/Esc=safe exit q=quit") - .block(Block::default().borders(Borders::ALL).title("Controls")); - frame.render_widget(footer, layout.footer); -} - -fn render_preflight(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = simple_action_layout(frame.area()); - - let mut lines = vec![Line::from("Review update preflight"), Line::from("")]; - if let Some(pending) = app.pending_update.as_ref() { - lines.push(Line::from(format!( - "Device: {} ({:04x}:{:04x})", - pending.target.name, pending.target.vid_pid.vid, pending.target.vid_pid.pid - ))); - lines.push(Line::from(format!( - "Firmware source: {}", - pending.firmware_source - ))); - lines.push(Line::from(format!( - "Firmware version: {}", - pending.firmware_version - ))); - lines.push(Line::from(format!( - "Image path: {}", - pending.firmware_path.display() - ))); - lines.push(Line::from(format!( - "Chunk size: {} bytes", - pending.plan.chunk_size - ))); - lines.push(Line::from(format!("Chunks: {}", pending.plan.chunks_total))); - lines.push(Line::from(format!( - "Estimated transfer time: {}s", - pending.plan.expected_seconds - ))); - lines.push(Line::from("")); - lines.push(Line::from("Warnings:")); - for warning in &pending.plan.warnings { - lines.push(Line::from(format!("- {warning}"))); - } - } else { - lines.push(Line::from("No preflight details available.")); - } - - lines.push(Line::from("")); - lines.push(Line::from("Click I Understand to start transfer.")); - - let body = - Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title("Preflight")); - frame.render_widget(body, layout.body); - - let buttons = action_buttons(layout.actions, &[HomeAction::Update, HomeAction::Quit]); - for (rect, action) in buttons { - let label = match action { - HomeAction::Update => "I Understand", - HomeAction::Quit => "Cancel", - _ => action.label(), - }; - render_button(frame, rect, label, false); - } - - let footer = Paragraph::new("Enter to confirm, Esc to cancel") - .block(Block::default().borders(Borders::ALL).title("Controls")); - frame.render_widget(footer, layout.footer); -} - -fn render_updating(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = simple_action_layout(frame.area()); - let selected = app - .selected_device() - .map(|d| format!("{} ({:04x}:{:04x})", d.name, d.vid_pid.vid, d.vid_pid.pid)) - .unwrap_or_else(|| "Unknown controller".to_owned()); - - let body_lines = vec![ - Line::from("Firmware transfer in progress"), - Line::from(""), - Line::from(format!("Device: {selected}")), - Line::from(format!("Status: {}", app.last_message)), - Line::from(""), - Line::from("Do not disconnect the controller during transfer."), - ]; - - let body = - Paragraph::new(body_lines).block(Block::default().borders(Borders::ALL).title("Updating")); - frame.render_widget(body, layout.body); - - let buttons = action_buttons(layout.actions, &[HomeAction::Quit]); - for (rect, action) in buttons { - let label = if action == HomeAction::Quit { - "Cancel Update" - } else { - action.label() - }; - render_button(frame, rect, label, false); - } - - let footer = Gauge::default() - .block(Block::default().title("Progress").borders(Borders::ALL)) - .gauge_style(Style::default().fg(Color::Green)) - .percent(app.progress as u16) - .label(format!("{}%", app.progress)); - frame.render_widget(footer, layout.footer); -} - -fn render_final_report(frame: &mut Frame<'_>, app: &TuiApp) { - let layout = simple_action_layout(frame.area()); - - let (status, message) = app - .final_report - .as_ref() - .map(|report| { - ( - format!("{:?}", report.status), - format!( - "{}\nChunks sent: {}/{}", - report.message, report.chunks_sent, report.chunks_total - ), - ) - }) - .unwrap_or_else(|| ("Unknown".to_owned(), app.last_message.clone())); - - let body = Paragraph::new(vec![ - Line::from("Update finished"), - Line::from(""), - Line::from(format!("Result: {status}")), - Line::from(message), - Line::from(""), - Line::from("Done returns to Home. Quit exits OpenBitdo."), - ]) - .block( - Block::default() - .borders(Borders::ALL) - .title("Final Summary"), - ); - frame.render_widget(body, layout.body); - - let buttons = action_buttons(layout.actions, &[HomeAction::Refresh, HomeAction::Quit]); - for (rect, action) in buttons { - let label = if action == HomeAction::Refresh { - "Done" - } else { - action.label() - }; - render_button(frame, rect, label, false); - } - - let footer = Paragraph::new(app.last_message.as_str()) - .block(Block::default().borders(Borders::ALL).title("Status")); - frame.render_widget(footer, layout.footer); -} - -pub async fn run_tui_flow( - core: OpenBitdoCore, - request: TuiRunRequest, -) -> Result { - let mut app = TuiApp { - state: TuiWorkflowState::Preflight, - selected: Some(request.vid_pid), - last_message: format!("preflighting {}", request.vid_pid), - ..Default::default() - }; - - let mut terminal = if request.no_ui { - None - } else { - Some(init_terminal()?) - }; - render_if_needed(&mut terminal, &app)?; - - let preflight = core - .preflight_firmware(FirmwarePreflightRequest { - vid_pid: request.vid_pid, - firmware_path: request.firmware_path.clone(), - allow_unsafe: request.allow_unsafe, - brick_risk_ack: request.brick_risk_ack, - experimental: request.experimental, - chunk_size: request.chunk_size, - }) - .await?; - - if !preflight.gate.allowed { - teardown_terminal(&mut terminal)?; - return Err(anyhow!( - "preflight denied: {}", - preflight - .gate - .message - .unwrap_or_else(|| "policy denied".to_owned()) - )); - } - - let plan = preflight.plan.expect("plan exists when gate is allowed"); - app.set_session(plan.session_id.clone()); - render_if_needed(&mut terminal, &app)?; - - core.start_firmware(FirmwareStartRequest { - session_id: plan.session_id.clone(), - }) - .await?; - - core.confirm_firmware(FirmwareConfirmRequest { - session_id: plan.session_id.clone(), - acknowledged_risk: request.acknowledged_risk, - }) - .await?; - - let mut receiver = core.subscribe_events(&plan.session_id.0).await?; - loop { - tokio::select! { - evt = receiver.recv() => { - if let Ok(evt) = evt { - app.apply_progress(evt.progress, format!("{}: {}", evt.stage, evt.message)); - render_if_needed(&mut terminal, &app)?; - if evt.terminal { - break; - } - } - } - _ = sleep(Duration::from_millis(10)) => { - if let Some(report) = core.firmware_report(&plan.session_id.0).await? { - app.complete(report.clone()); - render_if_needed(&mut terminal, &app)?; - teardown_terminal(&mut terminal)?; - return Ok(report); - } - } - } - - if !request.no_ui && event::poll(Duration::from_millis(1))? { - if let CEvent::Key(key) = event::read()? { - if matches!(key.code, KeyCode::Char('q') | KeyCode::Esc) { - let report = core - .cancel_firmware(FirmwareCancelRequest { - session_id: plan.session_id.clone(), - }) - .await?; - app.complete(report.clone()); - render_if_needed(&mut terminal, &app)?; - teardown_terminal(&mut terminal)?; - return Ok(report); - } - } - } - } - - let report = core - .firmware_report(&plan.session_id.0) - .await? - .ok_or_else(|| anyhow!("missing final report"))?; - app.complete(report.clone()); - render_if_needed(&mut terminal, &app)?; - teardown_terminal(&mut terminal)?; - Ok(report) -} - -fn init_terminal() -> Result>> { - use crossterm::event::EnableMouseCapture; - - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let terminal = Terminal::new(backend)?; - Ok(terminal) -} - -fn teardown_terminal(terminal: &mut Option>>) -> Result<()> { - use crossterm::event::DisableMouseCapture; - - if let Some(mut t) = terminal.take() { - disable_raw_mode()?; - execute!(t.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; - t.show_cursor()?; - } - Ok(()) -} - -fn prompt_line( - terminal: &mut Option>>, - prompt: &str, -) -> Result { - let had_terminal = terminal.is_some(); - if had_terminal { - teardown_terminal(terminal)?; - } - - print!("{prompt}"); - io::stdout().flush()?; - - let mut line = String::new(); - io::stdin().read_line(&mut line)?; - - if had_terminal { - *terminal = Some(init_terminal()?); - } - - Ok(line.trim().to_owned()) -} - -fn handle_report_hotkey(app: &mut TuiApp, key: KeyCode) -> Result { - let Some(path) = app.latest_report_path.clone() else { - return Ok(false); - }; - - match key { - KeyCode::Char('c') => { - copy_text_to_clipboard(path.to_string_lossy().as_ref())?; - app.last_message = format!( - "Copied report path to clipboard: {}", - path.to_string_lossy() - ); - Ok(true) - } - KeyCode::Char('o') => { - open_path_with_default_app(&path)?; - app.last_message = format!("Opened report: {}", path.to_string_lossy()); - Ok(true) - } - KeyCode::Char('f') => { - let folder = path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(std::env::temp_dir); - open_path_with_default_app(&folder)?; - app.last_message = format!("Opened report folder: {}", folder.to_string_lossy()); - Ok(true) - } - _ => Ok(false), - } -} - #[cfg(test)] mod tests; diff --git a/sdk/crates/bitdo_tui/src/persistence/mod.rs b/sdk/crates/bitdo_tui/src/persistence/mod.rs new file mode 100644 index 0000000..ca821c2 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/persistence/mod.rs @@ -0,0 +1 @@ +pub mod ui_state; diff --git a/sdk/crates/bitdo_tui/src/persistence/ui_state.rs b/sdk/crates/bitdo_tui/src/persistence/ui_state.rs new file mode 100644 index 0000000..eb861a6 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/persistence/ui_state.rs @@ -0,0 +1,82 @@ +use crate::{DashboardLayoutMode, PanelFocus, ReportSaveMode}; +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PersistedUiState { + #[serde(default = "default_settings_schema_version")] + pub schema_version: u32, + #[serde(default)] + pub advanced_mode: bool, + #[serde(default)] + pub report_save_mode: ReportSaveMode, + #[serde(default)] + pub device_filter_text: String, + #[serde(default)] + pub dashboard_layout_mode: DashboardLayoutMode, + #[serde(default)] + pub last_panel_focus: PanelFocus, +} + +impl Default for PersistedUiState { + fn default() -> Self { + Self { + schema_version: default_settings_schema_version(), + advanced_mode: false, + report_save_mode: ReportSaveMode::FailureOnly, + device_filter_text: String::new(), + dashboard_layout_mode: DashboardLayoutMode::Wide, + last_panel_focus: PanelFocus::Devices, + } + } +} + +const fn default_settings_schema_version() -> u32 { + 2 +} + +pub fn load_ui_state(path: &Path) -> Result { + let raw = match std::fs::read_to_string(path) { + Ok(raw) => raw, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(PersistedUiState::default()) + } + Err(err) => return Err(err.into()), + }; + + let mut loaded: PersistedUiState = toml::from_str(&raw) + .map_err(|err| anyhow!("failed to parse ui state {}: {err}", path.display()))?; + loaded.schema_version = default_settings_schema_version(); + + if !loaded.advanced_mode && loaded.report_save_mode == ReportSaveMode::Off { + loaded.report_save_mode = ReportSaveMode::FailureOnly; + } + + Ok(loaded) +} + +pub fn persist_ui_state( + path: &Path, + advanced_mode: bool, + report_save_mode: ReportSaveMode, + device_filter_text: String, + dashboard_layout_mode: DashboardLayoutMode, + last_panel_focus: PanelFocus, +) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let body = toml::to_string_pretty(&PersistedUiState { + schema_version: default_settings_schema_version(), + advanced_mode, + report_save_mode, + device_filter_text, + dashboard_layout_mode, + last_panel_focus, + }) + .map_err(|err| anyhow!("failed to serialize ui state: {err}"))?; + std::fs::write(path, body)?; + Ok(()) +} diff --git a/sdk/crates/bitdo_tui/src/runtime/effect_executor.rs b/sdk/crates/bitdo_tui/src/runtime/effect_executor.rs new file mode 100644 index 0000000..648ac54 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/runtime/effect_executor.rs @@ -0,0 +1,314 @@ +use crate::app::effect::{Effect, MappingApplyDraft}; +use crate::app::event::AppEvent; +use crate::app::state::AppState; +use crate::persistence::ui_state::persist_ui_state; +use crate::support_report::persist_support_report; +use bitdo_app_core::{ + FirmwareCancelRequest, FirmwareConfirmRequest, FirmwarePreflightRequest, FirmwareStartRequest, + OpenBitdoCore, U2SlotId, +}; +use std::path::Path; + +pub async fn execute_effect( + core: &OpenBitdoCore, + state: &AppState, + effect: Effect, +) -> Vec { + match effect { + Effect::RefreshDevices => match core.list_devices().await { + Ok(mut devices) => { + devices.sort_by_key(|d| (d.vid_pid.vid, d.vid_pid.pid)); + vec![AppEvent::DevicesLoaded(devices)] + } + Err(err) => vec![AppEvent::DevicesLoadFailed(err.to_string())], + }, + Effect::RunDiagnostics { vid_pid } => match core.diag_probe(vid_pid).await { + Ok(result) => { + let summary = state + .devices + .iter() + .find(|device| device.vid_pid == vid_pid) + .map(|device| core.beginner_diag_summary(device, &result)) + .unwrap_or_else(|| "Diagnostics completed".to_owned()); + vec![AppEvent::DiagnosticsCompleted { + vid_pid, + result, + summary, + }] + } + Err(err) => vec![AppEvent::DiagnosticsFailed { + vid_pid, + error: err.to_string(), + }], + }, + Effect::LoadMappings { vid_pid } => { + let device = state.devices.iter().find(|d| d.vid_pid == vid_pid); + if let Some(device) = device { + if device.capability.supports_jp108_dedicated_map { + match core.jp108_read_dedicated_mapping(vid_pid).await { + Ok(mappings) => vec![AppEvent::MappingsLoadedJp108 { vid_pid, mappings }], + Err(err) => vec![AppEvent::MappingLoadFailed(err.to_string())], + } + } else if device.capability.supports_u2_button_map + && device.capability.supports_u2_slot_config + { + match core.u2_read_core_profile(vid_pid, U2SlotId::Slot1).await { + Ok(profile) => vec![AppEvent::MappingsLoadedUltimate2 { vid_pid, profile }], + Err(err) => vec![AppEvent::MappingLoadFailed(err.to_string())], + } + } else { + vec![AppEvent::MappingLoadFailed( + "Device does not support mapping editor".to_owned(), + )] + } + } else { + vec![AppEvent::MappingLoadFailed("No device selected".to_owned())] + } + } + Effect::ApplyMappings { vid_pid, draft } => match draft { + MappingApplyDraft::Jp108(mappings) => match core + .jp108_apply_dedicated_mapping_with_recovery(vid_pid, mappings, true) + .await + { + Ok(report) => { + let recovery_lock = report.rollback_failed(); + let message = if report.write_applied { + "JP108 mapping applied".to_owned() + } else if recovery_lock { + "Apply failed and rollback failed; writes locked until restart".to_owned() + } else { + "Apply failed but rollback restored prior mapping".to_owned() + }; + vec![AppEvent::MappingApplied { + backup_id: report.backup_id, + message, + recovery_lock, + }] + } + Err(err) => vec![AppEvent::MappingApplyFailed(err.to_string())], + }, + MappingApplyDraft::Ultimate2(profile) => match core + .u2_apply_core_profile_with_recovery( + vid_pid, + profile.slot, + profile.mode, + profile.mappings, + profile.l2_analog, + profile.r2_analog, + true, + ) + .await + { + Ok(report) => { + let recovery_lock = report.rollback_failed(); + let message = if report.write_applied { + "Ultimate2 profile applied".to_owned() + } else if recovery_lock { + "Apply failed and rollback failed; writes locked until restart".to_owned() + } else { + "Apply failed but rollback restored prior profile".to_owned() + }; + vec![AppEvent::MappingApplied { + backup_id: report.backup_id, + message, + recovery_lock, + }] + } + Err(err) => vec![AppEvent::MappingApplyFailed(err.to_string())], + }, + }, + Effect::RestoreBackup { backup_id } => match core.restore_backup(backup_id).await { + Ok(_) => vec![AppEvent::BackupRestoreCompleted( + "Backup restore completed".to_owned(), + )], + Err(err) => vec![AppEvent::BackupRestoreFailed(format!( + "Backup restore failed: {err}" + ))], + }, + Effect::PreparePreflight { + vid_pid, + firmware_path_override, + allow_unsafe, + brick_risk_ack, + experimental, + chunk_size, + } => { + let device = state.devices.iter().find(|d| d.vid_pid == vid_pid); + let Some(device) = device else { + return vec![AppEvent::PreflightBlocked("No selected device".to_owned())]; + }; + let (firmware_path, source, version, downloaded_firmware_path) = + if let Some(path) = firmware_path_override { + (path, "local file".to_owned(), "manual".to_owned(), None) + } else { + match core.download_recommended_firmware(vid_pid).await { + Ok(download) => { + let path = download.firmware_path; + ( + path.clone(), + "recommended verified download".to_owned(), + download.version, + Some(path), + ) + } + Err(err) => { + return vec![AppEvent::PreflightBlocked(format!( + "Recommended firmware unavailable: {err}" + ))] + } + } + }; + + match core + .preflight_firmware(FirmwarePreflightRequest { + vid_pid: device.vid_pid, + firmware_path: firmware_path.clone(), + allow_unsafe, + brick_risk_ack, + experimental, + chunk_size, + }) + .await + { + Ok(preflight) => { + if !preflight.gate.allowed { + if let Some(path) = downloaded_firmware_path.as_ref() { + let _ = cleanup_temp_file(path).await; + } + vec![AppEvent::PreflightBlocked( + preflight + .gate + .message + .unwrap_or_else(|| "Preflight denied by policy".to_owned()), + )] + } else if let Some(plan) = preflight.plan { + vec![AppEvent::PreflightReady { + vid_pid, + firmware_path, + source, + version, + plan, + downloaded_firmware_path, + }] + } else { + if let Some(path) = downloaded_firmware_path.as_ref() { + let _ = cleanup_temp_file(path).await; + } + vec![AppEvent::PreflightBlocked( + "Preflight allowed but no transfer plan was returned".to_owned(), + )] + } + } + Err(err) => { + if let Some(path) = downloaded_firmware_path.as_ref() { + let _ = cleanup_temp_file(path).await; + } + vec![AppEvent::PreflightBlocked(format!( + "Preflight failed: {err}" + ))] + } + } + } + Effect::StartFirmware { + session_id, + acknowledged_risk, + } => { + if let Err(err) = core + .start_firmware(FirmwareStartRequest { + session_id: session_id.clone(), + }) + .await + { + return vec![AppEvent::UpdateFailed(err.to_string())]; + } + + if let Err(err) = core + .confirm_firmware(FirmwareConfirmRequest { + session_id: session_id.clone(), + acknowledged_risk, + }) + .await + { + return vec![AppEvent::UpdateFailed(err.to_string())]; + } + + vec![AppEvent::UpdateStarted { + session_id: session_id.0, + source: "selected firmware".to_owned(), + version: "target".to_owned(), + }] + } + Effect::CancelFirmware { session_id } => match core + .cancel_firmware(FirmwareCancelRequest { session_id }) + .await + { + Ok(report) => vec![AppEvent::UpdateFinished(report)], + Err(err) => vec![AppEvent::UpdateFailed(err.to_string())], + }, + Effect::PollFirmwareReport { session_id } => { + match core.firmware_report(&session_id.0).await { + Ok(Some(report)) => vec![AppEvent::UpdateFinished(report)], + Ok(None) => Vec::new(), + Err(err) => vec![AppEvent::UpdateFailed(err.to_string())], + } + } + Effect::DeleteTempFile { path } => match cleanup_temp_file(&path).await { + Ok(_) => Vec::new(), + Err(err) => vec![AppEvent::Error(format!( + "Failed to delete temporary firmware {}: {err}", + path.display() + ))], + }, + Effect::PersistSettings { + path, + advanced_mode, + report_save_mode, + device_filter_text, + dashboard_layout_mode, + last_panel_focus, + } => match persist_ui_state( + &path, + advanced_mode, + report_save_mode, + device_filter_text, + dashboard_layout_mode, + last_panel_focus, + ) { + Ok(_) => vec![AppEvent::SettingsPersisted], + Err(err) => vec![AppEvent::Error(format!("Settings save failed: {err}"))], + }, + Effect::PersistSupportReport { + operation, + vid_pid, + status, + message, + diag, + firmware, + } => { + let device = vid_pid.and_then(|id| state.devices.iter().find(|d| d.vid_pid == id)); + match persist_support_report( + &operation, + device, + &status, + message, + diag.as_ref(), + firmware.as_ref(), + ) + .await + { + Ok(path) => vec![AppEvent::SupportReportSaved(path)], + Err(err) => vec![AppEvent::Error(format!( + "Support report save failed: {err}" + ))], + } + } + } +} + +async fn cleanup_temp_file(path: &Path) -> std::io::Result<()> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err), + } +} diff --git a/sdk/crates/bitdo_tui/src/runtime/loop.rs b/sdk/crates/bitdo_tui/src/runtime/loop.rs new file mode 100644 index 0000000..946e159 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/runtime/loop.rs @@ -0,0 +1,281 @@ +use super::effect_executor::execute_effect; +use crate::app::event::AppEvent; +use crate::app::reducer::reduce; +use crate::app::state::{AppState, EventLevel, Screen, TaskMode}; +use crate::persistence::ui_state::load_ui_state; +use crate::support_report::prune_reports_on_startup; +use crate::ui::layout::{self, HitTarget}; +use crate::UiLaunchOptions; +use anyhow::Result; +use bitdo_app_core::{FirmwareProgressEvent, OpenBitdoCore}; +use crossterm::event::{self, Event as CEvent, KeyCode, MouseButton, MouseEvent, MouseEventKind}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::backend::CrosstermBackend; +use ratatui::Terminal; +use std::collections::VecDeque; +use std::io::Stdout; +use tokio::sync::broadcast; +use tokio::time::Duration; + +pub async fn run_ui_loop(core: OpenBitdoCore, opts: UiLaunchOptions) -> Result<()> { + let _ = prune_reports_on_startup().await; + + let mut state = AppState::new(&opts); + if let Some(path) = state.settings_path.as_ref() { + match load_ui_state(path) { + Ok(persisted) => { + state.device_filter = persisted.device_filter_text; + state.dashboard_layout_mode = persisted.dashboard_layout_mode; + state.last_panel_focus = persisted.last_panel_focus; + state.advanced_mode = persisted.advanced_mode; + state.report_save_mode = persisted.report_save_mode; + } + Err(err) => { + state.set_status("Settings file invalid; using defaults"); + state.append_event(EventLevel::Warning, format!("Settings load failed: {err}")); + } + } + } + + let mut terminal = init_terminal()?; + let mut hit_map = layout::HitMap::default(); + let mut firmware_events: Option> = None; + + process_event(&core, &mut state, AppEvent::Init).await; + + loop { + if let Ok(size) = terminal.size() { + state.set_layout_from_size(size.width, size.height); + } + + ensure_firmware_subscription(&core, &state, &mut firmware_events).await?; + poll_firmware_events(&mut state, &mut firmware_events).await; + process_event(&core, &mut state, AppEvent::Tick).await; + + terminal.draw(|frame| { + hit_map = layout::render(frame, &state); + })?; + + if state.quit_requested { + break; + } + + if !event::poll(Duration::from_millis(80))? { + continue; + } + + match event::read()? { + CEvent::Key(key) => { + if let Some(app_event) = key_to_event(&state, key.code) { + process_event(&core, &mut state, app_event).await; + } + } + CEvent::Mouse(mouse) => { + if let Some(app_event) = mouse_to_event(&state, &hit_map, mouse) { + process_event(&core, &mut state, app_event).await; + } + } + CEvent::Resize(width, height) => { + state.set_layout_from_size(width, height); + } + _ => {} + } + } + + teardown_terminal(&mut terminal)?; + Ok(()) +} + +async fn process_event(core: &OpenBitdoCore, state: &mut AppState, initial: AppEvent) { + let mut queue = VecDeque::from([initial]); + while let Some(event) = queue.pop_front() { + let effects = reduce(state, event); + for effect in effects { + let emitted = execute_effect(core, state, effect).await; + for next in emitted { + queue.push_back(next); + } + } + } +} + +async fn ensure_firmware_subscription( + core: &OpenBitdoCore, + state: &AppState, + receiver: &mut Option>, +) -> Result<()> { + if receiver.is_some() { + return Ok(()); + } + + let Some(task) = state.task_state.as_ref() else { + return Ok(()); + }; + + if !matches!(task.mode, TaskMode::Updating) { + return Ok(()); + } + + let Some(plan) = task.plan.as_ref() else { + return Ok(()); + }; + + *receiver = Some(core.subscribe_events(&plan.session_id.0).await?); + Ok(()) +} + +async fn poll_firmware_events( + state: &mut AppState, + receiver: &mut Option>, +) { + let Some(rx) = receiver.as_mut() else { + return; + }; + + let mut events = Vec::new(); + loop { + match rx.try_recv() { + Ok(evt) => events.push(evt), + Err(broadcast::error::TryRecvError::Empty) => break, + Err(broadcast::error::TryRecvError::Lagged(_)) => continue, + Err(broadcast::error::TryRecvError::Closed) => { + *receiver = None; + break; + } + } + } + + for evt in events { + let _ = reduce(state, AppEvent::UpdateProgress(evt)); + } +} + +fn key_to_event(state: &AppState, key: KeyCode) -> Option { + match key { + KeyCode::Char('q') => Some(AppEvent::Quit), + KeyCode::Esc => Some(AppEvent::Back), + KeyCode::Enter => Some(AppEvent::ConfirmPrimary), + KeyCode::Down => match state.screen { + Screen::Dashboard => Some(AppEvent::SelectNextDevice), + Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectNextCheck), + Screen::MappingEditor => Some(AppEvent::MappingMoveSelection(1)), + _ => Some(AppEvent::SelectNextAction), + }, + KeyCode::Up => match state.screen { + Screen::Dashboard => Some(AppEvent::SelectPrevDevice), + Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectPrevCheck), + Screen::MappingEditor => Some(AppEvent::MappingMoveSelection(-1)), + _ => Some(AppEvent::SelectPrevAction), + }, + KeyCode::Left => match state.screen { + Screen::MappingEditor => Some(AppEvent::MappingAdjust(-1)), + Screen::Diagnostics => Some(AppEvent::SelectPrevAction), + _ => Some(AppEvent::SelectPrevAction), + }, + KeyCode::Right => match state.screen { + Screen::MappingEditor => Some(AppEvent::MappingAdjust(1)), + Screen::Diagnostics => Some(AppEvent::SelectNextAction), + _ => Some(AppEvent::SelectNextAction), + }, + KeyCode::Tab => { + if state.screen == Screen::Diagnostics { + Some(AppEvent::DiagnosticsShiftFilter(1)) + } else { + None + } + } + KeyCode::BackTab => { + if state.screen == Screen::Diagnostics { + Some(AppEvent::DiagnosticsShiftFilter(-1)) + } else { + None + } + } + KeyCode::Backspace => { + if state.screen == Screen::Dashboard { + Some(AppEvent::DeviceFilterBackspace) + } else { + None + } + } + KeyCode::Char('t') => { + if state.screen == Screen::Settings { + Some(AppEvent::ToggleAdvancedMode) + } else { + None + } + } + KeyCode::Char('r') => { + if state.screen == Screen::Settings { + Some(AppEvent::CycleReportSaveMode) + } else { + None + } + } + KeyCode::Char(ch) => { + if state.screen == Screen::Dashboard && !ch.is_control() { + Some(AppEvent::DeviceFilterInput(ch)) + } else { + None + } + } + _ => None, + } +} + +fn mouse_to_event( + state: &AppState, + hit_map: &layout::HitMap, + mouse: MouseEvent, +) -> Option { + match mouse.kind { + MouseEventKind::Down(MouseButton::Left) => match hit_map.hit(mouse.column, mouse.row) { + Some(HitTarget::DeviceRow(idx)) => Some(AppEvent::SelectFilteredDevice(idx)), + Some(HitTarget::QuickAction(action)) => Some(AppEvent::TriggerAction(action)), + Some(HitTarget::FilterInput) => Some(AppEvent::SelectFilteredDevice(0)), + Some(HitTarget::DiagnosticsCheck(idx)) => Some(AppEvent::DiagnosticsSelectCheck(idx)), + Some(HitTarget::DiagnosticsFilter(filter)) => { + Some(AppEvent::DiagnosticsSetFilter(filter)) + } + Some(HitTarget::ToggleAdvancedMode) => Some(AppEvent::ToggleAdvancedMode), + Some(HitTarget::CycleReportMode) => Some(AppEvent::CycleReportSaveMode), + None => None, + }, + MouseEventKind::ScrollDown => match state.screen { + Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectNextCheck), + _ => Some(AppEvent::SelectNextDevice), + }, + MouseEventKind::ScrollUp => match state.screen { + Screen::Diagnostics => Some(AppEvent::DiagnosticsSelectPrevCheck), + _ => Some(AppEvent::SelectPrevDevice), + }, + _ => None, + } +} + +fn init_terminal() -> Result>> { + use crossterm::event::EnableMouseCapture; + + enable_raw_mode()?; + let mut stdout = std::io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let terminal = Terminal::new(backend)?; + Ok(terminal) +} + +fn teardown_terminal(terminal: &mut Terminal>) -> Result<()> { + use crossterm::event::DisableMouseCapture; + + disable_raw_mode()?; + execute!( + terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + )?; + terminal.show_cursor()?; + Ok(()) +} diff --git a/sdk/crates/bitdo_tui/src/runtime/mod.rs b/sdk/crates/bitdo_tui/src/runtime/mod.rs new file mode 100644 index 0000000..88d7769 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/runtime/mod.rs @@ -0,0 +1,2 @@ +pub mod effect_executor; +pub mod r#loop; diff --git a/sdk/crates/bitdo_tui/src/settings.rs b/sdk/crates/bitdo_tui/src/settings.rs deleted file mode 100644 index a2abbaf..0000000 --- a/sdk/crates/bitdo_tui/src/settings.rs +++ /dev/null @@ -1,38 +0,0 @@ -use crate::ReportSaveMode; -use anyhow::{anyhow, Result}; -use serde::{Deserialize, Serialize}; -use std::path::Path; - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct PersistedSettings { - #[serde(default = "default_settings_schema_version")] - schema_version: u32, - #[serde(default)] - advanced_mode: bool, - #[serde(default)] - report_save_mode: ReportSaveMode, -} - -const fn default_settings_schema_version() -> u32 { - 1 -} - -/// Persist beginner/advanced preferences in a small TOML config file. -pub(crate) fn persist_user_settings( - path: &Path, - advanced_mode: bool, - report_save_mode: ReportSaveMode, -) -> Result<()> { - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let body = toml::to_string_pretty(&PersistedSettings { - schema_version: default_settings_schema_version(), - advanced_mode, - report_save_mode, - }) - .map_err(|err| anyhow!("failed to serialize user settings: {err}"))?; - std::fs::write(path, body)?; - Ok(()) -} diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_dashboard_80x24.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_dashboard_80x24.snap new file mode 100644 index 0000000..b5dbb65 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_dashboard_80x24.snap @@ -0,0 +1,29 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 196 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Dashboard • 3 devices • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Search filter────────────────╮╭Device full──────────╮╭Actions Enter/click──╮ +│Search active ││Ultimate2 2dc8:5209 ││› Refresh • scan │ +╰──────────────────────────────╯│Support: supported ││ Diagnose • probe │ +╭Controllers detected─────────╮│Protocol: Standard64 ││ Recommended Update │ +│› 2dc8:5209 Ultimate2 ││Evidence: Confirmed ││ Edit Mapping • map│ +│full • S64 • conf ││ ││ Settings • prefs │ +│ 2dc8:6009 Ultimate ││Capabilities ││ Quit • exit │ +│ro • S64 • infer ││• firmware ││ │ +│ 2dc8:901a Candidate ││• profile rw │╰──────────────────────╯ +│ro • unknown • untest ││• mode switch │╭Activity events──────╮ +│ ││• JP108 mapping ││ │ +│ ││• U2 slot + map ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +│ ││ ││ │ +╰──────────────────────────────╯╰──────────────────────╯╰──────────────────────╯ +╭Status────────────────────────────────────────────────────────────────────────╮ +│Ready │ +│Ultimate2 • click • arrows • Enter • Esc/q │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_100x30.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_100x30.snap new file mode 100644 index 0000000..ee42f2c --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_100x30.snap @@ -0,0 +1,35 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 317 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮ +│3/5 passed • 2 issues • 2 experimental │ +│Tier: full • Family: Standard64 • Transport: ready │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Filter All──╮╭Filter Issu╮╭Filter Exper╮╭Selected Check detail────────────────────────────────╮ +│All ││Issues ││Experimental ││GetPid OK │ +╰─────────────╯╰────────────╯╰─────────────╯│Severity: Ok │ +╭Checks click a row───────────────────────╮│Confidence: Confirmed │ +│› OK GetPid detected pid 0x5… ││Experimental: no │ +│ OK GetMode mode 2 ││Error code: none │ +│ OK GetSuperButton exp ok ││Response: Ok • attempts 1 │ +│ WARN ReadProfile exp timeout while wa… ││IO: wrote 64B, read 64B │ +│ ATTN Version invalid response… ││Validator: test:GetPid │ +│ │╰──────────────────────────────────────────────────────╯ +│ │╭Next Steps guidance──────────────────────────────────╮ +│ ││Action: Return to the dashboard and choose Recomme… │ +│ ││Summary: 3/5 checks passed. Experimental checks: 1… │ +│ ││Saved report: not yet saved in this screen │ +│ ││ │ +│ ││ │ +│ ││ │ +╰──────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯ +╭Run Again──────────────────────╮╭Save Report────────────────────╮╭Back───────────────────────────╮ +│Run Again ││Save Report ││Back │ +│rerun safe-read probe ││write support report ││return to dashboard │ +╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_80x24.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_80x24.snap new file mode 100644 index 0000000..3ce602e --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_80x24.snap @@ -0,0 +1,29 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 327 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Diagnostics summary──────────────────────────────────────────────────────────╮ +│3/5 passed • 2 issues • 2 experimental │ +│Tier: full • Family: Standard64 • Transport: ready │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Checks tab cycles filter─────────────────────────────────────────────────────╮ +│All 5 Issues 2 Exp 2 │ +│› OK GetPid detected pid 0x5209 │ +│ OK GetMode mode 2 │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Selected Check detail────────────────────────────────────────────────────────╮ +│GetPid OK │ +│Severity: Ok │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Next Steps guidance──────────────────────────────────────────────────────────╮ +│Action: Return to the dashboard and choose Recommended Update or Edit Mapp… │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Run Again───────────────╮╭Save Report──────────────╮╭Back────────────────────╮ +│Run Again ││Save Report ││Back │ +│rerun safe-read probe ││write support report ││return to dashboard │ +╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_with_saved_report.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_with_saved_report.snap new file mode 100644 index 0000000..dc1187a --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_diagnostics_screen_with_saved_report.snap @@ -0,0 +1,35 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 343 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Diagnostics • 3/5 passed • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Diagnostics summary──────────────────────────────────────────────────────────────────────────────╮ +│3/5 passed • 2 issues • 2 experimental │ +│Tier: full • Family: Standard64 • Transport: ready │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Filter All──╮╭Filter Issu╮╭Filter Exper╮╭Selected Check detail────────────────────────────────╮ +│All ││Issues ││Experimental ││Version ATTN │ +╰─────────────╯╰────────────╯╰─────────────╯│Severity: NeedsAttention │ +╭Checks click a row───────────────────────╮│Confidence: Confirmed │ +│ WARN ReadProfile exp timeout while wa… ││Experimental: no │ +│› ATTN Version invalid response… ││Error code: InvalidResponse │ +│ ││Response: Invalid • attempts 1 │ +│ ││IO: wrote 64B, read 8B │ +│ ││Validator: test:Version │ +│ │╰──────────────────────────────────────────────────────╯ +│ │╭Next Steps guidance──────────────────────────────────╮ +│ ││Action: Return to the dashboard and choose Recomme… │ +│ ││Summary: 3/5 checks passed. Experimental checks: 1… │ +│ ││Saved report: /tmp/openbitdo-diag-report.toml │ +│ ││ │ +│ ││ │ +│ ││ │ +╰──────────────────────────────────────────╯╰──────────────────────────────────────────────────────╯ +╭Run Again──────────────────────╮╭Save Report────────────────────╮╭Back───────────────────────────╮ +│Run Again ││Save Report ││Back │ +│rerun safe-read probe ││write support report ││return to dashboard │ +╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_mapping_editor_screen.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_mapping_editor_screen.snap new file mode 100644 index 0000000..2afd4b6 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_mapping_editor_screen.snap @@ -0,0 +1,35 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 233 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Mappings • draft modified • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Mapping Studio modified──────────────────────────────────────────────────────────────────────────╮ +│Apply is explicit. Arrow keys adjust only the highlighted mapping. │ +│ │ +│JP108 dedicated mapping │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Mappings up/down select • left/right adjust────────────────╮╭Inspector selected mapping─────────╮ +│› A -> 0x0005 ││Button: A │ +│ ││Target HID: 0x0005 │ +│ ││ │ +│ ││Left/right cycles preset targets. │ +│ ││ │ +│ ││Ready │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰────────────────────────────────────────────────────────────╯╰────────────────────────────────────╯ +╭Apply──────────────────────────╮╭Undo───────────────────────────╮╭Reset──────────────────────────╮ +│Apply ││Undo ││Reset │ +│write current draft ││restore last edit ││discard draft changes │ +╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯ +╭Restore Backup─────────────────╮╭Firmware───────────────────────╮╭Back───────────────────────────╮ +│Restore Backup ││Firmware ││Back │ +│recover saved backup ││switch to firmware flow ││return to dashboard │ +╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_recovery_screen.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_recovery_screen.snap new file mode 100644 index 0000000..096b1da --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_recovery_screen.snap @@ -0,0 +1,29 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 243 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Recovery • write lock active • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Recovery safe rollback path──────────────────────────────────────────────────╮ +│Recovery lock is active │ +│ │ +│Write operations stay blocked until the app restarts. │ +│Restore a backup if one exists, validate the device, then restart. │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Guidance recommended sequence────────────────────────────────────────────────╮ +│1. Restore backup if available. │ +│2. Confirm the controller responds normally. │ +│3. Restart OpenBitDo before any further writes. │ +│ │ +│No backup is registered for this session. │ +│Ready │ +│ │ +│ │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭Restore Backup──────────╮╭Back─────────────────────╮╭Quit────────────────────╮ +│Restore Backup ││Back ││Quit │ +│attempt rollback ││return to dashboard ││exit openbitdo │ +╰────────────────────────╯╰─────────────────────────╯╰────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_task_screen_100x30.snap b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_task_screen_100x30.snap new file mode 100644 index 0000000..2c04f1a --- /dev/null +++ b/sdk/crates/bitdo_tui/src/snapshots/bitdo_tui__tests__snapshot_task_screen_100x30.snap @@ -0,0 +1,35 @@ +--- +source: crates/bitdo_tui/src/tests.rs +assertion_line: 212 +expression: rendered +--- +╭Session───────────────────────────────────────────────────────────────────────────────────────────╮ +│OpenBitDo Workflow • Ready • reports fail-only • safe │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Preflight status and intent──────────────────────────────────────────────────────────────────────╮ +│Preflight Workflow preflight safety check │ +│ │ +│Ready to confirm transfer │ +│ │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭Details workflow context───────────────────────────────╮╭Progress transfer state────────────────╮ +│Ready to confirm transfer ││█████ │ +│ ││█████ 12% │ +│ │╰────────────────────────────────────────╯ +│ │╭Context current session────────────────╮ +│ ││Stage: preflight safety check │ +│ ││Progress: 12% │ +│ ││Reports: failure_only │ +│ ││Ready │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +│ ││ │ +╰────────────────────────────────────────────────────────╯╰────────────────────────────────────────╯ +╭Confirm────────────────────────╮╭Cancel─────────────────────────╮╭Back───────────────────────────╮ +│Confirm ││Cancel ││Back │ +│acknowledge risk + start ││stop this workflow ││return to dashboard │ +╰───────────────────────────────╯╰───────────────────────────────╯╰───────────────────────────────╯ diff --git a/sdk/crates/bitdo_tui/src/tests.rs b/sdk/crates/bitdo_tui/src/tests.rs index 30fd13e..6a36f0e 100644 --- a/sdk/crates/bitdo_tui/src/tests.rs +++ b/sdk/crates/bitdo_tui/src/tests.rs @@ -1,370 +1,654 @@ use super::*; -use crate::support_report::report_subject_token; -use bitdo_app_core::{FirmwareOutcome, OpenBitdoCoreConfig}; -use bitdo_proto::SupportLevel; - -#[test] -fn about_state_roundtrip_returns_home() { - let mut app = TuiApp::default(); - app.refresh_devices(vec![AppDevice { - vid_pid: VidPid::new(0x2dc8, 0x6009), - name: "Test".to_owned(), - support_level: SupportLevel::Full, - support_tier: SupportTier::Full, - protocol_family: bitdo_proto::ProtocolFamily::Standard64, - capability: bitdo_proto::PidCapability::full(), - evidence: bitdo_proto::SupportEvidence::Confirmed, - serial: Some("SERIAL1".to_owned()), - connected: true, - }]); - app.open_about(); - assert_eq!(app.state, TuiWorkflowState::About); - app.close_overlay(); - assert_eq!(app.state, TuiWorkflowState::Home); -} - -#[test] -fn refresh_devices_without_any_device_enters_wait_state() { - let mut app = TuiApp::default(); - app.refresh_devices(Vec::new()); - assert_eq!(app.state, TuiWorkflowState::WaitForDevice); - assert!(app.selected.is_none()); -} - -#[test] -fn refresh_devices_autoselects_single_device() { - let mut app = TuiApp::default(); - app.refresh_devices(vec![AppDevice { - vid_pid: VidPid::new(0x2dc8, 0x6009), - name: "One".to_owned(), - support_level: SupportLevel::Full, - support_tier: SupportTier::Full, - protocol_family: bitdo_proto::ProtocolFamily::Standard64, - capability: bitdo_proto::PidCapability::full(), - evidence: bitdo_proto::SupportEvidence::Confirmed, - serial: None, - connected: true, - }]); - - assert_eq!(app.state, TuiWorkflowState::Home); - assert_eq!(app.selected_index, 0); - assert_eq!(app.selected, Some(VidPid::new(0x2dc8, 0x6009))); -} - -#[test] -fn serial_token_prefers_serial_then_vidpid() { - let with_serial = AppDevice { - vid_pid: VidPid::new(0x2dc8, 0x6009), - name: "S".to_owned(), - support_level: SupportLevel::Full, - support_tier: SupportTier::Full, - protocol_family: bitdo_proto::ProtocolFamily::Standard64, - capability: bitdo_proto::PidCapability::full(), - evidence: bitdo_proto::SupportEvidence::Confirmed, - serial: Some("ABC 123".to_owned()), - connected: true, - }; - assert_eq!(report_subject_token(Some(&with_serial)), "ABC_123"); - - let without_serial = AppDevice { - serial: None, - ..with_serial - }; - assert_eq!(report_subject_token(Some(&without_serial)), "2dc86009"); -} - -#[test] -fn launch_options_default_to_failure_only_reports() { - let opts = TuiLaunchOptions::default(); - assert_eq!(opts.report_save_mode, ReportSaveMode::FailureOnly); -} - -#[test] -fn blocked_panel_text_matches_support_tier() { - let mut app = TuiApp::default(); - app.refresh_devices(vec![AppDevice { - vid_pid: VidPid::new(0x2dc8, 0x2100), - name: "Candidate".to_owned(), - support_level: SupportLevel::DetectOnly, - support_tier: SupportTier::CandidateReadOnly, - protocol_family: bitdo_proto::ProtocolFamily::Standard64, - capability: bitdo_proto::PidCapability { - supports_mode: true, - supports_profile_rw: true, - supports_boot: false, - supports_firmware: false, - supports_jp108_dedicated_map: false, - supports_u2_slot_config: false, - supports_u2_button_map: false, - }, - evidence: bitdo_proto::SupportEvidence::Inferred, - serial: None, - connected: true, - }]); - let selected = app.selected_device().expect("selected"); - let text = blocked_action_panel_text(selected); - assert!(text.contains("blocked")); - assert!(text.contains("Status shown as Blocked")); - assert_eq!(beginner_status_label(selected), "Blocked"); -} - -#[test] -fn non_advanced_report_mode_skips_off_setting() { - let mut app = TuiApp { - advanced_mode: false, - ..Default::default() - }; - assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly); - app.cycle_report_save_mode().expect("cycle"); - assert_eq!(app.report_save_mode, ReportSaveMode::Always); - app.cycle_report_save_mode().expect("cycle"); - assert_eq!(app.report_save_mode, ReportSaveMode::FailureOnly); -} - -#[test] -fn unknown_device_label_is_beginner_friendly() { - let device = AppDevice { - vid_pid: VidPid::new(0x2dc8, 0xabcd), - name: "PID_UNKNOWN".to_owned(), - support_level: SupportLevel::DetectOnly, - support_tier: SupportTier::DetectOnly, - protocol_family: bitdo_proto::ProtocolFamily::Unknown, - capability: bitdo_proto::PidCapability::identify_only(), - evidence: bitdo_proto::SupportEvidence::Untested, - serial: None, - connected: true, - }; - let label = super::display_device_name(&device); - assert!(label.contains("Unknown 8BitDo Device")); - assert!(label.contains("2dc8:abcd")); -} +use crate::app::action::QuickAction; +use crate::app::event::AppEvent; +use crate::app::reducer::reduce; +use crate::app::state::{ + AppState, DiagnosticsFilter, DiagnosticsState, MappingDraftState, Screen, TaskMode, +}; +use crate::persistence::ui_state::{load_ui_state, persist_ui_state}; +use crate::runtime::effect_executor::execute_effect; +use bitdo_app_core::{DedicatedButtonId, DedicatedButtonMapping, OpenBitdoCoreConfig}; +use bitdo_proto::{ + BitdoErrorCode, CommandId, DiagCommandStatus, DiagProbeResult, DiagSeverity, + EvidenceConfidence, ResponseStatus, SupportTier, VidPid, +}; +use insta::assert_snapshot; +use ratatui::backend::TestBackend; +use ratatui::Terminal; +use std::collections::BTreeMap; +use std::path::PathBuf; #[tokio::test] -async fn home_refresh_loads_devices() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { +async fn quick_action_matrix_blocks_update_for_read_only() { + let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig { mock_mode: true, - default_chunk_size: 16, - progress_interval_ms: 1, ..Default::default() }); - let mut app = TuiApp::default(); - app.refresh_devices(core.list_devices().await.expect("devices")); + let mut state = AppState::new(&UiLaunchOptions::default()); + let devices = core.list_devices().await.expect("devices"); + let _ = reduce(&mut state, AppEvent::DevicesLoaded(devices)); - assert!(!app.devices.is_empty()); - assert!(app.selected_device().is_some()); + let update = state + .quick_actions + .iter() + .find(|a| a.action == QuickAction::RecommendedUpdate) + .expect("update action"); + assert!(update.enabled); + + let readonly_idx = state + .devices + .iter() + .position(|d| d.support_tier != SupportTier::Full) + .expect("readonly device"); + state.selected_device_id = Some(state.devices[readonly_idx].vid_pid); + state.recompute_quick_actions(); + + let update = state + .quick_actions + .iter() + .find(|a| a.action == QuickAction::RecommendedUpdate) + .expect("update action"); + assert!(!update.enabled); +} + +#[test] +fn mapping_draft_undo_and_reset() { + let mut state = AppState::new(&UiLaunchOptions::default()); + state.screen = Screen::MappingEditor; + state.mapping_draft_state = Some(MappingDraftState::Jp108 { + loaded: vec![DedicatedButtonMapping { + button: DedicatedButtonId::A, + target_hid_usage: 0x0004, + }], + current: vec![DedicatedButtonMapping { + button: DedicatedButtonId::A, + target_hid_usage: 0x0004, + }], + undo_stack: Vec::new(), + selected_row: 0, + }); + + let _ = reduce(&mut state, AppEvent::MappingAdjust(1)); + assert!(state.mapping_has_changes()); + + let _ = reduce(&mut state, AppEvent::TriggerAction(QuickAction::UndoDraft)); + assert!(!state.mapping_has_changes()); + + let _ = reduce(&mut state, AppEvent::MappingAdjust(1)); + assert!(state.mapping_has_changes()); + let _ = reduce(&mut state, AppEvent::TriggerAction(QuickAction::ResetDraft)); + assert!(!state.mapping_has_changes()); +} + +#[test] +fn settings_schema_v2_roundtrip() { + let path = std::env::temp_dir().join("bitdo-tui-ui-state-v2.toml"); + persist_ui_state( + &path, + true, + ReportSaveMode::Always, + "ultimate".to_owned(), + DashboardLayoutMode::Compact, + PanelFocus::QuickActions, + ) + .expect("persist"); + + let loaded = load_ui_state(&path).expect("load"); + assert_eq!(loaded.schema_version, 2); + assert!(loaded.advanced_mode); + assert_eq!(loaded.report_save_mode, ReportSaveMode::Always); + assert_eq!(loaded.device_filter_text, "ultimate"); + assert_eq!(loaded.dashboard_layout_mode, DashboardLayoutMode::Compact); + assert_eq!(loaded.last_panel_focus, PanelFocus::QuickActions); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn invalid_ui_state_returns_error() { + let path = std::env::temp_dir().join("bitdo-tui-invalid-ui-state.toml"); + std::fs::write(&path, "advanced_mode = [").expect("write invalid"); + + let err = load_ui_state(&path).expect_err("invalid ui state must error"); + assert!(err.to_string().contains("failed to parse ui state")); + + let _ = std::fs::remove_file(path); +} + +#[test] +fn launch_defaults_are_safe() { + let ui = UiLaunchOptions::default(); + assert!(!ui.allow_unsafe); + assert!(!ui.brick_risk_ack); + + let headless = RunLaunchOptions::default(); + assert!(!headless.allow_unsafe); + assert!(!headless.brick_risk_ack); + assert!(!headless.acknowledged_risk); } #[tokio::test] -async fn run_tui_app_no_ui_blocks_detect_only_pid() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { +async fn integration_refresh_select_preflight_cancel_path() { + let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig { mock_mode: true, - default_chunk_size: 16, - progress_interval_ms: 1, ..Default::default() }); - let result = run_tui_app( - core, - TuiLaunchOptions { - no_ui: true, - selected_vid_pid: Some(VidPid::new(0x2dc8, 0x2100)), - ..Default::default() - }, + let mut state = AppState::new(&UiLaunchOptions::default()); + drive(&core, &mut state, AppEvent::Init).await; + + assert!(!state.devices.is_empty()); + + let full_support_index = state + .devices + .iter() + .position(|device| device.support_tier == SupportTier::Full) + .expect("full-support device"); + drive( + &core, + &mut state, + AppEvent::SelectFilteredDevice(full_support_index), + ) + .await; + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::RecommendedUpdate), ) .await; - assert!(result.is_err()); + assert_eq!(state.screen, Screen::Task); + assert!(state.task_state.is_some()); + let downloaded_path = state + .task_state + .as_ref() + .and_then(|task| task.downloaded_firmware_path.clone()) + .expect("downloaded firmware path"); + assert!(downloaded_path.exists()); + + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::Cancel), + ) + .await; + assert_eq!(state.screen, Screen::Dashboard); + assert!(!downloaded_path.exists()); } #[tokio::test] -async fn run_tui_app_no_ui_full_support_completes() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { +async fn integration_diagnostics_run_rerun_save_and_back() { + let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig { + mock_mode: true, + ..Default::default() + }); + + let mut state = AppState::new(&UiLaunchOptions::default()); + drive(&core, &mut state, AppEvent::Init).await; + drive(&core, &mut state, AppEvent::SelectFilteredDevice(0)).await; + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::Diagnose), + ) + .await; + + assert_eq!(state.screen, Screen::Diagnostics); + assert!(state.diagnostics_state.is_some()); + assert!(state.task_state.is_none()); + + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::RunAgain), + ) + .await; + assert_eq!(state.screen, Screen::Diagnostics); + assert!(state.diagnostics_state.is_some()); + + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::SaveReport), + ) + .await; + let saved_path = state + .diagnostics_state + .as_ref() + .and_then(|diagnostics| diagnostics.latest_report_path.clone()) + .expect("diagnostics report path"); + assert!(saved_path.exists()); + + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::Back), + ) + .await; + assert_eq!(state.screen, Screen::Dashboard); + + let _ = std::fs::remove_file(saved_path); +} + +#[test] +fn diagnostics_filter_changes_visible_rows() { + let mut state = snapshot_state(); + state.screen = Screen::Diagnostics; + state.diagnostics_state = Some(sample_diagnostics_state(None)); + state.recompute_quick_actions(); + + assert_eq!(state.diagnostics_filtered_indices(), vec![0, 1, 2, 3, 4]); + + let _ = reduce( + &mut state, + AppEvent::DiagnosticsSetFilter(DiagnosticsFilter::Issues), + ); + assert_eq!(state.diagnostics_filtered_indices(), vec![3, 4]); + assert_eq!( + state + .selected_diagnostics_check() + .map(|check| check.command), + Some(CommandId::ReadProfile) + ); + + let _ = reduce( + &mut state, + AppEvent::DiagnosticsSetFilter(DiagnosticsFilter::Experimental), + ); + assert_eq!(state.diagnostics_filtered_indices(), vec![2, 3]); + assert_eq!( + state + .selected_diagnostics_check() + .map(|check| check.command), + Some(CommandId::ReadProfile) + ); +} + +#[tokio::test] +async fn manual_save_report_updates_diagnostics_state() { + let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig { + mock_mode: true, + ..Default::default() + }); + + let mut state = snapshot_state(); + state.screen = Screen::Diagnostics; + state.diagnostics_state = Some(sample_diagnostics_state(None)); + state.recompute_quick_actions(); + + drive( + &core, + &mut state, + AppEvent::TriggerAction(QuickAction::SaveReport), + ) + .await; + + let saved_path = state + .diagnostics_state + .as_ref() + .and_then(|diagnostics| diagnostics.latest_report_path.clone()) + .expect("saved diagnostics report path"); + assert!(saved_path.exists()); + + let _ = std::fs::remove_file(saved_path); +} + +#[test] +fn recovery_transition_is_preserved() { + let mut state = AppState::new(&UiLaunchOptions::default()); + let _ = reduce( + &mut state, + AppEvent::MappingApplied { + backup_id: None, + message: "rollback failed".to_owned(), + recovery_lock: true, + }, + ); + assert_eq!(state.screen, Screen::Recovery); + assert!(state.write_lock_until_restart); +} + +#[tokio::test] +async fn headless_human_and_json_modes_complete() { + let core = bitdo_app_core::OpenBitdoCore::new(OpenBitdoCoreConfig { mock_mode: true, - default_chunk_size: 16, progress_interval_ms: 1, ..Default::default() }); - run_tui_app( - core, - TuiLaunchOptions { - no_ui: true, - selected_vid_pid: Some(VidPid::new(0x2dc8, 0x6009)), + let report_human = run_headless( + core.clone(), + RunLaunchOptions { + vid_pid: VidPid::new(0x2dc8, 0x6009), + use_recommended: true, + allow_unsafe: true, + brick_risk_ack: true, + acknowledged_risk: true, + output_mode: HeadlessOutputMode::Human, + emit_events: false, ..Default::default() }, ) .await - .expect("run app"); -} + .expect("human mode"); + assert_eq!( + report_human.status, + bitdo_app_core::FirmwareOutcome::Completed + ); -#[tokio::test] -async fn tui_flow_with_manual_path_completes() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { - mock_mode: true, - default_chunk_size: 16, - progress_interval_ms: 1, - ..Default::default() - }); - - let path = std::env::temp_dir().join("openbitdo-tui-flow.bin"); - tokio::fs::write(&path, vec![1u8; 128]) - .await - .expect("write"); - - let report = run_tui_flow( + let report_json = run_headless( core, - TuiRunRequest { + RunLaunchOptions { vid_pid: VidPid::new(0x2dc8, 0x6009), - firmware_path: path.clone(), + use_recommended: true, allow_unsafe: true, brick_risk_ack: true, - experimental: true, - chunk_size: Some(32), acknowledged_risk: true, - no_ui: true, + output_mode: HeadlessOutputMode::Json, + emit_events: true, + ..Default::default() }, ) .await - .expect("run tui flow"); - - assert_eq!(report.status, FirmwareOutcome::Completed); - let _ = tokio::fs::remove_file(path).await; + .expect("json mode"); + assert_eq!( + report_json.status, + bitdo_app_core::FirmwareOutcome::Completed + ); } -#[tokio::test] -async fn support_report_is_toml_file() { - let device = AppDevice { - vid_pid: VidPid::new(0x2dc8, 0x6009), - name: "Test".to_owned(), - support_level: SupportLevel::Full, - support_tier: SupportTier::Full, +#[test] +fn snapshot_dashboard_80x24() { + let mut state = snapshot_state(); + state.dashboard_layout_mode = DashboardLayoutMode::Wide; + let rendered = render_state(&mut state, 80, 24); + assert_snapshot!(rendered); +} + +#[test] +fn snapshot_task_screen_100x30() { + let mut state = snapshot_state(); + state.screen = Screen::Task; + state.task_state = Some(crate::app::state::TaskState { + mode: TaskMode::Preflight, + plan: None, + progress: 12, + status: "Ready to confirm transfer".to_owned(), + final_report: None, + downloaded_firmware_path: None, + }); + state.recompute_quick_actions(); + let rendered = render_state(&mut state, 100, 30); + assert_snapshot!(rendered); +} + +#[test] +fn snapshot_diagnostics_screen_100x30() { + let mut state = snapshot_state(); + state.screen = Screen::Diagnostics; + state.diagnostics_state = Some(sample_diagnostics_state(None)); + state.recompute_quick_actions(); + let rendered = render_state(&mut state, 100, 30); + assert_snapshot!(rendered); +} + +#[test] +fn snapshot_diagnostics_screen_80x24() { + let mut state = snapshot_state(); + state.screen = Screen::Diagnostics; + state.diagnostics_state = Some(sample_diagnostics_state(None)); + state.recompute_quick_actions(); + let rendered = render_state(&mut state, 80, 24); + assert_snapshot!(rendered); +} + +#[test] +fn snapshot_diagnostics_screen_with_saved_report() { + let mut state = snapshot_state(); + state.screen = Screen::Diagnostics; + state.diagnostics_state = Some(sample_diagnostics_state(Some(PathBuf::from( + "/tmp/openbitdo-diag-report.toml", + )))); + if let Some(diagnostics) = state.diagnostics_state.as_mut() { + diagnostics.active_filter = DiagnosticsFilter::Issues; + diagnostics.selected_check_index = 4; + } + state.recompute_quick_actions(); + let rendered = render_state(&mut state, 100, 30); + assert_snapshot!(rendered); +} + +#[test] +fn snapshot_mapping_editor_screen() { + let mut state = snapshot_state(); + state.screen = Screen::MappingEditor; + state.mapping_draft_state = Some(MappingDraftState::Jp108 { + loaded: vec![DedicatedButtonMapping { + button: DedicatedButtonId::A, + target_hid_usage: 0x0004, + }], + current: vec![DedicatedButtonMapping { + button: DedicatedButtonId::A, + target_hid_usage: 0x0005, + }], + undo_stack: Vec::new(), + selected_row: 0, + }); + state.recompute_quick_actions(); + let rendered = render_state(&mut state, 100, 30); + assert_snapshot!(rendered); +} + +#[test] +fn snapshot_recovery_screen() { + let mut state = snapshot_state(); + state.screen = Screen::Recovery; + state.write_lock_until_restart = true; + state.recompute_quick_actions(); + let rendered = render_state(&mut state, 80, 24); + assert_snapshot!(rendered); +} + +async fn drive(core: &bitdo_app_core::OpenBitdoCore, state: &mut AppState, initial: AppEvent) { + let mut queue = std::collections::VecDeque::from([initial]); + while let Some(event) = queue.pop_front() { + let effects = reduce(state, event); + for effect in effects { + let emitted = execute_effect(core, state, effect).await; + for next in emitted { + queue.push_back(next); + } + } + } +} + +fn snapshot_state() -> AppState { + let mut state = AppState::new(&UiLaunchOptions::default()); + let _ = reduce( + &mut state, + AppEvent::DevicesLoaded(vec![ + bitdo_app_core::AppDevice { + vid_pid: VidPid::new(0x2dc8, 0x5209), + name: "Ultimate2".to_owned(), + support_level: bitdo_proto::SupportLevel::Full, + support_tier: bitdo_proto::SupportTier::Full, + protocol_family: bitdo_proto::ProtocolFamily::Standard64, + capability: bitdo_proto::PidCapability::full(), + evidence: bitdo_proto::SupportEvidence::Confirmed, + serial: None, + connected: true, + }, + bitdo_app_core::AppDevice { + vid_pid: VidPid::new(0x2dc8, 0x6009), + name: "Ultimate".to_owned(), + support_level: bitdo_proto::SupportLevel::DetectOnly, + support_tier: bitdo_proto::SupportTier::CandidateReadOnly, + protocol_family: bitdo_proto::ProtocolFamily::Standard64, + capability: bitdo_proto::PidCapability::identify_only(), + evidence: bitdo_proto::SupportEvidence::Inferred, + serial: None, + connected: true, + }, + bitdo_app_core::AppDevice { + vid_pid: VidPid::new(0x2dc8, 0x901a), + name: "Candidate".to_owned(), + support_level: bitdo_proto::SupportLevel::DetectOnly, + support_tier: bitdo_proto::SupportTier::CandidateReadOnly, + protocol_family: bitdo_proto::ProtocolFamily::Unknown, + capability: bitdo_proto::PidCapability::identify_only(), + evidence: bitdo_proto::SupportEvidence::Untested, + serial: None, + connected: true, + }, + ]), + ); + state.event_log.clear(); + state.status_line = "Ready".to_owned(); + state +} + +fn sample_diagnostics_state(report_path: Option) -> DiagnosticsState { + DiagnosticsState { + result: sample_diagnostics_result(), + summary: "3/5 checks passed. Experimental checks: 1/2 passed. Issues: 2 total, 1 need attention. Transport ready: yes. Standard64 diagnostics are available. This device is full-support.".to_owned(), + selected_check_index: 0, + active_filter: DiagnosticsFilter::All, + latest_report_path: report_path, + } +} + +fn sample_diagnostics_result() -> DiagProbeResult { + DiagProbeResult { + target: VidPid::new(0x2dc8, 0x5209), + profile_name: "Ultimate2".to_owned(), + support_level: bitdo_proto::SupportLevel::Full, + support_tier: bitdo_proto::SupportTier::Full, protocol_family: bitdo_proto::ProtocolFamily::Standard64, capability: bitdo_proto::PidCapability::full(), evidence: bitdo_proto::SupportEvidence::Confirmed, - serial: Some("RPT-1".to_owned()), - connected: true, - }; - - let report_path = persist_support_report( - "diag-probe", - Some(&device), - "ok", - "all checks passed".to_owned(), - None, - None, - ) - .await - .expect("report path"); - - assert_eq!( - report_path.extension().and_then(|s| s.to_str()), - Some("toml") - ); - let _ = tokio::fs::remove_file(report_path).await; + transport_ready: true, + command_checks: vec![ + diag_check( + CommandId::GetPid, + DiagCheckFixture { + ok: true, + confidence: EvidenceConfidence::Confirmed, + is_experimental: false, + severity: DiagSeverity::Ok, + error_code: None, + detail: "detected pid 0x5209", + parsed_facts: [("detected_pid", 0x5209)].into_iter().collect(), + }, + ), + diag_check( + CommandId::GetMode, + DiagCheckFixture { + ok: true, + confidence: EvidenceConfidence::Confirmed, + is_experimental: false, + severity: DiagSeverity::Ok, + error_code: None, + detail: "mode 2", + parsed_facts: [("mode", 2)].into_iter().collect(), + }, + ), + diag_check( + CommandId::GetSuperButton, + DiagCheckFixture { + ok: true, + confidence: EvidenceConfidence::Inferred, + is_experimental: true, + severity: DiagSeverity::Ok, + error_code: None, + detail: "ok", + parsed_facts: BTreeMap::new(), + }, + ), + diag_check( + CommandId::ReadProfile, + DiagCheckFixture { + ok: false, + confidence: EvidenceConfidence::Inferred, + is_experimental: true, + severity: DiagSeverity::Warning, + error_code: Some(BitdoErrorCode::Timeout), + detail: "timeout while waiting for device response", + parsed_facts: BTreeMap::new(), + }, + ), + diag_check( + CommandId::Version, + DiagCheckFixture { + ok: false, + confidence: EvidenceConfidence::Confirmed, + is_experimental: false, + severity: DiagSeverity::NeedsAttention, + error_code: Some(BitdoErrorCode::InvalidResponse), + detail: "invalid response for Version: response signature mismatch", + parsed_facts: [("version_x100", 4200), ("beta", 0)].into_iter().collect(), + }, + ), + ], + } } -#[tokio::test] -async fn update_action_enters_jp108_wizard_for_jp108_device() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { - mock_mode: true, - ..Default::default() - }); - let mut app = TuiApp::default(); - app.refresh_devices(core.list_devices().await.expect("devices")); - let jp108_idx = app - .devices - .iter() - .position(|d| d.vid_pid.pid == 0x5209) - .expect("jp108 fixture"); - app.select_index(jp108_idx); - app.state = TuiWorkflowState::Home; - - let mut terminal = None; - let mut events = None; - let opts = TuiLaunchOptions::default(); - execute_home_action( - &core, - &mut terminal, - &mut app, - &opts, - &mut events, - HomeAction::Update, - ) - .await - .expect("execute"); - - assert_eq!(app.state, TuiWorkflowState::Jp108Mapping); - assert!(!app.jp108_mappings.is_empty()); +struct DiagCheckFixture<'a> { + ok: bool, + confidence: EvidenceConfidence, + is_experimental: bool, + severity: DiagSeverity, + error_code: Option, + detail: &'a str, + parsed_facts: BTreeMap<&'a str, u32>, } -#[tokio::test] -async fn update_action_enters_u2_wizard_for_ultimate2_device() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { - mock_mode: true, - ..Default::default() - }); - let mut app = TuiApp::default(); - app.refresh_devices(core.list_devices().await.expect("devices")); - let u2_idx = app - .devices - .iter() - .position(|d| d.vid_pid.pid == 0x6012) - .expect("u2 fixture"); - app.select_index(u2_idx); - app.state = TuiWorkflowState::Home; - - let mut terminal = None; - let mut events = None; - let opts = TuiLaunchOptions::default(); - execute_home_action( - &core, - &mut terminal, - &mut app, - &opts, - &mut events, - HomeAction::Update, - ) - .await - .expect("execute"); - - assert_eq!(app.state, TuiWorkflowState::U2CoreProfile); - assert!(app.u2_profile.is_some()); +fn diag_check(command: CommandId, fixture: DiagCheckFixture<'_>) -> DiagCommandStatus { + DiagCommandStatus { + command, + ok: fixture.ok, + confidence: fixture.confidence, + is_experimental: fixture.is_experimental, + severity: fixture.severity, + attempts: 1, + validator: format!("test:{command:?}"), + response_status: if fixture.ok { + ResponseStatus::Ok + } else { + ResponseStatus::Invalid + }, + bytes_written: 64, + bytes_read: if fixture.ok { 64 } else { 8 }, + error_code: fixture.error_code, + detail: fixture.detail.to_owned(), + parsed_facts: fixture + .parsed_facts + .into_iter() + .map(|(key, value)| (key.to_owned(), value)) + .collect(), + } } -#[tokio::test] -async fn device_flow_backup_apply_sets_backup_id() { - let core = OpenBitdoCore::new(OpenBitdoCoreConfig { - mock_mode: true, - ..Default::default() - }); - let mut app = TuiApp::default(); - app.refresh_devices(core.list_devices().await.expect("devices")); - let jp108_idx = app - .devices - .iter() - .position(|d| d.vid_pid.pid == 0x5209) - .expect("jp108 fixture"); - app.select_index(jp108_idx); - app.begin_jp108_mapping( - core.jp108_read_dedicated_mapping(VidPid::new(0x2dc8, 0x5209)) - .await - .expect("read"), - ); +fn render_state(state: &mut AppState, width: u16, height: u16) -> String { + state.set_layout_from_size(width, height); + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).expect("terminal"); + terminal + .draw(|frame| { + let _ = crate::ui::layout::render(frame, state); + }) + .expect("draw"); - let mut terminal = None; - let mut events = None; - let opts = TuiLaunchOptions::default(); - execute_device_flow_action( - &core, - &mut terminal, - &mut app, - &opts, - &mut events, - DeviceFlowAction::BackupApply, - ) - .await - .expect("apply"); + let backend = terminal.backend(); + let buffer = backend.buffer(); + let mut lines = Vec::new(); + for y in 0..height { + let mut line = String::new(); + for x in 0..width { + line.push_str(buffer[(x, y)].symbol()); + } + lines.push(line.trim_end().to_owned()); + } - assert!(app.latest_backup.is_some()); + lines.join("\n") } diff --git a/sdk/crates/bitdo_tui/src/ui/layout.rs b/sdk/crates/bitdo_tui/src/ui/layout.rs new file mode 100644 index 0000000..e8ca2ac --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/layout.rs @@ -0,0 +1,283 @@ +use crate::app::action::QuickAction; +use crate::app::state::{AppState, DiagnosticsFilter, Screen}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; +use ratatui::Frame; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use super::screens::{dashboard, diagnostics, mapping_editor, recovery, settings, task}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HitTarget { + DeviceRow(usize), + QuickAction(QuickAction), + FilterInput, + DiagnosticsCheck(usize), + DiagnosticsFilter(DiagnosticsFilter), + ToggleAdvancedMode, + CycleReportMode, +} + +#[derive(Clone, Copy, Debug)] +pub struct HitRegion { + pub rect: Rect, + pub target: HitTarget, +} + +#[derive(Clone, Debug, Default)] +pub struct HitMap { + pub regions: Vec, +} + +#[derive(Clone, Debug)] +pub struct ActionDescriptor { + pub action: QuickAction, + pub label: String, + pub caption: String, + pub enabled: bool, + pub active: bool, +} + +impl HitMap { + pub fn push(&mut self, rect: Rect, target: HitTarget) { + self.regions.push(HitRegion { rect, target }); + } + + pub fn extend(&mut self, regions: Vec) { + self.regions.extend(regions); + } + + pub fn hit(&self, x: u16, y: u16) -> Option { + self.regions + .iter() + .find(|region| point_in_rect(x, y, region.rect)) + .map(|region| region.target) + } +} + +pub fn render(frame: &mut Frame<'_>, state: &AppState) -> HitMap { + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(1)]) + .split(frame.area()); + + render_header(frame, root[0], state); + + match state.screen { + Screen::Dashboard => dashboard::render(frame, state, root[1]), + Screen::Task => task::render(frame, state, root[1]), + Screen::Diagnostics => diagnostics::render(frame, state, root[1]), + Screen::MappingEditor => mapping_editor::render(frame, state, root[1]), + Screen::Recovery => recovery::render(frame, state, root[1]), + Screen::Settings => settings::render(frame, state, root[1]), + } +} + +pub fn render_action_strip( + frame: &mut Frame<'_>, + area: Rect, + actions: &[ActionDescriptor], +) -> Vec { + if actions.is_empty() { + return Vec::new(); + } + + let columns = action_columns(area.width, actions.len()); + let rows = actions.len().div_ceil(columns); + let row_constraints = vec![Constraint::Length(4); rows]; + let row_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(row_constraints) + .split(area); + + let mut regions = Vec::new(); + + for (row_idx, row_rect) in row_chunks.iter().copied().enumerate() { + let start = row_idx * columns; + let end = (start + columns).min(actions.len()); + let row_actions = &actions[start..end]; + let constraints = vec![ + Constraint::Percentage((100 / row_actions.len()).max(1) as u16); + row_actions.len() + ]; + let col_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints(constraints) + .split(row_rect); + + for (descriptor, rect) in row_actions.iter().zip(col_chunks.iter().copied()) { + let label_width = rect.width.saturating_sub(4) as usize; + let style = crate::ui::theme::action_label_style(descriptor.active, descriptor.enabled); + let border = crate::ui::theme::border_style(descriptor.active, descriptor.enabled); + let label = truncate_to_width(&descriptor.label, label_width); + let caption = truncate_to_width(&descriptor.caption, label_width); + let body = Paragraph::new(vec![ + Line::from(Span::styled(label, style)), + Line::from(Span::styled( + caption, + crate::ui::theme::action_caption_style(descriptor.enabled), + )), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(border) + .title(descriptor.action.label()), + ); + frame.render_widget(body, rect); + regions.push(HitRegion { + rect, + target: HitTarget::QuickAction(descriptor.action), + }); + } + } + + regions +} + +pub fn action_grid_height(width: u16, count: usize) -> u16 { + if count == 0 { + 0 + } else { + let columns = action_columns(width, count); + let rows = count.div_ceil(columns); + (rows as u16).saturating_mul(4) + } +} + +pub fn panel_block<'a>(title: &'a str, subtitle: Option<&'a str>, active: bool) -> Block<'a> { + let mut title_spans = vec![Span::styled(title, crate::ui::theme::title_style())]; + if let Some(subtitle) = subtitle.filter(|subtitle| !subtitle.is_empty()) { + title_spans.push(Span::styled(" ", crate::ui::theme::subtle_style())); + title_spans.push(Span::styled(subtitle, crate::ui::theme::subtle_style())); + } + + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .style(crate::ui::theme::border_style(active, true)) + .title(Line::from(title_spans)) +} + +pub fn inner_rect(rect: Rect, horizontal: u16, vertical: u16) -> Rect { + let x = rect.x.saturating_add(horizontal); + let y = rect.y.saturating_add(vertical); + let width = rect.width.saturating_sub(horizontal.saturating_mul(2)); + let height = rect.height.saturating_sub(vertical.saturating_mul(2)); + Rect::new(x, y, width, height) +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, state: &AppState) { + let filtered = state.filtered_device_indices().len(); + let mode = if state.advanced_mode { "adv" } else { "safe" }; + let summary = match state.screen { + Screen::Dashboard => format!("{filtered} devices"), + Screen::Task => truncate_to_width(&state.status_line, 20), + Screen::Diagnostics => state + .diagnostics_state + .as_ref() + .map(|diagnostics| { + let passed = diagnostics + .result + .command_checks + .iter() + .filter(|check| check.ok) + .count(); + format!( + "{passed}/{} passed", + diagnostics.result.command_checks.len() + ) + }) + .unwrap_or_else(|| "diagnostics".to_owned()), + Screen::MappingEditor => { + if state.mapping_has_changes() { + "draft modified".to_owned() + } else { + "draft clean".to_owned() + } + } + Screen::Recovery => "write lock active".to_owned(), + Screen::Settings => "preferences".to_owned(), + }; + + let line = Line::from(vec![ + Span::styled("OpenBitDo", crate::ui::theme::app_title_style()), + Span::raw(" "), + Span::styled( + screen_label(state.screen), + crate::ui::theme::screen_title_style(), + ), + Span::raw(" • "), + Span::styled(summary, crate::ui::theme::subtle_style()), + Span::raw(" • "), + Span::styled( + format!("reports {}", report_mode_short(state.report_save_mode)), + crate::ui::theme::subtle_style(), + ), + Span::raw(" • "), + Span::styled(mode, crate::ui::theme::subtle_style()), + ]); + + let header = Paragraph::new(line).block(panel_block("Session", None, true)); + frame.render_widget(header, area); +} + +fn action_columns(width: u16, count: usize) -> usize { + let desired = if width >= 110 { + 4 + } else if width >= 76 { + 3 + } else if width >= 48 { + 2 + } else { + 1 + }; + desired.min(count.max(1)) +} + +fn screen_label(screen: Screen) -> &'static str { + match screen { + Screen::Dashboard => "Dashboard", + Screen::Task => "Workflow", + Screen::Diagnostics => "Diagnostics", + Screen::MappingEditor => "Mappings", + Screen::Recovery => "Recovery", + Screen::Settings => "Settings", + } +} + +fn report_mode_short(mode: crate::ReportSaveMode) -> &'static str { + match mode { + crate::ReportSaveMode::Off => "off", + crate::ReportSaveMode::Always => "always", + crate::ReportSaveMode::FailureOnly => "fail-only", + } +} + +pub fn point_in_rect(x: u16, y: u16, rect: Rect) -> bool { + x >= rect.x + && y >= rect.y + && x < rect.x.saturating_add(rect.width) + && y < rect.y.saturating_add(rect.height) +} + +pub fn truncate_to_width(input: &str, max_width: usize) -> String { + if UnicodeWidthStr::width(input) <= max_width { + return input.to_owned(); + } + + let mut out = String::new(); + let mut width = 0usize; + for ch in input.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width >= max_width.saturating_sub(1) { + break; + } + out.push(ch); + width += ch_width; + } + out.push('…'); + out +} diff --git a/sdk/crates/bitdo_tui/src/ui/mod.rs b/sdk/crates/bitdo_tui/src/ui/mod.rs new file mode 100644 index 0000000..86c7ec1 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/mod.rs @@ -0,0 +1,3 @@ +pub mod layout; +pub mod screens; +pub mod theme; diff --git a/sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs b/sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs new file mode 100644 index 0000000..526d42c --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/dashboard.rs @@ -0,0 +1,411 @@ +use crate::app::state::{AppState, DashboardLayoutMode, PanelFocus}; +use crate::ui::layout::{inner_rect, panel_block, truncate_to_width, HitMap, HitRegion, HitTarget}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, Paragraph}; +use ratatui::Frame; + +pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap { + let mut map = HitMap::default(); + + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(10), Constraint::Length(4)]) + .split(area); + + let status_hint = match state.dashboard_layout_mode { + DashboardLayoutMode::Compact => "compact layout • resize for full three-panel view", + DashboardLayoutMode::Wide => "click • arrows • Enter • Esc/q", + }; + let selected_summary = state + .selected_device() + .map(|device| device.name.clone()) + .unwrap_or_else(|| "No controller selected".to_owned()); + let status = Paragraph::new(vec![ + Line::from(Span::raw(state.status_line.clone())), + Line::from(vec![ + Span::styled(selected_summary, crate::ui::theme::subtle_style()), + Span::raw(" • "), + Span::styled(status_hint, crate::ui::theme::subtle_style()), + ]), + ]) + .block(panel_block("Status", None, false)); + frame.render_widget(status, root[1]); + + match state.dashboard_layout_mode { + DashboardLayoutMode::Wide => render_wide(frame, state, root[0], &mut map), + DashboardLayoutMode::Compact => render_compact(frame, state, root[0], &mut map), + } + + map +} + +fn render_wide(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(40), + Constraint::Percentage(30), + Constraint::Percentage(30), + ]) + .split(area); + + render_devices(frame, state, columns[0], map); + render_selected_device(frame, state, columns[1]); + render_sidebar(frame, state, columns[2], map); +} + +fn render_compact(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(9), Constraint::Min(6)]) + .split(area); + + let top = if area.width < 60 { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(rows[0]) + } else { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(rows[0]) + }; + + render_devices(frame, state, top[0], map); + render_selected_device(frame, state, top[1]); + render_sidebar(frame, state, rows[1], map); +} + +fn render_devices(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(5)]) + .split(area); + + let filter_label = if state.last_panel_focus == PanelFocus::Devices { + format!("{} active", state.device_filter) + } else { + state.device_filter.clone() + }; + + let filter = Paragraph::new(Line::from(vec![ + Span::styled("Search ", crate::ui::theme::title_style()), + Span::raw(if filter_label.is_empty() { + "type a model, VID, or PID".to_owned() + } else { + filter_label + }), + ])) + .block(panel_block( + "Search", + Some("filter"), + state.last_panel_focus == PanelFocus::Devices, + )); + frame.render_widget(filter, chunks[0]); + map.push(chunks[0], HitTarget::FilterInput); + + let filtered = state.filtered_device_indices(); + let mut rows = Vec::new(); + if filtered.is_empty() { + rows.push(ListItem::new(vec![ + Line::from("No matching devices"), + Line::from(Span::styled( + "Try a broader search or refresh the device list", + crate::ui::theme::subtle_style(), + )), + ])); + } else { + for (display_idx, device_idx) in filtered.iter().copied().enumerate() { + let dev = &state.devices[device_idx]; + let selected = state + .selected_device_id + .map(|id| id == dev.vid_pid) + .unwrap_or(false) + || display_idx == state.selected_filtered_index; + let prefix = if selected { "›" } else { " " }; + let title = format!( + "{prefix} {:04x}:{:04x} {}", + dev.vid_pid.vid, + dev.vid_pid.pid, + truncate_to_width(&dev.name, chunks[1].width.saturating_sub(18) as usize) + ); + let detail = format!( + "{} • {} • {}", + support_tier_short(dev.support_tier), + protocol_short(dev.protocol_family), + evidence_short(dev.evidence) + ); + let style = if selected { + crate::ui::theme::selected_row_style() + } else { + Style::default() + }; + rows.push( + ListItem::new(vec![ + Line::from(Span::styled(title, style)), + Line::from(Span::styled(detail, crate::ui::theme::subtle_style())), + ]) + .style(style), + ); + } + } + + let list = List::new(rows).block(panel_block("Controllers", Some("detected"), true)); + frame.render_widget(list, chunks[1]); + + map.extend(device_regions( + chunks[1], + state.filtered_device_indices().len(), + )); +} + +fn render_selected_device(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let selected = state.selected_device(); + let lines = if let Some(device) = selected { + let mut details = vec![ + Line::from(vec![ + Span::styled(device.name.clone(), crate::ui::theme::screen_title_style()), + Span::raw(" "), + Span::styled( + format!("{:04x}:{:04x}", device.vid_pid.vid, device.vid_pid.pid), + crate::ui::theme::subtle_style(), + ), + ]), + Line::from(format!( + "Support: {}", + support_tier_label(device.support_tier) + )), + Line::from(format!("Protocol: {:?}", device.protocol_family)), + Line::from(format!("Evidence: {:?}", device.evidence)), + Line::from(""), + Line::from("Capabilities"), + ]; + + for capability in capability_lines(device) { + details.push(Line::from(Span::styled( + capability, + crate::ui::theme::subtle_style(), + ))); + } + + if device.support_tier != bitdo_proto::SupportTier::Full { + details.push(Line::from("")); + details.push(Line::from(Span::styled( + "Write actions stay blocked until hardware confirmation lands.", + crate::ui::theme::warning_style(), + ))); + } + + details + } else { + vec![ + Line::from("No controller selected"), + Line::from(""), + Line::from(Span::styled( + "Refresh the dashboard or connect a device to continue.", + crate::ui::theme::subtle_style(), + )), + ] + }; + + let subtitle = selected + .map(|device| support_tier_short(device.support_tier)) + .unwrap_or("idle"); + let panel = Paragraph::new(lines).block(panel_block("Device", Some(subtitle), true)); + frame.render_widget(panel, area); +} + +fn render_sidebar(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(9), Constraint::Min(5)]) + .split(area); + + render_actions(frame, state, rows[0], map); + render_events(frame, state, rows[1]); +} + +fn render_actions(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + let lines = state + .quick_actions + .iter() + .enumerate() + .map(|(idx, quick)| { + let prefix = if idx == state.selected_action_index { + "›" + } else { + " " + }; + let caption = quick + .reason + .as_deref() + .map(compact_reason) + .unwrap_or_else(|| action_caption(quick.action).to_owned()); + let style = if idx == state.selected_action_index { + crate::ui::theme::selected_row_style() + } else if quick.enabled { + Style::default() + } else { + crate::ui::theme::muted_style() + }; + + ListItem::new(Line::from(vec![ + Span::styled(format!("{prefix} {}", quick.action.label()), style), + Span::raw(" • "), + Span::styled(caption, crate::ui::theme::subtle_style()), + ])) + }) + .collect::>(); + + let panel = List::new(lines).block(panel_block("Actions", Some("Enter/click"), true)); + frame.render_widget(panel, area); + + let inner = inner_rect(area, 1, 1); + let visible = inner.height as usize; + for idx in 0..state.quick_actions.len().min(visible) { + map.push( + Rect::new(inner.x, inner.y + idx as u16, inner.width, 1), + HitTarget::QuickAction(state.quick_actions[idx].action), + ); + } +} + +fn render_events(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let visible = area.height.saturating_sub(2) as usize; + let entries = state + .event_log + .iter() + .rev() + .take(visible) + .rev() + .map(|entry| { + let prefix = entry.timestamp_utc.to_string(); + let color = crate::ui::theme::level_color(entry.level); + let level = match entry.level { + crate::app::state::EventLevel::Info => "info", + crate::app::state::EventLevel::Warning => "warn", + crate::app::state::EventLevel::Error => "error", + }; + ListItem::new(Line::from(vec![ + Span::styled(prefix, Style::default().fg(color)), + Span::raw(" "), + Span::styled(format!("[{level}]"), Style::default().fg(color)), + Span::raw(" "), + Span::raw(truncate_to_width( + &entry.message, + area.width.saturating_sub(20) as usize, + )), + ])) + }) + .collect::>(); + + let widget = List::new(entries).block(panel_block("Activity", Some("events"), true)); + frame.render_widget(widget, area); +} + +fn device_regions(list_rect: Rect, total_rows: usize) -> Vec { + let visible_rows = list_rect.height.saturating_sub(2) as usize / 2; + let max = total_rows.min(visible_rows); + let mut out = Vec::with_capacity(max); + for idx in 0..max { + let rect = Rect::new( + list_rect.x.saturating_add(1), + list_rect + .y + .saturating_add(1 + (idx as u16).saturating_mul(2)), + list_rect.width.saturating_sub(2), + 2, + ); + out.push(HitRegion { + rect, + target: HitTarget::DeviceRow(idx), + }); + } + out +} + +fn truncate_reason(reason: &str) -> String { + truncate_to_width(reason, 24) +} + +fn action_caption(action: crate::app::action::QuickAction) -> &'static str { + match action { + crate::app::action::QuickAction::Refresh => "scan", + crate::app::action::QuickAction::Diagnose => "probe", + crate::app::action::QuickAction::RecommendedUpdate => "safe update", + crate::app::action::QuickAction::EditMappings => "mapping", + crate::app::action::QuickAction::Settings => "prefs", + crate::app::action::QuickAction::Quit => "exit", + _ => "available", + } +} + +fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str { + match tier { + bitdo_proto::SupportTier::Full => "supported", + bitdo_proto::SupportTier::CandidateReadOnly => "read-only", + bitdo_proto::SupportTier::DetectOnly => "detect-only", + } +} + +fn support_tier_short(tier: bitdo_proto::SupportTier) -> &'static str { + match tier { + bitdo_proto::SupportTier::Full => "full", + bitdo_proto::SupportTier::CandidateReadOnly => "ro", + bitdo_proto::SupportTier::DetectOnly => "detect", + } +} + +fn capability_lines(device: &bitdo_app_core::AppDevice) -> Vec { + let mut lines = Vec::new(); + + if device.capability.supports_firmware { + lines.push("• firmware".to_owned()); + } + if device.capability.supports_profile_rw { + lines.push("• profile rw".to_owned()); + } + if device.capability.supports_mode { + lines.push("• mode switch".to_owned()); + } + if device.capability.supports_jp108_dedicated_map { + lines.push("• JP108 mapping".to_owned()); + } + if device.capability.supports_u2_button_map || device.capability.supports_u2_slot_config { + lines.push("• U2 slot + map".to_owned()); + } + if lines.is_empty() { + lines.push("• detect only".to_owned()); + } + + lines +} + +fn compact_reason(reason: &str) -> String { + if reason.contains("Read-only") { + "read-only".to_owned() + } else if reason.contains("restart") { + "restart required".to_owned() + } else { + truncate_reason(reason) + } +} + +fn protocol_short(protocol: bitdo_proto::ProtocolFamily) -> &'static str { + match protocol { + bitdo_proto::ProtocolFamily::Standard64 => "S64", + bitdo_proto::ProtocolFamily::Unknown => "unknown", + _ => "other", + } +} + +fn evidence_short(evidence: bitdo_proto::SupportEvidence) -> &'static str { + match evidence { + bitdo_proto::SupportEvidence::Confirmed => "conf", + bitdo_proto::SupportEvidence::Inferred => "infer", + bitdo_proto::SupportEvidence::Untested => "untest", + } +} diff --git a/sdk/crates/bitdo_tui/src/ui/screens/diagnostics.rs b/sdk/crates/bitdo_tui/src/ui/screens/diagnostics.rs new file mode 100644 index 0000000..bc60827 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/diagnostics.rs @@ -0,0 +1,543 @@ +use crate::app::action::QuickAction; +use crate::app::state::{AppState, DiagnosticsFilter}; +use crate::ui::layout::{ + action_grid_height, inner_rect, panel_block, render_action_strip, truncate_to_width, + ActionDescriptor, HitMap, HitTarget, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, Paragraph}; +use ratatui::Frame; + +pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap { + let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(9), + Constraint::Length(action_height), + ]) + .split(area); + + render_summary(frame, state, rows[0]); + + let body = if rows[1].width >= 92 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(44), Constraint::Percentage(56)]) + .split(rows[1]) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(rows[1]) + }; + + let mut map = HitMap::default(); + render_check_panel(frame, state, body[0], &mut map); + + let detail_sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(body[1]); + render_selected_check(frame, state, detail_sections[0]); + render_next_steps(frame, state, detail_sections[1]); + + let actions = state + .quick_actions + .iter() + .enumerate() + .map(|(idx, action)| ActionDescriptor { + action: action.action, + label: action.action.label().to_owned(), + caption: diagnostics_action_caption(action.action).to_owned(), + enabled: action.enabled, + active: idx == state.selected_action_index, + }) + .collect::>(); + map.extend(render_action_strip(frame, rows[2], &actions)); + map +} + +fn render_summary(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let Some(diagnostics) = state.diagnostics_state.as_ref() else { + let empty = Paragraph::new("No diagnostics result loaded.").block(panel_block( + "Diagnostics", + Some("summary"), + true, + )); + frame.render_widget(empty, area); + return; + }; + + let total = diagnostics.result.command_checks.len(); + let passed = diagnostics + .result + .command_checks + .iter() + .filter(|check| check.ok) + .count(); + let issues = diagnostics + .result + .command_checks + .iter() + .filter(|check| !check.ok || check.severity != bitdo_proto::DiagSeverity::Ok) + .count(); + let experimental = diagnostics + .result + .command_checks + .iter() + .filter(|check| check.is_experimental) + .count(); + + let transport = if diagnostics.result.transport_ready { + "ready" + } else { + "degraded" + }; + + let lines = vec![ + Line::from(vec![ + Span::styled( + format!("{passed}/{total} passed"), + crate::ui::theme::screen_title_style(), + ), + Span::raw(" • "), + Span::styled(format!("{issues} issues"), severity_style(issues > 0)), + Span::raw(" • "), + Span::styled( + format!("{experimental} experimental"), + crate::ui::theme::subtle_style(), + ), + ]), + Line::from(vec![ + Span::styled( + format!( + "Tier: {}", + support_tier_label(diagnostics.result.support_tier) + ), + crate::ui::theme::subtle_style(), + ), + Span::raw(" • "), + Span::styled( + format!("Family: {:?}", diagnostics.result.protocol_family), + crate::ui::theme::subtle_style(), + ), + Span::raw(" • "), + Span::styled( + format!("Transport: {transport}"), + crate::ui::theme::subtle_style(), + ), + ]), + ]; + + let panel = Paragraph::new(lines).block(panel_block("Diagnostics", Some("summary"), true)); + frame.render_widget(panel, area); +} + +fn render_check_panel(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + if area.height < 8 { + render_compact_check_panel(frame, state, area, map); + return; + } + + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(5)]) + .split(area); + render_filter_row(frame, state, sections[0], map); + + let filtered = state.diagnostics_filtered_indices(); + let items = filtered + .iter() + .map(|check_index| { + let check = &state + .diagnostics_state + .as_ref() + .expect("diagnostics state present") + .result + .command_checks[*check_index]; + let selected = state + .diagnostics_state + .as_ref() + .map(|diagnostics| diagnostics.selected_check_index == *check_index) + .unwrap_or(false); + let marker = if selected { "›" } else { " " }; + let experimental = if check.is_experimental { " exp" } else { "" }; + let line = format!( + "{marker} {} {:?}{experimental} {}", + severity_badge(check.severity), + check.command, + truncate_to_width(&check.detail, sections[1].width.saturating_sub(26) as usize) + ); + let style = if selected { + crate::ui::theme::selected_row_style() + } else { + severity_row_style(check.severity) + }; + ListItem::new(line).style(style) + }) + .collect::>(); + + let list = if items.is_empty() { + List::new(vec![ListItem::new("No checks in this filter")]) + } else { + List::new(items) + } + .block(panel_block("Checks", Some("click a row"), true)); + frame.render_widget(list, sections[1]); + + let list_inner = inner_rect(sections[1], 1, 1); + let visible_rows = list_inner.height as usize; + for filtered_index in 0..filtered.len().min(visible_rows) { + map.push( + Rect::new( + list_inner.x, + list_inner.y + filtered_index as u16, + list_inner.width, + 1, + ), + HitTarget::DiagnosticsCheck(filtered_index), + ); + } +} + +fn render_compact_check_panel( + frame: &mut Frame<'_>, + state: &AppState, + area: Rect, + map: &mut HitMap, +) { + let diagnostics = state + .diagnostics_state + .as_ref() + .expect("diagnostics state present"); + let filtered = state.diagnostics_filtered_indices(); + let inner = inner_rect(area, 1, 1); + + let filter_segments = compact_filter_segments(diagnostics); + let filter_line = Line::from( + filter_segments + .iter() + .enumerate() + .map(|(idx, (filter, label))| { + let prefix = if idx == 0 { "" } else { " " }; + Span::styled( + format!("{prefix}{label}"), + if diagnostics.active_filter == *filter { + crate::ui::theme::screen_title_style() + } else { + crate::ui::theme::subtle_style() + }, + ) + }) + .collect::>(), + ); + + let mut lines = vec![filter_line]; + let visible_rows = inner.height.saturating_sub(1) as usize; + if filtered.is_empty() { + lines.push(Line::from("No checks in this filter")); + } else { + for check_index in filtered.iter().take(visible_rows) { + let check = &diagnostics.result.command_checks[*check_index]; + let marker = if diagnostics.selected_check_index == *check_index { + "›" + } else { + " " + }; + let experimental = if check.is_experimental { " exp" } else { "" }; + lines.push(Line::from(Span::styled( + format!( + "{marker} {} {:?}{experimental} {}", + severity_badge(check.severity), + check.command, + truncate_to_width(&check.detail, inner.width.saturating_sub(24) as usize,) + ), + if diagnostics.selected_check_index == *check_index { + crate::ui::theme::selected_row_style() + } else { + severity_row_style(check.severity) + }, + ))); + } + } + + let panel = Paragraph::new(lines).block(panel_block("Checks", Some("tab cycles filter"), true)); + frame.render_widget(panel, area); + + let mut x = inner.x; + for (idx, (filter, label)) in filter_segments.iter().enumerate() { + let text = if idx == 0 { + label.clone() + } else { + format!(" {label}") + }; + let width = (text.len() as u16).min(inner.width.saturating_sub(x.saturating_sub(inner.x))); + if width == 0 { + break; + } + map.push( + Rect::new(x, inner.y, width, 1), + HitTarget::DiagnosticsFilter(*filter), + ); + x = x.saturating_add(text.len() as u16); + } + + for (row, _) in filtered.iter().take(visible_rows).enumerate() { + map.push( + Rect::new( + inner.x, + inner.y.saturating_add(row as u16).saturating_add(1), + inner.width, + 1, + ), + HitTarget::DiagnosticsCheck(row), + ); + } +} + +fn render_filter_row(frame: &mut Frame<'_>, state: &AppState, area: Rect, map: &mut HitMap) { + let diagnostics = state + .diagnostics_state + .as_ref() + .expect("diagnostics state present"); + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(34), + Constraint::Percentage(33), + Constraint::Percentage(33), + ]) + .split(area); + + for (filter, rect) in DiagnosticsFilter::ALL + .into_iter() + .zip(chunks.iter().copied()) + { + let active = diagnostics.active_filter == filter; + let count = diagnostics + .result + .command_checks + .iter() + .filter(|check| filter.matches(check)) + .count(); + let chip = Paragraph::new(vec![ + Line::from(Span::styled( + filter.label(), + if active { + crate::ui::theme::screen_title_style() + } else { + Style::default() + }, + )), + Line::from(Span::styled( + format!("{count} checks"), + crate::ui::theme::subtle_style(), + )), + ]) + .block(panel_block("Filter", Some(filter.label()), active)); + frame.render_widget(chip, rect); + map.push(inner_rect(rect, 1, 1), HitTarget::DiagnosticsFilter(filter)); + } +} + +fn render_selected_check(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let lines = if let Some(check) = state.selected_diagnostics_check() { + let mut lines = vec![ + Line::from(vec![ + Span::styled( + format!("{:?}", check.command), + crate::ui::theme::screen_title_style(), + ), + Span::raw(" "), + Span::styled( + severity_badge(check.severity), + severity_row_style(check.severity), + ), + ]), + Line::from(format!("Severity: {:?}", check.severity)), + Line::from(format!("Confidence: {:?}", check.confidence)), + Line::from(format!( + "Experimental: {}", + if check.is_experimental { "yes" } else { "no" } + )), + Line::from(format!( + "Error code: {}", + check + .error_code + .map(|code| format!("{code:?}")) + .unwrap_or_else(|| "none".to_owned()) + )), + Line::from(format!( + "Response: {:?} • attempts {}", + check.response_status, check.attempts + )), + Line::from(format!( + "IO: wrote {}B, read {}B", + check.bytes_written, check.bytes_read + )), + Line::from(format!( + "Validator: {}", + truncate_to_width(&check.validator, area.width.saturating_sub(13) as usize) + )), + Line::from(""), + Line::from(Span::styled("Detail", crate::ui::theme::title_style())), + Line::from(check.detail.clone()), + ]; + + if !check.parsed_facts.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Parsed facts", + crate::ui::theme::title_style(), + ))); + for (key, value) in &check.parsed_facts { + lines.push(Line::from(format!("{key}: {value}"))); + } + } + lines + } else { + vec![ + Line::from("No diagnostics check selected."), + Line::from(""), + Line::from("Change filters or run diagnostics again."), + ] + }; + + let detail = Paragraph::new(lines).block(panel_block("Selected Check", Some("detail"), true)); + frame.render_widget(detail, area); +} + +fn render_next_steps(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let Some(diagnostics) = state.diagnostics_state.as_ref() else { + let empty = Paragraph::new("No diagnostics guidance available.").block(panel_block( + "Next Steps", + Some("guidance"), + true, + )); + frame.render_widget(empty, area); + return; + }; + + let report_line = diagnostics + .latest_report_path + .as_ref() + .map(|path| format!("Saved report: {}", path.display())) + .unwrap_or_else(|| "Saved report: not yet saved in this screen".to_owned()); + + let content_width = area.width.saturating_sub(4) as usize; + let action_line = format!( + "Action: {}", + truncate_to_width( + recommended_next_action(diagnostics), + content_width.saturating_sub(8) + ) + ); + let summary_line = format!( + "Summary: {}", + truncate_to_width(&diagnostics.summary, content_width.saturating_sub(9)) + ); + let report_line = truncate_to_width(&report_line, content_width); + let inner_height = area.height.saturating_sub(2); + + let lines = match inner_height { + 0 => Vec::new(), + 1 => vec![Line::from(if diagnostics.latest_report_path.is_some() { + report_line.clone() + } else { + action_line.clone() + })], + 2 => vec![ + Line::from(action_line), + Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())), + ], + _ => vec![ + Line::from(action_line), + Line::from(summary_line), + Line::from(Span::styled(report_line, crate::ui::theme::subtle_style())), + ], + }; + + let panel = Paragraph::new(lines).block(panel_block("Next Steps", Some("guidance"), true)); + frame.render_widget(panel, area); +} + +fn diagnostics_action_caption(action: QuickAction) -> &'static str { + match action { + QuickAction::RunAgain => "rerun safe-read probe", + QuickAction::SaveReport => "write support report", + QuickAction::Back => "return to dashboard", + _ => "available", + } +} + +fn recommended_next_action(diagnostics: &crate::app::state::DiagnosticsState) -> &'static str { + match diagnostics.result.support_tier { + bitdo_proto::SupportTier::Full => { + "Return to the dashboard and choose Recommended Update or Edit Mapping if needed." + } + bitdo_proto::SupportTier::CandidateReadOnly => { + "Save or share the report. Update and mapping remain blocked until confirmation lands." + } + bitdo_proto::SupportTier::DetectOnly => { + "Diagnostics only. Do not attempt update or mapping for this device." + } + } +} + +fn severity_badge(severity: bitdo_proto::DiagSeverity) -> &'static str { + match severity { + bitdo_proto::DiagSeverity::Ok => "OK", + bitdo_proto::DiagSeverity::Warning => "WARN", + bitdo_proto::DiagSeverity::NeedsAttention => "ATTN", + } +} + +fn severity_row_style(severity: bitdo_proto::DiagSeverity) -> Style { + match severity { + bitdo_proto::DiagSeverity::Ok => Style::default().fg(Color::White), + bitdo_proto::DiagSeverity::Warning => crate::ui::theme::warning_style(), + bitdo_proto::DiagSeverity::NeedsAttention => crate::ui::theme::danger_style(), + } +} + +fn severity_style(has_issues: bool) -> Style { + if has_issues { + crate::ui::theme::warning_style() + } else { + crate::ui::theme::positive_style() + } +} + +fn support_tier_label(tier: bitdo_proto::SupportTier) -> &'static str { + match tier { + bitdo_proto::SupportTier::Full => "full", + bitdo_proto::SupportTier::CandidateReadOnly => "candidate-readonly", + bitdo_proto::SupportTier::DetectOnly => "detect-only", + } +} + +fn compact_filter_segments( + diagnostics: &crate::app::state::DiagnosticsState, +) -> Vec<(DiagnosticsFilter, String)> { + DiagnosticsFilter::ALL + .into_iter() + .map(|filter| { + let count = diagnostics + .result + .command_checks + .iter() + .filter(|check| filter.matches(check)) + .count(); + let label = match filter { + DiagnosticsFilter::All => format!("All {count}"), + DiagnosticsFilter::Issues => format!("Issues {count}"), + DiagnosticsFilter::Experimental => format!("Exp {count}"), + }; + (filter, label) + }) + .collect() +} diff --git a/sdk/crates/bitdo_tui/src/ui/screens/mapping_editor.rs b/sdk/crates/bitdo_tui/src/ui/screens/mapping_editor.rs new file mode 100644 index 0000000..3639f0c --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/mapping_editor.rs @@ -0,0 +1,213 @@ +use crate::app::state::{AppState, MappingDraftState}; +use crate::ui::layout::{ + action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{List, ListItem, Paragraph}; +use ratatui::Frame; + +pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap { + let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Min(8), + Constraint::Length(action_height), + ]) + .split(area); + + let mut lines = vec![ + Line::from(Span::styled( + "Apply is explicit. Arrow keys adjust only the highlighted mapping.", + crate::ui::theme::subtle_style(), + )), + Line::from(""), + ]; + let mut mapping_rows = Vec::new(); + let mut inspector_lines = Vec::new(); + + match state.mapping_draft_state.as_ref() { + Some(MappingDraftState::Jp108 { + current, + selected_row, + .. + }) => { + lines.push(Line::from(Span::styled( + "JP108 dedicated mapping", + crate::ui::theme::screen_title_style(), + ))); + for (idx, entry) in current.iter().enumerate() { + let style = if idx == *selected_row { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let marker = if idx == *selected_row { "›" } else { " " }; + mapping_rows.push( + ListItem::new(format!( + "{marker} {:?} -> 0x{:04x}", + entry.button, entry.target_hid_usage + )) + .style(style), + ); + } + if let Some(selected) = current.get(*selected_row) { + inspector_lines.push(Line::from(format!("Button: {:?}", selected.button))); + inspector_lines.push(Line::from(format!( + "Target HID: 0x{:04x}", + selected.target_hid_usage + ))); + inspector_lines.push(Line::from("")); + inspector_lines.push(Line::from("Left/right cycles preset targets.")); + } + } + Some(MappingDraftState::Ultimate2 { + current, + selected_row, + .. + }) => { + lines.push(Line::from(Span::styled( + format!( + "Ultimate2 profile slot {:?} mode {}", + current.slot, current.mode + ), + crate::ui::theme::screen_title_style(), + ))); + lines.push(Line::from(Span::styled( + format!("L2 {:.2} | R2 {:.2}", current.l2_analog, current.r2_analog), + crate::ui::theme::subtle_style(), + ))); + for (idx, entry) in current.mappings.iter().enumerate() { + let style = if idx == *selected_row { + Style::default().add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let marker = if idx == *selected_row { "›" } else { " " }; + mapping_rows.push( + ListItem::new(format!( + "{marker} {:?} -> {} (0x{:04x})", + entry.button, + u2_target_label(entry.target_hid_usage), + entry.target_hid_usage + )) + .style(style), + ); + } + if let Some(selected) = current.mappings.get(*selected_row) { + inspector_lines.push(Line::from(format!("Button: {:?}", selected.button))); + inspector_lines.push(Line::from(format!( + "Target: {} (0x{:04x})", + u2_target_label(selected.target_hid_usage), + selected.target_hid_usage + ))); + inspector_lines.push(Line::from("")); + inspector_lines.push(Line::from("Left/right cycles preset targets.")); + } + } + None => { + lines.push(Line::from("No mapping draft loaded.")); + inspector_lines.push(Line::from( + "Select Edit Mapping from the dashboard to begin.", + )); + } + } + + let intro = Paragraph::new(lines).block(panel_block( + "Mapping Studio", + Some(if state.mapping_has_changes() { + "modified" + } else { + "clean" + }), + true, + )); + frame.render_widget(intro, rows[0]); + + let body = if rows[1].width >= 80 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(62), Constraint::Percentage(38)]) + .split(rows[1]) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(62), Constraint::Percentage(38)]) + .split(rows[1]) + }; + + let table = List::new(mapping_rows).block(panel_block( + "Mappings", + Some("up/down select • left/right adjust"), + true, + )); + frame.render_widget(table, body[0]); + + let mut status_lines = inspector_lines; + status_lines.push(Line::from("")); + status_lines.push(Line::from(Span::styled( + state.status_line.clone(), + crate::ui::theme::subtle_style(), + ))); + let status = Paragraph::new(status_lines).block(panel_block( + "Inspector", + Some("selected mapping"), + true, + )); + frame.render_widget(status, body[1]); + + let actions = state + .quick_actions + .iter() + .enumerate() + .map(|(idx, action)| ActionDescriptor { + action: action.action, + label: action.action.label().to_owned(), + caption: mapping_action_caption(action.action).to_owned(), + enabled: action.enabled, + active: idx == state.selected_action_index, + }) + .collect::>(); + + let mut map = HitMap::default(); + map.extend(render_action_strip(frame, rows[2], &actions)); + map +} + +fn mapping_action_caption(action: crate::app::action::QuickAction) -> &'static str { + match action { + crate::app::action::QuickAction::ApplyDraft => "write current draft", + crate::app::action::QuickAction::UndoDraft => "restore last edit", + crate::app::action::QuickAction::ResetDraft => "discard draft changes", + crate::app::action::QuickAction::RestoreBackup => "recover saved backup", + crate::app::action::QuickAction::Firmware => "switch to firmware flow", + crate::app::action::QuickAction::Back => "return to dashboard", + _ => "available", + } +} + +fn u2_target_label(target: u16) -> &'static str { + match target { + 0x0100 => "A", + 0x0101 => "B", + 0x0102 => "X", + 0x0103 => "Y", + 0x0104 => "L1", + 0x0105 => "R1", + 0x0106 => "L2", + 0x0107 => "R2", + 0x0108 => "L3", + 0x0109 => "R3", + 0x010a => "Select", + 0x010b => "Start", + 0x010c => "Home", + 0x010d => "DPadUp", + 0x010e => "DPadDown", + 0x010f => "DPadLeft", + 0x0110 => "DPadRight", + _ => "Unknown", + } +} diff --git a/sdk/crates/bitdo_tui/src/ui/screens/mod.rs b/sdk/crates/bitdo_tui/src/ui/screens/mod.rs new file mode 100644 index 0000000..134e012 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/mod.rs @@ -0,0 +1,6 @@ +pub mod dashboard; +pub mod diagnostics; +pub mod mapping_editor; +pub mod recovery; +pub mod settings; +pub mod task; diff --git a/sdk/crates/bitdo_tui/src/ui/screens/recovery.rs b/sdk/crates/bitdo_tui/src/ui/screens/recovery.rs new file mode 100644 index 0000000..4343f00 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/recovery.rs @@ -0,0 +1,76 @@ +use crate::app::state::AppState; +use crate::ui::layout::{ + action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap { + let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Min(6), + Constraint::Length(action_height), + ]) + .split(area); + + let body = Paragraph::new(vec![ + Line::from(Span::styled( + "Recovery lock is active", + crate::ui::theme::danger_style(), + )), + Line::from(""), + Line::from("Write operations stay blocked until the app restarts."), + Line::from("Restore a backup if one exists, validate the device, then restart."), + ]) + .block(panel_block("Recovery", Some("safe rollback path"), true)); + frame.render_widget(body, rows[0]); + + let backup_line = if state.latest_backup.is_some() { + "Backup detected. Restore Backup is available." + } else { + "No backup is registered for this session." + }; + let detail = Paragraph::new(vec![ + Line::from("1. Restore backup if available."), + Line::from("2. Confirm the controller responds normally."), + Line::from("3. Restart OpenBitDo before any further writes."), + Line::from(""), + Line::from(Span::styled(backup_line, crate::ui::theme::subtle_style())), + Line::from(Span::styled( + state.status_line.clone(), + crate::ui::theme::subtle_style(), + )), + ]) + .block(panel_block("Guidance", Some("recommended sequence"), true)); + frame.render_widget(detail, rows[1]); + + let actions = state + .quick_actions + .iter() + .enumerate() + .map(|(idx, action)| ActionDescriptor { + action: action.action, + label: action.action.label().to_owned(), + caption: recovery_action_caption(action.action).to_owned(), + enabled: action.enabled, + active: idx == state.selected_action_index, + }) + .collect::>(); + let mut map = HitMap::default(); + map.extend(render_action_strip(frame, rows[2], &actions)); + map +} + +fn recovery_action_caption(action: crate::app::action::QuickAction) -> &'static str { + match action { + crate::app::action::QuickAction::RestoreBackup => "attempt rollback", + crate::app::action::QuickAction::Back => "return to dashboard", + crate::app::action::QuickAction::Quit => "exit openbitdo", + _ => "available", + } +} diff --git a/sdk/crates/bitdo_tui/src/ui/screens/settings.rs b/sdk/crates/bitdo_tui/src/ui/screens/settings.rs new file mode 100644 index 0000000..d72f05d --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/settings.rs @@ -0,0 +1,101 @@ +use crate::app::state::AppState; +use crate::ui::layout::{ + action_grid_height, inner_rect, panel_block, render_action_strip, ActionDescriptor, HitMap, + HitTarget, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap { + let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(5), + Constraint::Length(5), + Constraint::Min(5), + Constraint::Length(action_height), + Constraint::Min(1), + ]) + .split(area); + + let adv = Paragraph::new(vec![ + Line::from(Span::styled( + if state.advanced_mode { + "Advanced mode is on" + } else { + "Advanced mode is off" + }, + crate::ui::theme::screen_title_style(), + )), + Line::from(Span::styled( + "Toggle to expose expert-only report and workflow options.", + crate::ui::theme::subtle_style(), + )), + ]) + .block(panel_block("Advanced", Some("press t or click"), true)); + frame.render_widget(adv, rows[0]); + + let report = Paragraph::new(vec![ + Line::from(Span::styled( + format!("Support reports: {}", state.report_save_mode.as_str()), + crate::ui::theme::screen_title_style(), + )), + Line::from(Span::styled( + "Cycle report persistence policy with r or mouse.", + crate::ui::theme::subtle_style(), + )), + ]) + .block(panel_block("Reports", Some("press r or click"), true)); + frame.render_widget(report, rows[1]); + + let settings_path = state + .settings_path + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "not configured".to_owned()); + let status = Paragraph::new(vec![ + Line::from(state.status_line.as_str()), + Line::from(""), + Line::from(format!("Config path: {settings_path}")), + Line::from(Span::styled( + "Dashboard layout and filter state persist when a settings path is configured.", + crate::ui::theme::subtle_style(), + )), + ]) + .block(panel_block("Status", Some("saved preferences"), true)); + frame.render_widget(status, rows[2]); + + let actions = state + .quick_actions + .iter() + .enumerate() + .map(|(idx, action)| ActionDescriptor { + action: action.action, + label: action.action.label().to_owned(), + caption: settings_action_caption(action.action).to_owned(), + enabled: action.enabled, + active: idx == state.selected_action_index, + }) + .collect::>(); + + let mut map = HitMap::default(); + map.push(inner_click_rect(rows[0]), HitTarget::ToggleAdvancedMode); + map.push(inner_click_rect(rows[1]), HitTarget::CycleReportMode); + map.extend(render_action_strip(frame, rows[3], &actions)); + map +} + +fn inner_click_rect(rect: Rect) -> Rect { + inner_rect(rect, 1, 1) +} + +fn settings_action_caption(action: crate::app::action::QuickAction) -> &'static str { + match action { + crate::app::action::QuickAction::Back => "return to dashboard", + crate::app::action::QuickAction::Quit => "exit openbitdo", + _ => "available", + } +} diff --git a/sdk/crates/bitdo_tui/src/ui/screens/task.rs b/sdk/crates/bitdo_tui/src/ui/screens/task.rs new file mode 100644 index 0000000..b7a35f4 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/screens/task.rs @@ -0,0 +1,180 @@ +use crate::app::state::{AppState, TaskMode}; +use crate::ui::layout::{ + action_grid_height, panel_block, render_action_strip, ActionDescriptor, HitMap, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::Style; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Gauge, Paragraph}; +use ratatui::Frame; + +pub fn render(frame: &mut Frame<'_>, state: &AppState, area: Rect) -> HitMap { + let action_height = action_grid_height(area.width, state.quick_actions.len()).max(4); + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), + Constraint::Min(8), + Constraint::Length(action_height), + ]) + .split(area); + + let task = state.task_state.as_ref(); + let title = match task.map(|t| t.mode) { + Some(TaskMode::Diagnostics) => "Diagnostics", + Some(TaskMode::Preflight) => "Preflight", + Some(TaskMode::Updating) => "Updating", + Some(TaskMode::Final) => "Result", + None => "Task", + }; + + let summary_lines = if let Some(task) = task { + vec![ + Line::from(vec![ + Span::styled( + format!("{title} Workflow"), + crate::ui::theme::screen_title_style(), + ), + Span::raw(" "), + Span::styled( + task_mode_caption(task.mode), + crate::ui::theme::subtle_style(), + ), + ]), + Line::from(""), + Line::from(task.status.clone()), + ] + } else { + vec![ + Line::from(Span::styled( + "No active workflow", + crate::ui::theme::screen_title_style(), + )), + Line::from(""), + Line::from("Select a device action from the dashboard to begin."), + ] + }; + + let summary = + Paragraph::new(summary_lines).block(panel_block(title, Some("status and intent"), true)); + frame.render_widget(summary, rows[0]); + + render_task_details(frame, state, rows[1]); + + let mut map = HitMap::default(); + let action_rows = state + .quick_actions + .iter() + .enumerate() + .map(|(idx, action)| ActionDescriptor { + action: action.action, + label: action.action.label().to_owned(), + caption: task_action_caption(action.action).to_owned(), + enabled: action.enabled, + active: idx == state.selected_action_index, + }) + .collect::>(); + map.extend(render_action_strip(frame, rows[2], &action_rows)); + map +} + +fn render_task_details(frame: &mut Frame<'_>, state: &AppState, area: Rect) { + let task = state.task_state.as_ref(); + let columns = if area.width >= 76 { + Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(58), Constraint::Percentage(42)]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(area) + }; + + let detail_lines = if let Some(task) = task { + let mut lines = vec![Line::from(task.status.clone())]; + if let Some(plan) = task.plan.as_ref() { + lines.push(Line::from("")); + lines.push(Line::from(format!("Session: {:?}", plan.session_id))); + lines.push(Line::from(format!("Chunk size: {} bytes", plan.chunk_size))); + lines.push(Line::from(format!("Chunks: {}", plan.chunks_total))); + lines.push(Line::from(format!("Estimated: {}s", plan.expected_seconds))); + if !plan.warnings.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Warnings", + crate::ui::theme::warning_style(), + ))); + for warning in &plan.warnings { + lines.push(Line::from(format!("• {warning}"))); + } + } + } + if let Some(final_report) = task.final_report.as_ref() { + lines.push(Line::from("")); + lines.push(Line::from(format!( + "Final status: {:?}", + final_report.status + ))); + lines.push(Line::from(format!( + "Transfer: {}/{} chunks", + final_report.chunks_sent, final_report.chunks_total + ))); + lines.push(Line::from(format!("Message: {}", final_report.message))); + } + lines + } else { + vec![Line::from("No details available")] + }; + let detail = + Paragraph::new(detail_lines).block(panel_block("Details", Some("workflow context"), true)); + frame.render_widget(detail, columns[0]); + + let progress = task.map(|task| task.progress).unwrap_or_default(); + let progress_rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(4), Constraint::Min(4)]) + .split(columns[1]); + let gauge = Gauge::default() + .block(panel_block("Progress", Some("transfer state"), true)) + .gauge_style(Style::default().fg(ratatui::style::Color::Green)) + .percent(progress as u16) + .label(format!("{progress}%")); + frame.render_widget(gauge, progress_rows[0]); + + let summary_lines = if let Some(task) = task { + vec![ + Line::from(format!("Stage: {}", task_mode_caption(task.mode))), + Line::from(format!("Progress: {progress}%")), + Line::from(format!("Reports: {}", state.report_save_mode.as_str())), + Line::from(Span::styled( + state.status_line.clone(), + crate::ui::theme::subtle_style(), + )), + ] + } else { + vec![Line::from("Select an action to see task details.")] + }; + let summary = + Paragraph::new(summary_lines).block(panel_block("Context", Some("current session"), true)); + frame.render_widget(summary, progress_rows[1]); +} + +fn task_mode_caption(mode: TaskMode) -> &'static str { + match mode { + TaskMode::Diagnostics => "diagnostic probe", + TaskMode::Preflight => "preflight safety check", + TaskMode::Updating => "firmware transfer", + TaskMode::Final => "final outcome", + } +} + +fn task_action_caption(action: crate::app::action::QuickAction) -> &'static str { + match action { + crate::app::action::QuickAction::Confirm => "acknowledge risk + start", + crate::app::action::QuickAction::Cancel => "stop this workflow", + crate::app::action::QuickAction::Back => "return to dashboard", + _ => "available", + } +} diff --git a/sdk/crates/bitdo_tui/src/ui/theme.rs b/sdk/crates/bitdo_tui/src/ui/theme.rs new file mode 100644 index 0000000..9268862 --- /dev/null +++ b/sdk/crates/bitdo_tui/src/ui/theme.rs @@ -0,0 +1,83 @@ +use ratatui::style::{Color, Modifier, Style}; + +pub fn app_title_style() -> Style { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) +} + +pub fn screen_title_style() -> Style { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) +} + +pub fn title_style() -> Style { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) +} + +pub fn subtle_style() -> Style { + Style::default().fg(Color::Gray) +} + +pub fn muted_style() -> Style { + Style::default().fg(Color::DarkGray) +} + +pub fn positive_style() -> Style { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) +} + +pub fn warning_style() -> Style { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) +} + +pub fn danger_style() -> Style { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) +} + +pub fn selected_row_style() -> Style { + Style::default() + .fg(Color::White) + .add_modifier(Modifier::BOLD) +} + +pub fn action_label_style(active: bool, enabled: bool) -> Style { + match (active, enabled) { + (true, true) => Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + (false, true) => Style::default().fg(Color::White), + (_, false) => muted_style(), + } +} + +pub fn action_caption_style(enabled: bool) -> Style { + if enabled { + subtle_style() + } else { + muted_style() + } +} + +pub fn border_style(active: bool, enabled: bool) -> Style { + match (active, enabled) { + (true, true) => Style::default().fg(Color::Cyan), + (false, true) => Style::default().fg(Color::Gray), + (_, false) => Style::default().fg(Color::DarkGray), + } +} + +pub fn level_color(level: crate::app::state::EventLevel) -> Color { + match level { + crate::app::state::EventLevel::Info => Color::White, + crate::app::state::EventLevel::Warning => Color::Yellow, + crate::app::state::EventLevel::Error => Color::Red, + } +} diff --git a/sdk/crates/openbitdo/Cargo.toml b/sdk/crates/openbitdo/Cargo.toml index a37c549..3907e41 100644 --- a/sdk/crates/openbitdo/Cargo.toml +++ b/sdk/crates/openbitdo/Cargo.toml @@ -15,6 +15,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } bitdo_app_core = { path = "../bitdo_app_core" } bitdo_tui = { path = "../bitdo_tui" } +bitdo_proto = { path = "../bitdo_proto" } [dev-dependencies] assert_cmd = "2.0" diff --git a/sdk/crates/openbitdo/src/lib.rs b/sdk/crates/openbitdo/src/lib.rs index db9422d..99f38e6 100644 --- a/sdk/crates/openbitdo/src/lib.rs +++ b/sdk/crates/openbitdo/src/lib.rs @@ -1,5 +1,5 @@ use bitdo_app_core::{signing_key_fingerprint_active_sha256, signing_key_fingerprint_next_sha256}; -use bitdo_tui::ReportSaveMode; +use bitdo_tui::{DashboardLayoutMode, PanelFocus, ReportSaveMode}; use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; @@ -89,6 +89,12 @@ pub struct UserSettings { pub advanced_mode: bool, #[serde(default)] pub report_save_mode: ReportSaveMode, + #[serde(default)] + pub device_filter_text: String, + #[serde(default)] + pub dashboard_layout_mode: DashboardLayoutMode, + #[serde(default)] + pub last_panel_focus: PanelFocus, } impl Default for UserSettings { @@ -97,12 +103,15 @@ impl Default for UserSettings { schema_version: default_settings_schema_version(), advanced_mode: false, report_save_mode: ReportSaveMode::FailureOnly, + device_filter_text: String::new(), + dashboard_layout_mode: DashboardLayoutMode::Wide, + last_panel_focus: PanelFocus::Devices, } } } const fn default_settings_schema_version() -> u32 { - 1 + 2 } pub fn user_settings_path() -> PathBuf { @@ -130,15 +139,17 @@ pub fn user_settings_path() -> PathBuf { std::env::temp_dir().join("openbitdo").join("config.toml") } -pub fn load_user_settings(path: &Path) -> UserSettings { +pub fn load_user_settings(path: &Path) -> anyhow::Result { let Ok(raw) = std::fs::read_to_string(path) else { - return UserSettings::default(); + return Ok(UserSettings::default()); }; - let mut settings: UserSettings = toml::from_str(&raw).unwrap_or_default(); + let mut settings: UserSettings = toml::from_str(&raw) + .map_err(|err| anyhow::anyhow!("failed to parse settings {}: {err}", path.display()))?; + settings.schema_version = default_settings_schema_version(); if !settings.advanced_mode && settings.report_save_mode == ReportSaveMode::Off { settings.report_save_mode = ReportSaveMode::FailureOnly; } - settings + Ok(settings) } pub fn save_user_settings(path: &Path, settings: &UserSettings) -> anyhow::Result<()> { @@ -194,16 +205,19 @@ mod tests { } #[test] - fn settings_roundtrip_toml() { + fn settings_roundtrip_toml_v2() { let tmp = - std::env::temp_dir().join(format!("openbitdo-settings-{}.toml", std::process::id())); + std::env::temp_dir().join(format!("openbitdo-settings-v2-{}.toml", std::process::id())); let settings = UserSettings { - schema_version: 1, + schema_version: 2, advanced_mode: true, report_save_mode: ReportSaveMode::Always, + device_filter_text: "ultimate".to_owned(), + dashboard_layout_mode: DashboardLayoutMode::Compact, + last_panel_focus: PanelFocus::QuickActions, }; save_user_settings(&tmp, &settings).expect("save settings"); - let loaded = load_user_settings(&tmp); + let loaded = load_user_settings(&tmp).expect("load settings"); assert_eq!(loaded, settings); let _ = std::fs::remove_file(tmp); } @@ -211,8 +225,35 @@ mod tests { #[test] fn missing_settings_uses_defaults() { let path = PathBuf::from("/tmp/openbitdo-nonexistent-settings.toml"); - let loaded = load_user_settings(&path); + let loaded = load_user_settings(&path).expect("load defaults"); assert!(!loaded.advanced_mode); assert_eq!(loaded.report_save_mode, ReportSaveMode::FailureOnly); + assert_eq!(loaded.schema_version, 2); + } + + #[test] + fn v1_settings_migrate_to_v2_defaults() { + let path = std::env::temp_dir().join("openbitdo-v1-migrate.toml"); + std::fs::write( + &path, + "schema_version = 1\nadvanced_mode = true\nreport_save_mode = \"always\"\n", + ) + .expect("write v1"); + let loaded = load_user_settings(&path).expect("load migrated settings"); + assert_eq!(loaded.schema_version, 2); + assert_eq!(loaded.device_filter_text, ""); + assert_eq!(loaded.dashboard_layout_mode, DashboardLayoutMode::Wide); + let _ = std::fs::remove_file(path); + } + + #[test] + fn invalid_settings_returns_error() { + let path = std::env::temp_dir().join("openbitdo-invalid-settings.toml"); + std::fs::write(&path, "advanced_mode = [").expect("write invalid"); + + let err = load_user_settings(&path).expect_err("invalid settings must error"); + assert!(err.to_string().contains("failed to parse settings")); + + let _ = std::fs::remove_file(path); } } diff --git a/sdk/crates/openbitdo/src/main.rs b/sdk/crates/openbitdo/src/main.rs index 3973e8f..d0f4afa 100644 --- a/sdk/crates/openbitdo/src/main.rs +++ b/sdk/crates/openbitdo/src/main.rs @@ -1,8 +1,8 @@ use anyhow::Result; use bitdo_app_core::{OpenBitdoCore, OpenBitdoCoreConfig}; -use bitdo_tui::{run_tui_app, TuiLaunchOptions}; +use bitdo_tui::{run_ui, UiLaunchOptions}; use clap::Parser; -use openbitdo::{load_user_settings, user_settings_path, BuildInfo}; +use openbitdo::{load_user_settings, user_settings_path, BuildInfo, UserSettings}; #[derive(Debug, Parser)] #[command(name = "openbitdo")] @@ -15,9 +15,19 @@ struct Cli { #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt::init(); + let cli = Cli::parse(); let settings_path = user_settings_path(); - let settings = load_user_settings(&settings_path); + let settings = match load_user_settings(&settings_path) { + Ok(settings) => settings, + Err(err) => { + eprintln!( + "warning: failed to load settings from {}: {err}; using defaults", + settings_path.display() + ); + UserSettings::default() + } + }; let core = OpenBitdoCore::new(OpenBitdoCoreConfig { mock_mode: cli.mock, @@ -25,9 +35,10 @@ async fn main() -> Result<()> { progress_interval_ms: 5, ..Default::default() }); - run_tui_app( + + run_ui( core, - TuiLaunchOptions { + UiLaunchOptions { build_info: BuildInfo::current().to_tui_info(), advanced_mode: settings.advanced_mode, report_save_mode: settings.report_save_mode, @@ -36,6 +47,7 @@ async fn main() -> Result<()> { }, ) .await?; + Ok(()) } @@ -51,7 +63,21 @@ mod tests { } #[test] - fn cli_rejects_cmd_subcommand() { + fn cli_rejects_ui_subcommand_form() { + let err = Cli::try_parse_from(["openbitdo", "ui", "--mock"]).expect_err("must reject ui"); + assert_eq!(err.kind(), ErrorKind::UnknownArgument); + } + + #[test] + fn cli_rejects_run_subcommand_form() { + let err = + Cli::try_parse_from(["openbitdo", "run", "--vidpid", "2dc8:6009", "--recommended"]) + .expect_err("must reject run"); + assert_eq!(err.kind(), ErrorKind::UnknownArgument); + } + + #[test] + fn cli_rejects_legacy_cmd_subcommand() { let err = Cli::try_parse_from(["openbitdo", "cmd"]).expect_err("must reject cmd"); assert_eq!(err.kind(), ErrorKind::UnknownArgument); } diff --git a/sdk/crates/openbitdo/tests/cli_smoke.rs b/sdk/crates/openbitdo/tests/cli_smoke.rs index e197d75..265482f 100644 --- a/sdk/crates/openbitdo/tests/cli_smoke.rs +++ b/sdk/crates/openbitdo/tests/cli_smoke.rs @@ -2,12 +2,27 @@ use assert_cmd::cargo::cargo_bin_cmd; use predicates::prelude::*; #[test] -fn help_mentions_beginner_flow() { +fn help_mentions_single_command_surface() { let mut cmd = cargo_bin_cmd!("openbitdo"); cmd.arg("--help") .assert() .success() - .stdout(predicate::str::contains("beginner-first")) + .stdout(predicate::str::contains("Usage: openbitdo [OPTIONS]")) .stdout(predicate::str::contains("--mock")) - .stdout(predicate::str::contains("cmd").not()); + .stdout(predicate::str::contains("ui").not()) + .stdout(predicate::str::contains("run").not()); +} + +#[test] +fn rejects_ui_subcommand_form() { + let mut cmd = cargo_bin_cmd!("openbitdo"); + cmd.args(["ui", "--mock"]).assert().failure(); +} + +#[test] +fn rejects_run_subcommand_form() { + let mut cmd = cargo_bin_cmd!("openbitdo"); + cmd.args(["run", "--vidpid", "2dc8:6009", "--recommended"]) + .assert() + .failure(); } diff --git a/sdk/scripts/cleanroom_guard.sh b/sdk/scripts/cleanroom_guard.sh index 6541c54..2ee395f 100755 --- a/sdk/scripts/cleanroom_guard.sh +++ b/sdk/scripts/cleanroom_guard.sh @@ -12,4 +12,20 @@ if rg -n --hidden -g '!target/**' -g '!scripts/cleanroom_guard.sh' "$forbidden_p exit 1 fi +# Prevent stale subcommand-era examples in active user docs. +active_docs=(../README.md README.md ../MIGRATION.md) +stale_command_pattern='cargo run -p openbitdo -- ui([[:space:]]|$)|cargo run -p openbitdo -- run([[:space:]]|$)|(^|[^[:alnum:]_])openbitdo ui([[:space:]]|$)|(^|[^[:alnum:]_])openbitdo run([[:space:]]|$)' +if rg -n "$stale_command_pattern" "${active_docs[@]}" | rg -v '\(legacy\)' | rg -v '\(historical\)'; then + echo "cleanroom guard failed: stale openbitdo subcommand surface found in active docs" + echo "expected current usage: openbitdo [--mock]" + exit 1 +fi + +stale_aur_pattern='packaging/aur/openbitdo(/|$)|`openbitdo` \(source build\)' +if rg -n "$stale_aur_pattern" "${active_docs[@]}" | rg -v '\(legacy\)' | rg -v '\(historical\)'; then + echo "cleanroom guard failed: stale source AUR package reference found in active docs" + echo "expected current AUR package surface: openbitdo-bin only" + exit 1 +fi + echo "cleanroom guard passed" diff --git a/sdk/tests/diag_probe.rs b/sdk/tests/diag_probe.rs index 5d534ff..c5b0d78 100644 --- a/sdk/tests/diag_probe.rs +++ b/sdk/tests/diag_probe.rs @@ -1,50 +1,15 @@ -use bitdo_proto::{DeviceSession, MockTransport, SessionConfig, VidPid}; +use bitdo_proto::{ + find_command, CommandId, DeviceSession, MockTransport, SafetyClass, SessionConfig, VidPid, +}; #[test] -fn diag_probe_returns_command_checks() { +fn diag_probe_expands_to_safe_read_commands_and_parsed_facts() { let mut transport = MockTransport::default(); - - let mut pid = vec![0u8; 64]; - pid[0] = 0x02; - pid[1] = 0x05; - pid[4] = 0xC1; - pid[22] = 0x09; - pid[23] = 0x60; - transport.push_read_data(pid); - - let mut rr = vec![0u8; 64]; - rr[0] = 0x02; - rr[1] = 0x04; - rr[5] = 0x01; - transport.push_read_data(rr); - - let mut mode = vec![0u8; 64]; - mode[0] = 0x02; - mode[1] = 0x05; - mode[5] = 2; - transport.push_read_data(mode); - - let mut ver = vec![0u8; 64]; - ver[0] = 0x02; - ver[1] = 0x22; - ver[2] = 0x2A; - ver[3] = 0x00; - ver[4] = 1; - transport.push_read_data(ver); - - let mut super_button = vec![0u8; 64]; - super_button[0] = 0x02; - super_button[1] = 0x05; - transport.push_read_data(super_button); - - let mut profile = vec![0u8; 64]; - profile[0] = 0x02; - profile[1] = 0x05; - transport.push_read_data(profile); + push_diag_success_sequence_for_u2(&mut transport); let mut session = DeviceSession::new( transport, - VidPid::new(0x2dc8, 24585), + VidPid::new(0x2dc8, 0x6012), SessionConfig { experimental: true, ..Default::default() @@ -53,6 +18,177 @@ fn diag_probe_returns_command_checks() { .expect("session init"); let diag = session.diag_probe(); - assert_eq!(diag.command_checks.len(), 6); - assert!(diag.command_checks.iter().all(|c| c.ok)); + + assert_eq!(diag.command_checks.len(), 12); + assert!(diag.transport_ready); + assert!(diag.command_checks.iter().all(|check| { + find_command(check.command) + .map(|row| row.safety_class == SafetyClass::SafeRead) + .unwrap_or(false) + })); + assert!(diag + .command_checks + .iter() + .any(|check| check.command == CommandId::U2GetCurrentSlot)); + + let pid_check = diag + .command_checks + .iter() + .find(|check| check.command == CommandId::GetPid) + .expect("pid check"); + assert_eq!( + pid_check.parsed_facts.get("detected_pid").copied(), + Some(0x6012) + ); + assert_eq!(pid_check.response_status, bitdo_proto::ResponseStatus::Ok); + + let revision_check = diag + .command_checks + .iter() + .find(|check| check.command == CommandId::GetReportRevision) + .expect("revision check"); + assert_eq!( + revision_check.parsed_facts.get("revision").copied(), + Some(1) + ); + + let version_check = diag + .command_checks + .iter() + .find(|check| check.command == CommandId::GetControllerVersion) + .expect("version check"); + assert_eq!( + version_check.parsed_facts.get("version_x100").copied(), + Some(42) + ); + assert_eq!(version_check.parsed_facts.get("beta").copied(), Some(1)); + + let slot_check = diag + .command_checks + .iter() + .find(|check| check.command == CommandId::U2GetCurrentSlot) + .expect("slot check"); + assert_eq!(slot_check.parsed_facts.get("slot").copied(), Some(2)); +} + +#[test] +fn diag_probe_get_mode_falls_back_to_get_mode_alt() { + let mut transport = MockTransport::default(); + push_diag_sequence_with_mode_fallback(&mut transport); + + let mut session = DeviceSession::new( + transport, + VidPid::new(0x2dc8, 0x6002), + SessionConfig { + experimental: true, + ..Default::default() + }, + ) + .expect("session init"); + + let diag = session.diag_probe(); + let mode_check = diag + .command_checks + .iter() + .find(|check| check.command == CommandId::GetMode) + .expect("mode check"); + + assert!(mode_check.ok); + assert_eq!(mode_check.parsed_facts.get("mode").copied(), Some(7)); + assert!(mode_check.detail.contains("GetModeAlt fallback")); + assert_eq!(mode_check.response_status, bitdo_proto::ResponseStatus::Ok); +} + +fn push_diag_success_sequence_for_u2(transport: &mut MockTransport) { + transport.push_read_data(pid_response(0x6012)); + transport.push_read_data(report_revision_response(1)); + transport.push_read_data(mode_response(2)); + transport.push_read_data(mode_response(2)); + transport.push_read_data(version_response(42, 1)); + transport.push_read_data(ok_read_response()); + transport.push_read_data(idle_response()); + transport.push_read_data(version_response(42, 1)); + transport.push_read_data(ok_read_response()); + transport.push_read_data(slot_response(2)); + transport.push_read_data(ok_read_response()); + transport.push_read_data(ok_read_response()); +} + +fn push_diag_sequence_with_mode_fallback(transport: &mut MockTransport) { + transport.push_read_data(pid_response(0x6002)); + transport.push_read_data(report_revision_response(1)); + transport.push_read_data(invalid_mode_response()); + transport.push_read_data(invalid_mode_response()); + transport.push_read_data(invalid_mode_response()); + transport.push_read_data(mode_response(7)); + transport.push_read_data(mode_response(7)); + transport.push_read_data(version_response(99, 0)); + transport.push_read_data(idle_response()); + transport.push_read_data(version_response(99, 0)); + transport.push_read_data(ok_read_response()); +} + +fn pid_response(pid: u16) -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response[1] = 0x05; + response[4] = 0xC1; + response[22] = (pid & 0x00ff) as u8; + response[23] = (pid >> 8) as u8; + response +} + +fn report_revision_response(revision: u8) -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response[1] = 0x04; + response[5] = revision; + response +} + +fn mode_response(mode: u8) -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response[1] = 0x05; + response[5] = mode; + response +} + +fn invalid_mode_response() -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x00; + response[1] = 0x00; + response +} + +fn version_response(version_x100: u16, beta: u8) -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response[1] = 0x22; + let bytes = version_x100.to_le_bytes(); + response[2] = bytes[0]; + response[3] = bytes[1]; + response[4] = beta; + response +} + +fn slot_response(slot: u8) -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response[1] = 0x05; + response[5] = slot; + response +} + +fn ok_read_response() -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response[1] = 0x05; + response +} + +fn idle_response() -> Vec { + let mut response = vec![0u8; 64]; + response[0] = 0x02; + response } diff --git a/sdk/tests/runtime_policy.rs b/sdk/tests/runtime_policy.rs index 7adc634..bb936e8 100644 --- a/sdk/tests/runtime_policy.rs +++ b/sdk/tests/runtime_policy.rs @@ -1,6 +1,6 @@ use bitdo_proto::{ find_command, BitdoError, CommandId, CommandRuntimePolicy, DeviceSession, DiagSeverity, - EvidenceConfidence, MockTransport, SessionConfig, VidPid, + EvidenceConfidence, MockTransport, ResponseStatus, SessionConfig, VidPid, }; #[test] @@ -71,6 +71,9 @@ fn diag_probe_marks_inferred_reads_as_experimental() { .expect("inferred check present"); assert!(inferred.is_experimental); assert_eq!(inferred.confidence, EvidenceConfidence::Inferred); + assert!(inferred.attempts >= 1); + assert_eq!(inferred.response_status, ResponseStatus::Malformed); + assert!(inferred.bytes_written > 0); assert!(matches!( inferred.severity, DiagSeverity::Ok | DiagSeverity::Warning | DiagSeverity::NeedsAttention