diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..fc882dd
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,164 @@
+# AGENTS.md
+
+This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
+
+## What this is
+
+AndroidTreeView is a **.NET 10 + Avalonia** Windows desktop tool for inspecting, testing and managing
+Android devices over ADB. Despite the name it is a C#/.NET solution, **not** a Java/Kotlin Android app.
+It has two shipping executables that share the same core:
+
+- **App** (`src/AndroidTreeView.App`) — the full Avalonia UI: device overview, per-category details,
+ screen mirroring (scrcpy), CLI tools, settings, updates.
+- **Mini** (`src/AndroidTreeView.Mini`) — a lightweight WinForms tray/monitor app that stays resident,
+ watches for devices, and auto-launches scrcpy once a device is connected and authorized. Mini
+ deliberately does **not** carry the Avalonia/Skia runtime.
+
+`Mini.Mac` is an Avalonia-based Mini variant for macOS.
+
+## Commands
+
+Requires the **.NET 10 SDK**. Run from the repo root.
+
+```bash
+dotnet restore AndroidTreeView.sln
+dotnet build AndroidTreeView.sln
+dotnet test AndroidTreeView.sln
+dotnet run --project src/AndroidTreeView.App
+dotnet run --project src/AndroidTreeView.Mini
+```
+
+Building on non-Windows (App/Mini target Windows) needs the targeting pack flag — CI uses it everywhere:
+
+```bash
+dotnet build AndroidTreeView.sln -p:EnableWindowsTargeting=true
+```
+
+Run a single test project / a single test:
+
+```bash
+dotnet test tests/AndroidTreeView.Adb.Tests
+dotnet test AndroidTreeView.sln --filter "FullyQualifiedName~BatteryParser"
+```
+
+Format check (CI runs this, non-blocking — but keep it clean):
+
+```bash
+dotnet format AndroidTreeView.sln --verify-no-changes
+```
+
+Package/verify the release ZIP link locally (real releases only ship via the GitHub `Publish` workflow,
+x64 only):
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+There are ready-made Rider/VS run configs under `.run/`.
+
+## Architecture
+
+The layering is strict and enforced by project references — respect the dependency direction. Lower
+layers never reference upper ones.
+
+```
+Models domain records/enums, no project deps
+ ↑
+Core interfaces, options (AppSettings, AdbOptions), typed exceptions, DeviceMonitor
+ ↑
+Adb / Infrastructure Adb: ProcessRunner, command builders, parsers, AdbDeviceService, LogcatService
+ ↑ Infrastructure: SettingsService (JSON), logging, update check/install
+Shared the shared composition root — AddAndroidTreeViewSharedServices() wires ADB, monitoring,
+ ↑ scrcpy, settings, and update services identically for both App and Mini
+App / Mini / Mini.Mac executables (each references Shared)
+```
+
+Key architectural rules (see `docs/architecture.md` for the full binding type/interface contract, and
+`docs/app-contract.md`):
+
+- **Shared is the single wiring point.** Both App and Mini call
+ `AddAndroidTreeViewSharedServices(...)` so their ADB/scrcpy/settings/update behavior can't drift.
+ When adding a service that both should use, register it there (it uses `TryAdd*`), not per-app.
+- **App is strict MVVM** with CommunityToolkit.Mvvm (`[ObservableProperty]` / `[RelayCommand]`) and
+ `Microsoft.Extensions` DI/Hosting/Logging. `Program.cs` builds the host + `IServiceProvider`, then
+ Avalonia resolves `MainWindowViewModel`. `ViewLocator` maps `*.ViewModels.XxxViewModel` →
+ `*.Views.XxxView` by name convention.
+- **All ADB is out-of-process** via `ProcessRunner` (async stdout/stderr, kills the whole process tree
+ on cancel/timeout). Device info comes from parsing ADB text output.
+- **Parsers are pure and unit-tested.** ADB output parsing lives in `AndroidTreeView.Adb.Parsers` as
+ stateless/static functions (`GetPropParser`, `BatteryParser`, `AdbDevicesParser`, etc.). Any change to
+ parsing logic must come with a parser test — fixtures live under the Adb.Tests project.
+- `AdbLocator` resolves adb (configured path → PATH → common SDK locations); `IAdbEnvironment` holds the
+ current location as a singleton. If adb is missing the App shows a Setup page instead of crashing.
+- `DeviceMonitor` owns a `PeriodicTimer` background loop and raises `DevicesChanged`; VMs marshal to the
+ UI thread themselves.
+
+## Conventions (from `.editorconfig` + `CONTRIBUTING.md`)
+
+- File-scoped namespaces, 4-space indent, interfaces `I`-prefixed, `using` outside the namespace,
+ CRLF line endings, UTF-8.
+- **Async-only for I/O**: `async` + `CancellationToken` propagated everywhere. No `.Result` / `.Wait()` /
+ `Task.Run` for ADB/process work. UI mutations only on the UI thread (`Dispatcher.UIThread.Post`).
+- Views use **compiled bindings** (`x:DataType`) and bind only to members that exist on the VM. No
+ business logic or ADB calls in views.
+- **No hardcoded user-facing strings.** Use localization for every user-visible string (see below).
+- **Read-only / safe by design**: do not introduce ADB commands that modify, wipe, or flash a device.
+- Keep files under ~400 lines; the App never crashes on normal ADB/device errors — map them to friendly
+ `ErrorMessage` state.
+
+## Localization
+
+Every user-facing string is localized — never hardcode display text. Default language is **Simplified
+Chinese**; English is the neutral/fallback resource.
+
+- **Contract**: `ILocalizationService` (`AndroidTreeView.Core/Interfaces`) — `Get(key)`, `Format(key, args)`,
+ the `this[key]` indexer, `SetLanguage(AppLanguage)`, and a `LanguageChanged` event. `AppLanguage` lives
+ in `AndroidTreeView.Core/Options/SettingsEnums.cs`. `Get` returns the key itself if a resource is
+ missing, so a missing translation is visible, not a crash.
+- **Implementation**: `LocalizationService` in `src/AndroidTreeView.App/Localization`, backed by two ResX
+ files with **identical key sets** (keep them in sync — currently 217 keys each):
+ - `src/AndroidTreeView.App/Resources/Strings.resx` — English (neutral fallback)
+ - `src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx` — Simplified Chinese (default)
+ - Keys are dotted/namespaced: `app.title`, `nav.devices`, `common.refresh`, etc.
+- **In ViewModels**: inject `ILocalizationService` and call `_localization.Get("key")` / `.Format(...)`.
+- **In XAML**: use the `{loc:Localize Key=some.key}` markup extension (`LocalizeExtension` +
+ `LocalizeKeyConverter`). Bindings watch `LocalizationService.LanguageTick` so text refreshes live when
+ the language changes (the indexer's own `INotifyPropertyChanged` was unreliable in this Avalonia
+ version — that's why the plain `LanguageTick` counter exists; keep using it for live-refresh bindings).
+
+**When adding a string**: add the key to **both** ResX files, then reference it via `Get`/`Format` (VMs)
+or `{loc:Localize}` (XAML). Never add a key to only one file.
+
+## Tests
+
+xUnit across four projects (`dotnet test AndroidTreeView.sln`, or target one project — see Commands).
+Fixtures are **inline `const` strings** inside the test classes, not external `.txt` files.
+
+- **`Adb.Tests`** — the bulk of the suite. `Parsers/` has one test class per parser/builder
+ (`BatteryParserTests`, `StorageParserTests`, `AdbDevicesParserTests`, `GetPropParserTests`,
+ `RootStatusParserTests`, `NetworkParserTests`, the `*BuilderTests`, etc.), `Commands/` covers argv
+ building (`AdbArgsTests`), `Services/` covers device-action/screen-capture services. **Any change to ADB
+ output parsing must add/update the matching parser test here** — this is where correctness is pinned.
+- **`Core.Tests`** — `AppSettings` clone/serialize, `DeviceMonitor` start/stop (with a fake
+ `IDeviceService`), `SemanticVersion`, file-transfer service.
+- **`Infrastructure.Tests`** — `SettingsService` persistence, update check (`GitHubUpdateService`) and
+ `UpdateInstaller`, using test doubles in `TestDoubles.cs`.
+- **`App.Tests`** — ViewModel logic with fake services (`Fakes.cs`): device-list reconcile keeps
+ selection, RawProperties filtering, Logcat bounding, DI graph resolves (`ServiceGraphTests`), and a boot
+ smoke test via `Avalonia.Headless` (`TestAppBuilder.cs`, `BootSmokeTests.cs`). No real ADB, no on-screen
+ rendering.
+
+## Versioning & updates
+
+Version is unified across runtime version, App/Mini assembly versions, manifests, and
+`packaging/build-update-zip.ps1` — keep them in sync when bumping (currently `1.0.6`). Update channels:
+`android-tree-view-app` (App) and `android-tree-view-mini` (Mini). `GitHubUpdateService` checks the latest
+GitHub Release, compares semver, and selects the matching product asset; `UpdateInstaller` downloads,
+verifies SHA-256, unpacks the x64 ZIP, and runs a local update script. Loose ZIPs without a supported
+`release.json` manifest are rejected.
+
+## Bundled tools
+
+`build/AndroidTreeView.Scrcpy.targets` downloads and bundles scrcpy (which ships adb) into `tools/scrcpy`
+at build/publish time on Windows. `tools/verify-scrcpy-latest.ps1` checks for newer scrcpy releases.
diff --git a/README-CN.md b/README-CN.md
index 1c653a0..8dcda41 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -13,6 +13,10 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面
当前发布请查看上方 Release 徽章或 [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。
+## 产品样式展示
+
+
+
## 功能
- 设备卡片总览与详情页。
@@ -34,6 +38,8 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
+功能规划与实施文档入口见 [docs/roadmap-features.md](docs/roadmap-features.md)。
+
## 使用说明
1. 安装 Android platform-tools,或让应用在启动时引导选择 `adb.exe`。
@@ -56,6 +62,26 @@ ADB 安装与排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
- Windows 更新包使用带 `release.json` 的 x64 ZIP;GitHub Release 同时包含 macOS Apple Silicon ZIP。
- ZIP 中没有受支持的发布清单时会拒绝安装,避免用户手动替换文件。
+## 打包
+
+正式发布只通过 GitHub Actions 的 `Publish` 工作流完成。本地命令仅用于验证打包链路:
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+产物版本由项目文件和发布工作流统一提供。验证输出包含主程序和 Mini 的上传 ZIP,例如:
+
+```text
+artifacts/AndroidTreeView-<版本>-win-x64.zip
+artifacts/AndroidTreeView-<版本>-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-<版本>-win-x64.zip
+artifacts/AndroidTreeView-Mini-<版本>-osx-arm64.zip
+```
+
+更多细节见 [docs/packaging.md](docs/packaging.md)。
+
## 验证
```bash
@@ -65,13 +91,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```
-## 许可证与致谢
-
-
-
-
-
-
-
+## 许可证
AndroidTreeView 基于 [MIT License](LICENSE) 开源。
diff --git a/README.en.md b/README.en.md
index e3e1681..fad4e57 100644
--- a/README.en.md
+++ b/README.en.md
@@ -13,6 +13,10 @@ AndroidTreeView is a desktop tool for inspecting, testing, and managing Android
See the Release badge above or [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest) for the current published version. Runtime versions, target frameworks, and packaging settings are defined by the project files and release workflow.
+## Product Preview
+
+
+
## Features
- Card-first device overview with serial, model, Android version, battery, charging, temperature, cycle count, connection state, root state, and last refresh time.
@@ -24,9 +28,29 @@ See the Release badge above or [GitHub Releases](https://github.com/Birditch/And
- Update automation downloads packages, verifies SHA-256 metadata when available, extracts Windows x64 ZIP packages, and starts a local update script so users are not asked to manually replace files.
- Simplified Chinese and English UI, with Light / Dark / System theme modes.
+## Repository Layout
+
+```text
+src/
+ AndroidTreeView.Models
+ AndroidTreeView.Core
+ AndroidTreeView.Adb
+ AndroidTreeView.Infrastructure
+ AndroidTreeView.Shared
+ AndroidTreeView.App
+ AndroidTreeView.Mini
+ AndroidTreeView.Mini.Mac
+tests/
+ AndroidTreeView.*.Tests
+packaging/
+ win-x64 / osx-arm64 ZIP packaging and optional WiX MSI packaging
+build/
+ Shared MSBuild targets
+```
+
## Build And Run
-Install the .NET SDK targeted by the project files:
+Requires the .NET 10 SDK:
```bash
dotnet restore AndroidTreeView.sln
@@ -36,6 +60,8 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
+See [docs/roadmap-features.md](docs/roadmap-features.md) for feature roadmaps and implementation plans.
+
If ADB is not found, the app opens an ADB setup screen. Install Android platform-tools and add it to `PATH`, or choose `adb.exe` manually.
## Enable USB Debugging
@@ -54,11 +80,31 @@ See [docs/adb-requirements.md](docs/adb-requirements.md) for platform-tools setu
3. Use the device cards for status, mirroring, CLI access, and non-destructive tools.
4. Use Settings or About to check and install updates.
+## Release ZIP Packaging
+
+Artifact versions are supplied consistently by the project files and release workflow. Official releases are produced only by the GitHub Actions `Publish` workflow.
+
+Local commands are for packaging validation only:
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+Example output:
+
+```text
+artifacts/AndroidTreeView--win-x64.zip
+artifacts/AndroidTreeView--osx-arm64.zip
+artifacts/AndroidTreeView-Mini--win-x64.zip
+artifacts/AndroidTreeView-Mini--osx-arm64.zip
+```
+
## Auto Update
- Full app update key: `android-tree-view-app`.
- Mini update key: `android-tree-view-mini`.
-- `NekoIndexUpdateService` checks the internal update channel and compares semantic versions.
+- `GitHubUpdateService` queries GitHub Releases and compares semantic versions.
- `UpdateInstaller` downloads, verifies, extracts, and launches the local update script.
- Supported Windows updater packages are x64 ZIP files with `release.json`; GitHub Releases also contain macOS Apple Silicon ZIPs.
- Loose-file ZIP replacement is intentionally rejected.
@@ -72,13 +118,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```
-## License And Credits
-
-
-
-
-
-
-
+## License
AndroidTreeView is open source under the [MIT License](LICENSE).
diff --git a/README.md b/README.md
index 7ecc995..d610975 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,10 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面
当前发布请查看上方 Release 徽章或 [GitHub Releases](https://github.com/Birditch/AndroidTreeView/releases/latest)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。
+## 产品样式展示
+
+
+
## 核心能力
- 设备卡片总览:展示设备名称、序列号、型号、Android 版本、电量、充电状态、温度、循环次数、连接状态、Root 状态和最后刷新时间。
@@ -25,9 +29,29 @@ AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面
- 自动更新会下载、校验 SHA-256、解包 x64 ZIP,并启动本地更新脚本完成替换和重启。
- 中英文 UI,支持跟随系统、浅色、深色主题。
+## 项目结构
+
+```text
+src/
+ AndroidTreeView.Models
+ AndroidTreeView.Core
+ AndroidTreeView.Adb
+ AndroidTreeView.Infrastructure
+ AndroidTreeView.Shared
+ AndroidTreeView.App
+ AndroidTreeView.Mini
+ AndroidTreeView.Mini.Mac
+tests/
+ AndroidTreeView.*.Tests
+packaging/
+ win-x64 / osx-arm64 ZIP packaging and optional WiX MSI packaging
+build/
+ Shared MSBuild targets
+```
+
## 开发运行
-需要安装项目文件目标框架对应的 .NET SDK:
+需要 .NET 10 SDK:
```bash
dotnet restore AndroidTreeView.sln
@@ -37,6 +61,8 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
+功能规划与实施文档入口见 [docs/roadmap-features.md](docs/roadmap-features.md)。
+
如果找不到 ADB,主程序会显示 ADB 设置页。请安装 Android platform-tools 并加入 `PATH`,或手动选择 `adb.exe`。
## 开启 USB 调试
@@ -61,11 +87,31 @@ ADB 安装和排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
- 首次打开若被 Gatekeeper 拦截(未签名),右键选择「打开」,或执行 `xattr -dr com.apple.quarantine AndroidTreeView.app` 放行。
- 设备卡片的「CLI 终端」在 macOS 上通过 Terminal.app 打开,提供与 Windows 一致的编号菜单(设备信息 / adb shell / logcat / 重启 / 关机 等)。
+## Release ZIP 打包
+
+产物版本由项目文件和发布工作流统一提供。正式发布只通过 GitHub Actions 的 `Publish` 工作流完成。
+
+本地命令仅用于验证打包链路:
+
+```powershell
+./packaging/build-update-zip.ps1 -Product App -Rid win-x64
+./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
+```
+
+示例输出:
+
+```text
+artifacts/AndroidTreeView-<版本>-win-x64.zip
+artifacts/AndroidTreeView-<版本>-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-<版本>-win-x64.zip
+artifacts/AndroidTreeView-Mini-<版本>-osx-arm64.zip
+```
+
## 自动更新
- 主程序更新通道:`android-tree-view-app`。
- Mini 更新通道:`android-tree-view-mini`。
-- `NekoIndexUpdateService` 检查内部更新通道并比较语义版本。
+- `GitHubUpdateService` 查询 GitHub Releases 并比较语义版本。
- `UpdateInstaller` 下载、校验、解包并启动本地更新脚本。
- Windows 自动更新支持带 `release.json` 的 x64 ZIP;GitHub Release 同时包含 macOS Apple Silicon ZIP。
- 没有受支持发布清单的散文件 ZIP 会被拒绝。
@@ -79,13 +125,6 @@ dotnet build src/AndroidTreeView.Mini.Mac/AndroidTreeView.Mini.Mac.csproj --no-r
dotnet test AndroidTreeView.sln --no-restore
```
-## 许可证与致谢
-
-
-
-
-
-
-
+## 许可证
AndroidTreeView 基于 [MIT License](LICENSE) 开源。
diff --git a/docs/app-contract.md b/docs/app-contract.md
index 0f9d351..4b71b19 100644
--- a/docs/app-contract.md
+++ b/docs/app-contract.md
@@ -21,7 +21,7 @@ Shared services include ADB location/environment, device monitoring, scrcpy laun
- Full App: `UpdateProductOptions.ForMainApp()` -> `AppInfo.AppUpdateKey`
- Mini: `UpdateProductOptions.ForMiniApp()` -> `AppInfo.MiniUpdateKey`
-Both products use `NekoIndexUpdateService` and `UpdateInstaller`.
+Both products use `GitHubUpdateService` and `UpdateInstaller`.
## App ViewModels
diff --git a/docs/publishing.md b/docs/publishing.md
index cbe0b4f..8dba1d0 100644
--- a/docs/publishing.md
+++ b/docs/publishing.md
@@ -75,52 +75,19 @@ The full app and Mini share the same update implementation but use different app
- Full app: `AppInfo.AppUpdateKey` -> `android-tree-view-app`
- Mini: `AppInfo.MiniUpdateKey` -> `android-tree-view-mini`
-The Windows update channel must point each product key at its own Windows ZIP:
+Each product resolves its own Windows ZIP from the latest GitHub Release:
- `android-tree-view-app` -> `AndroidTreeView-1.0.6-win-x64.zip`
- `android-tree-view-mini` -> `AndroidTreeView-Mini-1.0.6-win-x64.zip`
-`NekoIndexUpdateService` queries the configured internal update API and compares the returned version with `AppInfo.Version`. `UpdateInstaller` downloads the package, verifies SHA-256 metadata when present, extracts the ZIP, verifies the portable Windows x64 manifest, and starts the automated update script.
+`GitHubUpdateService` queries `AppInfo.LatestReleaseApiUrl`, compares the release tag with `AppInfo.Version`, and resolves the matching ZIP and `.sha256` assets. `UpdateInstaller` downloads the package, verifies SHA-256 metadata when present, extracts the ZIP, verifies the portable Windows x64 manifest, and starts the automated update script.
-The internal update API endpoint is:
+## GitHub Release Update Deployment
-```text
-GET http://192.168.89.71:14000/api/update/{appKey}/latest
-Accept: application/json
-```
-
-Minimum response contract:
-
-```json
-{
- "ok": true,
- "data": {
- "appKey": "android-tree-view-app",
- "title": "AndroidTreeView",
- "version": "1.0.6",
- "zip": {
- "sha256": "<64-character sha256>",
- "downloadUrl": "/api/resources/android-tree-view-app/versions/latest/archive"
- },
- "files": []
- },
- "error": null
-}
-```
-
-`downloadUrl` may be absolute or relative to `AppInfo.UpdateServerBaseUrl`. The updater requires the response `appKey` to match the configured product channel and the package `release.json` to match the same `appKey`, version, Windows x64 architecture, and executable.
-
-## Internal Update Deployment
-
-Use this flow for the intranet update server:
-
-1. Push a `v1.0.6` tag or run the `Publish` GitHub Actions workflow manually.
-2. Download the GitHub Actions-built Windows ZIP and `.sha256` sidecar for each Windows update channel.
-3. Upload the ZIPs to the internal server storage.
-4. Configure `/api/update/android-tree-view-app/latest` to return `AndroidTreeView-1.0.6-win-x64.zip` metadata.
-5. Configure `/api/update/android-tree-view-mini/latest` to return `AndroidTreeView-Mini-1.0.6-win-x64.zip` metadata.
-6. Confirm the `sha256` value matches the uploaded ZIP.
-7. Use the app "Check for updates" action to test download, validation, replacement, cleanup, and restart.
+1. Push a `v` tag or run the `Publish` GitHub Actions workflow manually.
+2. Confirm the release contains the App and Mini ZIPs plus their `.sha256` sidecars.
+3. Confirm the release tag is a valid semantic version and matches the asset filenames.
+4. Use the app "Check for updates" action to test download, validation, replacement, cleanup, and restart.
macOS Apple Silicon ZIPs are published to GitHub Releases as `.app` bundle ZIPs for distribution, but the current automated in-app updater accepts the Windows `portable-x64` package kind.
@@ -149,11 +116,10 @@ Preserved config-like files include `.env`, `settings.json`, `appsettings.json`,
4. Locally smoke-test at least the Windows App/Mini packaging scripts.
5. Push a `v` tag or run the `Publish` GitHub Actions workflow manually.
6. Confirm the workflow produced all four ZIPs and checksum sidecars.
-7. Point configured Windows update channels at the GitHub Actions-built Windows ZIPs only.
-8. Confirm each internal update API response returns the correct product key, version, download URL, and SHA-256.
-9. Confirm the full app update flow downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
-10. Confirm Mini auto-update downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
+7. Confirm the GitHub Release exposes the expected product ZIPs and checksum sidecars.
+8. Confirm the full app update flow downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
+9. Confirm Mini auto-update downloads, applies, removes obsolete non-config files, preserves config files, and restarts.
## GitHub Releases
-The `Publish` workflow creates a GitHub Release for each release tag and uploads the four ZIPs plus checksum files. The in-app updater may still consume an internal NekoIndex channel, but that channel must reference artifacts built by GitHub Actions, not local builds.
+The `Publish` workflow creates a GitHub Release for each release tag and uploads the four ZIPs plus checksum files. The in-app updater reads this public release directly and selects the Windows x64 asset for the running product.
diff --git a/docs/requirements-v1.md b/docs/requirements-v1.md
index 179ee4a..de483d0 100644
--- a/docs/requirements-v1.md
+++ b/docs/requirements-v1.md
@@ -17,7 +17,7 @@ This addendum extends `docs/architecture.md` and reflects the current App + Mini
- ADB location/environment/device monitor services
- scrcpy launch logic
- settings service
- - NekoIndex update checking
+ - GitHub Releases update checking
- update downloading/verification/automatic apply flow
- scrcpy asset distribution through `build/AndroidTreeView.Scrcpy.targets`
- Full App and Mini use different update keys:
@@ -33,7 +33,7 @@ This addendum extends `docs/architecture.md` and reflects the current App + Mini
## Update Automation
-- `IUpdateService` + `NekoIndexUpdateService` query the internal update API.
+- `IUpdateService` + `GitHubUpdateService` query the latest GitHub Release and resolve the matching product asset.
- `IUpdateInstaller` + `UpdateInstaller` download packages, verify SHA-256 metadata when present, extract Windows x64 ZIP packages, and start the automated apply flow.
- The user must not be asked to download a ZIP and replace files manually.
- ZIP packages without a supported `release.json` and executable are rejected.
diff --git a/docs/roadmap-features.md b/docs/roadmap-features.md
index 4e50d1b..23a631c 100644
--- a/docs/roadmap-features.md
+++ b/docs/roadmap-features.md
@@ -16,6 +16,14 @@ This roadmap captures the current App + Mini direction after the shared service
## Remaining Backlog
+### Semi-Automatic Root Wizard
+
+- Design: [`superpowers/specs/2026-07-09-semi-auto-root-design.md`](superpowers/specs/2026-07-09-semi-auto-root-design.md)
+- Implementation plan: [`superpowers/plans/2026-07-10-semi-auto-root-implementation.md`](superpowers/plans/2026-07-10-semi-auto-root-implementation.md)
+- Complete the M0 real-device Magisk patching spike before implementing the flashing workflow.
+- Keep all boot writes inside the guided flow with explicit confirmation, an original-image backup,
+ unambiguous fastboot device matching, and per-slot failure reporting.
+
### Device Actions
- Keep right-click/card actions universal, non-destructive by default, and no-root-required unless a root-only action is explicitly marked.
diff --git a/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md b/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
new file mode 100644
index 0000000..c7b835f
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
@@ -0,0 +1,384 @@
+# 半自动 Root 功能实施计划
+
+- 日期:2026-07-10
+- 状态:草案,等待实施
+- 设计依据:[`../specs/2026-07-09-semi-auto-root-design.md`](../specs/2026-07-09-semi-auto-root-design.md)
+- 目标平台:`win-x64`、`osx-arm64`
+
+## 1. 交付目标
+
+在完整 App 中增加可恢复、可取消、带两次风险确认的 Root 向导。用户选择与当前设备匹配的
+刷机包后,App 根据设备与包证据选择原始 `boot.img` 或 `init_boot.img`,完成备份和 Magisk 修补,
+验证 ADB → fastboot 设备身份连续性,再检测解锁与槽位并在明确确认后刷入、重启。
+
+Mini、Mini.Mac 不提供 Root 向导,也不携带 Magisk 或 payload-dumper。
+
+## 2. 仓库现状与计划校正
+
+实施时以当前代码为准,不能照设计稿重复建设已经存在的能力:
+
+- `IFastbootService`、`FastbootService`、fastboot 设备合并和卡片展示已经存在。
+- `build/AndroidTreeView.Scrcpy.targets` 与 `packaging/build-update-zip.ps1` 已把 fastboot 放在
+ App 的 `scrcpy/` 目录,Windows 与 macOS App 包均可携带 fastboot。
+- 完整 App 已是 `net10.0 + Avalonia`,`osx-arm64` 发布链路已经存在,不需要新建 macOS App。
+- 现有 `FastbootService` 面向设备列表,普通失败会被吞掉;Root 写入链路必须返回可诊断的严格结果。
+- 现有 `ProcessRunner` 是静态内部类,无法直接用假对象覆盖 payload-dumper 和 fastboot 错误路径。
+- 当前通用 `IDialogService` 不支持“阅读风险并勾选确认”,第二确认点应由 Root 页面状态门控。
+
+## 3. 不可跳过的技术门槛
+
+### M0:验证 Magisk 修补链路
+
+这是主实现的前置门槛。Magisk `v30.7` APK 中存在 `assets/boot_patch.sh`、
+`assets/util_functions.sh` 和各 ABI 的 `libmagisk*.so`,但 `boot_patch.sh` 明确要求脚本、
+`magiskboot`、`magiskinit`、`magisk`、`init-ld` 与 `stub.apk` 位于同一目录。安装 APK 并不等于
+这些文件可被普通 `adb shell` 直接访问。
+
+验证步骤:
+
+1. 使用可丢弃数据、bootloader 已解锁的测试机和与设备版本匹配的原始镜像;M0 必须分别验证
+ `boot` 目标与 Android 13+ GKI 的 `init_boot` 目标。recovery-only 设备明确判为首版不支持。
+2. 执行 `adb install -r Magisk-v30.7.apk`,记录 package path、ABI 与 shell 可访问的组件路径。
+3. 尝试在非 root 的 `adb shell` 中调用已安装 App 的修补组件,产出 `new-boot.img`。
+4. pull 修补结果并让 Magisk App/`magiskboot` 验证镜像;只有验证通过才进入 Task 1。
+
+通过标准:不依赖设备已 root,命令可重复执行,修补产物非空,清理后无残留临时文件。
+`boot` 与 `init_boot` 两条路径都通过后 M0 才算完成。
+
+失败处理:暂停主实现,更新设计规格并让用户选择“从已校验 APK 提取官方组件后 push”或其他
+方案。不得在未确认的情况下悄悄换成第三方桌面 `magiskboot`。
+
+### M1:首次真实刷写
+
+只在单元测试、包验证和 M0 全部通过后执行。先用非 A/B 测试机或可恢复设备验证单槽写入,再验证
+A/B 双槽及第二槽失败提示。真实刷写不放入 CI。
+
+## 4. 依赖顺序
+
+```text
+M0 Magisk 实机验证
+ -> Task 1 可替换的外部命令执行层
+ -> Task 2 领域模型与 Core 合约
+ -> Task 3 ZIP / 嵌套 ZIP 提取
+ -> Task 4 Root 工具固定版本与打包
+ -> Task 5 payload.bin 提取
+ -> Task 6 原始目标镜像备份
+ -> Task 7 Magisk 修补服务
+ -> Task 8 严格 fastboot 检测与刷写
+ -> Task 9 向导状态机
+ -> Task 10 App 页面、导航、本地化
+ -> Task 11 集成与回归测试
+ -> Task 12 发布包验证、文档和 M1
+```
+
+## 5. 分步实施
+
+### Task 1:建立可替换的外部命令执行层
+
+文件:
+
+- 新增 `src/AndroidTreeView.Core/Interfaces/IExternalCommandRunner.cs`
+- 新增 `src/AndroidTreeView.Core/Services/ExternalCommandRequest.cs`
+- 新增 `src/AndroidTreeView.Core/Services/ExternalCommandResult.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs`
+- 修改 `src/AndroidTreeView.Adb/Services/FastbootService.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs`
+
+步骤:
+
+1. 先写失败测试,覆盖 fastboot 缺失、超时、非零退出、stderr 输出和取消传播。
+2. 用 `ExternalCommandRunner` 适配现有 `ProcessRunner.RunAsync`,保留参数列表传递,禁止拼 shell 字符串。
+3. 让 `FastbootService` 注入 runner;设备列表相关 API 继续保持 best-effort 行为。
+4. 在 Shared 中用 `TryAddSingleton` 注册 runner,不改 Mini 的现有行为。
+
+验收:`dotnet test tests/AndroidTreeView.Adb.Tests --filter FullyQualifiedName~FastbootServiceTests`
+
+### Task 2:定义 Root 领域模型与 Core 合约
+
+文件:
+
+- 新增 `src/AndroidTreeView.Models/Rooting/BootImageSource.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/BootPartitionTarget.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/BootImageInfo.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/RootWizardState.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/RootWizardSnapshot.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/FastbootBootLayout.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/FirmwarePackageMetadata.cs`
+- 新增 `src/AndroidTreeView.Models/Rooting/RootErrorCode.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IBootImageExtractor.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IBootBackupService.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IMagiskPatcher.cs`
+- 新增 `src/AndroidTreeView.Core/Interfaces/IRootWizardService.cs`
+- 新增 `src/AndroidTreeView.Core/Exceptions/RootWorkflowException.cs`
+
+约束:
+
+- `BootImageInfo` 显式保存 `BootPartitionTarget`(`Boot` / `InitBoot`),禁止从文件名反推刷写分区。
+- `RootWizardSnapshot` 保存 ADB serial、USB 路径、product、重启前 fastboot 基线、匹配后的 fastboot
+ serial、包路径、目标分区、工作目录、原始/修补镜像、备份路径、槽位和错误码。
+- 错误对 UI 暴露稳定错误码,不直接把任意 stderr 当成用户文案。
+- 状态增加 `BlockedFastbootIdentity`:ADB 重启后没有出现新设备,或 serial / USB 路径 / product
+ 等证据不能证明 fastboot 设备就是所选 ADB 设备时禁止刷写;列表中仅有一台设备不算证明。
+- 所有 I/O 接口均为 async,并传递 `CancellationToken`。
+
+验收:Core 和 Models 无上层项目引用,`dotnet build AndroidTreeView.sln -p:EnableWindowsTargeting=true`。
+
+### Task 3:实现安全的 ZIP 与 Pixel 嵌套 ZIP 提取
+
+文件:
+
+- 新增 `src/AndroidTreeView.Adb/Parsers/PackageTypeDetector.cs`
+- 新增 `src/AndroidTreeView.Adb/Parsers/BootPartitionTargetDetector.cs`
+- 新增 `src/AndroidTreeView.Adb/Parsers/FirmwarePackageMetadataParser.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/BootImageExtractor.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/PackageTypeDetectorTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/BootPartitionTargetDetectorTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/FirmwarePackageMetadataParserTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs`
+
+测试先覆盖:顶层 `boot.img` / `init_boot.img`、两者同时存在、GKI 13+ 选择 `init_boot`、存在
+`init_boot` 的 Android 12 内核仍选择 `boot`、大小写差异、Pixel `image-*.zip`、payload、目标镜像
+缺失、设备与包证据冲突、recovery-only、重复目标、损坏 ZIP、路径穿越、取消和工作目录清理。
+
+实现约束:
+
+- 只允许一层嵌套 ZIP;条目路径必须经过 `Path.GetFullPath` 边界检查。
+- 不把整个大包读入内存;使用流复制。
+- 设定单条目和总展开大小上限,拒绝 ZIP bomb。
+- 读取 OTA `META-INF/com/android/metadata` 与 Pixel `android-info.txt`;元数据明确不匹配当前
+ `ro.product.device`/product 时硬阻止,元数据缺失时标记为“无法自动验证”并留给第二确认点。
+- 解析设备的 `init_boot` 分区存在性和内核/GKI 版本;仅当 `init_boot` 存在、内核满足 GKI 13+
+ 且包中包含 `init_boot.img` 时选择 `InitBoot`,否则选择 `Boot`。设备要求 `init_boot` 但包中缺失、
+ 证据矛盾或 Magisk 判定 recovery-only 时返回稳定阻塞错误,不回退猜测其他分区。
+- 每次会话使用独立的 `root-work//`,失败时清理,成功时由向导结束后清理。
+
+验收:提取测试全部通过,且测试用临时目录在成功、失败、取消后均被回收。
+
+### Task 4:固定并打包 Root 工具
+
+固定版本:
+
+- Magisk:`v30.7`,资产 `Magisk-v30.7.apk`,SHA-256
+ `e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5`。
+- payload-dumper-go:`1.3.0`,使用上游 `payload-dumper-go_sha256checksums.txt` 中的
+ `windows_amd64` 与 `darwin_arm64` 校验值。
+- fastboot:继续复用现有 App `scrcpy/fastboot[.exe]`,不新增第二份 platform-tools。
+
+文件:
+
+- 新增 `build/AndroidTreeView.RootTools.targets`
+- 修改 `src/AndroidTreeView.App/AndroidTreeView.App.csproj`
+- 修改 `packaging/build-update-zip.ps1`
+- 修改 `.github/workflows/publish.yml`
+- 新增 `tools/verify-roottools-latest.ps1`
+
+步骤:
+
+1. App 单独导入 RootTools target;Mini 和 Mini.Mac 不导入。
+2. 下载后先计算 SHA-256,不匹配立即失败,再解包或复制。
+3. 输出布局固定为 `root-tools/magisk/Magisk-v30.7.apk` 和
+ `root-tools/payload-dumper/payload-dumper-go[.exe]`。
+4. macOS 包为 payload-dumper 设置执行位。
+5. 发布工作流验证 App 包含三个 Root 必需工具,Mini 包不包含 `root-tools/`。
+
+验收:对 `win-x64` 与 `osx-arm64` 各运行一次 App 打包,并检查 ZIP 条目和哈希失败路径。
+
+### Task 5:接入 payload.bin 提取
+
+文件:
+
+- 修改 `src/AndroidTreeView.Adb/Services/BootImageExtractor.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/RootToolPaths.cs`
+- 扩展 `tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs`
+
+步骤:
+
+1. 用 fake runner 写测试,固定断言可执行文件、argv、超时和输出目录。
+2. 对裸 payload 与 ZIP 内 payload 使用同一分支,按已解析的 `BootPartitionTarget` 只请求
+ `boot` 或 `init_boot` 分区。
+3. runner 非零退出、超时或未产出目标镜像时抛稳定错误码。
+4. 校验输出文件存在、大小合理,再返回 `BootImageInfo`。
+
+验收:不安装真实 payload-dumper 也能覆盖所有流程分支;打包 smoke test 再验证真实二进制可启动。
+
+### Task 6:实现原始目标镜像备份
+
+文件:
+
+- 新增 `src/AndroidTreeView.Infrastructure/Rooting/BootBackupService.cs`
+- 新增 `tests/AndroidTreeView.Infrastructure.Tests/BootBackupServiceTests.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+
+约束:
+
+- 目录为 `~/.androidtreeview/root-backups/`。
+- serial 先清洗为文件名安全字符;使用 UTC 时间和随机后缀防碰撞。
+- 先写临时文件,再原子移动;取消或复制失败不得留下半个备份。
+- 返回绝对路径,并验证备份长度与源文件相同。
+
+验收:覆盖非法 serial、同秒多次备份、取消、源文件不存在和成功内容一致。
+
+### Task 7:实现 Magisk 修补服务
+
+文件:
+
+- 新增 `src/AndroidTreeView.Adb/Parsers/CpuAbiParser.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/MagiskPatcher.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/CpuAbiParserTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/MagiskPatcherTests.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+
+严格按 M0 验证通过的命令序列实现:安装固定 APK、探测 ABI、创建会话临时目录、push 镜像、执行
+官方修补脚本、pull 结果、校验产物,并在 `finally` 中清理手机临时目录。
+每条 ADB 命令都必须通过 argv 显式指定 `RootWizardSnapshot` 中锁定的 ADB serial。
+
+测试覆盖:安装失败、未知 ABI、push 失败、脚本失败、pull 失败、空产物、取消和清理失败不遮蔽主错误。
+日志不得输出完整敏感 stderr;UI 只接收错误码与经过裁剪的诊断摘要。
+
+验收:`MagiskPatcherTests` 全部通过,随后在 M0 测试机重复一次端到端修补。
+
+### Task 8:增加严格 fastboot 检测与刷写结果
+
+文件:
+
+- 新增 `src/AndroidTreeView.Adb/Commands/FastbootArgs.cs`
+- 新增 `src/AndroidTreeView.Adb/Parsers/FastbootVarParser.cs`
+- 修改 `src/AndroidTreeView.Core/Interfaces/IFastbootService.cs`
+- 修改 `src/AndroidTreeView.Adb/Services/FastbootService.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Commands/FastbootArgsTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/FastbootVarParserTests.cs`
+- 扩展 `tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs`
+
+新增能力:重启前捕获 `fastboot devices -l` 基线,等待并匹配重启后新出现的 fastboot 设备,读取
+`unlocked`、`slot-count`、`has-slot:` 与 `current-slot`,按 `BootPartitionTarget` 执行单槽
+或 A/B 双槽刷写,返回失败分区和 stderr 摘要并重启系统。
+
+安全规则:
+
+- 重启前保存已有 fastboot serial 集合和所选 ADB 设备的 serial、USB 路径、product;重启后只考虑
+ 基线之外的新设备。优先匹配保持不变的 serial,再用 USB 路径和 `getvar product` 交叉验证。
+- 没有新设备、证据冲突或无法充分匹配时返回 `FastbootIdentityUnverified`,绝不因列表中只有一台
+ 就选择它。后续每条 fastboot 命令都必须显式带 `-s `。
+- 可执行文件先查已定位 adb 的同目录,再回退到 App 自带 `scrcpy/fastboot[.exe]`。
+- `unlocked` 不是明确 yes/true 时按未解锁处理。
+- `has-slot:=no` 才判为非 A/B;`has-slot:=yes` 且槽位数明确时判为 A/B;信息矛盾或
+ 缺失时不猜布局,进入阻塞状态。
+- A/B 的 `_a` 成功、`_b` 失败时返回部分写入结果,UI 必须显示设备处于危险中间态。
+- 非零退出和超时不得继续执行下一条写命令。
+
+验收:argv、stdout/stderr 解析和全部失败分支均由纯单测固定;至少覆盖“已有其他 fastboot 设备且
+目标重启失败”“目标作为新设备出现”“serial 改变但 USB/product 匹配”“唯一设备但无身份证据”四种场景。
+
+### Task 9:实现可恢复的 Root 向导状态机
+
+文件:
+
+- 新增 `src/AndroidTreeView.Core/Services/RootWizardService.cs`
+- 新增 `tests/AndroidTreeView.Core.Tests/RootWizardServiceTests.cs`
+- 修改 `src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs`
+
+状态转换由显式方法驱动:`SelectPackage`、`ExtractAndPatchAsync`、`ConfirmBootloaderAsync`、
+`DetectFastbootAsync`、`ConfirmFlashAsync`、`RetryAsync`、`CancelAsync`。服务不弹 UI,也不自行越过
+确认点。
+`ConfirmBootloaderAsync` 必须先捕获 fastboot 基线,再对锁定的 ADB serial 执行 reboot;
+`DetectFastbootAsync` 只接收 `WaitForMatchingFastbootDeviceAsync` 返回的已验证 serial。
+
+测试固定以下不变量:
+
+- 未完成备份不能进入 flash 确认。
+- 包元数据明确与设备不匹配时不能进入修补或 flash;无法验证时必须显示额外风险提示。
+- 未解锁、槽位未知、目标分区未知、fastboot 身份未验证、未勾选风险确认均不能调用 flash。
+- 取消会杀掉当前外部进程并保留原始备份。
+- 重试只重跑失败步骤,不重复刷已成功的分区。
+- 完成/中止后清理工作目录,但永不删除备份。
+
+验收:状态转换表的每条边和每个阻塞状态至少有一个测试。
+
+### Task 10:接入 App 导航、页面和本地化
+
+文件:
+
+- 新增 `src/AndroidTreeView.App/ViewModels/RootWizardViewModel.cs`
+- 新增 `src/AndroidTreeView.App/Views/RootWizardView.axaml`
+- 新增 `src/AndroidTreeView.App/Views/RootWizardView.axaml.cs`
+- 修改 `src/AndroidTreeView.App/ViewModels/AppEnums.cs`
+- 修改 `src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs`
+- 修改 `src/AndroidTreeView.App/Views/MainWindow.axaml`
+- 修改 `src/AndroidTreeView.App/AppServices.cs`
+- 修改 `src/AndroidTreeView.App/Services/IFilePickerService.cs`
+- 修改 `src/AndroidTreeView.App/Services/FilePickerService.cs`
+- 修改两个 `Strings*.resx`
+
+实现:
+
+- Root VM 注册为 singleton,使用户切换导航后不丢工作流。
+- 向导第一步列出在线且已授权的 ADB 设备并要求单选;多设备时不默认选第一台。开始提取后锁定
+ serial、USB 路径和 product,中途切换设备必须先中止当前向导。
+- 文件选择器新增单选刷机包入口,只接受 ZIP 与 payload;给 `FilePickerService` 注入
+ `ILocalizationService`,选择器标题与文件类型名称也必须本地化。
+- 第一次确认可用现有 `IDialogService`;第二次在 Root 页面显示备份路径、目标分区和风险复选框。
+- 第二确认点同时显示 ADB/fastboot 身份匹配结果、设备代号、目标分区和包元数据匹配结果;无法自动
+ 验证设备身份时不显示可绕过警告,而是保持刷写按钮禁用。
+- 新增 `root.blocked.fastboot.identity`、`root.blocked.partition.unsupported` 和
+ `root.confirm.flash.targetpartition` 等中英文资源键,两个 ResX 键集保持一致。
+- `FlashCommand` 的 CanExecute 同时依赖状态和 `HasAcknowledgedRisk`。
+- 页面只绑定 VM,使用 `x:DataType`;所有正常错误映射成 `root.error.*`。
+- 狭窄窗口下步骤条可横向滚动,底部操作区不遮挡错误和日志。
+
+验收:两个 ResX 键集合完全一致,App headless boot 能解析 Root 页面全部 compiled bindings。
+
+### Task 11:补齐 App 集成和回归测试
+
+文件:
+
+- 新增 `tests/AndroidTreeView.App.Tests/RootWizardViewModelTests.cs`
+- 修改 `tests/AndroidTreeView.App.Tests/Fakes.cs`
+- 修改 `tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs`
+- 修改 `tests/AndroidTreeView.App.Tests/BootSmokeTests.cs`
+
+覆盖:导航、单/多设备选择、禁止默认选择第一台、选包取消、`boot` / `init_boot` 目标选择、
+recovery-only 阻塞、两个确认门、锁定设备、ADB → fastboot 身份匹配失败、A/B 警告、第二槽失败、
+重试、取消、语言切换、DI 图和 XAML 启动。
+再运行全量 build/test/format,确认设备列表与现有 best-effort fastboot 行为未回归。
+
+验收命令:
+
+```bash
+dotnet restore AndroidTreeView.sln -p:EnableWindowsTargeting=true
+dotnet build AndroidTreeView.sln -c Release --no-restore -p:EnableWindowsTargeting=true
+dotnet test AndroidTreeView.sln -c Release --no-build -p:EnableWindowsTargeting=true
+dotnet format AndroidTreeView.sln --verify-no-changes
+```
+
+### Task 12:发布验证、文档同步和 M1
+
+修改:`CLAUDE.md`、`AGENTS.md`、`docs/architecture.md`、`docs/app-contract.md`、
+`docs/packaging.md`、`docs/publishing.md`、`docs/roadmap-features.md` 和统一版本位置。
+
+发布前:
+
+1. 生成 App 的 `win-x64` 与 `osx-arm64` ZIP,确认 fastboot、Magisk APK、payload-dumper 和执行位。
+2. 生成两个 Mini ZIP,确认没有 `root-tools/`,体积与依赖没有意外增长。
+3. 在干净 Windows 与 macOS 机器启动 App,完成选包、提取和到达第一次确认点的无写入 smoke test。
+4. 按 M1 在可恢复真机执行刷写;记录设备、包版本、槽位、命令结果和回滚方式。
+5. M1 通过后再 bump 版本、更新 changelog 并进入发布流程。
+
+## 6. 完成定义
+
+- M0、M1 有可复核记录,且没有未说明的手工步骤。
+- 两个平台可从发布 ZIP 启动并走完整向导。
+- 未解锁、fastboot 身份未验证、目标分区或槽位未知、包不匹配、包损坏、工具缺失和命令失败都
+ 无法越过写入门。
+- 原始目标镜像在任何刷写前已完成本地备份,失败或取消不会删除。
+- App 不因正常设备/命令错误崩溃;错误均有中英文文案和可操作的下一步。
+- 全量测试通过,两个 ResX 键集合一致,Mini 不携带 Root 工具。
+
+## 7. 明确不在本计划内
+
+- 自动解锁 bootloader。
+- 刷写 `boot` / `init_boot` 以外分区。
+- 自动回刷、卸载 Root 或救砖向导。
+- 厂商私有包与动态分区拆包。
+- Linux 发布包或 macOS x64 发布包。
diff --git a/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
new file mode 100644
index 0000000..1f7d8d8
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
@@ -0,0 +1,344 @@
+# 半自动 Root 功能 — 设计规格
+
+- 日期:2026-07-09
+- 状态:设计已确认,实施计划已起草;Magisk 修补链路待 M0 实机验证
+- 关联记忆:`root-feature-relaxes-safety`
+- 实施计划:[`../plans/2026-07-10-semi-auto-root-implementation.md`](../plans/2026-07-10-semi-auto-root-implementation.md)
+
+> 仓库现状校正(2026-07-10):当前代码已包含 `IFastbootService` / `FastbootService`、fastboot
+> 设备列表合并、Windows/macOS App 打包和 App 内 fastboot 分发。实施时扩展这些现有能力,
+> 不再新建第二套 fastboot locator/environment 或 platform-tools 目录。Magisk“安装 APK 后直接
+> 调用组件”仍是 M0 硬门槛,未通过前不得进入刷写主链路。
+
+## 1. 目标与范围
+
+为 AndroidTreeView 增加一个**半自动 Root 功能**:用户手动上传刷机包,工具根据设备安全选择
+`boot.img` 或 `init_boot.img`、用官方 Magisk 组件修补、引导进入 bootloader、把修补后的镜像
+刷入对应分区。以**分步向导**形态交付,写操作前强制确认,刷入前自动备份原始未修补镜像。
+
+### 1.1 第一版明确要做
+
+- 上传刷机包(本地文件选择)。
+- 提取目标启动镜像:
+ - zip 内含裸 `boot.img` / `init_boot.img`(含 Pixel factory 的嵌套 `image-*.zip`)。
+ - `payload.bin`(A/B OTA),通过打包的 `payload_dumper` 按需解出 `boot` / `init_boot`。
+ - 设备明确存在 `init_boot`、内核满足 GKI 13+ 且包中也有 `init_boot.img` 时选择 `init_boot`;
+ 其他受支持设备选择 `boot`。证据矛盾、目标镜像缺失,或 Magisk 判定必须修补 recovery 时
+ 阻止流程,第一版不猜测、不刷 recovery。
+- Magisk 修补:工具**构建时打包固定版本官方 Magisk APK**(SHA-256 校验),运行时
+ `adb install` 到手机,调用**已装 Magisk App 自带的** `boot_patch.sh` + native 组件在手机端
+ 完成修补,pull 回修补后镜像。
+- 写操作链路:`adb push` → `adb reboot bootloader` → **flash 目标启动分区(A/B 机型两个槽都刷)** →
+ `fastboot reboot`。
+ - **非 A/B 机型**:`fastboot flash
`。
+ - **A/B 机型**:`fastboot flash _a
` **且** `fastboot flash _b
`
+ (两槽都刷入同一修补后镜像,避免槽位翻转/刷错槽导致 Magisk 不生效)。
+ - 刷入前**强制提示用户**双槽都会被写入及其风险(见 §5.3 确认 2、§3 已知风险)。
+- 只读检测:`fastboot getvar unlocked`(解锁状态)、**A/B 布局探测
+ (联合 `slot-count`、`has-slot:`、`current-slot`,矛盾或缺失时阻止刷写)**、设备 ABI 探测。
+- ADB → fastboot 身份连续性:重启前保存 fastboot 基线和所选 ADB 设备身份;重启后只接受新出现且
+ serial / USB 路径 / product 等证据能匹配的设备。无法证明是同一设备时阻止刷写。
+- **刷入前自动备份原始未修补目标镜像** 到本地用户目录。
+- 平台:**Windows + macOS 双平台**,共用同一套 MVVM 代码。
+
+### 1.2 第一版明确不做(后续再议)
+
+- `fastboot flashing unlock`(解锁 bootloader,会抹数据)——**仅检测状态 + 文字引导,不代执行**。
+- 刷其他分区(system / vbmeta / recovery / super 等)。
+- 卸载 root / 一键恢复原厂 boot(仅提供备份文件与引导,不做自动回刷向导)。
+- 厂商私有包格式(`.ozip`、动态分区 super.img 拆分等)。
+
+## 2. 方向性决策(已与用户确认)
+
+| 决策 | 选择 | 理由 |
+|---|---|---|
+| 与"只读/安全"定位冲突 | **放宽定位**,接受工具含刷机写操作 | 用户明确要 fastboot 刷入的半自动 root |
+| 支持平台 | **Windows + macOS 双平台** | 两平台都要能真正刷机 |
+| Magisk 修补执行位置 | **手机端执行官方 magiskboot/组件** | 官方 Android 二进制天然可跑,比桌面第三方二进制可靠 |
+| Magisk 组件获取 | **构建时打包固定版 Magisk APK,运行时 `adb install`,用已装 App 自带组件修补** | APK 内即含各 ABI native 组件;装 App 后 `boot_patch.sh` 环境完整,比裸跑 `/data/local/tmp` 坑少 |
+| 自动化程度 | **分步向导 + 写操作前强制确认** | 变砖风险高,不做无人值守一键刷 |
+| 包格式范围 | **zip(含 Pixel 嵌套) + payload.bin** | 覆盖 factory image 与现代 A/B OTA |
+| 目标启动分区 | **仅在 `init_boot` 存在 + GKI 13+ + 包含对应镜像时选 `init_boot`,否则选 `boot`;歧义或 recovery-only 时阻止** | 对齐 Magisk 官方 `find_boot_image` 逻辑,避免把补丁刷进错误分区 |
+| ADB → fastboot 身份连续性 | **重启前记录基线,重启后只接受有充分身份证据的新设备** | 唯一设备不等于目标设备,无法证明身份时必须阻止刷写 |
+| A/B 机型刷入策略 | **目标分区的 `_a` / `_b` 两个槽都刷同一镜像 + 刷入前强制提示** | 免探测 active slot、免刷错槽;代价(跨版本双槽变砖)以提示告知用户承担 |
+| 工具打包 | **构建时按平台下载打包进 `tools/`** | 对齐现有 scrcpy/adb 打包,开箱即用 |
+| UI 落点 | **App 新导航页,复用现有 Windows/macOS App 发布链路** | 双平台共用同一套 Avalonia MVVM 代码 |
+| 修补第 4 步实现 | **安装官方 Magisk APK 后跑其自带 `boot_patch.sh`** | 最贴近官方;实机验证列为高风险里程碑 |
+
+## 3. 可行性结论
+
+核心链路技术上全部可实现,跨平台可行。真正的风险**不在技术**,而在三个工具无法根除、
+必须由用户承担的前提:
+
+- **前提 1 — bootloader 已解锁**:未解锁则 `fastboot flash` 必然失败。工具只检测并引导,
+ 不自动解锁(解锁抹数据、需在手机上按键、部分厂商需解锁码)。
+- **前提 2 — 包与设备匹配**:刷错版本/架构可能变砖。工具做基本校验与警告,但无法保证包正确。
+- **前提 3 — A/B 双槽刷入的跨版本风险(已知、以提示承担)**:A/B 机型两个槽都刷入同一
+ 修补后镜像。若两个槽当前系统版本不一致(如 OTA 后未满一个周期),把当前版本的目标启动镜像刷进
+ 另一槽会造成 boot 与该槽 system/vendor 版本错配,**日后回滚到该槽可能 bootloop**(延迟变砖,
+ 刷入当下无异常)。工具**无法可靠检测两槽版本**,故在**确认 2 强制提示**用户此风险,由用户
+ 在知情后承担(对应 §2 决策:省掉刷错槽 → 接受此代价)。
+
+设计上以**分步向导 + 强制确认 + 自动备份原始目标镜像** 将风险降到可控。
+
+**最高技术不确定性**:Magisk 修补的第 4 步(执行 `boot_patch.sh`)不是单条命令。现方案先
+`adb install` 官方 Magisk APK,再调用其自带的 `boot_patch.sh` + native 组件——装 App 后
+`boot_patch.sh` 环境(`MAGISKBIN`/APK 资源)更完整,比裸跑 `/data/local/tmp` 可靠,但
+**"已装 App 的 `boot_patch.sh` 能否被 `adb shell` 直接调到"仍需实机验证**(assets 内脚本安装后
+不一定落在可直接执行位置,可能仍需从 APK 提取),**必须实机验证**(见 §8 里程碑)。
+
+## 4. 分层与工程落点
+
+严格遵循项目分层(下层不引用上层):
+
+```
+Models FlashPackage, BootImageInfo(Path, Source, OriginalPackageName, TargetPartition),
+ BootPartitionTarget(enum), RootWizardState(enum), DeviceFastbootStatus, PackageType(enum)
+ ↑
+Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWizardService
+ IExternalCommandRunner、RootException 类型
+ ↑
+Adb / Infrastructure
+ Adb:扩展现有 FastbootService、
+ BootImageExtractor(zip + payload.bin)、MagiskPatcher(install APK + 手机端执行)、
+ Parsers:FastbootVarParser、PackageTypeDetector、BootPartitionTargetDetector、CpuAbiParser
+ Infra:BootBackupService(备份原始目标镜像到用户目录)
+ ↑
+Shared AddAndroidTreeViewSharedServices() 内 TryAdd 注册全部新服务
+ ↑
+App RootWizardViewModel + RootWizardView(新导航页,分步向导)
+```
+
+- 所有 fastboot/adb 写操作走 `ProcessRunner`(异步、可取消、杀进程树)。
+- 解析类为纯函数放 `AndroidTreeView.Adb.Parsers`,配套解析测试(项目硬规则)。
+- fastboot 复用 App 已有 `scrcpy/fastboot[.exe]`;Magisk APK 与 payload-dumper 通过新建
+ `build/AndroidTreeView.RootTools.targets` 按发布 RID 下载、校验并只打包进完整 App。
+
+## 5. 向导状态机与流程
+
+### 5.1 状态枚举 `RootWizardState`
+
+```
+Idle 初始
+PackageSelected 已选刷机包
+Extracting 检测并提取 boot/init_boot 目标镜像(自动)
+BootExtracted 已提取(含来源:PlainZip / NestedZip / Payload)
+Blocked_UnsupportedTarget 目标分区证据冲突、镜像缺失或设备必须修补 recovery,禁止继续
+Patching 修补中(adb install Magisk APK → 手机执行 boot_patch.sh → pull 回)
+BootPatched 已得修补后目标镜像 + 已本地备份原始目标镜像
+AwaitingBootloaderConfirm ⛔ 强制确认点 1:即将重启进 bootloader
+RebootingToBootloader adb reboot bootloader(自动)
+InFastboot 已确认同一设备进入 fastboot,检测解锁状态 + 目标分区 A/B 布局
+Blocked_DeviceMismatch 无法证明 fastboot 设备与所选 ADB 设备相同,禁止继续
+Blocked_Locked 未解锁:引导用户,终止自动流程(可重新检测)
+AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash 目标分区(显示分区/槽位;A/B 双槽都刷)
+Flashing fastboot flash (A/B:先 _a 再 _b,自动)
+Rebooting fastboot reboot(自动)
+Completed 完成
+Failed 任一步失败(带错误信息 + 重试当前步 / 中止)
+```
+
+### 5.2 Happy Path 时序
+
+```
+选包 → [自动]提取 → [自动]修补+备份 → ⛔确认1 → [自动]进bootloader
+ → 检测解锁 → ⛔确认2 → [自动]flash → [自动]reboot → 完成
+```
+
+### 5.3 两个强制确认点
+
+1. **确认 1(进 bootloader 前)**:提示手机将重启进 fastboot、保持数据线稳定。
+2. **确认 2(flash 前)**:显示目标分区 `boot` 或 `init_boot`、修补后镜像、**原始镜像备份路径**、
+ **目标槽位(A/B 机型明确显示将同时刷入 `_a` 和 `_b`)**、红色"刷错可能变砖"警告;
+ **A/B 机型额外红字提示**:两个槽都会被写入同一镜像,若两槽系统版本不一致,日后回滚到另一槽
+ 可能变砖(§3 前提 3);用户勾选"我已了解风险"后方可点"刷入"。
+
+### 5.4 关键分支
+
+- **Blocked_Locked(未解锁)**:向导停止,显示解锁引导(开发者选项 OEM 解锁、
+ `fastboot flashing unlock` 会抹数据、部分厂商需解锁码)。**工具不代执行解锁**。
+ 用户自行解锁后可"重新检测"继续。
+- **Blocked_DeviceMismatch(身份不连续)**:若目标重启后没有出现新 fastboot 设备,或新设备的
+ serial / USB 路径 / product 无法与重启前记录相互印证,禁止继续;不得因列表中只剩一台设备就选它。
+- **Blocked_UnsupportedTarget(目标分区不受支持)**:设备与包对 `boot` / `init_boot` 的证据冲突、
+ 目标镜像缺失,或设备必须修补 recovery 时终止流程;第一版不允许用户绕过该阻塞。
+- **Failed(任一步失败)**:显示该步 stderr/错误摘要(映射成友好文案),提供"重试当前步"或
+ "中止"。中止不留危险中间态(若已在 fastboot,引导用户 `fastboot reboot` 回系统)。
+- **可取消**:每个自动步走 `CancellationToken`,用户可随时取消(取消后进入 Failed/可重试)。
+
+## 6. 核心机制
+
+### 6.1 目标启动镜像提取(`BootImageExtractor`)
+
+输入包文件和设备探测结果,按类型分派,输出目标镜像本地路径、目标分区 + 来源标记
+(`BootImageInfo { Path, TargetPartition, Source, OriginalPackageName }`)。
+
+**包类型判定**(纯函数 `PackageTypeDetector`,读文件头/枚举 zip 条目):
+
+- zip 顶层有 `boot.img` / `init_boot.img` → **PlainZip**
+- zip 含 `image-*.zip`(Pixel factory)→ **NestedZip**(解一层内层 zip 再取目标镜像)
+- zip 含 `payload.bin` → **Payload**
+- 裸 `payload.bin` 文件 → **Payload**
+- 都不匹配 → 抛 `RootException`,友好提示"未在包内找到受支持的 boot/init_boot 镜像"
+
+**提取**:
+
+- PlainZip / NestedZip → 纯 C# `System.IO.Compression`,无外部依赖。
+- Payload → 调用打包的 `payload_dumper`(走 `ProcessRunner`)按探测结果解出 `boot` 或 `init_boot`。
+- 目标判定必须同时满足设备侧分区、GKI 版本证据与包内候选镜像;只有 `init_boot` 存在且内核为
+ GKI 13+ 时才选择 `init_boot`。设备要求 `init_boot` 但包中缺失,或两侧证据矛盾时返回阻塞错误。
+ Magisk 判定 recovery-only 时明确提示首版不支持。
+- 输出到工作区 `~/.androidtreeview/root-work//`。
+
+**可测试性**:`PackageTypeDetector` 纯函数(zip 条目列表/文件头样本做测试);payload 解包用假
+`ProcessRunner` 测试流程/错误。
+
+### 6.2 Magisk 修补(`MagiskPatcher`,安装官方 APK 后手机端执行)
+
+前提:设备已连接、adb 已授权(复用现有 `DeviceMonitor` / 设备状态)。
+
+1. **安装 Magisk App**(`EnsureMagiskInstalledAsync`):`adb install -r tools/magisk/Magisk.apk`
+ (`-r` 覆盖重装,兼容用户已装旧版)。APK 内即含各 ABI native 组件(`lib//lib*.so`)。
+ 装 App **不设新强制确认点**,仅在步骤条/日志告知"正在安装 Magisk App"(loc key
+ `root.step.install_magisk`)。install 失败进 `Failed`。
+2. **探测 ABI**:`adb shell getprop ro.product.cpu.abi`(纯函数 `CpuAbiParser`),用于定位已装
+ Magisk App 的 native 组件路径(`lib/`)。
+3. **本地备份原始目标镜像**(`BootBackupService`):复制到
+ `~/.androidtreeview/root-backups/---original.img`,路径供确认 2 显示。
+4. **修补**:push 待修补目标镜像到手机临时目录 `/data/local/tmp/atv_root/`,`adb shell` 调用
+ **已装 Magisk App 自带的** `boot_patch.sh`(其可自解析 `MAGISKBIN`/APK 资源,内部调用
+ `magiskboot unpack/cpio/repack` + `magiskinit`/`magisk`),产出 `new-boot.img`。
+5. **pull 回**:`adb pull .../new-boot.img /boot-patched.img`。
+6. **清理**手机临时目录。
+
+上述所有 ADB 命令都必须显式指定向导开始时锁定的 ADB serial,不依赖 adb 的“唯一设备”隐式选择。
+
+> ⚠️ 第 4 步为最高技术不确定性环节:装 App 后 `boot_patch.sh` 环境更完整,但**"已装 App 的
+> `boot_patch.sh` 能否被 `adb shell` 直接调到"仍未完全消除不确定性**(APK 内 `assets` 脚本安装后
+> 不一定落在可直接执行位置,可能仍需从 APK 提取脚本再 push),需实机验证(见 §8)。
+
+### 6.3 fastboot 服务(`FastbootService`,走 `ProcessRunner`,全部 async + CancellationToken)
+
+| 方法 | 命令 | 说明 |
+|---|---|---|
+| `RebootToBootloaderAsync` | `adb reboot bootloader` | 从系统进 fastboot |
+| `CaptureFastbootBaselineAsync` | `fastboot devices -l` | 重启前记录已有 fastboot serial / USB 身份,防止误认其他设备 |
+| `WaitForMatchingFastbootDeviceAsync` | `fastboot devices -l` 轮询 + `getvar product` | 只接受新出现且能与所选 ADB 设备相互印证的设备 |
+| `GetUnlockStatusAsync` | `fastboot getvar unlocked` | 只读,`FastbootVarParser` 解析 `unlocked: yes/no` |
+| `GetBootLayoutAsync` | `fastboot getvar slot-count` + `has-slot:` + `current-slot` | 只读;联合判定目标分区 A/B,矛盾或缺失时返回 Unknown |
+| `FlashBootAsync` | 非 A/B:`fastboot flash
`;A/B:依次刷 `_a`、`_b` | 核心写操作;A/B 两槽都刷同一镜像 |
+| `RebootAsync` | `fastboot reboot` | 刷完回系统 |
+
+解析 `getvar` / `fastboot devices -l` 输出为纯函数 + 解析测试。所有设备命令都必须携带
+`-s `;单纯“列表中只有一台”不是身份匹配证据。`slot-count`、
+`has-slot:`、`current-slot` 复用同一 `FastbootVarParser`。A/B 双槽刷入时,第一槽成功、第二槽失败要
+视为整体失败(进 `Failed`,错误摘要注明哪个槽失败),不可停在"只刷了一个槽"的中间态。
+
+### 6.4 fastboot 定位(复用现有 `FastbootService`)
+
+现有 `FastbootService.ExecutablePath` 从 `IAdbEnvironment` 已定位的 adb 同目录解析
+`fastboot[.exe]`;发布脚本也已把 fastboot 放入 App 的 `scrcpy/` 目录。Root 向导扩展该服务的
+严格检测/刷写结果,不新建第二套 locator/environment。缺失时向导显示“未找到 fastboot”,不崩溃。
+
+### 6.5 备份服务(`BootBackupService`,Infrastructure 层)
+
+- 修补前把原始目标镜像存到 `~/.androidtreeview/root-backups/`,文件名含序列号、目标分区 + 时间戳。
+- 提供"列出/打开备份目录"给 UI,便于变砖时找回。
+
+## 7. 工具打包 — `build/AndroidTreeView.RootTools.targets`
+
+仿 `AndroidTreeView.Scrcpy.targets`,构建/发布时按平台下载并打包:
+
+```
+tools/
+ scrcpy/ 现有目录,已含 adb + fastboot
+ root-tools/
+ payload-dumper/ win-x64 / osx-arm64 对应 payload-dumper-go
+ magisk/
+ Magisk.apk 固定版本官方 APK,运行时 adb install
+```
+
+- Root 工具只打包进完整 App;Mini / Mini.Mac 不携带。
+- 下载源写明 URL + SHA-256 校验,文档登记版本,对齐项目"下载工具需校验 SHA-256"风格:
+ - fastboot:复用现有 Google platform-tools 下载与 App 打包路径。
+ - magisk:官方 Magisk GitHub release 的 APK 整包(不在桌面侧拆 native 二进制,组件随 App
+ 安装到手机后使用)。
+ - payload_dumper:可信开源发布。
+- 新增 `tools/verify-roottools-latest.ps1`(仿 `verify-scrcpy-latest.ps1`)检查上游新版本。
+
+## 8. 高风险里程碑(实机验证)
+
+以下无法单测,须实机验证并在开发中预留调整轮次:
+
+1. **`boot_patch.sh` 修补链路** — `adb install` 官方 Magisk APK 后,能调用其自带
+ `boot_patch.sh` 在真机产出可用 `new-boot.img`(重点验证已装 App 的脚本是否可被 `adb shell`
+ 直接调到);`boot` 与 `init_boot` 两种目标都必须留下验证记录。
+2. **真实 flash** — `fastboot flash boot` 与 `fastboot flash init_boot` 分别在对应的已解锁真机上
+ 成功且能正常开机。
+3. **payload.bin 解包** — `payload_dumper` 在 `win-x64`、`osx-arm64` 均能按目标解出
+ `boot` / `init_boot` 分区。
+4. **跨平台 fastboot/adb** — Windows x64 与 macOS arm64 下二进制均能定位与执行。
+
+## 9. App UI
+
+- 新导航页,命名遵循 ViewLocator 约定(`RootWizardViewModel` → `RootWizardView`),严格 MVVM
+ (`[ObservableProperty]` / `[RelayCommand]`),编译绑定 `x:DataType`。
+- 向导第一步先显示在线且已授权的设备单选列表;多设备时不预选,必须由用户明确选择。开始提取后
+ 锁定所选设备身份,除非中止并重开向导,否则不能切换目标设备。
+- 侧栏新增导航项(新 loc key `nav.root`)。页面:顶部步骤条反映 `RootWizardState`,中间当前步
+ 内容/进度/日志,底部操作按钮(下一步/确认/取消/重试)。
+- 两个强制确认为醒目对话框 + 复选框(未勾选"我已了解风险"则"刷入"禁用)。
+- flash 步骤用红色警告样式,与只读功能区分。
+- `RootWizardViewModel` 仅编排 `IRootWizardService`,负责状态→UI 映射、UI 线程 marshal
+ (`Dispatcher.UIThread.Post`)、错误映射成友好 `ErrorMessage`(App 永不因正常错误崩溃)。
+- macOS 支持:让 App 能在 macOS 构建/运行,Root 页双平台共用同一 MVVM 代码。
+
+## 10. 本地化
+
+- 所有向导文案、按钮、警告、错误映射、引导说明新增 key 到**两个 ResX 都加**
+ (`Strings.resx` 英文 + `Strings.zh-Hans.resx` 中文),键集保持一致。
+- 命名示例:`root.wizard.title`、`root.step.extract`、`root.step.install_magisk`(安装 Magisk
+ App 步骤/日志文案)、`root.confirm.flash.warning`、
+ `root.confirm.flash.ab.dualslot`(双槽刷入提示)、`root.confirm.flash.targetslot`(目标槽位显示)、
+ `root.confirm.flash.targetpartition`(`boot` / `init_boot`)、
+ `root.blocked.fastboot.identity`(ADB → fastboot 身份无法验证)、
+ `root.blocked.partition.unsupported`(目标分区歧义或 recovery-only)、
+ `root.blocked.locked.guide`、`root.error.*`(含 `root.error.install.magisk`:装 APK 失败;
+ `root.error.flash.slotb`:第二槽刷入失败)。
+- XAML 用 `{loc:Localize Key=...}`,VM 用 `_localization.Get/Format`。
+
+## 11. 测试
+
+- **Adb.Tests** 新增:
+ - `Parsers/FastbootVarParserTests`(`unlocked` / `slot-count` / `has-slot:` /
+ `current-slot` / `fastboot devices -l` / 身份证据)
+ - `Parsers/PackageTypeDetectorTests`(zip 条目 → 包类型)
+ - `Parsers/BootPartitionTargetDetectorTests`(设备/包证据 → `boot` / `init_boot` / 阻塞)
+ - `Parsers/CpuAbiParserTests`(`ro.product.cpu.abi` 解析)
+ - `Commands/`:fastboot/adb argv 构建测试(含 `boot` / `init_boot` 和 A/B 双槽 argv)
+ - `Services/`:`BootImageExtractor`、`MagiskPatcher`、`FastbootService`(假
+ `IExternalCommandRunner`,
+ 覆盖流程与错误路径;`MagiskPatcher` 覆盖 `adb install` 成功→调 `boot_patch.sh`、install
+ 失败→`Failed`、ABI 探测;`FlashBootAsync` 覆盖 `boot` / `init_boot`、非 A/B 单槽、A/B 双槽成功、
+ A/B 第二槽失败→整体失败)
+- **App.Tests** 新增:`RootWizardViewModel` 状态机推进、确认门控(未勾选不能 flash)、错误映射、
+ 未解锁→Blocked 分支;DI 图解析新服务(`ServiceGraphTests`)。
+- **实机验证**(§8):无法单测,作为手动验证清单。
+
+## 12. CLAUDE.md 更新
+
+因放宽只读定位,实现时需:
+
+- 修改"Read-only / safe by design"条目:说明工具现含**受控、需多重确认的刷机写操作**(仅
+ Root 向导内 boot 刷入链路),其余功能仍保持只读;解锁 bootloader 仍不自动执行。
+- Architecture / Bundled tools 段补充 fastboot、magisk 组件、payload_dumper 打包说明。
+- 版本按发布规则统一 bump。
+
+## 13. 明确的非目标(YAGNI)
+
+- 不做 bootloader 自动解锁。
+- 不做 boot 以外分区刷写。
+- 不做一键恢复/卸载 root 向导(仅备份 + 引导)。
+- 不支持厂商私有包格式。
+- 不引入桌面版第三方 `magiskboot`(改用安装官方 Magisk APK 后调用其自带组件,不在桌面侧拆
+ native 二进制)。
diff --git a/packaging/build-update-zip.ps1 b/packaging/build-update-zip.ps1
index e5f807f..ba1b4ed 100644
--- a/packaging/build-update-zip.ps1
+++ b/packaging/build-update-zip.ps1
@@ -272,8 +272,8 @@ function New-IcnsFromPng {
}
New-Item -ItemType Directory -Force -Path $iconsetDir | Out-Null
- # name => pixel size for the standard iconset slots (1x + @2x). Upscaled slots beyond the
- # 256px source are slightly soft but valid; Dock/Finder common sizes stay crisp.
+ # name => pixel size for the standard iconset slots (1x + @2x). macOS app artwork occupies
+ # about 81% of each icon canvas; the transparent safe area keeps it aligned with system icons.
$slots = [ordered]@{
'icon_16x16.png' = 16
'icon_16x16@2x.png' = 32
@@ -289,10 +289,20 @@ function New-IcnsFromPng {
foreach ($entry in $slots.GetEnumerator()) {
$target = Join-Path $iconsetDir $entry.Key
- & sips -z $entry.Value $entry.Value $SourcePng --out $target *> $null
+ $contentSize = [Math]::Max(1, [Math]::Round($entry.Value * 0.8125))
+ $resizedTarget = "$target.resized.png"
+
+ & sips -z $contentSize $contentSize $SourcePng --out $resizedTarget *> $null
if ($LASTEXITCODE -ne 0) {
- throw "sips failed to render '$($entry.Key)' from '$SourcePng'."
+ throw "sips failed to resize '$($entry.Key)' from '$SourcePng'."
}
+
+ & sips -p $entry.Value $entry.Value $resizedTarget --out $target *> $null
+ if ($LASTEXITCODE -ne 0) {
+ throw "sips failed to add the macOS safe area to '$($entry.Key)'."
+ }
+
+ Remove-Item -Force -LiteralPath $resizedTarget
}
& iconutil -c icns $iconsetDir -o $OutputIcns
@@ -415,6 +425,23 @@ $iconPlistEntry CFBundleIdentifier
Set-ExecutableBits -RidInfo $RidInfo -Directory $macOSDir -Names @($ProductConfig.Executable)
Set-ExecutableBits -RidInfo $RidInfo -Directory (Join-Path $macOSDir 'scrcpy') -Names @('scrcpy', 'adb', 'fastboot')
+ if (-not (Get-Command codesign -ErrorAction SilentlyContinue)) {
+ throw 'codesign is required to produce a valid macOS app bundle.'
+ }
+
+ # dotnet signs the apphost before it is wrapped in the final bundle. Sign again after adding
+ # Info.plist and Resources so the bundle resource envelope matches the shipped contents.
+ # New-PackageArchive uses ditto to preserve the extended-attribute signatures on managed DLLs.
+ & codesign --force --deep --sign - --timestamp=none $bundleRoot
+ if ($LASTEXITCODE -ne 0) {
+ throw "codesign failed for '$bundleRoot'."
+ }
+
+ & codesign --verify --deep --strict $bundleRoot
+ if ($LASTEXITCODE -ne 0) {
+ throw "codesign verification failed for '$bundleRoot'."
+ }
+
return $BundleStageDir
}
@@ -529,16 +556,18 @@ function New-PackageArchive {
}
if ($RidInfo.IsMacOS) {
- $zipFullPath = [System.IO.Path]::GetFullPath($ZipPath)
- Push-Location $SourceDir
- try {
- & zip -qry $zipFullPath .
- if ($LASTEXITCODE -ne 0) {
- throw "zip failed with exit code $LASTEXITCODE."
- }
+ if (-not (Get-Command ditto -ErrorAction SilentlyContinue)) {
+ throw 'ditto is required to preserve macOS app bundle signatures in ZIP packages.'
}
- finally {
- Pop-Location
+
+ $appBundles = @(Get-ChildItem -LiteralPath $SourceDir -Directory -Filter '*.app')
+ if ($appBundles.Count -ne 1) {
+ throw "Expected exactly one .app bundle under '$SourceDir', found $($appBundles.Count)."
+ }
+
+ & ditto -c -k --sequesterRsrc --keepParent $appBundles[0].FullName $ZipPath
+ if ($LASTEXITCODE -ne 0) {
+ throw "ditto failed with exit code $LASTEXITCODE."
}
return
diff --git a/src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs b/src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs
new file mode 100644
index 0000000..155ee52
--- /dev/null
+++ b/src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs
@@ -0,0 +1,31 @@
+using AndroidTreeView.Adb.Internal;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+
+namespace AndroidTreeView.Adb.Services;
+
+/// adapter over the shared process runner.
+public sealed class ExternalCommandRunner : IExternalCommandRunner
+{
+ public async Task RunAsync(
+ ExternalCommandRequest request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+
+ var result = await ProcessRunner.RunAsync(
+ request.FileName,
+ request.Arguments,
+ request.Timeout,
+ ct).ConfigureAwait(false);
+
+ return new ExternalCommandResult
+ {
+ ExitCode = result.ExitCode,
+ StandardOutput = result.StandardOutput,
+ StandardError = result.StandardError,
+ TimedOut = result.TimedOut,
+ Duration = result.Duration
+ };
+ }
+}
diff --git a/src/AndroidTreeView.Adb/Services/FastbootService.cs b/src/AndroidTreeView.Adb/Services/FastbootService.cs
index 0c15257..67f616b 100644
--- a/src/AndroidTreeView.Adb/Services/FastbootService.cs
+++ b/src/AndroidTreeView.Adb/Services/FastbootService.cs
@@ -1,5 +1,5 @@
-using AndroidTreeView.Adb.Internal;
using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
using Microsoft.Extensions.Logging;
namespace AndroidTreeView.Adb.Services;
@@ -15,11 +15,16 @@ public sealed class FastbootService : IFastbootService
private static readonly TimeSpan ActionTimeout = TimeSpan.FromSeconds(8);
private readonly IAdbEnvironment _environment;
+ private readonly IExternalCommandRunner _runner;
private readonly ILogger _logger;
- public FastbootService(IAdbEnvironment environment, ILogger logger)
+ public FastbootService(
+ IAdbEnvironment environment,
+ IExternalCommandRunner runner,
+ ILogger logger)
{
_environment = environment;
+ _runner = runner;
_logger = logger;
}
@@ -55,8 +60,9 @@ public async Task> ListSerialsAsync(CancellationToken ct =
try
{
- var result = await ProcessRunner.RunAsync(exe, new[] { "devices" }, ListTimeout, ct).ConfigureAwait(false);
- return result.TimedOut || result.ExitCode != 0
+ var result = await _runner.RunAsync(CreateRequest(exe, ["devices"], ListTimeout), ct)
+ .ConfigureAwait(false);
+ return !result.IsSuccess
? Array.Empty()
: ParseSerials(result.StandardOutput);
}
@@ -82,10 +88,15 @@ public async Task> GetVariablesAsync(string
try
{
- var result = await ProcessRunner
- .RunAsync(exe, new[] { "-s", serial, "getvar", "all" }, ListTimeout, ct)
+ var result = await _runner
+ .RunAsync(CreateRequest(exe, ["-s", serial, "getvar", "all"], ListTimeout), ct)
.ConfigureAwait(false);
+ if (!result.IsSuccess)
+ {
+ return empty;
+ }
+
// fastboot writes getvar output to STDERR, so parse both streams.
return ParseVariables(result.StandardOutput + "\n" + result.StandardError);
}
@@ -124,7 +135,7 @@ private async Task RunAsync(string[] args, CancellationToken ct)
try
{
- await ProcessRunner.RunAsync(exe, args, ActionTimeout, ct).ConfigureAwait(false);
+ await _runner.RunAsync(CreateRequest(exe, args, ActionTimeout), ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -136,6 +147,17 @@ private async Task RunAsync(string[] args, CancellationToken ct)
}
}
+ private static ExternalCommandRequest CreateRequest(
+ string executablePath,
+ IReadOnlyList arguments,
+ TimeSpan timeout)
+ => new()
+ {
+ FileName = executablePath,
+ Arguments = arguments,
+ Timeout = timeout
+ };
+
// Lines look like "SERIAL\tfastboot" (tabs or spaces). Keep the serial of any line that names fastboot.
private static IReadOnlyList ParseSerials(string output)
{
diff --git a/src/AndroidTreeView.App/Resources/Strings.resx b/src/AndroidTreeView.App/Resources/Strings.resx
index 62acc49..d6b9297 100644
--- a/src/AndroidTreeView.App/Resources/Strings.resx
+++ b/src/AndroidTreeView.App/Resources/Strings.resx
@@ -661,7 +661,7 @@
Could not start the update process
- Ready to check the internal update channel
+ Ready to check GitHub Releases
Update Source
@@ -670,13 +670,13 @@
Package Key
- No build has been uploaded for this channel yet
+ No GitHub Release has been published yet
- Could not reach the internal update server
+ Could not reach GitHub Releases
- The update server rejected the request
+ GitHub rejected the update request
The update metadata is incomplete
@@ -715,4 +715,3 @@
Failed to load {0}
-
diff --git a/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx b/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
index ee83efa..0276d87 100644
--- a/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
+++ b/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
@@ -661,7 +661,7 @@
无法启动更新流程
- 可以检查内网更新通道
+ 可以检查 GitHub Releases
更新源
@@ -670,13 +670,13 @@
包标识
- 这个通道还没有上传版本
+ GitHub 仓库还没有发布版本
- 无法连接内网更新服务
+ 无法连接 GitHub Releases
- 更新服务拒绝了本次请求
+ GitHub 拒绝了本次更新请求
更新信息不完整
@@ -715,4 +715,3 @@
加载 {0} 失败
-
diff --git a/src/AndroidTreeView.Core/AppInfo.cs b/src/AndroidTreeView.Core/AppInfo.cs
index 205eef4..2a6b9df 100644
--- a/src/AndroidTreeView.Core/AppInfo.cs
+++ b/src/AndroidTreeView.Core/AppInfo.cs
@@ -1,26 +1,57 @@
-namespace AndroidTreeView.Core;
-
-///
-/// Application identity and update endpoint constants. These are public app identifiers only, not secrets.
-///
-public static class AppInfo
-{
- public const string Name = "AndroidTreeView";
-
- /// Release version. Keep this aligned with the App and Mini project Version values.
- public const string Version = "1.0.6";
-
- public const string GitHubOwner = "Birditch";
- public const string GitHubRepo = "AndroidTreeView";
-
- public const string ProjectUrl = "https://github.com/Birditch/AndroidTreeView";
-
- public const string UpdateServerBaseUrl = "http://192.168.89.71:14000";
- public const string AppUpdateKey = "android-tree-view-app";
- public const string MiniUpdateKey = "android-tree-view-mini";
-
- public const string ReleasesUrl = UpdateServerBaseUrl;
-
- public const string LatestReleaseApiUrl =
- UpdateServerBaseUrl + "/api/update/" + AppUpdateKey + "/latest";
-}
+using System.Reflection;
+
+namespace AndroidTreeView.Core;
+
+///
+/// Application identity and update endpoint constants. These are public app identifiers only, not secrets.
+///
+public static class AppInfo
+{
+ public const string Name = "AndroidTreeView";
+
+ /// The version embedded in the executable that is currently running.
+ public static string Version => GetVersion();
+
+ public const string GitHubOwner = "Birditch";
+ public const string GitHubRepo = "AndroidTreeView";
+
+ public const string ProjectUrl = "https://github.com/Birditch/AndroidTreeView";
+
+ public const string UpdateServerBaseUrl = ProjectUrl;
+ public const string AppUpdateKey = "android-tree-view-app";
+ public const string MiniUpdateKey = "android-tree-view-mini";
+
+ public const string ReleasesUrl = ProjectUrl + "/releases";
+
+ public const string LatestReleaseApiUrl =
+ "https://api.github.com/repos/" + GitHubOwner + "/" + GitHubRepo + "/releases/latest";
+
+ public static string GetVersion(Assembly? assembly = null)
+ {
+ assembly ??= GetProductAssembly();
+ var informationalVersion = assembly
+ .GetCustomAttribute()?
+ .InformationalVersion;
+
+ if (!string.IsNullOrWhiteSpace(informationalVersion))
+ {
+ var metadataSeparator = informationalVersion.IndexOf('+');
+ return metadataSeparator >= 0
+ ? informationalVersion[..metadataSeparator]
+ : informationalVersion;
+ }
+
+ return assembly.GetName().Version?.ToString(3) ?? "0.0.0";
+ }
+
+ private static Assembly GetProductAssembly()
+ {
+ var entryAssembly = Assembly.GetEntryAssembly();
+ var entryName = entryAssembly?.GetName().Name;
+ return entryAssembly is not null
+ && entryName is not null
+ && entryName.StartsWith("AndroidTreeView.App", StringComparison.Ordinal)
+ ? entryAssembly
+ : typeof(AppInfo).Assembly;
+ }
+}
diff --git a/src/AndroidTreeView.Core/Interfaces/IExternalCommandRunner.cs b/src/AndroidTreeView.Core/Interfaces/IExternalCommandRunner.cs
new file mode 100644
index 0000000..1950acd
--- /dev/null
+++ b/src/AndroidTreeView.Core/Interfaces/IExternalCommandRunner.cs
@@ -0,0 +1,12 @@
+using AndroidTreeView.Core.Services;
+
+namespace AndroidTreeView.Core.Interfaces;
+
+/// Runs an external process without invoking a command shell.
+public interface IExternalCommandRunner
+{
+ /// Runs a command to completion and returns its captured output.
+ Task RunAsync(
+ ExternalCommandRequest request,
+ CancellationToken ct = default);
+}
diff --git a/src/AndroidTreeView.Core/Options/UpdateProductOptions.cs b/src/AndroidTreeView.Core/Options/UpdateProductOptions.cs
index 98127e1..7862543 100644
--- a/src/AndroidTreeView.Core/Options/UpdateProductOptions.cs
+++ b/src/AndroidTreeView.Core/Options/UpdateProductOptions.cs
@@ -1,41 +1,53 @@
-using AndroidTreeView.Core;
-
-namespace AndroidTreeView.Core.Options;
-
-///
-/// Identifies the concrete package channel used by the update API. The full app and Mini share the
-/// update implementation, but each points at its own app key.
-///
-public sealed class UpdateProductOptions
-{
- public string Name { get; init; } = AppInfo.Name;
-
- public string Version { get; init; } = AppInfo.Version;
-
- public string AppKey { get; init; } = AppInfo.AppUpdateKey;
-
- public string UpdateServerBaseUrl { get; init; } = AppInfo.UpdateServerBaseUrl;
-
- public string ReleasesUrl { get; init; } = AppInfo.ReleasesUrl;
-
- public string LatestReleaseApiUrl =>
- UpdateServerBaseUrl.TrimEnd('/') + "/api/update/" + AppKey + "/latest";
-
- public static UpdateProductOptions ForMainApp() => new()
- {
- Name = AppInfo.Name,
- Version = AppInfo.Version,
- AppKey = AppInfo.AppUpdateKey,
- UpdateServerBaseUrl = AppInfo.UpdateServerBaseUrl,
- ReleasesUrl = AppInfo.ReleasesUrl,
- };
-
- public static UpdateProductOptions ForMiniApp() => new()
- {
- Name = AppInfo.Name + " Mini",
- Version = AppInfo.Version,
- AppKey = AppInfo.MiniUpdateKey,
- UpdateServerBaseUrl = AppInfo.UpdateServerBaseUrl,
- ReleasesUrl = AppInfo.ReleasesUrl,
- };
-}
+using AndroidTreeView.Core;
+
+namespace AndroidTreeView.Core.Options;
+
+///
+/// Identifies the GitHub Release asset used by each shipping executable.
+///
+public sealed class UpdateProductOptions
+{
+ public string Name { get; init; } = AppInfo.Name;
+
+ public string Version { get; init; } = AppInfo.Version;
+
+ public string AppKey { get; init; } = AppInfo.AppUpdateKey;
+
+ public string UpdateServerBaseUrl { get; init; } = AppInfo.UpdateServerBaseUrl;
+
+ public string ReleasesUrl { get; init; } = AppInfo.ReleasesUrl;
+
+ public string LatestReleaseApiUrl { get; init; } = AppInfo.LatestReleaseApiUrl;
+
+ public string ReleaseAssetPrefix { get; init; } = "AndroidTreeView";
+
+ public string? ReleaseAssetRid { get; init; } = GetInstallableRid();
+
+ public static UpdateProductOptions ForMainApp() => new()
+ {
+ Name = AppInfo.Name,
+ Version = AppInfo.Version,
+ AppKey = AppInfo.AppUpdateKey,
+ UpdateServerBaseUrl = AppInfo.UpdateServerBaseUrl,
+ ReleasesUrl = AppInfo.ReleasesUrl,
+ LatestReleaseApiUrl = AppInfo.LatestReleaseApiUrl,
+ ReleaseAssetPrefix = "AndroidTreeView",
+ };
+
+ public static UpdateProductOptions ForMiniApp() => new()
+ {
+ Name = AppInfo.Name + " Mini",
+ Version = AppInfo.Version,
+ AppKey = AppInfo.MiniUpdateKey,
+ UpdateServerBaseUrl = AppInfo.UpdateServerBaseUrl,
+ ReleasesUrl = AppInfo.ReleasesUrl,
+ LatestReleaseApiUrl = AppInfo.LatestReleaseApiUrl,
+ ReleaseAssetPrefix = "AndroidTreeView-Mini",
+ };
+
+ private static string? GetInstallableRid() =>
+ OperatingSystem.IsWindows() && System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture
+ == System.Runtime.InteropServices.Architecture.X64
+ ? "win-x64"
+ : null;
+}
diff --git a/src/AndroidTreeView.Core/Services/ExternalCommandRequest.cs b/src/AndroidTreeView.Core/Services/ExternalCommandRequest.cs
new file mode 100644
index 0000000..12f2a1c
--- /dev/null
+++ b/src/AndroidTreeView.Core/Services/ExternalCommandRequest.cs
@@ -0,0 +1,14 @@
+namespace AndroidTreeView.Core.Services;
+
+/// Describes one external process invocation.
+public sealed class ExternalCommandRequest
+{
+ /// Executable name or path passed directly to the operating system.
+ public required string FileName { get; init; }
+
+ /// Individual arguments passed without shell interpolation.
+ public required IReadOnlyList Arguments { get; init; }
+
+ /// Maximum time the process may run before its process tree is terminated.
+ public required TimeSpan Timeout { get; init; }
+}
diff --git a/src/AndroidTreeView.Core/Services/ExternalCommandResult.cs b/src/AndroidTreeView.Core/Services/ExternalCommandResult.cs
new file mode 100644
index 0000000..b97f127
--- /dev/null
+++ b/src/AndroidTreeView.Core/Services/ExternalCommandResult.cs
@@ -0,0 +1,20 @@
+namespace AndroidTreeView.Core.Services;
+
+/// Buffered result of a completed external process invocation.
+public sealed class ExternalCommandResult
+{
+ public int ExitCode { get; init; }
+
+ public string StandardOutput { get; init; } = string.Empty;
+
+ public string StandardError { get; init; } = string.Empty;
+
+ /// True when the command was terminated after exceeding its timeout.
+ public bool TimedOut { get; init; }
+
+ /// Wall-clock duration of the command.
+ public TimeSpan Duration { get; init; }
+
+ /// True when the command exited cleanly before its timeout.
+ public bool IsSuccess => ExitCode == 0 && !TimedOut;
+}
diff --git a/src/AndroidTreeView.Infrastructure/Update/GitHubUpdateService.cs b/src/AndroidTreeView.Infrastructure/Update/GitHubUpdateService.cs
new file mode 100644
index 0000000..f983887
--- /dev/null
+++ b/src/AndroidTreeView.Infrastructure/Update/GitHubUpdateService.cs
@@ -0,0 +1,201 @@
+using System.Net;
+using System.Text.Json;
+using AndroidTreeView.Core;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Options;
+using AndroidTreeView.Models;
+using Microsoft.Extensions.Logging;
+
+namespace AndroidTreeView.Infrastructure.Update;
+
+///
+/// Checks GitHub Releases for a newer version and resolves the matching Windows x64 package.
+/// Failures are returned as UI-safe result states rather than thrown to callers.
+///
+public sealed class GitHubUpdateService : IUpdateService
+{
+ private const string GitHubAcceptHeader = "application/vnd.github+json";
+ private const string TagNameProperty = "tag_name";
+ private const string HtmlUrlProperty = "html_url";
+ private const string BodyProperty = "body";
+ private const string AssetsProperty = "assets";
+ private const string NameProperty = "name";
+ private const string DownloadUrlProperty = "browser_download_url";
+
+ private readonly HttpClient _httpClient;
+ private readonly ISettingsService _settings;
+ private readonly UpdateProductOptions _product;
+ private readonly ILogger _logger;
+
+ public GitHubUpdateService(
+ HttpClient httpClient,
+ ISettingsService settings,
+ ILogger logger)
+ : this(httpClient, settings, logger, UpdateProductOptions.ForMainApp())
+ {
+ }
+
+ public GitHubUpdateService(
+ HttpClient httpClient,
+ ISettingsService settings,
+ ILogger logger,
+ UpdateProductOptions product)
+ {
+ ArgumentNullException.ThrowIfNull(httpClient);
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(logger);
+ ArgumentNullException.ThrowIfNull(product);
+
+ _httpClient = httpClient;
+ _settings = settings;
+ _logger = logger;
+ _product = product;
+ }
+
+ public string CurrentVersion => _product.Version;
+
+ public async Task CheckForUpdatesAsync(bool userInitiated, CancellationToken ct = default)
+ {
+ if (!userInitiated && !_settings.Current.AutoCheckUpdates)
+ {
+ return UpdateCheckResult.DisabledFor(CurrentVersion);
+ }
+
+ try
+ {
+ using var request = BuildRequest();
+ using var response = await _httpClient
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct)
+ .ConfigureAwait(false);
+
+ if (TryMapStatusFailure(response.StatusCode, out var failure))
+ {
+ return failure;
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
+ return BuildResult(document.RootElement);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (TaskCanceledException ex)
+ {
+ _logger.LogWarning(ex, "GitHub update check timed out.");
+ return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NetworkError, "The update check timed out.");
+ }
+ catch (HttpRequestException ex)
+ {
+ _logger.LogWarning(ex, "GitHub update check failed due to a network error.");
+ return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NetworkError, ex.Message);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex, "GitHub returned malformed release JSON.");
+ return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.InvalidData, "The release payload could not be parsed.");
+ }
+ }
+
+ private HttpRequestMessage BuildRequest()
+ {
+ var request = new HttpRequestMessage(HttpMethod.Get, _product.LatestReleaseApiUrl);
+ request.Headers.UserAgent.ParseAdd(_product.Name.Replace(' ', '-') + "-UpdateChecker");
+ request.Headers.Accept.ParseAdd(GitHubAcceptHeader);
+ return request;
+ }
+
+ private bool TryMapStatusFailure(HttpStatusCode status, out UpdateCheckResult failure)
+ {
+ switch (status)
+ {
+ case HttpStatusCode.NotFound:
+ failure = UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NoRelease, "No published GitHub release was found.");
+ return true;
+ case HttpStatusCode.Forbidden:
+ failure = UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.RateLimited, "The GitHub API rate limit was reached.");
+ return true;
+ }
+
+ if ((int)status is < 200 or >= 300)
+ {
+ _logger.LogWarning("GitHub update check returned unexpected status {Status}.", (int)status);
+ failure = UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NetworkError, $"Unexpected HTTP status {(int)status}.");
+ return true;
+ }
+
+ failure = default!;
+ return false;
+ }
+
+ private UpdateCheckResult BuildResult(JsonElement root)
+ {
+ var tagName = ReadString(root, TagNameProperty);
+ var releaseUrl = ReadString(root, HtmlUrlProperty) ?? _product.ReleasesUrl;
+ var releaseNotes = ReadString(root, BodyProperty);
+
+ if (!SemanticVersion.TryParse(tagName, out var latest) || latest is null)
+ {
+ _logger.LogWarning("GitHub release tag '{Tag}' is not a valid semantic version.", tagName);
+ return new UpdateCheckResult
+ {
+ CurrentVersion = CurrentVersion,
+ Status = UpdateCheckStatus.InvalidData,
+ ReleaseUrl = releaseUrl,
+ ReleaseNotes = releaseNotes,
+ ErrorMessage = "The release tag was not a recognizable semantic version.",
+ };
+ }
+
+ var updateAvailable = !SemanticVersion.TryParse(CurrentVersion, out var current)
+ || current is null
+ || latest.IsNewerThan(current);
+
+ var packageName = GetPackageName(latest.ToString());
+ var downloadUrl = packageName is null ? null : FindAssetUrl(root, packageName);
+ var sha256Url = packageName is null ? null : FindAssetUrl(root, packageName + ".sha256");
+
+ return new UpdateCheckResult
+ {
+ CurrentVersion = CurrentVersion,
+ LatestVersion = latest.ToString(),
+ UpdateAvailable = updateAvailable,
+ ReleaseUrl = releaseUrl,
+ DownloadUrl = downloadUrl,
+ Sha256Url = sha256Url,
+ ReleaseNotes = releaseNotes,
+ Status = updateAvailable ? UpdateCheckStatus.UpdateAvailable : UpdateCheckStatus.UpToDate,
+ };
+ }
+
+ private string? GetPackageName(string version) =>
+ string.IsNullOrWhiteSpace(_product.ReleaseAssetRid)
+ ? null
+ : $"{_product.ReleaseAssetPrefix}-{version}-{_product.ReleaseAssetRid}.zip";
+
+ private static string? FindAssetUrl(JsonElement root, string assetName)
+ {
+ if (!root.TryGetProperty(AssetsProperty, out var assets) || assets.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+
+ foreach (var asset in assets.EnumerateArray())
+ {
+ if (string.Equals(ReadString(asset, NameProperty), assetName, StringComparison.OrdinalIgnoreCase))
+ {
+ return ReadString(asset, DownloadUrlProperty);
+ }
+ }
+
+ return null;
+ }
+
+ private static string? ReadString(JsonElement root, string propertyName) =>
+ root.ValueKind == JsonValueKind.Object
+ && root.TryGetProperty(propertyName, out var value)
+ && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+}
diff --git a/src/AndroidTreeView.Infrastructure/Update/NekoIndexUpdateService.cs b/src/AndroidTreeView.Infrastructure/Update/NekoIndexUpdateService.cs
deleted file mode 100644
index 04bb3b9..0000000
--- a/src/AndroidTreeView.Infrastructure/Update/NekoIndexUpdateService.cs
+++ /dev/null
@@ -1,243 +0,0 @@
-using System.Net;
-using System.Text.Json;
-using AndroidTreeView.Core;
-using AndroidTreeView.Core.Interfaces;
-using AndroidTreeView.Core.Options;
-using AndroidTreeView.Models;
-using Microsoft.Extensions.Logging;
-
-namespace AndroidTreeView.Infrastructure.Update;
-
-///
-/// Checks the internal NekoIndex update API for a newer version. Fully resilient: every failure mode maps
-/// to an and is returned rather than thrown, so callers and the UI never
-/// have to guard against exceptions. Cooperative cancellation via the caller's token still propagates.
-///
-public sealed class NekoIndexUpdateService : IUpdateService
-{
- private const string JsonAcceptHeader = "application/json";
-
- private const string OkProperty = "ok";
- private const string DataProperty = "data";
- private const string ErrorProperty = "error";
- private const string AppKeyProperty = "appKey";
- private const string VersionProperty = "version";
- private const string TitleProperty = "title";
- private const string ZipProperty = "zip";
- private const string DownloadUrlProperty = "downloadUrl";
- private const string Sha256Property = "sha256";
-
- private readonly HttpClient _httpClient;
- private readonly ISettingsService _settings;
- private readonly UpdateProductOptions _product;
- private readonly ILogger _logger;
-
- public NekoIndexUpdateService(
- HttpClient httpClient,
- ISettingsService settings,
- ILogger logger)
- : this(httpClient, settings, logger, UpdateProductOptions.ForMainApp())
- {
- }
-
- public NekoIndexUpdateService(
- HttpClient httpClient,
- ISettingsService settings,
- ILogger logger,
- UpdateProductOptions product)
- {
- ArgumentNullException.ThrowIfNull(httpClient);
- ArgumentNullException.ThrowIfNull(settings);
- ArgumentNullException.ThrowIfNull(logger);
- ArgumentNullException.ThrowIfNull(product);
- _httpClient = httpClient;
- _settings = settings;
- _logger = logger;
- _product = product;
- }
-
- ///
- public string CurrentVersion => _product.Version;
-
- ///
- public async Task CheckForUpdatesAsync(bool userInitiated, CancellationToken ct = default)
- {
- if (!userInitiated && !_settings.Current.AutoCheckUpdates)
- {
- return UpdateCheckResult.DisabledFor(CurrentVersion);
- }
-
- try
- {
- using var request = BuildRequest();
- using var response = await _httpClient
- .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct)
- .ConfigureAwait(false);
-
- if (TryMapStatusFailure(response.StatusCode, out var failure))
- {
- return failure;
- }
-
- await using var stream = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
- using var document = await JsonDocument.ParseAsync(stream, cancellationToken: ct).ConfigureAwait(false);
- return BuildResult(document.RootElement);
- }
- catch (OperationCanceledException) when (ct.IsCancellationRequested)
- {
- throw;
- }
- catch (TaskCanceledException ex)
- {
- _logger.LogWarning(ex, "Update check timed out.");
- return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NetworkError, "The update check timed out.");
- }
- catch (HttpRequestException ex)
- {
- _logger.LogWarning(ex, "Update check failed due to a network error.");
- return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NetworkError, ex.Message);
- }
- catch (JsonException ex)
- {
- _logger.LogWarning(ex, "Update check returned malformed JSON.");
- return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.InvalidData, "The update payload could not be parsed.");
- }
- }
-
- private HttpRequestMessage BuildRequest()
- {
- var request = new HttpRequestMessage(HttpMethod.Get, _product.LatestReleaseApiUrl);
- request.Headers.UserAgent.ParseAdd(_product.Name.Replace(' ', '-') + "-UpdateChecker");
- request.Headers.Accept.ParseAdd(JsonAcceptHeader);
- return request;
- }
-
- private bool TryMapStatusFailure(HttpStatusCode status, out UpdateCheckResult failure)
- {
- switch (status)
- {
- case HttpStatusCode.NotFound:
- failure = UpdateCheckResult.Error(
- CurrentVersion,
- UpdateCheckStatus.NoRelease,
- $"No published release was found for appKey '{_product.AppKey}'.");
- return true;
- case HttpStatusCode.Forbidden:
- failure = UpdateCheckResult.Error(
- CurrentVersion,
- UpdateCheckStatus.RateLimited,
- "The update API rejected the request.");
- return true;
- }
-
- if ((int)status is < 200 or >= 300)
- {
- _logger.LogWarning("Update check returned unexpected status {Status}.", (int)status);
- failure = UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.NetworkError, $"Unexpected HTTP status {(int)status}.");
- return true;
- }
-
- failure = default!;
- return false;
- }
-
- private UpdateCheckResult BuildResult(JsonElement root)
- {
- if (root.ValueKind != JsonValueKind.Object)
- {
- return Invalid("The update payload root was not an object.");
- }
-
- if (!root.TryGetProperty(OkProperty, out var okElement) || okElement.ValueKind != JsonValueKind.True)
- {
- var error = ReadString(root, ErrorProperty) ?? "The update API returned ok=false.";
- return UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.InvalidData, error);
- }
-
- if (!root.TryGetProperty(DataProperty, out var data) || data.ValueKind != JsonValueKind.Object)
- {
- return Invalid("The update payload did not include a data object.");
- }
-
- var appKey = ReadString(data, AppKeyProperty);
- if (!string.Equals(appKey, _product.AppKey, StringComparison.Ordinal))
- {
- _logger.LogWarning(
- "Update payload appKey '{PayloadAppKey}' did not match configured appKey '{ConfiguredAppKey}'.",
- appKey,
- _product.AppKey);
- return Invalid("The update payload was for a different application channel.");
- }
-
- var version = ReadString(data, VersionProperty);
- if (!SemanticVersion.TryParse(version, out var latest) || latest is null)
- {
- _logger.LogWarning("Update version '{Version}' is not a valid semantic version.", version);
- return Invalid("The update version was not a recognizable semantic version.");
- }
-
- var downloadUrl = ReadZipString(data, DownloadUrlProperty);
- var absoluteDownloadUrl = ResolveUpdateUrl(downloadUrl);
- var sha256 = ReadZipString(data, Sha256Property);
- var releaseNotes = ReadString(data, TitleProperty);
-
- var updateAvailable = !SemanticVersion.TryParse(CurrentVersion, out var current)
- || current is null
- || latest.IsNewerThan(current);
-
- return new UpdateCheckResult
- {
- CurrentVersion = CurrentVersion,
- LatestVersion = latest.ToString(),
- UpdateAvailable = updateAvailable,
- ReleaseUrl = absoluteDownloadUrl ?? _product.ReleasesUrl,
- DownloadUrl = absoluteDownloadUrl,
- Sha256 = sha256,
- ReleaseNotes = releaseNotes,
- Status = updateAvailable ? UpdateCheckStatus.UpdateAvailable : UpdateCheckStatus.UpToDate,
- };
- }
-
- private UpdateCheckResult Invalid(string message) =>
- UpdateCheckResult.Error(CurrentVersion, UpdateCheckStatus.InvalidData, message);
-
- private static string? ReadZipString(JsonElement root, string propertyName)
- {
- if (!root.TryGetProperty(ZipProperty, out var zip) || zip.ValueKind != JsonValueKind.Object)
- {
- return null;
- }
-
- return ReadString(zip, propertyName);
- }
-
- private static string? ReadString(JsonElement root, string propertyName) =>
- root.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
- ? value.GetString()
- : null;
-
- private string? ResolveUpdateUrl(string? url)
- {
- if (string.IsNullOrWhiteSpace(url))
- {
- return null;
- }
-
- var trimmedUrl = url.Trim();
- var baseUri = new Uri(_product.UpdateServerBaseUrl.TrimEnd('/') + "/");
- if (trimmedUrl.StartsWith("/", StringComparison.Ordinal) && !trimmedUrl.StartsWith("//", StringComparison.Ordinal))
- {
- return new Uri(baseUri, trimmedUrl.TrimStart('/')).ToString();
- }
-
- if (Uri.TryCreate(trimmedUrl, UriKind.Absolute, out var absolute))
- {
- return absolute.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
- || absolute.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
- ? absolute.ToString()
- : null;
- }
-
- return new Uri(baseUri, trimmedUrl).ToString();
- }
-}
diff --git a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
index 5ca6ec0..580de18 100644
--- a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
+++ b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
@@ -1,55 +1,56 @@
-using AndroidTreeView.Adb.Services;
-using AndroidTreeView.Core.Interfaces;
-using AndroidTreeView.Core.Options;
-using AndroidTreeView.Core.Services;
-using AndroidTreeView.Infrastructure.Settings;
-using AndroidTreeView.Infrastructure.Update;
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.DependencyInjection.Extensions;
-
-namespace AndroidTreeView.Shared;
-
-///
-/// Shared composition root for everything the full app and Mini should run identically: ADB discovery,
-/// device monitoring, scrcpy launching, settings, update checks and update installation.
-///
-public static class AndroidTreeViewSharedServices
-{
- public static IServiceCollection AddAndroidTreeViewSharedServices(
- this IServiceCollection services,
- UpdateProductOptions? updateProduct = null)
- {
- ArgumentNullException.ThrowIfNull(services);
-
- updateProduct ??= UpdateProductOptions.ForMainApp();
-
- services.AddLogging();
- services.TryAddSingleton(updateProduct);
- services.TryAddSingleton(sp => CreateHttpClient(sp.GetRequiredService()));
- services.TryAddSingleton();
-
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
- services.TryAddSingleton();
-
- return services;
- }
-
- private static HttpClient CreateHttpClient(UpdateProductOptions product)
- {
- var client = new HttpClient();
- client.DefaultRequestHeaders.UserAgent.ParseAdd($"{product.Name.Replace(' ', '-')}/{product.Version}");
- return client;
- }
-}
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Options;
+using AndroidTreeView.Core.Services;
+using AndroidTreeView.Infrastructure.Settings;
+using AndroidTreeView.Infrastructure.Update;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace AndroidTreeView.Shared;
+
+///
+/// Shared composition root for everything the full app and Mini should run identically: ADB discovery,
+/// device monitoring, scrcpy launching, settings, update checks and update installation.
+///
+public static class AndroidTreeViewSharedServices
+{
+ public static IServiceCollection AddAndroidTreeViewSharedServices(
+ this IServiceCollection services,
+ UpdateProductOptions? updateProduct = null)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+
+ updateProduct ??= UpdateProductOptions.ForMainApp();
+
+ services.AddLogging();
+ services.TryAddSingleton(updateProduct);
+ services.TryAddSingleton(sp => CreateHttpClient(sp.GetRequiredService()));
+ services.TryAddSingleton();
+
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+ services.TryAddSingleton();
+
+ return services;
+ }
+
+ private static HttpClient CreateHttpClient(UpdateProductOptions product)
+ {
+ var client = new HttpClient();
+ client.DefaultRequestHeaders.UserAgent.ParseAdd($"{product.Name.Replace(' ', '-')}/{product.Version}");
+ return client;
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs b/tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs
new file mode 100644
index 0000000..c504f99
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs
@@ -0,0 +1,138 @@
+using AndroidTreeView.Adb.Services;
+using AndroidTreeView.Adb.Tests.TestDoubles;
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Adb.Tests.Services;
+
+public sealed class FastbootServiceTests : IDisposable
+{
+ private readonly string _toolsDirectory = Path.Combine(
+ Path.GetTempPath(),
+ $"AndroidTreeView-fastboot-tests-{Guid.NewGuid():N}");
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenFastbootIsMissing_ReturnsEmptyWithoutRunningCommand()
+ {
+ Directory.CreateDirectory(_toolsDirectory);
+ var runner = new FakeExternalCommandRunner();
+ var service = BuildService(runner);
+
+ var serials = await service.ListSerialsAsync();
+
+ Assert.Empty(serials);
+ Assert.Empty(runner.Requests);
+ }
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenCommandTimesOut_ReturnsEmptyAndPreservesRequestArguments()
+ {
+ var fastbootPath = CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ runner.Enqueue(new ExternalCommandResult { ExitCode = -1, TimedOut = true });
+ var service = BuildService(runner);
+
+ var serials = await service.ListSerialsAsync();
+
+ Assert.Empty(serials);
+ var request = Assert.Single(runner.Requests);
+ Assert.Equal(fastbootPath, request.FileName);
+ Assert.Equal(new[] { "devices" }, request.Arguments);
+ Assert.Equal(TimeSpan.FromSeconds(4), request.Timeout);
+ }
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenCommandExitsNonZero_ReturnsEmpty()
+ {
+ CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ runner.Enqueue(new ExternalCommandResult
+ {
+ ExitCode = 1,
+ StandardOutput = "serial-ignored\tfastboot\n",
+ StandardError = "failed"
+ });
+ var service = BuildService(runner);
+
+ var serials = await service.ListSerialsAsync();
+
+ Assert.Empty(serials);
+ }
+
+ [Fact]
+ public async Task GetVariablesAsync_ParsesFastbootStderrAndTargetsRequestedSerial()
+ {
+ CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ runner.Enqueue(new ExternalCommandResult
+ {
+ ExitCode = 0,
+ StandardError = "(bootloader) product: devon\n(bootloader) unlocked: yes\nFinished. Total time: 0.001s"
+ });
+ var service = BuildService(runner);
+
+ var variables = await service.GetVariablesAsync("device-123");
+
+ Assert.Equal("devon", variables["product"]);
+ Assert.Equal("yes", variables["unlocked"]);
+ var request = Assert.Single(runner.Requests);
+ Assert.Equal(new[] { "-s", "device-123", "getvar", "all" }, request.Arguments);
+ }
+
+ [Fact]
+ public async Task ListSerialsAsync_WhenCancelled_PropagatesCancellation()
+ {
+ CreateFastbootFile();
+ var runner = new FakeExternalCommandRunner();
+ var service = BuildService(runner);
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Assert.ThrowsAnyAsync(
+ () => service.ListSerialsAsync(cts.Token));
+ }
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_toolsDirectory))
+ {
+ Directory.Delete(_toolsDirectory, recursive: true);
+ }
+ }
+
+ private FastbootService BuildService(FakeExternalCommandRunner runner)
+ {
+ var adbPath = Path.Combine(_toolsDirectory, OperatingSystem.IsWindows() ? "adb.exe" : "adb");
+ var environment = new TestAdbEnvironment(adbPath);
+ return new FastbootService(environment, runner, NullLogger.Instance);
+ }
+
+ private string CreateFastbootFile()
+ {
+ Directory.CreateDirectory(_toolsDirectory);
+ var path = Path.Combine(
+ _toolsDirectory,
+ OperatingSystem.IsWindows() ? "fastboot.exe" : "fastboot");
+ File.WriteAllText(path, "test executable placeholder");
+ return path;
+ }
+
+ private sealed class TestAdbEnvironment(string executablePath) : IAdbEnvironment
+ {
+ public bool IsAvailable => true;
+
+ public AdbLocation? Location { get; } = new()
+ {
+ ExecutablePath = executablePath,
+ Source = AdbLocationSource.Configured
+ };
+
+ public string ExecutablePath => executablePath;
+
+ public event EventHandler? Changed { add { } remove { } }
+
+ public void Set(AdbLocation? location) => throw new NotSupportedException();
+ }
+}
diff --git a/tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs b/tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs
new file mode 100644
index 0000000..48a0a29
--- /dev/null
+++ b/tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs
@@ -0,0 +1,35 @@
+using AndroidTreeView.Core.Interfaces;
+using AndroidTreeView.Core.Services;
+
+namespace AndroidTreeView.Adb.Tests.TestDoubles;
+
+internal sealed class FakeExternalCommandRunner : IExternalCommandRunner
+{
+ private readonly Queue _results = new();
+ private readonly List _requests = [];
+
+ public IReadOnlyList Requests => _requests;
+
+ public Exception? Exception { get; set; }
+
+ public void Enqueue(ExternalCommandResult result) => _results.Enqueue(result);
+
+ public Task RunAsync(
+ ExternalCommandRequest request,
+ CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ _requests.Add(request);
+
+ if (Exception is not null)
+ {
+ return Task.FromException(Exception);
+ }
+
+ var result = _results.Count > 0
+ ? _results.Dequeue()
+ : new ExternalCommandResult { ExitCode = 0 };
+
+ return Task.FromResult(result);
+ }
+}
diff --git a/tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs b/tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs
new file mode 100644
index 0000000..1731745
--- /dev/null
+++ b/tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs
@@ -0,0 +1,191 @@
+using System.Net;
+using System.Reflection;
+using System.Text;
+using AndroidTreeView.Core;
+using AndroidTreeView.Core.Options;
+using AndroidTreeView.Infrastructure.Update;
+using AndroidTreeView.Models;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace AndroidTreeView.Infrastructure.Tests;
+
+public sealed class GitHubUpdateServiceTests
+{
+ private static GitHubUpdateService CreateService(
+ FakeHttpMessageHandler handler,
+ out FakeSettingsService settings,
+ string version = "1.0.6",
+ string? rid = "win-x64",
+ string assetPrefix = "AndroidTreeView")
+ {
+ settings = new FakeSettingsService();
+ var product = new UpdateProductOptions
+ {
+ Version = version,
+ AppKey = assetPrefix == "AndroidTreeView-Mini" ? AppInfo.MiniUpdateKey : AppInfo.AppUpdateKey,
+ LatestReleaseApiUrl = AppInfo.LatestReleaseApiUrl,
+ ReleasesUrl = AppInfo.ReleasesUrl,
+ ReleaseAssetPrefix = assetPrefix,
+ ReleaseAssetRid = rid,
+ };
+ return new GitHubUpdateService(
+ new HttpClient(handler),
+ settings,
+ NullLogger.Instance,
+ product);
+ }
+
+ private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) =>
+ new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
+
+ private static string ReleaseJson(string tag, string prefix = "AndroidTreeView") =>
+ $$"""
+ {
+ "tag_name": "{{tag}}",
+ "html_url": "https://github.com/Birditch/AndroidTreeView/releases/tag/{{tag}}",
+ "body": "Release notes for {{tag}}.",
+ "assets": [
+ {
+ "name": "{{prefix}}-2.5.1-win-x64.zip",
+ "browser_download_url": "https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/{{prefix}}-2.5.1-win-x64.zip"
+ },
+ {
+ "name": "{{prefix}}-2.5.1-win-x64.zip.sha256",
+ "browser_download_url": "https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/{{prefix}}-2.5.1-win-x64.zip.sha256"
+ }
+ ]
+ }
+ """;
+
+ [Fact]
+ public void Version_ComesFromAssemblyInformationalVersion()
+ {
+ var assembly = typeof(AppInfo).Assembly;
+ var expected = assembly.GetCustomAttribute()!.InformationalVersion;
+ Assert.Equal(expected.Split('+')[0], AppInfo.GetVersion(assembly));
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_SameVersion_ReturnsUpToDate()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v1.0.6")));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.UpToDate, result.Status);
+ Assert.False(result.UpdateAvailable);
+ Assert.Equal("1.0.6", result.LatestVersion);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_NewerVersion_UsesGitHubReleaseAssets()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1")));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.UpdateAvailable, result.Status);
+ Assert.True(result.UpdateAvailable);
+ Assert.Equal("2.5.1", result.LatestVersion);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/tag/v2.5.1", result.ReleaseUrl);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/AndroidTreeView-2.5.1-win-x64.zip", result.DownloadUrl);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/download/v2.5.1/AndroidTreeView-2.5.1-win-x64.zip.sha256", result.Sha256Url);
+ Assert.Equal("Release notes for v2.5.1.", result.ReleaseNotes);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_Mini_UsesMiniAsset()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1", "AndroidTreeView-Mini")));
+ var service = CreateService(handler, out _, assetPrefix: "AndroidTreeView-Mini");
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Contains("AndroidTreeView-Mini-2.5.1-win-x64.zip", result.DownloadUrl);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_NonWindows_LeavesInstallDisabled()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1")));
+ var service = CreateService(handler, out _, rid: null);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.True(result.UpdateAvailable);
+ Assert.Null(result.DownloadUrl);
+ Assert.Equal("https://github.com/Birditch/AndroidTreeView/releases/tag/v2.5.1", result.ReleaseUrl);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_SendsRequiredGitHubHeaders()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("v1.0.6")));
+ var service = CreateService(handler, out _);
+
+ await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.NotNull(handler.LastRequest);
+ Assert.True(handler.LastRequest!.Headers.UserAgent.Count > 0);
+ Assert.Contains(handler.LastRequest.Headers.Accept, h => h.MediaType == "application/vnd.github+json");
+ Assert.Equal(AppInfo.LatestReleaseApiUrl, handler.LastRequest.RequestUri!.ToString());
+ }
+
+ [Theory]
+ [InlineData(HttpStatusCode.NotFound, UpdateCheckStatus.NoRelease)]
+ [InlineData(HttpStatusCode.Forbidden, UpdateCheckStatus.RateLimited)]
+ [InlineData(HttpStatusCode.InternalServerError, UpdateCheckStatus.NetworkError)]
+ public async Task CheckForUpdates_HttpFailure_ReturnsExpectedStatus(HttpStatusCode status, UpdateCheckStatus expected)
+ {
+ var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(status));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(expected, result.Status);
+ Assert.False(result.UpdateAvailable);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_InvalidTag_ReturnsInvalidData()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, ReleaseJson("not-a-version")));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_MalformedJson_ReturnsInvalidData()
+ {
+ var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{ broken"));
+ var service = CreateService(handler, out _);
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: true);
+
+ Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
+ }
+
+ [Fact]
+ public async Task CheckForUpdates_DisabledAndNotUserInitiated_SkipsRequest()
+ {
+ var called = false;
+ var handler = new FakeHttpMessageHandler(_ =>
+ {
+ called = true;
+ return JsonResponse(HttpStatusCode.OK, ReleaseJson("v2.5.1"));
+ });
+ var service = CreateService(handler, out var settings);
+ settings.Current = new() { AutoCheckUpdates = false };
+
+ var result = await service.CheckForUpdatesAsync(userInitiated: false);
+
+ Assert.Equal(UpdateCheckStatus.Disabled, result.Status);
+ Assert.False(called);
+ }
+}
diff --git a/tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs b/tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs
deleted file mode 100644
index e0ecca1..0000000
--- a/tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs
+++ /dev/null
@@ -1,233 +0,0 @@
-using System.Net;
-using System.Text;
-using AndroidTreeView.Core;
-using AndroidTreeView.Core.Options;
-using AndroidTreeView.Infrastructure.Update;
-using AndroidTreeView.Models;
-using Microsoft.Extensions.Logging.Abstractions;
-using Xunit;
-
-namespace AndroidTreeView.Infrastructure.Tests;
-
-public sealed class NekoIndexUpdateServiceTests
-{
- private static NekoIndexUpdateService CreateService(
- FakeHttpMessageHandler handler,
- out FakeSettingsService settings)
- {
- settings = new FakeSettingsService();
- var client = new HttpClient(handler);
- return new NekoIndexUpdateService(client, settings, NullLogger.Instance);
- }
-
- private static HttpResponseMessage JsonResponse(HttpStatusCode status, string body) =>
- new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") };
-
- private static string UpdateJson(
- string version,
- string downloadUrl = "/api/resources/android-tree-view-app/versions/latest/archive",
- string sha256 = "0123456789abcdef",
- string appKey = AppInfo.AppUpdateKey)
- => $$"""
- {
- "ok": true,
- "data": {
- "appKey": "{{appKey}}",
- "title": "AndroidTreeView",
- "version": "{{version}}",
- "releasedAt": "2026-07-08T07:40:04.829Z",
- "zip": {
- "size": 1344,
- "sha256": "{{sha256}}",
- "downloadUrl": "{{downloadUrl}}"
- },
- "files": []
- },
- "error": null
- }
- """;
-
- [Fact]
- public void CurrentVersion_MatchesAppInfo()
- {
- var service = CreateService(new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("1.0.0"))), out _);
- Assert.Equal(AppInfo.Version, service.CurrentVersion);
- }
-
- [Fact]
- public async Task CheckForUpdates_SameVersion_ReturnsUpToDate()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson(AppInfo.Version)));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.UpToDate, result.Status);
- Assert.False(result.UpdateAvailable);
- Assert.Equal(AppInfo.Version, result.LatestVersion);
- }
-
- [Fact]
- public async Task CheckForUpdates_NewerVersion_ReturnsUpdateAvailable()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("v2.5.1")));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.UpdateAvailable, result.Status);
- Assert.True(result.UpdateAvailable);
- Assert.Equal("2.5.1", result.LatestVersion);
- Assert.Equal("http://192.168.89.71:14000/api/resources/android-tree-view-app/versions/latest/archive", result.ReleaseUrl);
- Assert.Equal(result.ReleaseUrl, result.DownloadUrl);
- Assert.Equal("0123456789abcdef", result.Sha256);
- Assert.Equal("AndroidTreeView", result.ReleaseNotes);
- }
-
- [Fact]
- public async Task CheckForUpdates_SendsRequiredInternalApiHeaders()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("1.0.0")));
- var service = CreateService(handler, out _);
-
- await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.NotNull(handler.LastRequest);
- Assert.True(handler.LastRequest!.Headers.UserAgent.Count > 0);
- Assert.Contains(handler.LastRequest.Headers.Accept, h => h.MediaType == "application/json");
- Assert.Equal(AppInfo.LatestReleaseApiUrl, handler.LastRequest.RequestUri!.ToString());
- }
-
- [Fact]
- public async Task CheckForUpdates_UsesConfiguredMiniUpdateChannel()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(
- HttpStatusCode.OK,
- UpdateJson("2.0.0", appKey: AppInfo.MiniUpdateKey)));
- var settings = new FakeSettingsService();
- var product = UpdateProductOptions.ForMiniApp();
- var service = new NekoIndexUpdateService(
- new HttpClient(handler),
- settings,
- NullLogger.Instance,
- product);
-
- await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(product.LatestReleaseApiUrl, handler.LastRequest!.RequestUri!.ToString());
- }
-
- [Fact]
- public async Task CheckForUpdates_MismatchedAppKey_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(
- HttpStatusCode.OK,
- UpdateJson("2.0.0", appKey: AppInfo.MiniUpdateKey)));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- Assert.False(result.UpdateAvailable);
- }
-
- [Fact]
- public async Task CheckForUpdates_Forbidden_ReturnsRateLimited()
- {
- var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.RateLimited, result.Status);
- Assert.False(result.UpdateAvailable);
- }
-
- [Fact]
- public async Task CheckForUpdates_NotFound_ReturnsNoRelease()
- {
- var handler = new FakeHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.NoRelease, result.Status);
- }
-
- [Fact]
- public async Task CheckForUpdates_NetworkFailure_ReturnsNetworkError()
- {
- var handler = new FakeHttpMessageHandler(_ => throw new HttpRequestException("connection refused"));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.NetworkError, result.Status);
- }
-
- [Fact]
- public async Task CheckForUpdates_InvalidVersion_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("not-a-version")));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- Assert.False(result.UpdateAvailable);
- }
-
- [Fact]
- public async Task CheckForUpdates_OkFalse_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, """{"ok":false,"data":null,"error":"missing"}"""));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- Assert.Equal("missing", result.ErrorMessage);
- }
-
- [Fact]
- public async Task CheckForUpdates_MalformedJson_ReturnsInvalidData()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, "{ broken"));
- var service = CreateService(handler, out _);
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.InvalidData, result.Status);
- }
-
- [Fact]
- public async Task CheckForUpdates_DisabledAndNotUserInitiated_ReturnsDisabledWithoutHttpCall()
- {
- var called = false;
- var handler = new FakeHttpMessageHandler(_ =>
- {
- called = true;
- return JsonResponse(HttpStatusCode.OK, UpdateJson("9.9.9"));
- });
- var service = CreateService(handler, out var settings);
- settings.Current = new AppSettings { AutoCheckUpdates = false };
-
- var result = await service.CheckForUpdatesAsync(userInitiated: false);
-
- Assert.Equal(UpdateCheckStatus.Disabled, result.Status);
- Assert.Equal(AppInfo.Version, result.CurrentVersion);
- Assert.False(called);
- }
-
- [Fact]
- public async Task CheckForUpdates_DisabledButUserInitiated_StillChecks()
- {
- var handler = new FakeHttpMessageHandler(_ => JsonResponse(HttpStatusCode.OK, UpdateJson("3.0.0")));
- var service = CreateService(handler, out var settings);
- settings.Current = new AppSettings { AutoCheckUpdates = false };
-
- var result = await service.CheckForUpdatesAsync(userInitiated: true);
-
- Assert.Equal(UpdateCheckStatus.UpdateAvailable, result.Status);
- }
-}