From f013f32556f85610517f01b471086a59eb05a2c2 Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:43:56 +0800
Subject: [PATCH 01/10] Fix macOS CLI terminal and add app bundle icon
- CliLauncher: branch by platform. macOS now writes an equivalent .command
shell script with the same numbered menu and opens it in Terminal.app;
previously it hardcoded cmd.exe + .bat and failed silently on macOS.
- Surface launch failures via Notifier (toast) instead of only logging.
- Add cli.unsupported / cli.launchFailed strings to both ResX files.
- Packaging: generate AppIcon.icns from atv-icon.png (sips + iconutil) and
set CFBundleIconFile so the macOS .app shows an icon in Dock/Finder.
---
CLAUDE.md | 163 +++++++++++++++++
packaging/build-update-zip.ps1 | 81 ++++++++-
.../Controls/SkeletonBlock.axaml | 41 +++--
.../Controls/SkeletonBlock.axaml.cs | 15 ++
.../Resources/Strings.resx | 6 +
.../Resources/Strings.zh-Hans.resx | 6 +
.../Services/CliLauncher.cs | 164 ++++++++++++++++--
.../ViewModels/MainWindowViewModel.cs | 43 +++--
8 files changed, 471 insertions(+), 48 deletions(-)
create mode 100644 CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..4dafdeb
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,163 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.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 215 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 (`NekoIndexUpdateService`) 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.5`). Update channels:
+`android-tree-view-app` (App) and `android-tree-view-mini` (Mini). `NekoIndexUpdateService` checks the
+channel and compares semver; `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/packaging/build-update-zip.ps1 b/packaging/build-update-zip.ps1
index fa688df..0b47896 100644
--- a/packaging/build-update-zip.ps1
+++ b/packaging/build-update-zip.ps1
@@ -250,6 +250,60 @@ function ConvertTo-PlistString {
return [System.Security.SecurityElement]::Escape($Value)
}
+function New-IcnsFromPng {
+ param(
+ [Parameter(Mandatory = $true)]
+ [string]$SourcePng,
+
+ [Parameter(Mandatory = $true)]
+ [string]$OutputIcns
+ )
+
+ # macOS .app icons are .icns files; the repo only ships a 256x256 PNG, so build the .icns from it
+ # with the system tools (sips resizes each slot, iconutil packs the iconset). Requires macOS.
+ if (-not (Get-Command sips -ErrorAction SilentlyContinue) -or -not (Get-Command iconutil -ErrorAction SilentlyContinue)) {
+ Write-Warning 'sips/iconutil not found; skipping macOS app icon generation.'
+ return $false
+ }
+
+ $iconsetDir = "$OutputIcns.iconset"
+ if (Test-Path -LiteralPath $iconsetDir) {
+ Remove-Item -Recurse -Force -LiteralPath $iconsetDir
+ }
+ 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.
+ $slots = [ordered]@{
+ 'icon_16x16.png' = 16
+ 'icon_16x16@2x.png' = 32
+ 'icon_32x32.png' = 32
+ 'icon_32x32@2x.png' = 64
+ 'icon_128x128.png' = 128
+ 'icon_128x128@2x.png' = 256
+ 'icon_256x256.png' = 256
+ 'icon_256x256@2x.png' = 512
+ 'icon_512x512.png' = 512
+ 'icon_512x512@2x.png' = 1024
+ }
+
+ foreach ($entry in $slots.GetEnumerator()) {
+ $target = Join-Path $iconsetDir $entry.Key
+ & sips -z $entry.Value $entry.Value $SourcePng --out $target *> $null
+ if ($LASTEXITCODE -ne 0) {
+ throw "sips failed to render '$($entry.Key)' from '$SourcePng'."
+ }
+ }
+
+ & iconutil -c icns $iconsetDir -o $OutputIcns
+ if ($LASTEXITCODE -ne 0) {
+ throw "iconutil failed to build '$OutputIcns'."
+ }
+
+ Remove-Item -Recurse -Force -LiteralPath $iconsetDir
+ return $true
+}
+
function New-MacOSAppBundle {
param(
[Parameter(Mandatory = $true)]
@@ -306,6 +360,25 @@ function New-MacOSAppBundle {
$bundleIdentifier = "com.birditch.$($ProductConfig.AppKey.Replace('-', '.'))"
$displayName = $ProductConfig.ProductName
$executableName = $ProductConfig.Executable
+
+ # Build the app icon (.icns) from the App's PNG so the bundle shows in Dock/Finder.
+ $iconSourcePng = Join-Path $repoRoot 'src/AndroidTreeView.App/Assets/atv-icon.png'
+ $iconFileName = ''
+ if (Test-Path -LiteralPath $iconSourcePng) {
+ $icnsPath = Join-Path $resourcesDir 'AppIcon.icns'
+ if (New-IcnsFromPng -SourcePng $iconSourcePng -OutputIcns $icnsPath) {
+ $iconFileName = 'AppIcon'
+ }
+ } else {
+ Write-Warning "Icon source '$iconSourcePng' not found; bundle will have no icon."
+ }
+
+ $iconPlistEntry = if ($iconFileName) {
+ " CFBundleIconFile`n $(ConvertTo-PlistString $iconFileName)`n"
+ } else {
+ ''
+ }
+
$infoPlist = @"
@@ -317,7 +390,7 @@ function New-MacOSAppBundle {
$(ConvertTo-PlistString $displayName)
CFBundleExecutable
$(ConvertTo-PlistString $executableName)
- CFBundleIdentifier
+$iconPlistEntry CFBundleIdentifier
$(ConvertTo-PlistString $bundleIdentifier)
CFBundleInfoDictionaryVersion
6.0
@@ -491,6 +564,10 @@ $zipPath = Join-Path $artifacts "$baseName.zip"
$zipChecksumPath = "$zipPath.sha256"
$packageKind = if ($ridInfo.IsWindows) { 'portable-x64' } else { "portable-$($ridInfo.Rid)" }
$bundleFastbootValue = if ($productConfig.BundleFastboot) { 'true' } else { 'false' }
+# macOS has no system-level "install .NET runtime" prompt like Windows, so a framework-dependent
+# .app silently fails to launch on machines without the runtime. Bundle the runtime into the macOS
+# .app (self-contained). Windows stays framework-dependent: its apphost shows the OS download prompt.
+$selfContainedValue = if ($ridInfo.IsMacOS) { 'true' } else { 'false' }
New-Item -ItemType Directory -Force -Path $artifacts | Out-Null
Assert-UnderDirectory -Path $publishDir -Parent $artifacts
@@ -513,7 +590,7 @@ $publishArgs = @(
$productConfig.Project,
'--configuration', $Configuration,
'--runtime', $ridInfo.Rid,
- '--self-contained', 'false',
+ '--self-contained', $selfContainedValue,
"-p:Version=$Version",
"-p:AssemblyVersion=$Version.0",
"-p:FileVersion=$Version.0",
diff --git a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml
index c0230e1..2038e5c 100644
--- a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml
+++ b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml
@@ -4,27 +4,30 @@
x:Class="AndroidTreeView.App.Controls.SkeletonBlock"
x:CompileBindings="False"
MinHeight="16">
+
+
+
+
-
-
-
-
+ CornerRadius="{Binding CornerRadius, RelativeSource={RelativeSource AncestorType=controls:SkeletonBlock}}" />
diff --git a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs
index 619a9db..50045f2 100644
--- a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs
+++ b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using Avalonia.Layout;
namespace AndroidTreeView.App.Controls;
@@ -9,5 +10,19 @@ public partial class SkeletonBlock : UserControl
public SkeletonBlock()
{
InitializeComponent();
+
+ // The shimmer is an INFINITE animation. Avalonia keeps such a clock ticking even when this
+ // control is only collapsed by a parent (the loading panel toggles IsVisible), which drives
+ // the render loop to repaint every frame — the app feels laggy and runs hot while "doing
+ // nothing". EffectiveViewportChanged fires when an ancestor's visibility collapses/expands
+ // our on-screen viewport; gate the animation on a non-empty viewport via the `:active`
+ // pseudo-class so the clock stops whenever the skeleton isn't actually shown.
+ EffectiveViewportChanged += OnEffectiveViewportChanged;
+ }
+
+ private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
+ {
+ var visible = e.EffectiveViewport.Width > 0 && e.EffectiveViewport.Height > 0;
+ PseudoClasses.Set(":active", visible);
}
}
diff --git a/src/AndroidTreeView.App/Resources/Strings.resx b/src/AndroidTreeView.App/Resources/Strings.resx
index ff3f94f..62acc49 100644
--- a/src/AndroidTreeView.App/Resources/Strings.resx
+++ b/src/AndroidTreeView.App/Resources/Strings.resx
@@ -171,6 +171,12 @@
CLI Terminal
+
+ The CLI terminal is not supported on this operating system.
+
+
+ Failed to open the CLI terminal.
+
Details
diff --git a/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx b/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
index 830a777..ee83efa 100644
--- a/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
+++ b/src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx
@@ -171,6 +171,12 @@
CLI 终端
+
+ 当前操作系统不支持 CLI 终端。
+
+
+ 打开 CLI 终端失败。
+
详情
diff --git a/src/AndroidTreeView.App/Services/CliLauncher.cs b/src/AndroidTreeView.App/Services/CliLauncher.cs
index 552c5ba..a38ad0f 100644
--- a/src/AndroidTreeView.App/Services/CliLauncher.cs
+++ b/src/AndroidTreeView.App/Services/CliLauncher.cs
@@ -27,13 +27,15 @@ public interface ICliLauncher
public sealed class CliLauncher : ICliLauncher
{
private readonly IAdbEnvironment _environment;
+ private readonly ILocalizationService _localization;
private readonly ILogger _logger;
private readonly List _processes = new();
private readonly object _gate = new();
- public CliLauncher(IAdbEnvironment environment, ILogger logger)
+ public CliLauncher(IAdbEnvironment environment, ILocalizationService localization, ILogger logger)
{
_environment = environment;
+ _localization = localization;
_logger = logger;
}
@@ -42,29 +44,81 @@ public void Open(string serial, string title, bool fastboot)
try
{
var toolsDir = ResolveToolsDir() ?? AppContext.BaseDirectory;
- var script = fastboot ? BuildFastbootMenu(serial, toolsDir) : BuildAdbMenu(serial, toolsDir);
-
var safe = new string(serial.Where(char.IsLetterOrDigit).ToArray());
- var batPath = Path.Combine(Path.GetTempPath(), $"atv-cli-{(safe.Length == 0 ? "device" : safe)}.bat");
- File.WriteAllText(batPath, script);
-
- var psi = new ProcessStartInfo
+ if (safe.Length == 0)
{
- FileName = "cmd.exe",
- Arguments = $"/K \"{batPath}\"",
- WorkingDirectory = toolsDir,
- UseShellExecute = true, // reliably opens a visible console window from a GUI process
- };
+ safe = "device";
+ }
- var process = Process.Start(psi);
- if (process is not null)
+ if (OperatingSystem.IsWindows())
+ {
+ OpenWindows(serial, safe, toolsDir, fastboot);
+ }
+ else if (OperatingSystem.IsMacOS())
+ {
+ OpenMac(serial, safe, toolsDir, fastboot);
+ }
+ else
{
- Track(process);
+ Notifier.Notify(_localization.Get("cli.unsupported"), NotifierLevel.Warning);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to open CLI terminal for {Serial}.", serial);
+ Notifier.Notify(_localization.Get("cli.launchFailed"), NotifierLevel.Error);
+ }
+ }
+
+ // Windows: write a menu .bat and open it in a cmd.exe /K console.
+ private void OpenWindows(string serial, string safe, string toolsDir, bool fastboot)
+ {
+ var script = fastboot ? BuildFastbootMenu(serial, toolsDir) : BuildAdbMenu(serial, toolsDir);
+ var batPath = Path.Combine(Path.GetTempPath(), $"atv-cli-{safe}.bat");
+ File.WriteAllText(batPath, script);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "cmd.exe",
+ Arguments = $"/K \"{batPath}\"",
+ WorkingDirectory = toolsDir,
+ UseShellExecute = true, // reliably opens a visible console window from a GUI process
+ };
+
+ var process = Process.Start(psi);
+ if (process is not null)
+ {
+ Track(process);
+ }
+ }
+
+ // macOS: write an equivalent .command shell script and open it in Terminal.app.
+ // Note: `open` returns immediately and the Terminal window is an independent process, so
+ // ShutdownAll() cannot close it — that's intentional; we don't force-close a user's terminal.
+ [System.Runtime.Versioning.SupportedOSPlatform("macos")]
+ private void OpenMac(string serial, string safe, string toolsDir, bool fastboot)
+ {
+ var script = fastboot ? BuildFastbootMenuSh(serial, toolsDir) : BuildAdbMenuSh(serial, toolsDir);
+ var scriptPath = Path.Combine(Path.GetTempPath(), $"atv-cli-{safe}.command");
+ File.WriteAllText(scriptPath, script);
+ File.SetUnixFileMode(
+ scriptPath,
+ UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
+ UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
+ UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = "open",
+ Arguments = $"-a Terminal.app \"{scriptPath}\"",
+ WorkingDirectory = toolsDir,
+ UseShellExecute = false,
+ };
+
+ var process = Process.Start(psi);
+ if (process is not null)
+ {
+ Track(process);
}
}
@@ -279,8 +333,88 @@ private static string BuildAdbMenu(string serial, string toolsDir) => Join(new[]
"exit",
});
+ // macOS fastboot menu — bash equivalent of BuildFastbootMenu (universal + non-destructive only).
+ private static string BuildFastbootMenuSh(string serial, string toolsDir) => JoinLf(new[]
+ {
+ "#!/bin/bash",
+ // ANSI colors (cyan headers / green numbers / yellow values) matching the Windows menu.
+ "H=$'\\033[96m'; N=$'\\033[92m'; V=$'\\033[93m'; R=$'\\033[0m'",
+ $"export PATH=\"{ShEscape(toolsDir)}:$PATH\"",
+ $"SERIAL=\"{ShEscape(serial)}\"",
+ "while true; do",
+ " clear",
+ " echo \"${H}================ AndroidTreeView Fastboot CLI ================${R}\"",
+ " echo \" 设备 Device: ${V}${SERIAL}${R} (fastboot / bootloader)\"",
+ " echo \"${H}-------------------------------------------------------------${R}\"",
+ " echo \" ${N}[1]${R} 设备信息 Device info (getvar all)\"",
+ " echo \" ${N}[2]${R} 重启到系统 Reboot to system\"",
+ " echo \" ${N}[3]${R} 重启到 Bootloader Reboot to bootloader\"",
+ " echo \" ${N}[4]${R} 重启到 Recovery Reboot to recovery\"",
+ " echo \" ${N}[5]${R} 关机 Power off\"",
+ " echo \" ${N}[6]${R} 切换到 A 槽 Set active slot A\"",
+ " echo \" ${N}[7]${R} 切换到 B 槽 Set active slot B\"",
+ " echo \" ${N}[9]${R} 原生命令行 Raw CLI (adb + fastboot on PATH)\"",
+ " echo \" ${N}[0]${R} 退出 Exit\"",
+ " echo \"${H}-------------------------------------------------------------${R}\"",
+ " read -r -p \"${H}输入序号并回车 Enter choice >${R} \" sel",
+ " case \"$sel\" in",
+ " 1) fastboot -s \"$SERIAL\" getvar all; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 2) fastboot -s \"$SERIAL\" reboot; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 3) fastboot -s \"$SERIAL\" reboot bootloader; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 4) fastboot -s \"$SERIAL\" reboot recovery; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 5) fastboot -s \"$SERIAL\" oem poweroff; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 6) fastboot -s \"$SERIAL\" set_active a; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 7) fastboot -s \"$SERIAL\" set_active b; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 9) echo \"adb / fastboot 已在 PATH。输入 exit 返回菜单 (type exit to return).\"; \"${SHELL:-/bin/bash}\" ;;",
+ " 0) exit 0 ;;",
+ " esac",
+ "done",
+ });
+
+ // macOS adb menu — bash equivalent of BuildAdbMenu (info / shell / logcat / reboots / power / raw).
+ private static string BuildAdbMenuSh(string serial, string toolsDir) => JoinLf(new[]
+ {
+ "#!/bin/bash",
+ "H=$'\\033[96m'; N=$'\\033[92m'; V=$'\\033[93m'; R=$'\\033[0m'",
+ $"export PATH=\"{ShEscape(toolsDir)}:$PATH\"",
+ $"SERIAL=\"{ShEscape(serial)}\"",
+ "while true; do",
+ " clear",
+ " echo \"${H}================== AndroidTreeView ADB CLI ==================${R}\"",
+ " echo \" 设备 Device: ${V}${SERIAL}${R}\"",
+ " echo \"${H}------------------------------------------------------------${R}\"",
+ " echo \" ${N}[1]${R} 设备信息 Device info (getprop)\"",
+ " echo \" ${N}[2]${R} 进入 Shell adb shell\"",
+ " echo \" ${N}[3]${R} 查看日志 Logcat (Ctrl+C 停止)\"",
+ " echo \" ${N}[4]${R} 重启 Reboot\"",
+ " echo \" ${N}[5]${R} 重启到 Bootloader Reboot to bootloader\"",
+ " echo \" ${N}[6]${R} 重启到 Recovery Reboot to recovery\"",
+ " echo \" ${N}[7]${R} 关机 Power off\"",
+ " echo \" ${N}[9]${R} 原生命令行 Raw CLI (adb + fastboot on PATH)\"",
+ " echo \" ${N}[0]${R} 退出 Exit\"",
+ " echo \"${H}------------------------------------------------------------${R}\"",
+ " read -r -p \"${H}输入序号并回车 Enter choice >${R} \" sel",
+ " case \"$sel\" in",
+ " 1) adb -s \"$SERIAL\" shell getprop; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 2) adb -s \"$SERIAL\" shell ;;",
+ " 3) adb -s \"$SERIAL\" logcat ;;",
+ " 4) adb -s \"$SERIAL\" reboot; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 5) adb -s \"$SERIAL\" reboot bootloader; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 6) adb -s \"$SERIAL\" reboot recovery; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 7) adb -s \"$SERIAL\" shell reboot -p; read -n1 -s -r -p \"按任意键返回菜单 Press any key... \" ;;",
+ " 9) echo \"adb / fastboot 已在 PATH。输入 exit 返回菜单 (type exit to return).\"; \"${SHELL:-/bin/bash}\" ;;",
+ " 0) exit 0 ;;",
+ " esac",
+ "done",
+ });
+
+ // Single-quote escape for embedding a literal into a double-quoted bash string safely.
+ private static string ShEscape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("$", "\\$").Replace("`", "\\`");
+
private static string Join(IEnumerable lines) => string.Join("\r\n", lines) + "\r\n";
+ private static string JoinLf(IEnumerable lines) => string.Join("\n", lines) + "\n";
+
private string? ResolveToolsDir()
{
var adb = _environment.Location?.ExecutablePath;
diff --git a/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs b/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
index 156d965..61ae0b2 100644
--- a/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
+++ b/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
@@ -7,6 +7,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
+using System.Linq;
namespace AndroidTreeView.App.ViewModels;
@@ -37,13 +38,19 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ILogger _logger;
// Per-device enrichment state (accessed only on the UI thread from OnDevicesChanged).
- // Identity + root are re-probed on this cadence (not just once) so a card never drifts out of sync
- // with the detail page (e.g. root becoming visible after a grant).
- private static readonly TimeSpan StaticRefreshInterval = TimeSpan.FromSeconds(8);
- private readonly Dictionary _staticEnrichedAt = new(StringComparer.Ordinal);
+ // Identity + root are static per connection: probed once when a device first appears and not
+ // re-polled on a timer. Re-probing all devices every few seconds forked a burst of adb processes
+ // (root alone is 6 probes/device) that stalled the main page when several devices were connected.
+ // A device that changes state (e.g. root granted) disconnects/reconnects or is refreshed on demand,
+ // which clears its entry and re-enriches it.
+ private readonly HashSet _staticEnriched = new(StringComparer.Ordinal);
private readonly HashSet _enriching = new(StringComparer.Ordinal);
private readonly HashSet _fastbootEnriching = new(StringComparer.Ordinal);
+ // Device-list poll interval, loaded from settings at startup (was hardcoded to 1s, which ignored
+ // the DeviceRefreshIntervalSeconds setting and hammered adb once per second per device).
+ private TimeSpan _refreshInterval = TimeSpan.FromSeconds(3);
+
private string? _releaseUrl;
private UpdateCheckResult? _latestUpdate;
private bool _monitorSubscribed;
@@ -221,6 +228,8 @@ public async Task InitializeAsync()
_themeService.Apply(settings.Theme);
_themeService.ApplyAccent(settings.AccentColor);
+ _refreshInterval = TimeSpan.FromSeconds(Math.Max(1, settings.DeviceRefreshIntervalSeconds));
+
var location = await _locator.LocateAsync(settings.AdbPath).ConfigureAwait(true);
_environment.Set(location);
@@ -239,7 +248,7 @@ public async Task InitializeAsync()
{
IsAdbAvailable = true;
AdbStatusText = _localization.Get("adb.status.ready");
- _monitor.UpdateInterval(TimeSpan.FromSeconds(1));
+ _monitor.UpdateInterval(_refreshInterval);
_monitor.Start();
CurrentContent = Devices;
}
@@ -349,6 +358,9 @@ private async Task RefreshDevicesAsync()
{
try
{
+ // A manual refresh is the user asking for fresh facts, so drop the one-time static cache
+ // to force identity/root to be re-probed on the next enrichment pass.
+ _staticEnriched.Clear();
await _monitor.RefreshNowAsync().ConfigureAwait(true);
}
catch (Exception ex)
@@ -474,7 +486,7 @@ private void OnAdbReady(object? sender, EventArgs e)
if (!_monitor.IsRunning)
{
- _monitor.UpdateInterval(TimeSpan.FromSeconds(1));
+ _monitor.UpdateInterval(_refreshInterval);
_monitor.Start();
}
@@ -509,9 +521,17 @@ private void OnLanguageChanged(object? sender, EventArgs e)
}
// Fills each online card with real data: battery every tick (fast), and static identity + root once
- // per device. A per-serial guard prevents overlapping runs from piling up under the 1 Hz monitor.
+ // per device (first time it appears). A per-serial guard prevents overlapping runs from piling up.
private void EnrichDevices(IReadOnlyList devices)
{
+ // Forget devices that dropped off the list so a later reconnect re-runs the one-time static
+ // enrichment (identity / root) instead of showing stale data.
+ if (_staticEnriched.Count > 0)
+ {
+ var present = new HashSet(devices.Select(d => d.Serial), StringComparer.Ordinal);
+ _staticEnriched.RemoveWhere(serial => !present.Contains(serial));
+ }
+
foreach (var device in devices)
{
// Fastboot devices have no OS to query over adb — read their fastboot getvar facts instead.
@@ -552,9 +572,10 @@ private async Task EnrichOneAsync(string serial)
var battery = await _deviceService.GetBatteryAsync(serial).ConfigureAwait(true);
card.ApplyBattery(battery);
- var due = !_staticEnrichedAt.TryGetValue(serial, out var last)
- || DateTimeOffset.Now - last > StaticRefreshInterval;
- if (due)
+ // Static identity + root are probed once per connection, not on a timer. Re-probing every
+ // device every few seconds was the main-page stall: root alone forks 6 adb processes per
+ // device, so N devices produced a periodic burst of dozens of concurrent adb calls.
+ if (_staticEnriched.Add(serial))
{
var overview = await _deviceService.GetOverviewAsync(serial).ConfigureAwait(true);
card.ApplyOverview(overview);
@@ -563,8 +584,6 @@ private async Task EnrichOneAsync(string serial)
card.ApplyRoot(root);
await card.RefreshActionStateAsync().ConfigureAwait(true);
-
- _staticEnrichedAt[serial] = DateTimeOffset.Now;
}
}
catch (Exception ex)
From d5c82c4d3ab7c1ef01b1f9a1e83716b260c88b3f Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:49:37 +0800
Subject: [PATCH 02/10] Bump version to 1.0.6
Sync version across AppInfo, App/Mini/Mini.Mac project metadata,
app.manifest, packaging scripts, wix, docs, and README.
---
.github/workflows/publish.yml | 2 +-
CLAUDE.md | 2 +-
README-CN.md | 14 ++++----
README.en.md | 14 ++++----
README.md | 14 ++++----
docs/app-contract.md | 2 +-
docs/packaging.md | 24 ++++++-------
docs/publishing.md | 34 +++++++++----------
docs/requirements-v1.md | 2 +-
packaging/AndroidTreeView.Package.wixproj | 6 ++--
packaging/Bundle.wxs | 6 ++--
packaging/Product.wxs | 4 +--
packaging/build-msi.ps1 | 4 +--
packaging/build-update-zip.ps1 | 2 +-
.../AndroidTreeView.App.csproj | 8 ++---
src/AndroidTreeView.App/app.manifest | 2 +-
src/AndroidTreeView.Core/AppInfo.cs | 2 +-
.../Interfaces/IUpdateService.cs | 2 +-
.../AndroidTreeView.Mini.Mac.csproj | 8 ++---
.../AndroidTreeView.Mini.csproj | 8 ++---
.../UpdateCheckResult.cs | 2 +-
21 files changed, 81 insertions(+), 81 deletions(-)
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 98b3de5..af90abb 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
version:
- description: "Release version, for example 1.0.5"
+ description: "Release version, for example 1.0.6"
required: true
type: string
diff --git a/CLAUDE.md b/CLAUDE.md
index 4dafdeb..1bdf281 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -152,7 +152,7 @@ Fixtures are **inline `const` strings** inside the test classes, not external `.
## 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.5`). Update channels:
+`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). `NekoIndexUpdateService` checks the
channel and compares semver; `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.
diff --git a/README-CN.md b/README-CN.md
index d84bcdd..ce942d7 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -9,11 +9,11 @@
AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的 Windows 桌面工具。主程序负责设备总览、详情、投屏、基础工具和设置;Mini 版本保持独立运行,常驻监听设备并自动投屏。
-当前版本:**v1.0.5**。
+当前版本:**v1.0.6**。
## 产品样式展示
-
+
## 功能
@@ -61,13 +61,13 @@ ADB 安装与排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
./packaging/build-update-zip.ps1 -Product Mini -Rid win-x64
```
-默认产物版本是 `1.0.5`。只发布 x64 架构。验证输出包含主程序和 Mini 的上传 ZIP,例如:
+默认产物版本是 `1.0.6`。只发布 x64 架构。验证输出包含主程序和 Mini 的上传 ZIP,例如:
```text
-artifacts/AndroidTreeView-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-1.0.5-osx-arm64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-osx-arm64.zip
+artifacts/AndroidTreeView-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-1.0.6-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip
```
更多细节见 [docs/packaging.md](docs/packaging.md)。
diff --git a/README.en.md b/README.en.md
index 3865168..c72aaae 100644
--- a/README.en.md
+++ b/README.en.md
@@ -9,11 +9,11 @@
AndroidTreeView is a desktop tool for inspecting, testing, and managing Android devices through ADB. The full app shows device cards, detail pages, mirroring, tools, settings, and updates. The Mini app stays resident, watches for devices, and starts mirroring automatically after authorization.
-Current version: **v1.0.5**. Current verification target: solution build passes, all tests pass, and GitHub Actions creates Windows x64 and macOS Apple Silicon ZIP artifacts for both App and Mini.
+Current version: **v1.0.6**. Current verification target: solution build passes, all tests pass, and GitHub Actions creates Windows x64 and macOS Apple Silicon ZIP artifacts for both App and Mini.
## Product Preview
-
+
## Features
@@ -78,7 +78,7 @@ See [docs/adb-requirements.md](docs/adb-requirements.md) for platform-tools setu
## Release ZIP Packaging
-The product version is currently `1.0.5` and is kept in sync across runtime version, App/Mini assembly metadata, manifest, and the ZIP build script. Official releases are produced only by the GitHub Actions `Publish` workflow.
+The product version is currently `1.0.6` and is kept in sync across runtime version, App/Mini assembly metadata, manifest, and the ZIP build script. Official releases are produced only by the GitHub Actions `Publish` workflow.
Local commands are for packaging validation only:
@@ -90,10 +90,10 @@ Local commands are for packaging validation only:
Example output:
```text
-artifacts/AndroidTreeView-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-1.0.5-osx-arm64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-osx-arm64.zip
+artifacts/AndroidTreeView-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-1.0.6-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip
```
## Auto Update
diff --git a/README.md b/README.md
index 441c240..b2e08ac 100644
--- a/README.md
+++ b/README.md
@@ -9,11 +9,11 @@
AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的 Windows 桌面工具。主程序负责设备总览、详情、投屏、基础工具、设置和更新;Mini 版本保持独立运行,常驻监听设备,并在授权后自动启动投屏。
-当前版本:**v1.0.5**。当前验证目标:App 构建通过、Mini 构建通过、全量测试通过,打包链路能为 App 和 Mini 分别生成 x64 上传 ZIP。
+当前版本:**v1.0.6**。当前验证目标:App 构建通过、Mini 构建通过、全量测试通过,打包链路能为 App 和 Mini 分别生成 x64 上传 ZIP。
## 产品样式展示
-
+
## 核心能力
@@ -78,7 +78,7 @@ ADB 安装和排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
## Release ZIP 打包
-当前版本号统一为 `1.0.5`,运行时版本、App/Mini 程序集版本、manifest 和 `build-update-zip.ps1` 默认版本保持一致。正式发布只通过 GitHub Actions 的 `Publish` 工作流完成,发布只接受 x64。
+当前版本号统一为 `1.0.6`,运行时版本、App/Mini 程序集版本、manifest 和 `build-update-zip.ps1` 默认版本保持一致。正式发布只通过 GitHub Actions 的 `Publish` 工作流完成,发布只接受 x64。
本地命令仅用于验证打包链路:
@@ -90,10 +90,10 @@ ADB 安装和排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
示例输出:
```text
-artifacts/AndroidTreeView-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-1.0.5-osx-arm64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-osx-arm64.zip
+artifacts/AndroidTreeView-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-1.0.6-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip
```
## 自动更新
diff --git a/docs/app-contract.md b/docs/app-contract.md
index 5e79538..0f9d351 100644
--- a/docs/app-contract.md
+++ b/docs/app-contract.md
@@ -2,7 +2,7 @@
This document records the App-layer contract for the full app and the Mini companion.
-Current product version: `1.0.5`.
+Current product version: `1.0.6`.
## Shared Services
diff --git a/docs/packaging.md b/docs/packaging.md
index 3cc2a6d..2c2d9f0 100644
--- a/docs/packaging.md
+++ b/docs/packaging.md
@@ -2,7 +2,7 @@
Packaging files live under `packaging/`.
-Current default product version: `1.0.5`.
+Current default product version: `1.0.6`.
Official release artifacts are created by GitHub Actions (`.github/workflows/publish.yml`) only. Local packaging commands are validation helpers and must not be used as the authority for publishing a release.
@@ -11,14 +11,14 @@ Official release artifacts are created by GitHub Actions (`.github/workflows/pub
Every GitHub Release must contain exactly these four ZIP packages plus matching `.sha256` sidecars:
```text
-artifacts/AndroidTreeView-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-1.0.5-win-x64.zip.sha256
-artifacts/AndroidTreeView-1.0.5-osx-arm64.zip
-artifacts/AndroidTreeView-1.0.5-osx-arm64.zip.sha256
-artifacts/AndroidTreeView-Mini-1.0.5-win-x64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-win-x64.zip.sha256
-artifacts/AndroidTreeView-Mini-1.0.5-osx-arm64.zip
-artifacts/AndroidTreeView-Mini-1.0.5-osx-arm64.zip.sha256
+artifacts/AndroidTreeView-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-1.0.6-win-x64.zip.sha256
+artifacts/AndroidTreeView-1.0.6-osx-arm64.zip
+artifacts/AndroidTreeView-1.0.6-osx-arm64.zip.sha256
+artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-win-x64.zip.sha256
+artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip
+artifacts/AndroidTreeView-Mini-1.0.6-osx-arm64.zip.sha256
```
Windows ZIPs contain the published application files, the platform-matched `scrcpy` bundle, and `release.json` at the ZIP root. macOS ZIPs contain a top-level `.app` bundle (`AndroidTreeView.app` or `AndroidTreeView Mini.app`); the published files, `scrcpy`, and `release.json` live inside the bundle.
@@ -74,7 +74,7 @@ The updater uses `release.json` to distinguish an automated release ZIP from a r
"product": "App",
"productName": "AndroidTreeView",
"appKey": "android-tree-view-app",
- "version": "1.0.5",
+ "version": "1.0.6",
"platform": "win",
"arch": "x64",
"rid": "win-x64",
@@ -102,8 +102,8 @@ The WiX project rejects non-x64 platforms.
`build-update-zip.ps1` writes checksums automatically. Manual verification:
```powershell
-Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-1.0.5-win-x64.zip
-Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-Mini-1.0.5-win-x64.zip
+Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-1.0.6-win-x64.zip
+Get-FileHash -Algorithm SHA256 artifacts\AndroidTreeView-Mini-1.0.6-win-x64.zip
```
The sidecar uses ` *` format for compatibility with `sha256sum -c`.
diff --git a/docs/publishing.md b/docs/publishing.md
index 63585c5..cbe0b4f 100644
--- a/docs/publishing.md
+++ b/docs/publishing.md
@@ -2,7 +2,7 @@
Repository: `Birditch/AndroidTreeView`.
-Current product version: `1.0.5`.
+Current product version: `1.0.6`.
## Release Policy
@@ -37,24 +37,24 @@ Keep these values identical for every release:
- `src/AndroidTreeView.App/app.manifest` -> `assemblyIdentity version`
- `packaging/build-update-zip.ps1` -> default `Version`
-For `1.0.5`, release artifacts are named:
+For `1.0.6`, release artifacts are named:
```text
-AndroidTreeView-1.0.5-win-x64.zip
-AndroidTreeView-1.0.5-win-x64.zip.sha256
-AndroidTreeView-1.0.5-osx-arm64.zip
-AndroidTreeView-1.0.5-osx-arm64.zip.sha256
-AndroidTreeView-Mini-1.0.5-win-x64.zip
-AndroidTreeView-Mini-1.0.5-win-x64.zip.sha256
-AndroidTreeView-Mini-1.0.5-osx-arm64.zip
-AndroidTreeView-Mini-1.0.5-osx-arm64.zip.sha256
+AndroidTreeView-1.0.6-win-x64.zip
+AndroidTreeView-1.0.6-win-x64.zip.sha256
+AndroidTreeView-1.0.6-osx-arm64.zip
+AndroidTreeView-1.0.6-osx-arm64.zip.sha256
+AndroidTreeView-Mini-1.0.6-win-x64.zip
+AndroidTreeView-Mini-1.0.6-win-x64.zip.sha256
+AndroidTreeView-Mini-1.0.6-osx-arm64.zip
+AndroidTreeView-Mini-1.0.6-osx-arm64.zip.sha256
```
## Build Upload ZIP Packages
Official publication happens through the `Publish` GitHub Actions workflow:
-- push a tag named `v..`, for example `v1.0.5`
+- push a tag named `v..`, for example `v1.0.6`
- or run `workflow_dispatch` and provide the same version string
The workflow validates on Windows, verifies the pinned scrcpy release against the latest upstream release, builds the two Windows portable ZIPs and two macOS `.app` bundle ZIPs, writes SHA-256 sidecars, uploads workflow artifacts, and creates the GitHub Release.
@@ -77,8 +77,8 @@ The full app and Mini share the same update implementation but use different app
The Windows update channel must point each product key at its own Windows ZIP:
-- `android-tree-view-app` -> `AndroidTreeView-1.0.5-win-x64.zip`
-- `android-tree-view-mini` -> `AndroidTreeView-Mini-1.0.5-win-x64.zip`
+- `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.
@@ -97,7 +97,7 @@ Minimum response contract:
"data": {
"appKey": "android-tree-view-app",
"title": "AndroidTreeView",
- "version": "1.0.5",
+ "version": "1.0.6",
"zip": {
"sha256": "<64-character sha256>",
"downloadUrl": "/api/resources/android-tree-view-app/versions/latest/archive"
@@ -114,11 +114,11 @@ Minimum response contract:
Use this flow for the intranet update server:
-1. Push a `v1.0.5` tag or run the `Publish` GitHub Actions workflow manually.
+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.5-win-x64.zip` metadata.
-5. Configure `/api/update/android-tree-view-mini/latest` to return `AndroidTreeView-Mini-1.0.5-win-x64.zip` metadata.
+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.
diff --git a/docs/requirements-v1.md b/docs/requirements-v1.md
index bf4147f..179ee4a 100644
--- a/docs/requirements-v1.md
+++ b/docs/requirements-v1.md
@@ -1,6 +1,6 @@
# AndroidTreeView Requirements Addendum
-Current product version: `1.0.5`.
+Current product version: `1.0.6`.
This addendum extends `docs/architecture.md` and reflects the current App + Mini direction.
diff --git a/packaging/AndroidTreeView.Package.wixproj b/packaging/AndroidTreeView.Package.wixproj
index bcf193e..27147e4 100644
--- a/packaging/AndroidTreeView.Package.wixproj
+++ b/packaging/AndroidTreeView.Package.wixproj
@@ -9,7 +9,7 @@
app and then invokes this project with the right properties, e.g.:
dotnet build packaging/AndroidTreeView.Package.wixproj -c Release ^
- -p:Platform=x64 -p:ProductVersion=1.0.5 -p:SelfContained=false ^
+ -p:Platform=x64 -p:ProductVersion=1.0.6 -p:SelfContained=false ^
-p:PublishDir=
Requires the WiX v5 SDK, restored automatically from nuget.org via the Sdk attribute.
@@ -23,7 +23,7 @@
- 1.0.5
+ 1.0.6
AndroidTreeView
AndroidTreeView.App.exe
{6C0B8E2A-4F1D-4B7C-9A3E-2D5F8B1C0E4A}
@@ -41,7 +41,7 @@
harvest. build-msi.ps1 sets this; the default is only for manual runs. -->
$(MSBuildThisFileDirectory)..\artifacts\publish\$(Platform)\
-
+
$(ProductArtifactName)-$(ProductVersion)-$(Platform)
Package
false
diff --git a/packaging/Bundle.wxs b/packaging/Bundle.wxs
index 45dd3b6..e305dad 100644
--- a/packaging/Bundle.wxs
+++ b/packaging/Bundle.wxs
@@ -8,13 +8,13 @@
Build after the MSI has been produced:
wix build packaging/Bundle.wxs -arch x64 ^
- -d ProductVersion=1.0.5 ^
+ -d ProductVersion=1.0.6 ^
-d Platform=x64 ^
- -d MsiPath=artifacts\AndroidTreeView-1.0.5-x64.msi ^
+ -d MsiPath=artifacts\AndroidTreeView-1.0.6-x64.msi ^
-d DotNetRuntimeExe=C:\path\windowsdesktop-runtime-10.0.x-win-x64.exe ^
-ext WixToolset.BootstrapperApplications.wixext ^
-ext WixToolset.Netfx.wixext ^
- -o artifacts\AndroidTreeView-1.0.5-x64-setup.exe
+ -o artifacts\AndroidTreeView-1.0.6-x64-setup.exe
-->
-
+
diff --git a/packaging/build-msi.ps1 b/packaging/build-msi.ps1
index d40e71c..3266be6 100644
--- a/packaging/build-msi.ps1
+++ b/packaging/build-msi.ps1
@@ -27,7 +27,7 @@
Build configuration. Default: Release.
.PARAMETER Version
- Product version. Default: 1.0.5. Must match AndroidTreeView.Core.AppInfo.Version.
+ Product version. Default: 1.0.6. Must match AndroidTreeView.Core.AppInfo.Version.
.EXAMPLE
./build-msi.ps1 -Product App -Arch x64
@@ -49,7 +49,7 @@ param(
[string]$Configuration = 'Release',
- [string]$Version = '1.0.5'
+ [string]$Version = '1.0.6'
)
$ErrorActionPreference = 'Stop'
diff --git a/packaging/build-update-zip.ps1 b/packaging/build-update-zip.ps1
index 0b47896..e5f807f 100644
--- a/packaging/build-update-zip.ps1
+++ b/packaging/build-update-zip.ps1
@@ -27,7 +27,7 @@ param(
[string]$Configuration = 'Release',
- [string]$Version = '1.0.5'
+ [string]$Version = '1.0.6'
)
$ErrorActionPreference = 'Stop'
diff --git a/src/AndroidTreeView.App/AndroidTreeView.App.csproj b/src/AndroidTreeView.App/AndroidTreeView.App.csproj
index 94aded3..de7dc78 100644
--- a/src/AndroidTreeView.App/AndroidTreeView.App.csproj
+++ b/src/AndroidTreeView.App/AndroidTreeView.App.csproj
@@ -8,10 +8,10 @@
Assets/atv-icon.ico
true
true
- 1.0.5
- 1.0.5.0
- 1.0.5.0
- 1.0.5
+ 1.0.6
+ 1.0.6.0
+ 1.0.6.0
+ 1.0.6
false
diff --git a/src/AndroidTreeView.App/app.manifest b/src/AndroidTreeView.App/app.manifest
index 5f15658..41e62b4 100644
--- a/src/AndroidTreeView.App/app.manifest
+++ b/src/AndroidTreeView.App/app.manifest
@@ -1,6 +1,6 @@
-
+
AndroidTreeView.App.mini
- 1.0.5
- 1.0.5.0
- 1.0.5.0
- 1.0.5
+ 1.0.6
+ 1.0.6.0
+ 1.0.6.0
+ 1.0.6
false
Assets\atv-icon.ico
diff --git a/src/AndroidTreeView.Models/UpdateCheckResult.cs b/src/AndroidTreeView.Models/UpdateCheckResult.cs
index e1e494c..bc6c9b3 100644
--- a/src/AndroidTreeView.Models/UpdateCheckResult.cs
+++ b/src/AndroidTreeView.Models/UpdateCheckResult.cs
@@ -31,7 +31,7 @@ public enum UpdateCheckStatus
///
public sealed class UpdateCheckResult
{
- /// The version currently running (e.g. 1.0.5).
+ /// The version currently running (e.g. 1.0.6).
public required string CurrentVersion { get; init; }
/// The latest published version, when known.
From 8564eca61f1fa7740ac78ecca89c25a2617fa304 Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Thu, 9 Jul 2026 15:54:42 +0800
Subject: [PATCH 03/10] docs: reflect macOS support in Chinese READMEs
- Platform badge Windows -> Windows + macOS
- Describe as cross-platform (Windows + macOS Apple Silicon), not Windows-only
- Generalize the usage section and add a macOS subsection (Terminal.app CLI,
drag to /Applications, Gatekeeper). English README already covered this.
---
README-CN.md | 12 +++++++++---
README.md | 13 ++++++++++---
2 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/README-CN.md b/README-CN.md
index ce942d7..df1c3cf 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -3,11 +3,11 @@
[](LICENSE)
[](https://dotnet.microsoft.com/)
[](https://avaloniaui.net/)
-[](#windows-使用说明)
+[](#使用说明)
[主文档](README.md) | **简体中文** | [English](README.en.md)
-AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的 Windows 桌面工具。主程序负责设备总览、详情、投屏、基础工具和设置;Mini 版本保持独立运行,常驻监听设备并自动投屏。
+AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面工具,支持 Windows 与 macOS(Apple Silicon)。主程序负责设备总览、详情、投屏、基础工具和设置;Mini 版本保持独立运行,常驻监听设备并自动投屏。
当前版本:**v1.0.6**。
@@ -36,13 +36,19 @@ dotnet run --project src/AndroidTreeView.App
dotnet run --project src/AndroidTreeView.Mini
```
-## Windows 使用说明
+## 使用说明
1. 安装 Android platform-tools,或让应用在启动时引导选择 `adb.exe`。
2. 在手机上开启开发者选项和 USB 调试。
3. 连接手机并允许 USB 调试授权。
4. 主程序会显示设备卡片;Mini 会自动监听并启动投屏。
+### macOS 说明
+
+- 从 Release 下载 `AndroidTreeView-<版本>-osx-arm64.zip`,解压后将 `AndroidTreeView.app` 拖入 `/Applications`。
+- 首次打开若被 Gatekeeper 拦截,右键「打开」,或执行 `xattr -dr com.apple.quarantine AndroidTreeView.app` 放行。
+- 设备卡片的「CLI 终端」在 macOS 上通过 Terminal.app 打开,编号菜单与 Windows 一致。
+
ADB 安装与排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
## 自动更新
diff --git a/README.md b/README.md
index b2e08ac..7fc49e1 100644
--- a/README.md
+++ b/README.md
@@ -3,11 +3,11 @@
[](LICENSE)
[](https://dotnet.microsoft.com/)
[](https://avaloniaui.net/)
-[](#windows-使用说明)
+[](#使用说明)
**简体中文** | [English](README.en.md) | [中文副本](README-CN.md)
-AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的 Windows 桌面工具。主程序负责设备总览、详情、投屏、基础工具、设置和更新;Mini 版本保持独立运行,常驻监听设备,并在授权后自动启动投屏。
+AndroidTreeView 是一个用于 Android 设备巡检、测试与管理的桌面工具,支持 Windows 与 macOS(Apple Silicon)。主程序负责设备总览、详情、投屏、基础工具、设置和更新;Mini 版本保持独立运行,常驻监听设备,并在授权后自动启动投屏。
当前版本:**v1.0.6**。当前验证目标:App 构建通过、Mini 构建通过、全量测试通过,打包链路能为 App 和 Mini 分别生成 x64 上传 ZIP。
@@ -38,6 +38,7 @@ src/
AndroidTreeView.Shared
AndroidTreeView.App
AndroidTreeView.Mini
+ AndroidTreeView.Mini.Mac
tests/
AndroidTreeView.*.Tests
packaging/
@@ -69,13 +70,19 @@ dotnet run --project src/AndroidTreeView.Mini
ADB 安装和排错见 [docs/adb-requirements.md](docs/adb-requirements.md)。
-## Windows 使用说明
+## 使用说明
1. 启动 AndroidTreeView。
2. 连接已开启并授权 USB 调试的 Android 设备。
3. 使用设备卡片查看状态、投屏、打开 CLI 或执行非破坏性工具。
4. 通过设置或关于页检查并安装更新。
+### macOS 说明
+
+- 从 Release 下载 `AndroidTreeView-<版本>-osx-arm64.zip`,解压后将 `AndroidTreeView.app` 拖入 `/Applications`。
+- 首次打开若被 Gatekeeper 拦截(未签名),右键选择「打开」,或执行 `xattr -dr com.apple.quarantine AndroidTreeView.app` 放行。
+- 设备卡片的「CLI 终端」在 macOS 上通过 Terminal.app 打开,提供与 Windows 一致的编号菜单(设备信息 / adb shell / logcat / 重启 / 关机 等)。
+
## Release ZIP 打包
当前版本号统一为 `1.0.6`,运行时版本、App/Mini 程序集版本、manifest 和 `build-update-zip.ps1` 默认版本保持一致。正式发布只通过 GitHub Actions 的 `Publish` 工作流完成,发布只接受 x64。
From b596ebd90335df48f468be638c703314736bde50 Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Thu, 9 Jul 2026 16:42:44 +0800
Subject: [PATCH 04/10] =?UTF-8?q?docs:=20=E5=8D=8A=E8=87=AA=E5=8A=A8=20Roo?=
=?UTF-8?q?t=20=E5=8A=9F=E8=83=BD=E8=AE=BE=E8=AE=A1=E8=A7=84=E6=A0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
分步向导:上传刷机包→提取 boot.img→手机端官方 Magisk 修补→进 bootloader→flash boot,写操作前强制确认+自动备份原始 boot。双平台(Windows/macOS)。
---
.../specs/2026-07-09-semi-auto-root-design.md | 276 ++++++++++++++++++
1 file changed, 276 insertions(+)
create mode 100644 docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
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..9334615
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
@@ -0,0 +1,276 @@
+# 半自动 Root 功能 — 设计规格
+
+- 日期:2026-07-09
+- 状态:已通过设计评审,待用户审阅规格文档
+- 关联记忆:`root-feature-relaxes-safety`
+
+## 1. 目标与范围
+
+为 AndroidTreeView 增加一个**半自动 Root 功能**:用户手动上传刷机包,工具自动提取
+`boot.img`、用官方 Magisk 组件修补、引导进入 bootloader、把修补后的 `boot.img` 刷入设备。
+以**分步向导**形态交付,写操作前强制确认,刷入前自动备份原始 `boot.img`。
+
+### 1.1 第一版明确要做
+
+- 上传刷机包(本地文件选择)。
+- 提取 `boot.img`:
+ - zip 内含裸 `boot.img`(含 Pixel factory 的嵌套 `image-*.zip`)。
+ - `payload.bin`(A/B OTA),通过打包的 `payload_dumper` 解出。
+- Magisk 修补:工具自带官方 Magisk 组件(各 ABI),push 到手机 `/data/local/tmp`,执行官方
+ `boot_patch.sh` 完成修补,pull 回修补后镜像。
+- 写操作链路:`adb push` → `adb reboot bootloader` → `fastboot flash boot` → `fastboot reboot`。
+- 只读检测:`fastboot getvar unlocked`(解锁状态)、设备 ABI 探测。
+- **刷入前自动备份原始未修补 `boot.img`** 到本地用户目录。
+- 平台:**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 二进制天然可跑,比桌面第三方二进制可靠 |
+| 自动化程度 | **分步向导 + 写操作前强制确认** | 变砖风险高,不做无人值守一键刷 |
+| 包格式范围 | **zip(含 Pixel 嵌套) + payload.bin** | 覆盖 factory image 与现代 A/B OTA |
+| 工具打包 | **构建时按平台下载打包进 `tools/`** | 对齐现有 scrcpy/adb 打包,开箱即用 |
+| UI 落点 | **App 新导航页,扩展 App 到 macOS** | Avalonia 本就跨平台,双平台共用 MVVM |
+| 修补第 4 步实现 | **自带完整 Magisk 组件跑官方 `boot_patch.sh`** | 最贴近官方;实机验证列为高风险里程碑 |
+
+## 3. 可行性结论
+
+核心链路技术上全部可实现,跨平台可行。真正的风险**不在技术**,而在两个工具无法根除、
+必须由用户承担的前提:
+
+- **前提 1 — bootloader 已解锁**:未解锁则 `fastboot flash` 必然失败。工具只检测并引导,
+ 不自动解锁(解锁抹数据、需在手机上按键、部分厂商需解锁码)。
+- **前提 2 — 包与设备匹配**:刷错版本/架构可能变砖。工具做基本校验与警告,但无法保证包正确。
+
+设计上以**分步向导 + 强制确认 + 自动备份原始 boot** 将风险降到可控。
+
+**最高技术不确定性**:Magisk 修补的第 4 步(执行 `boot_patch.sh`)不是单条命令,需一并打包
+`magiskinit` / `magisk` 并复现官方脚本流程,**必须实机验证**(见 §8 里程碑)。
+
+## 4. 分层与工程落点
+
+严格遵循项目分层(下层不引用上层):
+
+```
+Models FlashPackage, BootImageInfo(Path, Source, OriginalPackageName),
+ RootWizardState(enum), DeviceFastbootStatus, PackageType(enum)
+ ↑
+Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWizardService
+ IFastbootEnvironment(对齐 IAdbEnvironment),RootException 类型
+ ↑
+Adb / Infrastructure
+ Adb:FastbootService(ProcessRunner)、FastbootLocator、
+ BootImageExtractor(zip + payload.bin)、MagiskPatcher(push+执行)、
+ Parsers:FastbootVarParser、PackageTypeDetector、CpuAbiParser
+ Infra:BootBackupService(备份原始 boot 到用户目录)
+ ↑
+Shared AddAndroidTreeViewSharedServices() 内 TryAdd 注册全部新服务
+ ↑
+App RootWizardViewModel + RootWizardView(新导航页,分步向导)
+```
+
+- 所有 fastboot/adb 写操作走 `ProcessRunner`(异步、可取消、杀进程树)。
+- 解析类为纯函数放 `AndroidTreeView.Adb.Parsers`,配套解析测试(项目硬规则)。
+- 工具二进制通过新建 `build/AndroidTreeView.RootTools.targets` 按平台下载打包进 `tools/`。
+
+## 5. 向导状态机与流程
+
+### 5.1 状态枚举 `RootWizardState`
+
+```
+Idle 初始
+PackageSelected 已选刷机包
+Extracting 提取 boot.img 中(自动)
+BootExtracted 已提取(含来源:PlainZip / NestedZip / Payload)
+Patching 修补中(push magiskboot → 手机执行 boot_patch.sh → pull 回)
+BootPatched 已得修补后 boot + 已本地备份原始 boot
+AwaitingBootloaderConfirm ⛔ 强制确认点 1:即将重启进 bootloader
+RebootingToBootloader adb reboot bootloader(自动)
+InFastboot 已进 fastboot,检测解锁状态
+Blocked_Locked 未解锁:引导用户,终止自动流程(可重新检测)
+AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash boot
+Flashing fastboot flash boot(自动)
+Rebooting fastboot reboot(自动)
+Completed 完成
+Failed 任一步失败(带错误信息 + 重试当前步 / 中止)
+```
+
+### 5.2 Happy Path 时序
+
+```
+选包 → [自动]提取 → [自动]修补+备份 → ⛔确认1 → [自动]进bootloader
+ → 检测解锁 → ⛔确认2 → [自动]flash → [自动]reboot → 完成
+```
+
+### 5.3 两个强制确认点
+
+1. **确认 1(进 bootloader 前)**:提示手机将重启进 fastboot、保持数据线稳定。
+2. **确认 2(flash 前)**:显示目标分区 `boot`、修补后镜像、**原始 boot 备份路径**、
+ 红色"刷错可能变砖"警告;用户勾选"我已了解风险"后方可点"刷入"。
+
+### 5.4 关键分支
+
+- **Blocked_Locked(未解锁)**:向导停止,显示解锁引导(开发者选项 OEM 解锁、
+ `fastboot flashing unlock` 会抹数据、部分厂商需解锁码)。**工具不代执行解锁**。
+ 用户自行解锁后可"重新检测"继续。
+- **Failed(任一步失败)**:显示该步 stderr/错误摘要(映射成友好文案),提供"重试当前步"或
+ "中止"。中止不留危险中间态(若已在 fastboot,引导用户 `fastboot reboot` 回系统)。
+- **可取消**:每个自动步走 `CancellationToken`,用户可随时取消(取消后进入 Failed/可重试)。
+
+## 6. 核心机制
+
+### 6.1 boot.img 提取(`BootImageExtractor`)
+
+输入包文件,按类型分派,输出 `boot.img` 本地路径 + 来源标记
+(`BootImageInfo { Path, Source, OriginalPackageName }`)。
+
+**包类型判定**(纯函数 `PackageTypeDetector`,读文件头/枚举 zip 条目):
+
+- zip 顶层有 `boot.img` → **PlainZip**
+- zip 含 `image-*.zip`(Pixel factory)→ **NestedZip**(解一层内层 zip 再取 `boot.img`)
+- zip 含 `payload.bin` → **Payload**
+- 裸 `payload.bin` 文件 → **Payload**
+- 都不匹配 → 抛 `RootException`,友好提示"未在包内找到 boot.img"
+
+**提取**:
+
+- PlainZip / NestedZip → 纯 C# `System.IO.Compression`,无外部依赖。
+- Payload → 调用打包的 `payload_dumper`(走 `ProcessRunner`)解出 `boot` 分区。
+- 输出到工作区 `~/.androidtreeview/root-work//`。
+
+**可测试性**:`PackageTypeDetector` 纯函数(zip 条目列表/文件头样本做测试);payload 解包用假
+`ProcessRunner` 测试流程/错误。
+
+### 6.2 Magisk 修补(`MagiskPatcher`,手机端执行官方组件)
+
+前提:设备已连接、adb 已授权(复用现有 `DeviceMonitor` / 设备状态)。
+
+1. **探测 ABI**:`adb shell getprop ro.product.cpu.abi`(纯函数 `CpuAbiParser`),据此选
+ `tools/magisk//` 下对应二进制。
+2. **push 组件与镜像**到 `/data/local/tmp/atv_root/`:
+ `magiskboot`、`magiskinit`、`magisk`、`boot_patch.sh`、`boot.img`;`chmod 755` 可执行文件。
+3. **本地备份原始 boot.img**(`BootBackupService`):复制到
+ `~/.androidtreeview/root-backups/--boot-original.img`,路径供确认 2 显示。
+4. **修补**:在手机上执行官方 `boot_patch.sh`(内部调用 `magiskboot unpack/cpio/repack` +
+ `magiskinit`/`magisk`),产出 `new-boot.img`。
+5. **pull 回**:`adb pull .../new-boot.img /boot-patched.img`。
+6. **清理**手机临时目录。
+
+> ⚠️ 第 4 步为最高技术不确定性环节,需实机验证(见 §8)。
+
+### 6.3 fastboot 服务(`FastbootService`,走 `ProcessRunner`,全部 async + CancellationToken)
+
+| 方法 | 命令 | 说明 |
+|---|---|---|
+| `RebootToBootloaderAsync` | `adb reboot bootloader` | 从系统进 fastboot |
+| `WaitForFastbootDeviceAsync` | `fastboot devices` 轮询 | 等设备在 fastboot 出现(带超时) |
+| `GetUnlockStatusAsync` | `fastboot getvar unlocked` | 只读,`FastbootVarParser` 解析 `unlocked: yes/no` |
+| `FlashBootAsync` | `fastboot flash boot
` | 核心写操作 |
+| `RebootAsync` | `fastboot reboot` | 刷完回系统 |
+
+解析 `getvar` / `fastboot devices` 输出为纯函数 + 解析测试。
+
+### 6.4 fastboot 定位(`FastbootLocator` + `IFastbootEnvironment`)
+
+对齐 `AdbLocator` / `IAdbEnvironment`:配置路径 → 打包
+`tools/platform-tools//fastboot` → PATH → 常见 SDK 位置。单例持有当前路径。缺失时向导页
+显示"未找到 fastboot"引导(类比 App 现有 adb 缺失 Setup 页),不崩溃。
+
+### 6.5 备份服务(`BootBackupService`,Infrastructure 层)
+
+- 修补前把原始 boot.img 存到 `~/.androidtreeview/root-backups/`,文件名含序列号 + 时间戳。
+- 提供"列出/打开备份目录"给 UI,便于变砖时找回。
+
+## 7. 工具打包 — `build/AndroidTreeView.RootTools.targets`
+
+仿 `AndroidTreeView.Scrcpy.targets`,构建/发布时按平台下载并打包:
+
+```
+tools/
+ platform-tools/
+ win-x64/ fastboot.exe (+ adb.exe)
+ osx-x64/ fastboot
+ osx-arm64/ fastboot
+ payload-dumper/
+ win-x64/ osx-x64/ osx-arm64/ payload_dumper
+ magisk/
+ arm64-v8a/ armeabi-v7a/ x86_64/ x86/
+ magiskboot magiskinit magisk
+ boot_patch.sh
+```
+
+- **需支持 macOS 平台产出**(现有 scrcpy 仅 Windows 打包)。
+- 下载源写明 URL + SHA-256 校验,文档登记版本,对齐项目"下载工具需校验 SHA-256"风格:
+ - platform-tools:Google 官方 dl 链接。
+ - magisk 组件:官方 Magisk GitHub release 的 APK(zip)抽取各 ABI。
+ - payload_dumper:可信开源发布。
+- 新增 `tools/verify-roottools-latest.ps1`(仿 `verify-scrcpy-latest.ps1`)检查上游新版本。
+
+## 8. 高风险里程碑(实机验证)
+
+以下无法单测,须实机验证并在开发中预留调整轮次:
+
+1. **`boot_patch.sh` 修补链路** — push 完整 Magisk 组件后能在真机产出可用 `new-boot.img`。
+2. **真实 flash** — `fastboot flash boot` 在已解锁真机上成功且能正常开机。
+3. **payload.bin 解包** — `payload_dumper` 在三种 RID 上均能正确解出 boot 分区。
+4. **跨平台 fastboot/adb** — Windows 与 macOS(x64/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.confirm.flash.warning`、
+ `root.blocked.locked.guide`、`root.error.*`。
+- XAML 用 `{loc:Localize Key=...}`,VM 用 `_localization.Get/Format`。
+
+## 11. 测试
+
+- **Adb.Tests** 新增:
+ - `Parsers/FastbootVarParserTests`(`getvar unlocked` / `fastboot devices`)
+ - `Parsers/PackageTypeDetectorTests`(zip 条目 → 包类型)
+ - `Parsers/CpuAbiParserTests`(`ro.product.cpu.abi` 解析)
+ - `Commands/`:fastboot/adb argv 构建测试
+ - `Services/`:`BootImageExtractor`、`MagiskPatcher`、`FastbootService`(假 `ProcessRunner`,
+ 覆盖流程与错误路径)
+- **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`(改用手机端官方组件)。
From 5ea42a0e8af19629a2a14e86673dc3707e9c2559 Mon Sep 17 00:00:00 2001
From: Birditch <58v5tb4sjt@privaterelay.appleid.com>
Date: Thu, 9 Jul 2026 16:51:00 +0800
Subject: [PATCH 05/10] Clean up macOS CLI PR
---
CLAUDE.md | 163 ------------------
.../Controls/SkeletonBlock.axaml | 41 ++---
.../Controls/SkeletonBlock.axaml.cs | 15 --
.../Services/CliLauncher.cs | 12 +-
.../ViewModels/MainWindowViewModel.cs | 43 ++---
5 files changed, 40 insertions(+), 234 deletions(-)
delete mode 100644 CLAUDE.md
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 1bdf281..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,163 +0,0 @@
-# CLAUDE.md
-
-This file provides guidance to Claude Code (claude.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 215 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 (`NekoIndexUpdateService`) 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). `NekoIndexUpdateService` checks the
-channel and compares semver; `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/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml
index 2038e5c..c0230e1 100644
--- a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml
+++ b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml
@@ -4,30 +4,27 @@
x:Class="AndroidTreeView.App.Controls.SkeletonBlock"
x:CompileBindings="False"
MinHeight="16">
-
-
-
-
+ CornerRadius="{Binding CornerRadius, RelativeSource={RelativeSource AncestorType=controls:SkeletonBlock}}">
+
+
+
+
diff --git a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs
index 50045f2..619a9db 100644
--- a/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs
+++ b/src/AndroidTreeView.App/Controls/SkeletonBlock.axaml.cs
@@ -1,5 +1,4 @@
using Avalonia.Controls;
-using Avalonia.Layout;
namespace AndroidTreeView.App.Controls;
@@ -10,19 +9,5 @@ public partial class SkeletonBlock : UserControl
public SkeletonBlock()
{
InitializeComponent();
-
- // The shimmer is an INFINITE animation. Avalonia keeps such a clock ticking even when this
- // control is only collapsed by a parent (the loading panel toggles IsVisible), which drives
- // the render loop to repaint every frame — the app feels laggy and runs hot while "doing
- // nothing". EffectiveViewportChanged fires when an ancestor's visibility collapses/expands
- // our on-screen viewport; gate the animation on a non-empty viewport via the `:active`
- // pseudo-class so the clock stops whenever the skeleton isn't actually shown.
- EffectiveViewportChanged += OnEffectiveViewportChanged;
- }
-
- private void OnEffectiveViewportChanged(object? sender, EffectiveViewportChangedEventArgs e)
- {
- var visible = e.EffectiveViewport.Width > 0 && e.EffectiveViewport.Height > 0;
- PseudoClasses.Set(":active", visible);
}
}
diff --git a/src/AndroidTreeView.App/Services/CliLauncher.cs b/src/AndroidTreeView.App/Services/CliLauncher.cs
index a38ad0f..feb6e58 100644
--- a/src/AndroidTreeView.App/Services/CliLauncher.cs
+++ b/src/AndroidTreeView.App/Services/CliLauncher.cs
@@ -110,10 +110,12 @@ private void OpenMac(string serial, string safe, string toolsDir, bool fastboot)
var psi = new ProcessStartInfo
{
FileName = "open",
- Arguments = $"-a Terminal.app \"{scriptPath}\"",
WorkingDirectory = toolsDir,
UseShellExecute = false,
};
+ psi.ArgumentList.Add("-a");
+ psi.ArgumentList.Add("Terminal.app");
+ psi.ArgumentList.Add(scriptPath);
var process = Process.Start(psi);
if (process is not null)
@@ -408,8 +410,12 @@ private static string BuildAdbMenuSh(string serial, string toolsDir) => JoinLf(n
"done",
});
- // Single-quote escape for embedding a literal into a double-quoted bash string safely.
- private static string ShEscape(string value) => value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("$", "\\$").Replace("`", "\\`");
+ // Escape for embedding a literal into a double-quoted bash string safely.
+ private static string ShEscape(string value) =>
+ value.Replace("\\", "\\\\")
+ .Replace("\"", "\\\"")
+ .Replace("$", "\\$")
+ .Replace("`", "\\`");
private static string Join(IEnumerable lines) => string.Join("\r\n", lines) + "\r\n";
diff --git a/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs b/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
index 61ae0b2..156d965 100644
--- a/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
+++ b/src/AndroidTreeView.App/ViewModels/MainWindowViewModel.cs
@@ -7,7 +7,6 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.Logging;
-using System.Linq;
namespace AndroidTreeView.App.ViewModels;
@@ -38,19 +37,13 @@ public sealed partial class MainWindowViewModel : ViewModelBase
private readonly ILogger _logger;
// Per-device enrichment state (accessed only on the UI thread from OnDevicesChanged).
- // Identity + root are static per connection: probed once when a device first appears and not
- // re-polled on a timer. Re-probing all devices every few seconds forked a burst of adb processes
- // (root alone is 6 probes/device) that stalled the main page when several devices were connected.
- // A device that changes state (e.g. root granted) disconnects/reconnects or is refreshed on demand,
- // which clears its entry and re-enriches it.
- private readonly HashSet _staticEnriched = new(StringComparer.Ordinal);
+ // Identity + root are re-probed on this cadence (not just once) so a card never drifts out of sync
+ // with the detail page (e.g. root becoming visible after a grant).
+ private static readonly TimeSpan StaticRefreshInterval = TimeSpan.FromSeconds(8);
+ private readonly Dictionary _staticEnrichedAt = new(StringComparer.Ordinal);
private readonly HashSet _enriching = new(StringComparer.Ordinal);
private readonly HashSet _fastbootEnriching = new(StringComparer.Ordinal);
- // Device-list poll interval, loaded from settings at startup (was hardcoded to 1s, which ignored
- // the DeviceRefreshIntervalSeconds setting and hammered adb once per second per device).
- private TimeSpan _refreshInterval = TimeSpan.FromSeconds(3);
-
private string? _releaseUrl;
private UpdateCheckResult? _latestUpdate;
private bool _monitorSubscribed;
@@ -228,8 +221,6 @@ public async Task InitializeAsync()
_themeService.Apply(settings.Theme);
_themeService.ApplyAccent(settings.AccentColor);
- _refreshInterval = TimeSpan.FromSeconds(Math.Max(1, settings.DeviceRefreshIntervalSeconds));
-
var location = await _locator.LocateAsync(settings.AdbPath).ConfigureAwait(true);
_environment.Set(location);
@@ -248,7 +239,7 @@ public async Task InitializeAsync()
{
IsAdbAvailable = true;
AdbStatusText = _localization.Get("adb.status.ready");
- _monitor.UpdateInterval(_refreshInterval);
+ _monitor.UpdateInterval(TimeSpan.FromSeconds(1));
_monitor.Start();
CurrentContent = Devices;
}
@@ -358,9 +349,6 @@ private async Task RefreshDevicesAsync()
{
try
{
- // A manual refresh is the user asking for fresh facts, so drop the one-time static cache
- // to force identity/root to be re-probed on the next enrichment pass.
- _staticEnriched.Clear();
await _monitor.RefreshNowAsync().ConfigureAwait(true);
}
catch (Exception ex)
@@ -486,7 +474,7 @@ private void OnAdbReady(object? sender, EventArgs e)
if (!_monitor.IsRunning)
{
- _monitor.UpdateInterval(_refreshInterval);
+ _monitor.UpdateInterval(TimeSpan.FromSeconds(1));
_monitor.Start();
}
@@ -521,17 +509,9 @@ private void OnLanguageChanged(object? sender, EventArgs e)
}
// Fills each online card with real data: battery every tick (fast), and static identity + root once
- // per device (first time it appears). A per-serial guard prevents overlapping runs from piling up.
+ // per device. A per-serial guard prevents overlapping runs from piling up under the 1 Hz monitor.
private void EnrichDevices(IReadOnlyList devices)
{
- // Forget devices that dropped off the list so a later reconnect re-runs the one-time static
- // enrichment (identity / root) instead of showing stale data.
- if (_staticEnriched.Count > 0)
- {
- var present = new HashSet(devices.Select(d => d.Serial), StringComparer.Ordinal);
- _staticEnriched.RemoveWhere(serial => !present.Contains(serial));
- }
-
foreach (var device in devices)
{
// Fastboot devices have no OS to query over adb — read their fastboot getvar facts instead.
@@ -572,10 +552,9 @@ private async Task EnrichOneAsync(string serial)
var battery = await _deviceService.GetBatteryAsync(serial).ConfigureAwait(true);
card.ApplyBattery(battery);
- // Static identity + root are probed once per connection, not on a timer. Re-probing every
- // device every few seconds was the main-page stall: root alone forks 6 adb processes per
- // device, so N devices produced a periodic burst of dozens of concurrent adb calls.
- if (_staticEnriched.Add(serial))
+ var due = !_staticEnrichedAt.TryGetValue(serial, out var last)
+ || DateTimeOffset.Now - last > StaticRefreshInterval;
+ if (due)
{
var overview = await _deviceService.GetOverviewAsync(serial).ConfigureAwait(true);
card.ApplyOverview(overview);
@@ -584,6 +563,8 @@ private async Task EnrichOneAsync(string serial)
card.ApplyRoot(root);
await card.RefreshActionStateAsync().ConfigureAwait(true);
+
+ _staticEnrichedAt[serial] = DateTimeOffset.Now;
}
}
catch (Exception ex)
From 99f2c0fafc9b2ea371abe1b3a4df13747790c376 Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Fri, 10 Jul 2026 10:41:24 +0800
Subject: [PATCH 06/10] =?UTF-8?q?docs:=20root=20=E4=BF=AE=E8=A1=A5?=
=?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=AE=89=E8=A3=85=E5=AE=98=E6=96=B9=20Magisk?=
=?UTF-8?q?=20APK=20=E5=90=8E=E8=B0=83=E7=94=A8=E5=85=B6=E8=87=AA=E5=B8=A6?=
=?UTF-8?q?=E7=BB=84=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 构建时打包固定版 Magisk APK(SHA-256 校验),运行时 adb install
- 修补改用已装 App 自带的 boot_patch.sh + native 组件,不再桌面侧拆各 ABI 二进制
- 同步 §1.1/§2/§3/§4/§5.1/§6.2/§7/§8/§10/§11/§13
- 保留第 4 步实机验证警告(已装 App 脚本能否被 adb shell 直接调到仍待验证)
---
CLAUDE.md | 2 +-
.../specs/2026-07-09-semi-auto-root-design.md | 101 ++++++++++++------
2 files changed, 68 insertions(+), 35 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1bdf281..356d2c5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -117,7 +117,7 @@ Chinese**; English is the neutral/fallback resource.
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 215 keys each):
+ 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.
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
index 9334615..9a4f90c 100644
--- a/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
+++ b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
@@ -16,10 +16,17 @@
- 提取 `boot.img`:
- zip 内含裸 `boot.img`(含 Pixel factory 的嵌套 `image-*.zip`)。
- `payload.bin`(A/B OTA),通过打包的 `payload_dumper` 解出。
-- Magisk 修补:工具自带官方 Magisk 组件(各 ABI),push 到手机 `/data/local/tmp`,执行官方
- `boot_patch.sh` 完成修补,pull 回修补后镜像。
-- 写操作链路:`adb push` → `adb reboot bootloader` → `fastboot flash boot` → `fastboot reboot`。
-- 只读检测:`fastboot getvar unlocked`(解锁状态)、设备 ABI 探测。
+- Magisk 修补:工具**构建时打包固定版本官方 Magisk APK**(SHA-256 校验),运行时
+ `adb install` 到手机,调用**已装 Magisk App 自带的** `boot_patch.sh` + native 组件在手机端
+ 完成修补,pull 回修补后镜像。
+- 写操作链路:`adb push` → `adb reboot bootloader` → **flash boot(A/B 机型两个槽都刷)** →
+ `fastboot reboot`。
+ - **非 A/B 机型**:`fastboot flash boot
`。
+ - **A/B 机型**:`fastboot flash boot_a
` **且** `fastboot flash boot_b
`
+ (两槽都刷入同一修补后镜像,避免槽位翻转/刷错槽导致 Magisk 不生效)。
+ - 刷入前**强制提示用户**双槽都会被写入及其风险(见 §5.3 确认 2、§3 已知风险)。
+- 只读检测:`fastboot getvar unlocked`(解锁状态)、**A/B 布局探测
+ (`fastboot getvar slot-count`)**、设备 ABI 探测。
- **刷入前自动备份原始未修补 `boot.img`** 到本地用户目录。
- 平台:**Windows + macOS 双平台**,共用同一套 MVVM 代码。
@@ -37,11 +44,13 @@
| 与"只读/安全"定位冲突 | **放宽定位**,接受工具含刷机写操作 | 用户明确要 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 |
+| A/B 机型刷入策略 | **两个槽 `boot_a`/`boot_b` 都刷同一镜像 + 刷入前强制提示** | 免探测 active slot、免刷错槽;代价(跨版本双槽变砖)以提示告知用户承担 |
| 工具打包 | **构建时按平台下载打包进 `tools/`** | 对齐现有 scrcpy/adb 打包,开箱即用 |
| UI 落点 | **App 新导航页,扩展 App 到 macOS** | Avalonia 本就跨平台,双平台共用 MVVM |
-| 修补第 4 步实现 | **自带完整 Magisk 组件跑官方 `boot_patch.sh`** | 最贴近官方;实机验证列为高风险里程碑 |
+| 修补第 4 步实现 | **安装官方 Magisk APK 后跑其自带 `boot_patch.sh`** | 最贴近官方;实机验证列为高风险里程碑 |
## 3. 可行性结论
@@ -51,11 +60,19 @@
- **前提 1 — bootloader 已解锁**:未解锁则 `fastboot flash` 必然失败。工具只检测并引导,
不自动解锁(解锁抹数据、需在手机上按键、部分厂商需解锁码)。
- **前提 2 — 包与设备匹配**:刷错版本/架构可能变砖。工具做基本校验与警告,但无法保证包正确。
+- **前提 3 — A/B 双槽刷入的跨版本风险(已知、以提示承担)**:A/B 机型两个槽都刷入同一
+ 修补后镜像。若两个槽当前系统版本不一致(如 OTA 后未满一个周期),把当前版本的 boot 刷进
+ 另一槽会造成 boot 与该槽 system/vendor 版本错配,**日后回滚到该槽可能 bootloop**(延迟变砖,
+ 刷入当下无异常)。工具**无法可靠检测两槽版本**,故在**确认 2 强制提示**用户此风险,由用户
+ 在知情后承担(对应 §2 决策:省掉刷错槽 → 接受此代价)。
设计上以**分步向导 + 强制确认 + 自动备份原始 boot** 将风险降到可控。
-**最高技术不确定性**:Magisk 修补的第 4 步(执行 `boot_patch.sh`)不是单条命令,需一并打包
-`magiskinit` / `magisk` 并复现官方脚本流程,**必须实机验证**(见 §8 里程碑)。
+**最高技术不确定性**: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. 分层与工程落点
@@ -70,7 +87,7 @@ Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWi
↑
Adb / Infrastructure
Adb:FastbootService(ProcessRunner)、FastbootLocator、
- BootImageExtractor(zip + payload.bin)、MagiskPatcher(push+执行)、
+ BootImageExtractor(zip + payload.bin)、MagiskPatcher(install APK + 手机端执行)、
Parsers:FastbootVarParser、PackageTypeDetector、CpuAbiParser
Infra:BootBackupService(备份原始 boot 到用户目录)
↑
@@ -92,14 +109,14 @@ Idle 初始
PackageSelected 已选刷机包
Extracting 提取 boot.img 中(自动)
BootExtracted 已提取(含来源:PlainZip / NestedZip / Payload)
-Patching 修补中(push magiskboot → 手机执行 boot_patch.sh → pull 回)
+Patching 修补中(adb install Magisk APK → 手机执行 boot_patch.sh → pull 回)
BootPatched 已得修补后 boot + 已本地备份原始 boot
AwaitingBootloaderConfirm ⛔ 强制确认点 1:即将重启进 bootloader
RebootingToBootloader adb reboot bootloader(自动)
-InFastboot 已进 fastboot,检测解锁状态
+InFastboot 已进 fastboot,检测解锁状态 + A/B 布局(slot-count)
Blocked_Locked 未解锁:引导用户,终止自动流程(可重新检测)
-AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash boot
-Flashing fastboot flash boot(自动)
+AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash boot(显示目标槽位;A/B 明确告知双槽都刷)
+Flashing fastboot flash boot(A/B:先 boot_a 再 boot_b,自动)
Rebooting fastboot reboot(自动)
Completed 完成
Failed 任一步失败(带错误信息 + 重试当前步 / 中止)
@@ -116,7 +133,9 @@ Failed 任一步失败(带错误信息 + 重试当前步
1. **确认 1(进 bootloader 前)**:提示手机将重启进 fastboot、保持数据线稳定。
2. **确认 2(flash 前)**:显示目标分区 `boot`、修补后镜像、**原始 boot 备份路径**、
- 红色"刷错可能变砖"警告;用户勾选"我已了解风险"后方可点"刷入"。
+ **目标槽位(A/B 机型明确显示"将同时刷入 boot_a 和 boot_b")**、红色"刷错可能变砖"警告;
+ **A/B 机型额外红字提示**:两个槽都会被写入同一镜像,若两槽系统版本不一致,日后回滚到另一槽
+ 可能变砖(§3 前提 3);用户勾选"我已了解风险"后方可点"刷入"。
### 5.4 关键分支
@@ -151,22 +170,27 @@ Failed 任一步失败(带错误信息 + 重试当前步
**可测试性**:`PackageTypeDetector` 纯函数(zip 条目列表/文件头样本做测试);payload 解包用假
`ProcessRunner` 测试流程/错误。
-### 6.2 Magisk 修补(`MagiskPatcher`,手机端执行官方组件)
+### 6.2 Magisk 修补(`MagiskPatcher`,安装官方 APK 后手机端执行)
前提:设备已连接、adb 已授权(复用现有 `DeviceMonitor` / 设备状态)。
-1. **探测 ABI**:`adb shell getprop ro.product.cpu.abi`(纯函数 `CpuAbiParser`),据此选
- `tools/magisk//` 下对应二进制。
-2. **push 组件与镜像**到 `/data/local/tmp/atv_root/`:
- `magiskboot`、`magiskinit`、`magisk`、`boot_patch.sh`、`boot.img`;`chmod 755` 可执行文件。
+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. **本地备份原始 boot.img**(`BootBackupService`):复制到
`~/.androidtreeview/root-backups/--boot-original.img`,路径供确认 2 显示。
-4. **修补**:在手机上执行官方 `boot_patch.sh`(内部调用 `magiskboot unpack/cpio/repack` +
- `magiskinit`/`magisk`),产出 `new-boot.img`。
+4. **修补**:push 待修补 `boot.img` 到手机临时目录 `/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. **清理**手机临时目录。
-> ⚠️ 第 4 步为最高技术不确定性环节,需实机验证(见 §8)。
+> ⚠️ 第 4 步为最高技术不确定性环节:装 App 后 `boot_patch.sh` 环境更完整,但**"已装 App 的
+> `boot_patch.sh` 能否被 `adb shell` 直接调到"仍未完全消除不确定性**(APK 内 `assets` 脚本安装后
+> 不一定落在可直接执行位置,可能仍需从 APK 提取脚本再 push),需实机验证(见 §8)。
### 6.3 fastboot 服务(`FastbootService`,走 `ProcessRunner`,全部 async + CancellationToken)
@@ -175,10 +199,13 @@ Failed 任一步失败(带错误信息 + 重试当前步
| `RebootToBootloaderAsync` | `adb reboot bootloader` | 从系统进 fastboot |
| `WaitForFastbootDeviceAsync` | `fastboot devices` 轮询 | 等设备在 fastboot 出现(带超时) |
| `GetUnlockStatusAsync` | `fastboot getvar unlocked` | 只读,`FastbootVarParser` 解析 `unlocked: yes/no` |
-| `FlashBootAsync` | `fastboot flash boot
` | 核心写操作 |
+| `GetSlotCountAsync` | `fastboot getvar slot-count` | 只读,`FastbootVarParser` 解析槽位数;`>1` 视为 A/B |
+| `FlashBootAsync` | 非 A/B:`fastboot flash boot
`;A/B:`fastboot flash boot_a
` **且** `fastboot flash boot_b
` | 核心写操作;A/B 两槽都刷同一镜像 |
| `RebootAsync` | `fastboot reboot` | 刷完回系统 |
-解析 `getvar` / `fastboot devices` 输出为纯函数 + 解析测试。
+解析 `getvar` / `fastboot devices` 输出为纯函数 + 解析测试。`slot-count` 复用同一
+`FastbootVarParser`(解析 `slot-count: N`)。A/B 双槽刷入时,`boot_a` 成功、`boot_b` 失败要
+视为整体失败(进 `Failed`,错误摘要注明哪个槽失败),不可停在"只刷了一个槽"的中间态。
### 6.4 fastboot 定位(`FastbootLocator` + `IFastbootEnvironment`)
@@ -204,15 +231,14 @@ tools/
payload-dumper/
win-x64/ osx-x64/ osx-arm64/ payload_dumper
magisk/
- arm64-v8a/ armeabi-v7a/ x86_64/ x86/
- magiskboot magiskinit magisk
- boot_patch.sh
+ Magisk.apk 固定版本官方 APK(内含各 ABI native 组件),运行时 adb install
```
- **需支持 macOS 平台产出**(现有 scrcpy 仅 Windows 打包)。
- 下载源写明 URL + SHA-256 校验,文档登记版本,对齐项目"下载工具需校验 SHA-256"风格:
- platform-tools:Google 官方 dl 链接。
- - magisk 组件:官方 Magisk GitHub release 的 APK(zip)抽取各 ABI。
+ - magisk:官方 Magisk GitHub release 的 APK 整包(不在桌面侧拆 native 二进制,组件随 App
+ 安装到手机后使用)。
- payload_dumper:可信开源发布。
- 新增 `tools/verify-roottools-latest.ps1`(仿 `verify-scrcpy-latest.ps1`)检查上游新版本。
@@ -220,7 +246,9 @@ tools/
以下无法单测,须实机验证并在开发中预留调整轮次:
-1. **`boot_patch.sh` 修补链路** — push 完整 Magisk 组件后能在真机产出可用 `new-boot.img`。
+1. **`boot_patch.sh` 修补链路** — `adb install` 官方 Magisk APK 后,能调用其自带
+ `boot_patch.sh` 在真机产出可用 `new-boot.img`(重点验证已装 App 的脚本是否可被 `adb shell`
+ 直接调到)。
2. **真实 flash** — `fastboot flash boot` 在已解锁真机上成功且能正常开机。
3. **payload.bin 解包** — `payload_dumper` 在三种 RID 上均能正确解出 boot 分区。
4. **跨平台 fastboot/adb** — Windows 与 macOS(x64/arm64) 下二进制均能定位与执行。
@@ -241,19 +269,23 @@ tools/
- 所有向导文案、按钮、警告、错误映射、引导说明新增 key 到**两个 ResX 都加**
(`Strings.resx` 英文 + `Strings.zh-Hans.resx` 中文),键集保持一致。
-- 命名示例:`root.wizard.title`、`root.step.extract`、`root.confirm.flash.warning`、
- `root.blocked.locked.guide`、`root.error.*`。
+- 命名示例:`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.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`(`getvar unlocked` / `fastboot devices`)
+ - `Parsers/FastbootVarParserTests`(`getvar unlocked` / `slot-count` / `fastboot devices`)
- `Parsers/PackageTypeDetectorTests`(zip 条目 → 包类型)
- `Parsers/CpuAbiParserTests`(`ro.product.cpu.abi` 解析)
- - `Commands/`:fastboot/adb argv 构建测试
+ - `Commands/`:fastboot/adb argv 构建测试(含 A/B 双槽 `flash boot_a`/`boot_b` 的 argv)
- `Services/`:`BootImageExtractor`、`MagiskPatcher`、`FastbootService`(假 `ProcessRunner`,
- 覆盖流程与错误路径)
+ 覆盖流程与错误路径;`MagiskPatcher` 覆盖 `adb install` 成功→调 `boot_patch.sh`、install
+ 失败→`Failed`、ABI 探测;`FlashBootAsync` 覆盖非 A/B 单槽、A/B 双槽成功、A/B 第二槽失败→整体失败)
- **App.Tests** 新增:`RootWizardViewModel` 状态机推进、确认门控(未勾选不能 flash)、错误映射、
未解锁→Blocked 分支;DI 图解析新服务(`ServiceGraphTests`)。
- **实机验证**(§8):无法单测,作为手动验证清单。
@@ -273,4 +305,5 @@ tools/
- 不做 boot 以外分区刷写。
- 不做一键恢复/卸载 root 向导(仅备份 + 引导)。
- 不支持厂商私有包格式。
-- 不引入桌面版第三方 `magiskboot`(改用手机端官方组件)。
+- 不引入桌面版第三方 `magiskboot`(改用安装官方 Magisk APK 后调用其自带组件,不在桌面侧拆
+ native 二进制)。
From 6898c32a1ee34e137714c6ce4b38d8b16932e5fe Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Fri, 10 Jul 2026 13:35:40 +0800
Subject: [PATCH 07/10] Switch updates to GitHub Releases and sign macOS
bundles
---
AGENTS.md | 163 ++++++++
README-CN.md | 2 +
README.en.md | 2 +
README.md | 2 +
docs/roadmap-features.md | 8 +
...026-07-10-semi-auto-root-implementation.md | 356 ++++++++++++++++++
.../specs/2026-07-09-semi-auto-root-design.md | 64 ++--
packaging/build-update-zip.ps1 | 37 +-
.../Resources/Strings.resx | 9 +-
.../Resources/Strings.zh-Hans.resx | 9 +-
src/AndroidTreeView.Core/AppInfo.cs | 83 ++--
.../Options/UpdateProductOptions.cs | 94 +++--
.../Update/GitHubUpdateService.cs | 201 ++++++++++
.../Update/NekoIndexUpdateService.cs | 234 ------------
.../AndroidTreeViewSharedServices.cs | 110 +++---
.../GitHubUpdateServiceTests.cs | 191 ++++++++++
.../NekoIndexUpdateServiceTests.cs | 233 ------------
17 files changed, 1161 insertions(+), 637 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
create mode 100644 src/AndroidTreeView.Infrastructure/Update/GitHubUpdateService.cs
delete mode 100644 src/AndroidTreeView.Infrastructure/Update/NekoIndexUpdateService.cs
create mode 100644 tests/AndroidTreeView.Infrastructure.Tests/GitHubUpdateServiceTests.cs
delete mode 100644 tests/AndroidTreeView.Infrastructure.Tests/NekoIndexUpdateServiceTests.cs
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9d6357f
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,163 @@
+# 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 (`NekoIndexUpdateService`) 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). `NekoIndexUpdateService` checks the
+channel and compares semver; `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 df1c3cf..fba964b 100644
--- a/README-CN.md
+++ b/README-CN.md
@@ -36,6 +36,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`。
diff --git a/README.en.md b/README.en.md
index c72aaae..f665102 100644
--- a/README.en.md
+++ b/README.en.md
@@ -58,6 +58,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
diff --git a/README.md b/README.md
index 7fc49e1..676fcee 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,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 调试
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..8aa411c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
@@ -0,0 +1,356 @@
+# 半自动 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`、备份、用固定版本 Magisk 修补、检测 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 已解锁的测试机和与该机版本匹配的原始 `boot.img`。
+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,命令可重复执行,修补产物非空,清理后无残留临时文件。
+
+失败处理:暂停主实现,更新设计规格并让用户选择“从已校验 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 原始 boot 备份
+ -> 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/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`
+
+约束:
+
+- `RootWizardSnapshot` 保存设备 serial、包路径、工作目录、原始/修补镜像、备份路径、槽位和错误码。
+- 错误对 UI 暴露稳定错误码,不直接把任意 stderr 当成用户文案。
+- 状态增加 `BlockedAmbiguousFastboot`:ADB 重启后若出现多个 fastboot 设备且无法唯一匹配,禁止刷写。
+- 所有 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/FirmwarePackageMetadataParser.cs`
+- 新增 `src/AndroidTreeView.Adb/Services/BootImageExtractor.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/PackageTypeDetectorTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/FirmwarePackageMetadataParserTests.cs`
+- 新增 `tests/AndroidTreeView.Adb.Tests/Services/BootImageExtractorTests.cs`
+
+测试先覆盖:顶层 `boot.img`、大小写差异、Pixel `image-*.zip`、payload、无目标文件、重复目标、
+损坏 ZIP、路径穿越、取消和工作目录清理。
+
+实现约束:
+
+- 只允许一层嵌套 ZIP;条目路径必须经过 `Path.GetFullPath` 边界检查。
+- 不把整个大包读入内存;使用流复制。
+- 设定单条目和总展开大小上限,拒绝 ZIP bomb。
+- 读取 OTA `META-INF/com/android/metadata` 与 Pixel `android-info.txt`;元数据明确不匹配当前
+ `ro.product.device`/product 时硬阻止,元数据缺失时标记为“无法自动验证”并留给第二确认点。
+- 每次会话使用独立的 `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 使用同一分支,只请求 `boot` 分区。
+3. runner 非零退出、超时或未产出 `boot.img` 时抛稳定错误码。
+4. 校验输出文件存在、大小合理,再返回 `BootImageInfo`。
+
+验收:不安装真实 payload-dumper 也能覆盖所有流程分支;打包 smoke test 再验证真实二进制可启动。
+
+### Task 6:实现原始 boot 备份
+
+文件:
+
+- 新增 `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` 中清理手机临时目录。
+
+测试覆盖:安装失败、未知 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 设备、读取 `unlocked`、`slot-count`、`has-slot:boot` 与
+`current-slot`、单槽刷 `boot`、A/B 依次刷 `boot_a` 和 `boot_b`、返回失败分区和 stderr 摘要、
+重启系统。
+
+安全规则:
+
+- fastboot 设备无法唯一识别时返回 `AmbiguousDevice`,绝不选择列表第一台。
+- 可执行文件先查已定位 adb 的同目录,再回退到 App 自带 `scrcpy/fastboot[.exe]`。
+- `unlocked` 不是明确 yes/true 时按未解锁处理。
+- `has-slot:boot=no` 才判为非 A/B;`has-slot:boot=yes` 且槽位数明确时判为 A/B;信息矛盾或
+ 缺失时不猜布局,进入阻塞状态。
+- A/B 的 `boot_a` 成功、`boot_b` 失败时返回部分写入结果,UI 必须显示设备处于危险中间态。
+- 非零退出和超时不得继续执行下一条写命令。
+
+验收:argv、stdout/stderr 解析和全部失败分支均由纯单测固定。
+
+### 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,也不自行越过
+确认点。
+
+测试固定以下不变量:
+
+- 未完成备份不能进入 flash 确认。
+- 包元数据明确与设备不匹配时不能进入修补或 flash;无法验证时必须显示额外风险提示。
+- 未解锁、槽位未知、设备歧义、未勾选风险确认均不能调用 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,使用户切换导航后不丢工作流。
+- 文件选择器新增单选刷机包入口,只接受 ZIP 与 payload;给 `FilePickerService` 注入
+ `ILocalizationService`,选择器标题与文件类型名称也必须本地化。
+- 第一次确认可用现有 `IDialogService`;第二次在 Root 页面显示备份路径、目标分区和风险复选框。
+- 第二确认点同时显示设备代号、包元数据匹配结果;无法自动验证时使用独立醒目警告。
+- `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`
+
+覆盖:导航、选包取消、两个确认门、锁定设备、设备歧义、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 启动并走完整向导。
+- 未解锁、设备歧义、槽位未知、包不匹配、包损坏、工具缺失和命令失败都无法越过写入门。
+- 原始 boot 在任何刷写前已完成本地备份,失败或取消不会删除。
+- App 不因正常设备/命令错误崩溃;错误均有中英文文案和可操作的下一步。
+- 全量测试通过,两个 ResX 键集合一致,Mini 不携带 Root 工具。
+
+## 7. 明确不在本计划内
+
+- 自动解锁 bootloader。
+- 刷写 `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
index 9a4f90c..bea6286 100644
--- a/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
+++ b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
@@ -1,8 +1,14 @@
# 半自动 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. 目标与范围
@@ -26,7 +32,7 @@
(两槽都刷入同一修补后镜像,避免槽位翻转/刷错槽导致 Magisk 不生效)。
- 刷入前**强制提示用户**双槽都会被写入及其风险(见 §5.3 确认 2、§3 已知风险)。
- 只读检测:`fastboot getvar unlocked`(解锁状态)、**A/B 布局探测
- (`fastboot getvar slot-count`)**、设备 ABI 探测。
+ (联合 `slot-count`、`has-slot:boot`、`current-slot`,矛盾或缺失时阻止刷写)**、设备 ABI 探测。
- **刷入前自动备份原始未修补 `boot.img`** 到本地用户目录。
- 平台:**Windows + macOS 双平台**,共用同一套 MVVM 代码。
@@ -49,12 +55,12 @@
| 包格式范围 | **zip(含 Pixel 嵌套) + payload.bin** | 覆盖 factory image 与现代 A/B OTA |
| A/B 机型刷入策略 | **两个槽 `boot_a`/`boot_b` 都刷同一镜像 + 刷入前强制提示** | 免探测 active slot、免刷错槽;代价(跨版本双槽变砖)以提示告知用户承担 |
| 工具打包 | **构建时按平台下载打包进 `tools/`** | 对齐现有 scrcpy/adb 打包,开箱即用 |
-| UI 落点 | **App 新导航页,扩展 App 到 macOS** | Avalonia 本就跨平台,双平台共用 MVVM |
+| UI 落点 | **App 新导航页,复用现有 Windows/macOS App 发布链路** | 双平台共用同一套 Avalonia MVVM 代码 |
| 修补第 4 步实现 | **安装官方 Magisk APK 后跑其自带 `boot_patch.sh`** | 最贴近官方;实机验证列为高风险里程碑 |
## 3. 可行性结论
-核心链路技术上全部可实现,跨平台可行。真正的风险**不在技术**,而在两个工具无法根除、
+核心链路技术上全部可实现,跨平台可行。真正的风险**不在技术**,而在三个工具无法根除、
必须由用户承担的前提:
- **前提 1 — bootloader 已解锁**:未解锁则 `fastboot flash` 必然失败。工具只检测并引导,
@@ -83,10 +89,10 @@ Models FlashPackage, BootImageInfo(Path, Source, OriginalPackageName),
RootWizardState(enum), DeviceFastbootStatus, PackageType(enum)
↑
Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWizardService
- IFastbootEnvironment(对齐 IAdbEnvironment),RootException 类型
+ IExternalCommandRunner、RootException 类型
↑
Adb / Infrastructure
- Adb:FastbootService(ProcessRunner)、FastbootLocator、
+ Adb:扩展现有 FastbootService、
BootImageExtractor(zip + payload.bin)、MagiskPatcher(install APK + 手机端执行)、
Parsers:FastbootVarParser、PackageTypeDetector、CpuAbiParser
Infra:BootBackupService(备份原始 boot 到用户目录)
@@ -98,7 +104,8 @@ App RootWizardViewModel + RootWizardView(新导航页,分步向导)
- 所有 fastboot/adb 写操作走 `ProcessRunner`(异步、可取消、杀进程树)。
- 解析类为纯函数放 `AndroidTreeView.Adb.Parsers`,配套解析测试(项目硬规则)。
-- 工具二进制通过新建 `build/AndroidTreeView.RootTools.targets` 按平台下载打包进 `tools/`。
+- fastboot 复用 App 已有 `scrcpy/fastboot[.exe]`;Magisk APK 与 payload-dumper 通过新建
+ `build/AndroidTreeView.RootTools.targets` 按发布 RID 下载、校验并只打包进完整 App。
## 5. 向导状态机与流程
@@ -113,7 +120,7 @@ Patching 修补中(adb install Magisk APK → 手机执行
BootPatched 已得修补后 boot + 已本地备份原始 boot
AwaitingBootloaderConfirm ⛔ 强制确认点 1:即将重启进 bootloader
RebootingToBootloader adb reboot bootloader(自动)
-InFastboot 已进 fastboot,检测解锁状态 + A/B 布局(slot-count)
+InFastboot 已进 fastboot,检测解锁状态 + A/B 布局(多变量联合判定)
Blocked_Locked 未解锁:引导用户,终止自动流程(可重新检测)
AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash boot(显示目标槽位;A/B 明确告知双槽都刷)
Flashing fastboot flash boot(A/B:先 boot_a 再 boot_b,自动)
@@ -199,19 +206,19 @@ Failed 任一步失败(带错误信息 + 重试当前步
| `RebootToBootloaderAsync` | `adb reboot bootloader` | 从系统进 fastboot |
| `WaitForFastbootDeviceAsync` | `fastboot devices` 轮询 | 等设备在 fastboot 出现(带超时) |
| `GetUnlockStatusAsync` | `fastboot getvar unlocked` | 只读,`FastbootVarParser` 解析 `unlocked: yes/no` |
-| `GetSlotCountAsync` | `fastboot getvar slot-count` | 只读,`FastbootVarParser` 解析槽位数;`>1` 视为 A/B |
+| `GetBootLayoutAsync` | `fastboot getvar slot-count` + `has-slot:boot` + `current-slot` | 只读;联合判定 A/B,信息矛盾或缺失时返回 Unknown |
| `FlashBootAsync` | 非 A/B:`fastboot flash boot
`;A/B:`fastboot flash boot_a
` **且** `fastboot flash boot_b
` | 核心写操作;A/B 两槽都刷同一镜像 |
| `RebootAsync` | `fastboot reboot` | 刷完回系统 |
-解析 `getvar` / `fastboot devices` 输出为纯函数 + 解析测试。`slot-count` 复用同一
-`FastbootVarParser`(解析 `slot-count: N`)。A/B 双槽刷入时,`boot_a` 成功、`boot_b` 失败要
+解析 `getvar` / `fastboot devices` 输出为纯函数 + 解析测试。`slot-count`、`has-slot:boot`、
+`current-slot` 复用同一 `FastbootVarParser`。A/B 双槽刷入时,`boot_a` 成功、`boot_b` 失败要
视为整体失败(进 `Failed`,错误摘要注明哪个槽失败),不可停在"只刷了一个槽"的中间态。
-### 6.4 fastboot 定位(`FastbootLocator` + `IFastbootEnvironment`)
+### 6.4 fastboot 定位(复用现有 `FastbootService`)
-对齐 `AdbLocator` / `IAdbEnvironment`:配置路径 → 打包
-`tools/platform-tools//fastboot` → PATH → 常见 SDK 位置。单例持有当前路径。缺失时向导页
-显示"未找到 fastboot"引导(类比 App 现有 adb 缺失 Setup 页),不崩溃。
+现有 `FastbootService.ExecutablePath` 从 `IAdbEnvironment` 已定位的 adb 同目录解析
+`fastboot[.exe]`;发布脚本也已把 fastboot 放入 App 的 `scrcpy/` 目录。Root 向导扩展该服务的
+严格检测/刷写结果,不新建第二套 locator/environment。缺失时向导显示“未找到 fastboot”,不崩溃。
### 6.5 备份服务(`BootBackupService`,Infrastructure 层)
@@ -224,19 +231,16 @@ Failed 任一步失败(带错误信息 + 重试当前步
```
tools/
- platform-tools/
- win-x64/ fastboot.exe (+ adb.exe)
- osx-x64/ fastboot
- osx-arm64/ fastboot
- payload-dumper/
- win-x64/ osx-x64/ osx-arm64/ payload_dumper
- magisk/
- Magisk.apk 固定版本官方 APK(内含各 ABI native 组件),运行时 adb install
+ scrcpy/ 现有目录,已含 adb + fastboot
+ root-tools/
+ payload-dumper/ win-x64 / osx-arm64 对应 payload-dumper-go
+ magisk/
+ Magisk.apk 固定版本官方 APK,运行时 adb install
```
-- **需支持 macOS 平台产出**(现有 scrcpy 仅 Windows 打包)。
+- Root 工具只打包进完整 App;Mini / Mini.Mac 不携带。
- 下载源写明 URL + SHA-256 校验,文档登记版本,对齐项目"下载工具需校验 SHA-256"风格:
- - platform-tools:Google 官方 dl 链接。
+ - fastboot:复用现有 Google platform-tools 下载与 App 打包路径。
- magisk:官方 Magisk GitHub release 的 APK 整包(不在桌面侧拆 native 二进制,组件随 App
安装到手机后使用)。
- payload_dumper:可信开源发布。
@@ -250,8 +254,8 @@ tools/
`boot_patch.sh` 在真机产出可用 `new-boot.img`(重点验证已装 App 的脚本是否可被 `adb shell`
直接调到)。
2. **真实 flash** — `fastboot flash boot` 在已解锁真机上成功且能正常开机。
-3. **payload.bin 解包** — `payload_dumper` 在三种 RID 上均能正确解出 boot 分区。
-4. **跨平台 fastboot/adb** — Windows 与 macOS(x64/arm64) 下二进制均能定位与执行。
+3. **payload.bin 解包** — `payload_dumper` 在 `win-x64`、`osx-arm64` 均能正确解出 boot 分区。
+4. **跨平台 fastboot/adb** — Windows x64 与 macOS arm64 下二进制均能定位与执行。
## 9. App UI
@@ -279,11 +283,13 @@ tools/
## 11. 测试
- **Adb.Tests** 新增:
- - `Parsers/FastbootVarParserTests`(`getvar unlocked` / `slot-count` / `fastboot devices`)
+ - `Parsers/FastbootVarParserTests`(`unlocked` / `slot-count` / `has-slot:boot` /
+ `current-slot` / `fastboot devices`)
- `Parsers/PackageTypeDetectorTests`(zip 条目 → 包类型)
- `Parsers/CpuAbiParserTests`(`ro.product.cpu.abi` 解析)
- `Commands/`:fastboot/adb argv 构建测试(含 A/B 双槽 `flash boot_a`/`boot_b` 的 argv)
- - `Services/`:`BootImageExtractor`、`MagiskPatcher`、`FastbootService`(假 `ProcessRunner`,
+ - `Services/`:`BootImageExtractor`、`MagiskPatcher`、`FastbootService`(假
+ `IExternalCommandRunner`,
覆盖流程与错误路径;`MagiskPatcher` 覆盖 `adb install` 成功→调 `boot_patch.sh`、install
失败→`Failed`、ABI 探测;`FlashBootAsync` 覆盖非 A/B 单槽、A/B 双槽成功、A/B 第二槽失败→整体失败)
- **App.Tests** 新增:`RootWizardViewModel` 状态机推进、确认门控(未勾选不能 flash)、错误映射、
diff --git a/packaging/build-update-zip.ps1 b/packaging/build-update-zip.ps1
index e5f807f..595171b 100644
--- a/packaging/build-update-zip.ps1
+++ b/packaging/build-update-zip.ps1
@@ -415,6 +415,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 +546,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.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/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.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 50fbd42..0000000
--- a/src/AndroidTreeView.Infrastructure/Update/NekoIndexUpdateService.cs
+++ /dev/null
@@ -1,234 +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;
- }
-
- if (Uri.TryCreate(url, UriKind.Absolute, out var absolute))
- {
- return absolute.ToString();
- }
-
- var baseUri = new Uri(_product.UpdateServerBaseUrl.TrimEnd('/') + "/");
- return new Uri(baseUri, url).ToString();
- }
-}
diff --git a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
index 5ca6ec0..09d0d40 100644
--- a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
+++ b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
@@ -1,55 +1,55 @@
-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();
+
+ 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.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);
- }
-}
From 54acfa8fc4e9721eda0781ab0d69f752d6a9acb2 Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Fri, 10 Jul 2026 13:54:58 +0800
Subject: [PATCH 08/10] Add macOS safe area padding when generating app icons
---
packaging/build-update-zip.ps1 | 18 ++++++++++++++----
1 file changed, 14 insertions(+), 4 deletions(-)
diff --git a/packaging/build-update-zip.ps1 b/packaging/build-update-zip.ps1
index 595171b..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
From 3a92d50f5382fc6b9f92e8cee1978acc0c4cb91c Mon Sep 17 00:00:00 2001
From: backspace135 <46274807+backspace135@users.noreply.github.com>
Date: Fri, 10 Jul 2026 17:00:11 +0800
Subject: [PATCH 09/10] Refine semi-auto root plan for target partition
handling
---
...026-07-10-semi-auto-root-implementation.md | 78 ++++++----
.../specs/2026-07-09-semi-auto-root-design.md | 125 ++++++++++------
.../Services/ExternalCommandRunner.cs | 31 ++++
.../Services/FastbootService.cs | 36 ++++-
.../Interfaces/IExternalCommandRunner.cs | 12 ++
.../Services/ExternalCommandRequest.cs | 14 ++
.../Services/ExternalCommandResult.cs | 20 +++
.../AndroidTreeViewSharedServices.cs | 1 +
.../Services/FastbootServiceTests.cs | 138 ++++++++++++++++++
.../TestDoubles/FakeExternalCommandRunner.cs | 35 +++++
10 files changed, 410 insertions(+), 80 deletions(-)
create mode 100644 src/AndroidTreeView.Adb/Services/ExternalCommandRunner.cs
create mode 100644 src/AndroidTreeView.Core/Interfaces/IExternalCommandRunner.cs
create mode 100644 src/AndroidTreeView.Core/Services/ExternalCommandRequest.cs
create mode 100644 src/AndroidTreeView.Core/Services/ExternalCommandResult.cs
create mode 100644 tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs
create mode 100644 tests/AndroidTreeView.Adb.Tests/TestDoubles/FakeExternalCommandRunner.cs
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
index 8aa411c..c7b835f 100644
--- a/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
+++ b/docs/superpowers/plans/2026-07-10-semi-auto-root-implementation.md
@@ -8,8 +8,8 @@
## 1. 交付目标
在完整 App 中增加可恢复、可取消、带两次风险确认的 Root 向导。用户选择与当前设备匹配的
-刷机包后,App 提取原始 `boot.img`、备份、用固定版本 Magisk 修补、检测 fastboot 解锁与槽位,
-最后在明确确认后刷入并重启。
+刷机包后,App 根据设备与包证据选择原始 `boot.img` 或 `init_boot.img`,完成备份和 Magisk 修补,
+验证 ADB → fastboot 设备身份连续性,再检测解锁与槽位并在明确确认后刷入、重启。
Mini、Mini.Mac 不提供 Root 向导,也不携带 Magisk 或 payload-dumper。
@@ -36,12 +36,14 @@ Mini、Mini.Mac 不提供 Root 向导,也不携带 Magisk 或 payload-dumper
验证步骤:
-1. 使用一台可丢弃数据、bootloader 已解锁的测试机和与该机版本匹配的原始 `boot.img`。
+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`。
@@ -60,7 +62,7 @@ M0 Magisk 实机验证
-> Task 3 ZIP / 嵌套 ZIP 提取
-> Task 4 Root 工具固定版本与打包
-> Task 5 payload.bin 提取
- -> Task 6 原始 boot 备份
+ -> Task 6 原始目标镜像备份
-> Task 7 Magisk 修补服务
-> Task 8 严格 fastboot 检测与刷写
-> Task 9 向导状态机
@@ -98,6 +100,7 @@ M0 Magisk 实机验证
文件:
- 新增 `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`
@@ -112,9 +115,12 @@ M0 Magisk 实机验证
约束:
-- `RootWizardSnapshot` 保存设备 serial、包路径、工作目录、原始/修补镜像、备份路径、槽位和错误码。
+- `BootImageInfo` 显式保存 `BootPartitionTarget`(`Boot` / `InitBoot`),禁止从文件名反推刷写分区。
+- `RootWizardSnapshot` 保存 ADB serial、USB 路径、product、重启前 fastboot 基线、匹配后的 fastboot
+ serial、包路径、目标分区、工作目录、原始/修补镜像、备份路径、槽位和错误码。
- 错误对 UI 暴露稳定错误码,不直接把任意 stderr 当成用户文案。
-- 状态增加 `BlockedAmbiguousFastboot`:ADB 重启后若出现多个 fastboot 设备且无法唯一匹配,禁止刷写。
+- 状态增加 `BlockedFastbootIdentity`:ADB 重启后没有出现新设备,或 serial / USB 路径 / product
+ 等证据不能证明 fastboot 设备就是所选 ADB 设备时禁止刷写;列表中仅有一台设备不算证明。
- 所有 I/O 接口均为 async,并传递 `CancellationToken`。
验收:Core 和 Models 无上层项目引用,`dotnet build AndroidTreeView.sln -p:EnableWindowsTargeting=true`。
@@ -124,14 +130,17 @@ M0 Magisk 实机验证
文件:
- 新增 `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`、大小写差异、Pixel `image-*.zip`、payload、无目标文件、重复目标、
-损坏 ZIP、路径穿越、取消和工作目录清理。
+测试先覆盖:顶层 `boot.img` / `init_boot.img`、两者同时存在、GKI 13+ 选择 `init_boot`、存在
+`init_boot` 的 Android 12 内核仍选择 `boot`、大小写差异、Pixel `image-*.zip`、payload、目标镜像
+缺失、设备与包证据冲突、recovery-only、重复目标、损坏 ZIP、路径穿越、取消和工作目录清理。
实现约束:
@@ -140,6 +149,9 @@ M0 Magisk 实机验证
- 设定单条目和总展开大小上限,拒绝 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//`,失败时清理,成功时由向导结束后清理。
验收:提取测试全部通过,且测试用临时目录在成功、失败、取消后均被回收。
@@ -184,13 +196,14 @@ M0 Magisk 实机验证
步骤:
1. 用 fake runner 写测试,固定断言可执行文件、argv、超时和输出目录。
-2. 对裸 payload 与 ZIP 内 payload 使用同一分支,只请求 `boot` 分区。
-3. runner 非零退出、超时或未产出 `boot.img` 时抛稳定错误码。
+2. 对裸 payload 与 ZIP 内 payload 使用同一分支,按已解析的 `BootPartitionTarget` 只请求
+ `boot` 或 `init_boot` 分区。
+3. runner 非零退出、超时或未产出目标镜像时抛稳定错误码。
4. 校验输出文件存在、大小合理,再返回 `BootImageInfo`。
验收:不安装真实 payload-dumper 也能覆盖所有流程分支;打包 smoke test 再验证真实二进制可启动。
-### Task 6:实现原始 boot 备份
+### Task 6:实现原始目标镜像备份
文件:
@@ -219,6 +232,7 @@ M0 Magisk 实机验证
严格按 M0 验证通过的命令序列实现:安装固定 APK、探测 ABI、创建会话临时目录、push 镜像、执行
官方修补脚本、pull 结果、校验产物,并在 `finally` 中清理手机临时目录。
+每条 ADB 命令都必须通过 argv 显式指定 `RootWizardSnapshot` 中锁定的 ADB serial。
测试覆盖:安装失败、未知 ABI、push 失败、脚本失败、pull 失败、空产物、取消和清理失败不遮蔽主错误。
日志不得输出完整敏感 stderr;UI 只接收错误码与经过裁剪的诊断摘要。
@@ -237,21 +251,25 @@ M0 Magisk 实机验证
- 新增 `tests/AndroidTreeView.Adb.Tests/Parsers/FastbootVarParserTests.cs`
- 扩展 `tests/AndroidTreeView.Adb.Tests/Services/FastbootServiceTests.cs`
-新增能力:等待指定/唯一 fastboot 设备、读取 `unlocked`、`slot-count`、`has-slot:boot` 与
-`current-slot`、单槽刷 `boot`、A/B 依次刷 `boot_a` 和 `boot_b`、返回失败分区和 stderr 摘要、
-重启系统。
+新增能力:重启前捕获 `fastboot devices -l` 基线,等待并匹配重启后新出现的 fastboot 设备,读取
+`unlocked`、`slot-count`、`has-slot:` 与 `current-slot`,按 `BootPartitionTarget` 执行单槽
+或 A/B 双槽刷写,返回失败分区和 stderr 摘要并重启系统。
安全规则:
-- fastboot 设备无法唯一识别时返回 `AmbiguousDevice`,绝不选择列表第一台。
+- 重启前保存已有 fastboot serial 集合和所选 ADB 设备的 serial、USB 路径、product;重启后只考虑
+ 基线之外的新设备。优先匹配保持不变的 serial,再用 USB 路径和 `getvar product` 交叉验证。
+- 没有新设备、证据冲突或无法充分匹配时返回 `FastbootIdentityUnverified`,绝不因列表中只有一台
+ 就选择它。后续每条 fastboot 命令都必须显式带 `-s `。
- 可执行文件先查已定位 adb 的同目录,再回退到 App 自带 `scrcpy/fastboot[.exe]`。
- `unlocked` 不是明确 yes/true 时按未解锁处理。
-- `has-slot:boot=no` 才判为非 A/B;`has-slot:boot=yes` 且槽位数明确时判为 A/B;信息矛盾或
+- `has-slot:=no` 才判为非 A/B;`has-slot:=yes` 且槽位数明确时判为 A/B;信息矛盾或
缺失时不猜布局,进入阻塞状态。
-- A/B 的 `boot_a` 成功、`boot_b` 失败时返回部分写入结果,UI 必须显示设备处于危险中间态。
+- A/B 的 `_a` 成功、`_b` 失败时返回部分写入结果,UI 必须显示设备处于危险中间态。
- 非零退出和超时不得继续执行下一条写命令。
-验收:argv、stdout/stderr 解析和全部失败分支均由纯单测固定。
+验收:argv、stdout/stderr 解析和全部失败分支均由纯单测固定;至少覆盖“已有其他 fastboot 设备且
+目标重启失败”“目标作为新设备出现”“serial 改变但 USB/product 匹配”“唯一设备但无身份证据”四种场景。
### Task 9:实现可恢复的 Root 向导状态机
@@ -264,12 +282,14 @@ M0 Magisk 实机验证
状态转换由显式方法驱动:`SelectPackage`、`ExtractAndPatchAsync`、`ConfirmBootloaderAsync`、
`DetectFastbootAsync`、`ConfirmFlashAsync`、`RetryAsync`、`CancelAsync`。服务不弹 UI,也不自行越过
确认点。
+`ConfirmBootloaderAsync` 必须先捕获 fastboot 基线,再对锁定的 ADB serial 执行 reboot;
+`DetectFastbootAsync` 只接收 `WaitForMatchingFastbootDeviceAsync` 返回的已验证 serial。
测试固定以下不变量:
- 未完成备份不能进入 flash 确认。
- 包元数据明确与设备不匹配时不能进入修补或 flash;无法验证时必须显示额外风险提示。
-- 未解锁、槽位未知、设备歧义、未勾选风险确认均不能调用 flash。
+- 未解锁、槽位未知、目标分区未知、fastboot 身份未验证、未勾选风险确认均不能调用 flash。
- 取消会杀掉当前外部进程并保留原始备份。
- 重试只重跑失败步骤,不重复刷已成功的分区。
- 完成/中止后清理工作目录,但永不删除备份。
@@ -294,10 +314,15 @@ M0 Magisk 实机验证
实现:
- 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.*`。
- 狭窄窗口下步骤条可横向滚动,底部操作区不遮挡错误和日志。
@@ -313,8 +338,10 @@ M0 Magisk 实机验证
- 修改 `tests/AndroidTreeView.App.Tests/ServiceGraphTests.cs`
- 修改 `tests/AndroidTreeView.App.Tests/BootSmokeTests.cs`
-覆盖:导航、选包取消、两个确认门、锁定设备、设备歧义、A/B 警告、第二槽失败、重试、取消、语言切换、
-DI 图和 XAML 启动。再运行全量 build/test/format,确认设备列表与现有 best-effort fastboot 行为未回归。
+覆盖:导航、单/多设备选择、禁止默认选择第一台、选包取消、`boot` / `init_boot` 目标选择、
+recovery-only 阻塞、两个确认门、锁定设备、ADB → fastboot 身份匹配失败、A/B 警告、第二槽失败、
+重试、取消、语言切换、DI 图和 XAML 启动。
+再运行全量 build/test/format,确认设备列表与现有 best-effort fastboot 行为未回归。
验收命令:
@@ -342,15 +369,16 @@ dotnet format AndroidTreeView.sln --verify-no-changes
- M0、M1 有可复核记录,且没有未说明的手工步骤。
- 两个平台可从发布 ZIP 启动并走完整向导。
-- 未解锁、设备歧义、槽位未知、包不匹配、包损坏、工具缺失和命令失败都无法越过写入门。
-- 原始 boot 在任何刷写前已完成本地备份,失败或取消不会删除。
+- 未解锁、fastboot 身份未验证、目标分区或槽位未知、包不匹配、包损坏、工具缺失和命令失败都
+ 无法越过写入门。
+- 原始目标镜像在任何刷写前已完成本地备份,失败或取消不会删除。
- App 不因正常设备/命令错误崩溃;错误均有中英文文案和可操作的下一步。
- 全量测试通过,两个 ResX 键集合一致,Mini 不携带 Root 工具。
## 7. 明确不在本计划内
- 自动解锁 bootloader。
-- 刷写 `boot` 以外分区。
+- 刷写 `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
index bea6286..1f7d8d8 100644
--- a/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
+++ b/docs/superpowers/specs/2026-07-09-semi-auto-root-design.md
@@ -12,28 +12,33 @@
## 1. 目标与范围
-为 AndroidTreeView 增加一个**半自动 Root 功能**:用户手动上传刷机包,工具自动提取
-`boot.img`、用官方 Magisk 组件修补、引导进入 bootloader、把修补后的 `boot.img` 刷入设备。
-以**分步向导**形态交付,写操作前强制确认,刷入前自动备份原始 `boot.img`。
+为 AndroidTreeView 增加一个**半自动 Root 功能**:用户手动上传刷机包,工具根据设备安全选择
+`boot.img` 或 `init_boot.img`、用官方 Magisk 组件修补、引导进入 bootloader、把修补后的镜像
+刷入对应分区。以**分步向导**形态交付,写操作前强制确认,刷入前自动备份原始未修补镜像。
### 1.1 第一版明确要做
- 上传刷机包(本地文件选择)。
-- 提取 `boot.img`:
- - zip 内含裸 `boot.img`(含 Pixel factory 的嵌套 `image-*.zip`)。
- - `payload.bin`(A/B OTA),通过打包的 `payload_dumper` 解出。
+- 提取目标启动镜像:
+ - 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 boot(A/B 机型两个槽都刷)** →
+- 写操作链路:`adb push` → `adb reboot bootloader` → **flash 目标启动分区(A/B 机型两个槽都刷)** →
`fastboot reboot`。
- - **非 A/B 机型**:`fastboot flash boot
`。
- - **A/B 机型**:`fastboot flash boot_a
` **且** `fastboot flash boot_b
`
+ - **非 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:boot`、`current-slot`,矛盾或缺失时阻止刷写)**、设备 ABI 探测。
-- **刷入前自动备份原始未修补 `boot.img`** 到本地用户目录。
+ (联合 `slot-count`、`has-slot:`、`current-slot`,矛盾或缺失时阻止刷写)**、设备 ABI 探测。
+- ADB → fastboot 身份连续性:重启前保存 fastboot 基线和所选 ADB 设备身份;重启后只接受新出现且
+ serial / USB 路径 / product 等证据能匹配的设备。无法证明是同一设备时阻止刷写。
+- **刷入前自动备份原始未修补目标镜像** 到本地用户目录。
- 平台:**Windows + macOS 双平台**,共用同一套 MVVM 代码。
### 1.2 第一版明确不做(后续再议)
@@ -53,7 +58,9 @@
| 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 |
-| A/B 机型刷入策略 | **两个槽 `boot_a`/`boot_b` 都刷同一镜像 + 刷入前强制提示** | 免探测 active slot、免刷错槽;代价(跨版本双槽变砖)以提示告知用户承担 |
+| 目标启动分区 | **仅在 `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`** | 最贴近官方;实机验证列为高风险里程碑 |
@@ -67,12 +74,12 @@
不自动解锁(解锁抹数据、需在手机上按键、部分厂商需解锁码)。
- **前提 2 — 包与设备匹配**:刷错版本/架构可能变砖。工具做基本校验与警告,但无法保证包正确。
- **前提 3 — A/B 双槽刷入的跨版本风险(已知、以提示承担)**:A/B 机型两个槽都刷入同一
- 修补后镜像。若两个槽当前系统版本不一致(如 OTA 后未满一个周期),把当前版本的 boot 刷进
+ 修补后镜像。若两个槽当前系统版本不一致(如 OTA 后未满一个周期),把当前版本的目标启动镜像刷进
另一槽会造成 boot 与该槽 system/vendor 版本错配,**日后回滚到该槽可能 bootloop**(延迟变砖,
刷入当下无异常)。工具**无法可靠检测两槽版本**,故在**确认 2 强制提示**用户此风险,由用户
在知情后承担(对应 §2 决策:省掉刷错槽 → 接受此代价)。
-设计上以**分步向导 + 强制确认 + 自动备份原始 boot** 将风险降到可控。
+设计上以**分步向导 + 强制确认 + 自动备份原始目标镜像** 将风险降到可控。
**最高技术不确定性**:Magisk 修补的第 4 步(执行 `boot_patch.sh`)不是单条命令。现方案先
`adb install` 官方 Magisk APK,再调用其自带的 `boot_patch.sh` + native 组件——装 App 后
@@ -85,8 +92,8 @@
严格遵循项目分层(下层不引用上层):
```
-Models FlashPackage, BootImageInfo(Path, Source, OriginalPackageName),
- RootWizardState(enum), DeviceFastbootStatus, PackageType(enum)
+Models FlashPackage, BootImageInfo(Path, Source, OriginalPackageName, TargetPartition),
+ BootPartitionTarget(enum), RootWizardState(enum), DeviceFastbootStatus, PackageType(enum)
↑
Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWizardService
IExternalCommandRunner、RootException 类型
@@ -94,8 +101,8 @@ Core 接口:IBootImageExtractor, IMagiskPatcher, IFastbootService, IRootWi
Adb / Infrastructure
Adb:扩展现有 FastbootService、
BootImageExtractor(zip + payload.bin)、MagiskPatcher(install APK + 手机端执行)、
- Parsers:FastbootVarParser、PackageTypeDetector、CpuAbiParser
- Infra:BootBackupService(备份原始 boot 到用户目录)
+ Parsers:FastbootVarParser、PackageTypeDetector、BootPartitionTargetDetector、CpuAbiParser
+ Infra:BootBackupService(备份原始目标镜像到用户目录)
↑
Shared AddAndroidTreeViewSharedServices() 内 TryAdd 注册全部新服务
↑
@@ -114,16 +121,18 @@ App RootWizardViewModel + RootWizardView(新导航页,分步向导)
```
Idle 初始
PackageSelected 已选刷机包
-Extracting 提取 boot.img 中(自动)
+Extracting 检测并提取 boot/init_boot 目标镜像(自动)
BootExtracted 已提取(含来源:PlainZip / NestedZip / Payload)
+Blocked_UnsupportedTarget 目标分区证据冲突、镜像缺失或设备必须修补 recovery,禁止继续
Patching 修补中(adb install Magisk APK → 手机执行 boot_patch.sh → pull 回)
-BootPatched 已得修补后 boot + 已本地备份原始 boot
+BootPatched 已得修补后目标镜像 + 已本地备份原始目标镜像
AwaitingBootloaderConfirm ⛔ 强制确认点 1:即将重启进 bootloader
RebootingToBootloader adb reboot bootloader(自动)
-InFastboot 已进 fastboot,检测解锁状态 + A/B 布局(多变量联合判定)
+InFastboot 已确认同一设备进入 fastboot,检测解锁状态 + 目标分区 A/B 布局
+Blocked_DeviceMismatch 无法证明 fastboot 设备与所选 ADB 设备相同,禁止继续
Blocked_Locked 未解锁:引导用户,终止自动流程(可重新检测)
-AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash boot(显示目标槽位;A/B 明确告知双槽都刷)
-Flashing fastboot flash boot(A/B:先 boot_a 再 boot_b,自动)
+AwaitingFlashConfirm ⛔ 强制确认点 2:即将 flash 目标分区(显示分区/槽位;A/B 双槽都刷)
+Flashing fastboot flash (A/B:先 _a 再 _b,自动)
Rebooting fastboot reboot(自动)
Completed 完成
Failed 任一步失败(带错误信息 + 重试当前步 / 中止)
@@ -139,8 +148,8 @@ Failed 任一步失败(带错误信息 + 重试当前步
### 5.3 两个强制确认点
1. **确认 1(进 bootloader 前)**:提示手机将重启进 fastboot、保持数据线稳定。
-2. **确认 2(flash 前)**:显示目标分区 `boot`、修补后镜像、**原始 boot 备份路径**、
- **目标槽位(A/B 机型明确显示"将同时刷入 boot_a 和 boot_b")**、红色"刷错可能变砖"警告;
+2. **确认 2(flash 前)**:显示目标分区 `boot` 或 `init_boot`、修补后镜像、**原始镜像备份路径**、
+ **目标槽位(A/B 机型明确显示将同时刷入 `_a` 和 `_b`)**、红色"刷错可能变砖"警告;
**A/B 机型额外红字提示**:两个槽都会被写入同一镜像,若两槽系统版本不一致,日后回滚到另一槽
可能变砖(§3 前提 3);用户勾选"我已了解风险"后方可点"刷入"。
@@ -149,29 +158,36 @@ Failed 任一步失败(带错误信息 + 重试当前步
- **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 boot.img 提取(`BootImageExtractor`)
+### 6.1 目标启动镜像提取(`BootImageExtractor`)
-输入包文件,按类型分派,输出 `boot.img` 本地路径 + 来源标记
-(`BootImageInfo { Path, Source, OriginalPackageName }`)。
+输入包文件和设备探测结果,按类型分派,输出目标镜像本地路径、目标分区 + 来源标记
+(`BootImageInfo { Path, TargetPartition, Source, OriginalPackageName }`)。
**包类型判定**(纯函数 `PackageTypeDetector`,读文件头/枚举 zip 条目):
-- zip 顶层有 `boot.img` → **PlainZip**
-- zip 含 `image-*.zip`(Pixel factory)→ **NestedZip**(解一层内层 zip 再取 `boot.img`)
+- zip 顶层有 `boot.img` / `init_boot.img` → **PlainZip**
+- zip 含 `image-*.zip`(Pixel factory)→ **NestedZip**(解一层内层 zip 再取目标镜像)
- zip 含 `payload.bin` → **Payload**
- 裸 `payload.bin` 文件 → **Payload**
-- 都不匹配 → 抛 `RootException`,友好提示"未在包内找到 boot.img"
+- 都不匹配 → 抛 `RootException`,友好提示"未在包内找到受支持的 boot/init_boot 镜像"
**提取**:
- PlainZip / NestedZip → 纯 C# `System.IO.Compression`,无外部依赖。
-- Payload → 调用打包的 `payload_dumper`(走 `ProcessRunner`)解出 `boot` 分区。
+- 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 解包用假
@@ -187,14 +203,16 @@ Failed 任一步失败(带错误信息 + 重试当前步
`root.step.install_magisk`)。install 失败进 `Failed`。
2. **探测 ABI**:`adb shell getprop ro.product.cpu.abi`(纯函数 `CpuAbiParser`),用于定位已装
Magisk App 的 native 组件路径(`lib/`)。
-3. **本地备份原始 boot.img**(`BootBackupService`):复制到
- `~/.androidtreeview/root-backups/--boot-original.img`,路径供确认 2 显示。
-4. **修补**:push 待修补 `boot.img` 到手机临时目录 `/data/local/tmp/atv_root/`,`adb shell` 调用
+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)。
@@ -204,14 +222,16 @@ Failed 任一步失败(带错误信息 + 重试当前步
| 方法 | 命令 | 说明 |
|---|---|---|
| `RebootToBootloaderAsync` | `adb reboot bootloader` | 从系统进 fastboot |
-| `WaitForFastbootDeviceAsync` | `fastboot devices` 轮询 | 等设备在 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:boot` + `current-slot` | 只读;联合判定 A/B,信息矛盾或缺失时返回 Unknown |
-| `FlashBootAsync` | 非 A/B:`fastboot flash boot
`;A/B:`fastboot flash boot_a
` **且** `fastboot flash boot_b
` | 核心写操作;A/B 两槽都刷同一镜像 |
+| `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` 输出为纯函数 + 解析测试。`slot-count`、`has-slot:boot`、
-`current-slot` 复用同一 `FastbootVarParser`。A/B 双槽刷入时,`boot_a` 成功、`boot_b` 失败要
+解析 `getvar` / `fastboot devices -l` 输出为纯函数 + 解析测试。所有设备命令都必须携带
+`-s `;单纯“列表中只有一台”不是身份匹配证据。`slot-count`、
+`has-slot:`、`current-slot` 复用同一 `FastbootVarParser`。A/B 双槽刷入时,第一槽成功、第二槽失败要
视为整体失败(进 `Failed`,错误摘要注明哪个槽失败),不可停在"只刷了一个槽"的中间态。
### 6.4 fastboot 定位(复用现有 `FastbootService`)
@@ -222,7 +242,7 @@ Failed 任一步失败(带错误信息 + 重试当前步
### 6.5 备份服务(`BootBackupService`,Infrastructure 层)
-- 修补前把原始 boot.img 存到 `~/.androidtreeview/root-backups/`,文件名含序列号 + 时间戳。
+- 修补前把原始目标镜像存到 `~/.androidtreeview/root-backups/`,文件名含序列号、目标分区 + 时间戳。
- 提供"列出/打开备份目录"给 UI,便于变砖时找回。
## 7. 工具打包 — `build/AndroidTreeView.RootTools.targets`
@@ -252,15 +272,19 @@ tools/
1. **`boot_patch.sh` 修补链路** — `adb install` 官方 Magisk APK 后,能调用其自带
`boot_patch.sh` 在真机产出可用 `new-boot.img`(重点验证已装 App 的脚本是否可被 `adb shell`
- 直接调到)。
-2. **真实 flash** — `fastboot flash boot` 在已解锁真机上成功且能正常开机。
-3. **payload.bin 解包** — `payload_dumper` 在 `win-x64`、`osx-arm64` 均能正确解出 boot 分区。
+ 直接调到);`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`,中间当前步
内容/进度/日志,底部操作按钮(下一步/确认/取消/重试)。
- 两个强制确认为醒目对话框 + 复选框(未勾选"我已了解风险"则"刷入"禁用)。
@@ -276,6 +300,9 @@ tools/
- 命名示例:`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`。
@@ -283,15 +310,17 @@ tools/
## 11. 测试
- **Adb.Tests** 新增:
- - `Parsers/FastbootVarParserTests`(`unlocked` / `slot-count` / `has-slot:boot` /
- `current-slot` / `fastboot devices`)
+ - `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 构建测试(含 A/B 双槽 `flash boot_a`/`boot_b` 的 argv)
+ - `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` 覆盖非 A/B 单槽、A/B 双槽成功、A/B 第二槽失败→整体失败)
+ 失败→`Failed`、ABI 探测;`FlashBootAsync` 覆盖 `boot` / `init_boot`、非 A/B 单槽、A/B 双槽成功、
+ A/B 第二槽失败→整体失败)
- **App.Tests** 新增:`RootWizardViewModel` 状态机推进、确认门控(未勾选不能 flash)、错误映射、
未解锁→Blocked 分支;DI 图解析新服务(`ServiceGraphTests`)。
- **实机验证**(§8):无法单测,作为手动验证清单。
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.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/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.Shared/AndroidTreeViewSharedServices.cs b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
index 09d0d40..580de18 100644
--- a/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
+++ b/src/AndroidTreeView.Shared/AndroidTreeViewSharedServices.cs
@@ -30,6 +30,7 @@ public static IServiceCollection AddAndroidTreeViewSharedServices(
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
services.TryAddSingleton();
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);
+ }
+}
From 4973903d82d76318bf48678992ddb7a37412da2c Mon Sep 17 00:00:00 2001
From: Birditch
Date: Fri, 10 Jul 2026 20:31:04 +0800
Subject: [PATCH 10/10] Update GitHub release documentation
---
AGENTS.md | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 9d6357f..fc882dd 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -142,7 +142,7 @@ Fixtures are **inline `const` strings** inside the test classes, not external `.
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 (`NekoIndexUpdateService`) and
+- **`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
@@ -153,9 +153,10 @@ Fixtures are **inline `const` strings** inside the test classes, not external `.
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). `NekoIndexUpdateService` checks the
-channel and compares semver; `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.
+`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