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)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。 +## 产品样式展示 + +![AndroidTreeView 设备总览](docs/images/product-devices-v1.0.5.png) + ## 功能 - 设备卡片总览与详情页。 @@ -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 ``` -## 许可证与致谢 - -

- MIT License - JetBrains Rider - .NET SDK - Avalonia UI -

+## 许可证 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 + +![AndroidTreeView device overview](docs/images/product-devices-v1.0.5.png) + ## 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 - -

- MIT License - JetBrains Rider - .NET SDK - Avalonia UI -

+## 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)。运行时版本、目标框架和打包配置以项目文件与发布工作流为准。 +## 产品样式展示 + +![AndroidTreeView 设备总览](docs/images/product-devices-v1.0.5.png) + ## 核心能力 - 设备卡片总览:展示设备名称、序列号、型号、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 ``` -## 许可证与致谢 - -

- MIT License - JetBrains Rider - .NET SDK - Avalonia UI -

+## 许可证 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); - } -}