Skip to content

Commit 5cb060a

Browse files
backspace135claude
andcommitted
feat: semi-automatic Root wizard with Magisk boot patching (v1.0.7)
Adds the semi-automatic Root wizard: extract boot/init_boot from a user-supplied firmware package, patch it with components taken from the pinned official Magisk APK, and flash it after backup, ADB-to-fastboot identity verification, unlock/layout checks and explicit confirmation. This is a deliberate exception to the read-only stance: the wizard writes only an evidence-backed boot / init_boot image. It never unlocks the bootloader and never touches system, vendor, vbmeta, recovery or super. Patch flags are probed on the device with official Magisk's own app_init (app_functions.sh) and passed to boot_patch.sh as environment variables. This is not optional: boot_patch.sh silently defaults a missing flag to false and ends in a bare `true`, so its exit code proves nothing. On a system-as-root device KEEPVERITY=false strips `logical` and `first_stage_mount` off the /system fstab entry, leaving first-stage init unable to mount the root filesystem — a guaranteed bootloop. Verified on a real Redmi Note 9 5G (cannon, MT6853, Android 12/MIUI 14): with the probed flags the patcher reproduces the official Magisk app's output byte-for-byte; without them it does not. As defence in depth, the patched image's own config is read back out of the ramdisk and compared against the probed flags before the image is allowed anywhere near the flash step. Neither the exit code nor a header check can catch a wrongly-flagged image — it is structurally valid. Bumps every aligned version field to 1.0.7 and points the README device screenshot at the image that actually exists (v1.0.5); it had referenced a v1.0.6 file that was never added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4973903 commit 5cb060a

85 files changed

Lines changed: 8277 additions & 96 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/publish.yml

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,10 @@ jobs:
8484
shell: pwsh
8585
run: ./tools/verify-scrcpy-latest.ps1
8686

87+
- name: Verify Root tool release pins
88+
shell: pwsh
89+
run: ./tools/verify-roottools-latest.ps1
90+
8791
package:
8892
name: Package ${{ matrix.product }} ${{ matrix.rid }}
8993
runs-on: ${{ matrix.os }}
@@ -167,6 +171,98 @@ jobs:
167171
168172
Write-Host "Verified macOS ZIP contains $appName."
169173
174+
- name: Verify package integrity and Root tools isolation
175+
shell: pwsh
176+
run: |
177+
$ErrorActionPreference = 'Stop'
178+
Add-Type -AssemblyName System.IO.Compression.FileSystem
179+
180+
$zip = Get-ChildItem -Path artifacts -Filter '*.zip' | Select-Object -First 1
181+
if ($null -eq $zip) {
182+
throw 'No ZIP artifact was produced.'
183+
}
184+
185+
$sidecarPath = "$($zip.FullName).sha256"
186+
if (-not (Test-Path -LiteralPath $sidecarPath)) {
187+
throw "ZIP '$($zip.Name)' is missing its SHA-256 sidecar."
188+
}
189+
190+
$sidecar = (Get-Content -LiteralPath $sidecarPath -Raw).Trim()
191+
if ($sidecar -notmatch '^([0-9a-fA-F]{64}) \*([^\\/\r\n]+)$') {
192+
throw "SHA-256 sidecar for '$($zip.Name)' has an invalid format."
193+
}
194+
195+
$expectedHash = $Matches[1].ToLowerInvariant()
196+
$expectedName = $Matches[2]
197+
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $zip.FullName).Hash.ToLowerInvariant()
198+
if ($expectedName -ne $zip.Name -or $expectedHash -ne $actualHash) {
199+
throw "SHA-256 sidecar does not match ZIP '$($zip.Name)'."
200+
}
201+
202+
$product = '${{ matrix.product }}'
203+
$rid = '${{ matrix.rid }}'
204+
$appPrefix = if ($rid -eq 'osx-arm64') { 'AndroidTreeView.app/Contents/MacOS/' } else { '' }
205+
$archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName)
206+
try {
207+
$entries = @($archive.Entries | ForEach-Object { $_.FullName })
208+
} finally {
209+
$archive.Dispose()
210+
}
211+
212+
if ($rid -eq 'win-x64') {
213+
$executable = if ($product -eq 'Mini') { 'AndroidTreeView.App.mini.exe' } else { 'AndroidTreeView.App.exe' }
214+
$requiredWindowsEntries = @(
215+
$executable,
216+
'release.json',
217+
'scrcpy/scrcpy.exe',
218+
'scrcpy/adb.exe'
219+
)
220+
foreach ($entry in $requiredWindowsEntries) {
221+
if ($entries -notcontains $entry) {
222+
throw "Windows ZIP '$($zip.Name)' is missing required entry '$entry'."
223+
}
224+
}
225+
}
226+
227+
$rootEntries = @($entries | Where-Object { $_ -match '(^|/)root-tools/' })
228+
if ($product -eq 'Mini') {
229+
if ($rootEntries.Count -ne 0) {
230+
throw "Mini ZIP '$($zip.Name)' contains App-only Root tools: $($rootEntries -join ', ')."
231+
}
232+
Write-Host "Verified Mini ZIP does not contain root-tools/."
233+
exit 0
234+
}
235+
236+
$payloadExecutable = if ($rid -eq 'win-x64') { 'payload-dumper-go.exe' } else { 'payload-dumper-go' }
237+
$fastbootExecutable = if ($rid -eq 'win-x64') { 'fastboot.exe' } else { 'fastboot' }
238+
$requiredEntries = @(
239+
"${appPrefix}scrcpy/$fastbootExecutable",
240+
"${appPrefix}root-tools/magisk/Magisk-v30.7.apk",
241+
"${appPrefix}root-tools/payload-dumper/$payloadExecutable"
242+
)
243+
foreach ($entry in $requiredEntries) {
244+
if ($entries -notcontains $entry) {
245+
throw "App ZIP '$($zip.Name)' is missing required Root tool '$entry'."
246+
}
247+
}
248+
249+
if ($rid -eq 'osx-arm64') {
250+
$extractDir = Join-Path $env:RUNNER_TEMP 'root-tools-mode-check'
251+
New-Item -ItemType Directory -Force -Path $extractDir | Out-Null
252+
& unzip -q $zip.FullName -d $extractDir
253+
if ($LASTEXITCODE -ne 0) {
254+
throw "Could not extract '$($zip.FullName)' to verify executable mode."
255+
}
256+
257+
$payloadPath = Join-Path $extractDir "${appPrefix}root-tools/payload-dumper/$payloadExecutable"
258+
& /usr/bin/test -x $payloadPath
259+
if ($LASTEXITCODE -ne 0) {
260+
throw "macOS payload-dumper-go is not executable in '$($zip.Name)'."
261+
}
262+
}
263+
264+
Write-Host "Verified App ZIP contains fastboot, Magisk, and payload-dumper-go."
265+
170266
- name: Upload package artifact
171267
uses: actions/upload-artifact@v7
172268
with:

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ coverage*.info
5454
# ---- Bundled tooling (Android platform-tools, fetched at build time) ----
5555
tools/*
5656
!tools/verify-scrcpy-latest.ps1
57+
!tools/verify-roottools-latest.ps1
58+
59+
# ---- Local agent/tooling config ----
60+
.claude/
5761

5862
# ---- OS cruft ----
5963
.DS_Store

AGENTS.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,11 @@ Key architectural rules (see `docs/architecture.md` for the full binding type/in
103103
- Views use **compiled bindings** (`x:DataType`) and bind only to members that exist on the VM. No
104104
business logic or ADB calls in views.
105105
- **No hardcoded user-facing strings.** Use localization for every user-visible string (see below).
106-
- **Read-only / safe by design**: do not introduce ADB commands that modify, wipe, or flash a device.
106+
- **Safe by design**: device inspection remains read-only. The only write exception is the dedicated
107+
semi-automatic Root wizard, which may install the pinned Magisk APK and flash only an evidence-backed
108+
`boot` / `init_boot` image after backup, ADB-to-fastboot identity verification, unlock/layout checks,
109+
and two explicit confirmations. Never automate bootloader unlock, erase/format, or other partitions.
110+
Real flash is gated only by those in-app confirmations; there is no environment-variable override.
107111
- Keep files under ~400 lines; the App never crashes on normal ADB/device errors — map them to friendly
108112
`ErrorMessage` state.
109113

@@ -117,7 +121,7 @@ Chinese**; English is the neutral/fallback resource.
117121
in `AndroidTreeView.Core/Options/SettingsEnums.cs`. `Get` returns the key itself if a resource is
118122
missing, so a missing translation is visible, not a crash.
119123
- **Implementation**: `LocalizationService` in `src/AndroidTreeView.App/Localization`, backed by two ResX
120-
files with **identical key sets** (keep them in sync — currently 217 keys each):
124+
files with **identical key sets** (keep them in sync — currently 329 keys each):
121125
- `src/AndroidTreeView.App/Resources/Strings.resx` — English (neutral fallback)
122126
- `src/AndroidTreeView.App/Resources/Strings.zh-Hans.resx` — Simplified Chinese (default)
123127
- Keys are dotted/namespaced: `app.title`, `nav.devices`, `common.refresh`, etc.
@@ -146,13 +150,14 @@ Fixtures are **inline `const` strings** inside the test classes, not external `.
146150
`UpdateInstaller`, using test doubles in `TestDoubles.cs`.
147151
- **`App.Tests`** — ViewModel logic with fake services (`Fakes.cs`): device-list reconcile keeps
148152
selection, RawProperties filtering, Logcat bounding, DI graph resolves (`ServiceGraphTests`), and a boot
149-
smoke test via `Avalonia.Headless` (`TestAppBuilder.cs`, `BootSmokeTests.cs`). No real ADB, no on-screen
150-
rendering.
153+
smoke test via `Avalonia.Headless` (`TestAppBuilder.cs`, `BootSmokeTests.cs`). Root wizard tests cover
154+
device locking, both confirmation gates, retry/partial-write states, localization, and compiled bindings.
155+
No real ADB, no on-screen rendering.
151156

152157
## Versioning & updates
153158

154159
Version is unified across runtime version, App/Mini assembly versions, manifests, and
155-
`packaging/build-update-zip.ps1` — keep them in sync when bumping (currently `1.0.6`). Update channels:
160+
`packaging/build-update-zip.ps1` — keep them in sync when bumping (currently `1.0.7`). Update channels:
156161
`android-tree-view-app` (App) and `android-tree-view-mini` (Mini). `GitHubUpdateService` checks the latest
157162
GitHub Release, compares semver, and selects the matching product asset; `UpdateInstaller` downloads,
158163
verifies SHA-256, unpacks the x64 ZIP, and runs a local update script. Loose ZIPs without a supported
@@ -162,3 +167,6 @@ verifies SHA-256, unpacks the x64 ZIP, and runs a local update script. Loose ZIP
162167

163168
`build/AndroidTreeView.Scrcpy.targets` downloads and bundles scrcpy (which ships adb) into `tools/scrcpy`
164169
at build/publish time on Windows. `tools/verify-scrcpy-latest.ps1` checks for newer scrcpy releases.
170+
The full App also imports `build/AndroidTreeView.RootTools.targets`, which pins and verifies the official
171+
Magisk APK and payload-dumper-go for `win-x64` / `osx-arm64`. Mini variants never import or ship Root tools.
172+
Fastboot comes from pinned Android SDK Platform-Tools 37.0.0 and is hash-verified before packaging.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
<Project>
2+
3+
<!-- Root workflow tools are App-only. Mini projects must never import this target. -->
4+
<PropertyGroup>
5+
<MagiskVersion Condition="'$(MagiskVersion)' == ''">30.7</MagiskVersion>
6+
<MagiskAssetName Condition="'$(MagiskAssetName)' == ''">Magisk-v$(MagiskVersion).apk</MagiskAssetName>
7+
<MagiskUrl Condition="'$(MagiskUrl)' == ''">https://github.com/topjohnwu/Magisk/releases/download/v$(MagiskVersion)/$(MagiskAssetName)</MagiskUrl>
8+
<MagiskSha256 Condition="'$(MagiskSha256)' == ''">e0d32d2123532860f97123d927b1bb86c4e08e6fd8a48bfc6b5bee0afae9ebd5</MagiskSha256>
9+
10+
<PayloadDumperVersion Condition="'$(PayloadDumperVersion)' == ''">1.3.0</PayloadDumperVersion>
11+
<PayloadDumperPlatform Condition="'$(RuntimeIdentifier)' == 'win-x64'">windows_amd64</PayloadDumperPlatform>
12+
<PayloadDumperPlatform Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">darwin_arm64</PayloadDumperPlatform>
13+
<PayloadDumperExecutableName Condition="'$(RuntimeIdentifier)' == 'win-x64'">payload-dumper-go.exe</PayloadDumperExecutableName>
14+
<PayloadDumperExecutableName Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">payload-dumper-go</PayloadDumperExecutableName>
15+
<PayloadDumperSha256 Condition="'$(RuntimeIdentifier)' == 'win-x64'">0f96e07477963327f7f50a03bf2aa9dac5c76dba110ab332dc759321ae345d52</PayloadDumperSha256>
16+
<PayloadDumperSha256 Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">e6b95df4b08e4bf452077e35cc2c0d644ce8fd454696d1aceedde6887ef0df84</PayloadDumperSha256>
17+
<PayloadDumperExecutableSha256 Condition="'$(RuntimeIdentifier)' == 'win-x64'">cd017857a28d029e80b0830531bcc960be5bbd4b8c937b122024285197012cd7</PayloadDumperExecutableSha256>
18+
<PayloadDumperExecutableSha256 Condition="'$(RuntimeIdentifier)' == 'osx-arm64'">56d5dd0f402cecc2548a563d840f3fd9e707521b5bd398430f59894c79d08450</PayloadDumperExecutableSha256>
19+
<PayloadDumperAssetName Condition="'$(PayloadDumperPlatform)' != ''">payload-dumper-go_$(PayloadDumperVersion)_$(PayloadDumperPlatform).tar.gz</PayloadDumperAssetName>
20+
<PayloadDumperUrl Condition="'$(PayloadDumperUrl)' == '' and '$(PayloadDumperAssetName)' != ''">https://github.com/ssut/payload-dumper-go/releases/download/$(PayloadDumperVersion)/$(PayloadDumperAssetName)</PayloadDumperUrl>
21+
22+
<RootToolsDir Condition="'$(RootToolsDir)' == ''">$(IntermediateOutputPath)root-tools</RootToolsDir>
23+
<RootToolsDownloadDir>$(IntermediateOutputPath)root-tools-download</RootToolsDownloadDir>
24+
<AndroidTreeViewBundleRootTools Condition="'$(AndroidTreeViewBundleRootTools)' == ''">true</AndroidTreeViewBundleRootTools>
25+
<_RootToolsSupportedRid Condition="'$(RuntimeIdentifier)' == 'win-x64' or '$(RuntimeIdentifier)' == 'osx-arm64'">true</_RootToolsSupportedRid>
26+
</PropertyGroup>
27+
28+
<Target Name="AtvEnsureRootTools" BeforeTargets="Build;Publish"
29+
Condition="'$(AndroidTreeViewBundleRootTools)' == 'true' and '$(_RootToolsSupportedRid)' == 'true' and (!Exists('$(RootToolsDir)/magisk/$(MagiskAssetName)') or !Exists('$(RootToolsDir)/payload-dumper/$(PayloadDumperExecutableName)'))">
30+
<Message Importance="high" Text="AndroidTreeView: fetching verified App-only Root tools for $(RuntimeIdentifier)..." />
31+
<MakeDir Directories="$(RootToolsDownloadDir);$(RootToolsDownloadDir)/payload-extract;$(RootToolsDir)/magisk;$(RootToolsDir)/payload-dumper" />
32+
33+
<DownloadFile SourceUrl="$(MagiskUrl)" DestinationFolder="$(RootToolsDownloadDir)"
34+
DestinationFileName="$(MagiskAssetName)" Retries="1">
35+
<Output TaskParameter="DownloadedFile" PropertyName="_MagiskDownload" />
36+
</DownloadFile>
37+
<GetFileHash Files="$(RootToolsDownloadDir)/$(MagiskAssetName)" Algorithm="SHA256" HashEncoding="hex">
38+
<Output TaskParameter="Items" ItemName="_MagiskHash" />
39+
</GetFileHash>
40+
<Error Condition="'%(_MagiskHash.FileHash)' != '$(MagiskSha256)'"
41+
Text="Magisk SHA-256 mismatch: expected $(MagiskSha256), got %(_MagiskHash.FileHash)." />
42+
<Copy SourceFiles="$(RootToolsDownloadDir)/$(MagiskAssetName)"
43+
DestinationFiles="$(RootToolsDir)/magisk/$(MagiskAssetName)" />
44+
45+
<DownloadFile SourceUrl="$(PayloadDumperUrl)" DestinationFolder="$(RootToolsDownloadDir)"
46+
DestinationFileName="$(PayloadDumperAssetName)" Retries="1">
47+
<Output TaskParameter="DownloadedFile" PropertyName="_PayloadDumperDownload" />
48+
</DownloadFile>
49+
<GetFileHash Files="$(RootToolsDownloadDir)/$(PayloadDumperAssetName)" Algorithm="SHA256" HashEncoding="hex">
50+
<Output TaskParameter="Items" ItemName="_PayloadDumperHash" />
51+
</GetFileHash>
52+
<Error Condition="'%(_PayloadDumperHash.FileHash)' != '$(PayloadDumperSha256)'"
53+
Text="payload-dumper-go SHA-256 mismatch: expected $(PayloadDumperSha256), got %(_PayloadDumperHash.FileHash)." />
54+
<Exec Command="tar -xzf &quot;$(RootToolsDownloadDir)/$(PayloadDumperAssetName)&quot; -C &quot;$(RootToolsDownloadDir)/payload-extract&quot;" />
55+
<Copy SourceFiles="$(RootToolsDownloadDir)/payload-extract/$(PayloadDumperExecutableName)"
56+
DestinationFiles="$(RootToolsDir)/payload-dumper/$(PayloadDumperExecutableName)" />
57+
<Exec Condition="'$(RuntimeIdentifier)' == 'osx-arm64'"
58+
Command="chmod +x &quot;$(RootToolsDir)/payload-dumper/$(PayloadDumperExecutableName)&quot;" />
59+
60+
<Error Condition="!Exists('$(RootToolsDir)/magisk/$(MagiskAssetName)')"
61+
Text="Verified Magisk asset was not copied to the Root tools layout." />
62+
<Error Condition="!Exists('$(RootToolsDir)/payload-dumper/$(PayloadDumperExecutableName)')"
63+
Text="Verified payload-dumper-go asset was not copied to the Root tools layout." />
64+
</Target>
65+
66+
<Target Name="AtvVerifyRootTools" BeforeTargets="Build;Publish" DependsOnTargets="AtvEnsureRootTools"
67+
Condition="'$(AndroidTreeViewBundleRootTools)' == 'true' and '$(_RootToolsSupportedRid)' == 'true'">
68+
<Error Condition="!Exists('$(RootToolsDir)/magisk/$(MagiskAssetName)') or !Exists('$(RootToolsDir)/payload-dumper/$(PayloadDumperExecutableName)')"
69+
Text="Root tools are incomplete for $(RuntimeIdentifier)." />
70+
<GetFileHash Files="$(RootToolsDir)/magisk/$(MagiskAssetName)" Algorithm="SHA256" HashEncoding="hex">
71+
<Output TaskParameter="Items" ItemName="_VerifiedMagiskHash" />
72+
</GetFileHash>
73+
<GetFileHash Files="$(RootToolsDir)/payload-dumper/$(PayloadDumperExecutableName)" Algorithm="SHA256" HashEncoding="hex">
74+
<Output TaskParameter="Items" ItemName="_VerifiedPayloadDumperHash" />
75+
</GetFileHash>
76+
<Error Condition="'%(_VerifiedMagiskHash.FileHash)' != '$(MagiskSha256)'"
77+
Text="Cached Magisk SHA-256 mismatch: expected $(MagiskSha256), got %(_VerifiedMagiskHash.FileHash)." />
78+
<Error Condition="'%(_VerifiedPayloadDumperHash.FileHash)' != '$(PayloadDumperExecutableSha256)'"
79+
Text="Cached payload-dumper-go SHA-256 mismatch: expected $(PayloadDumperExecutableSha256), got %(_VerifiedPayloadDumperHash.FileHash)." />
80+
</Target>
81+
82+
<Target Name="AtvCopyRootToolsToOutput" AfterTargets="Build"
83+
Condition="'$(AndroidTreeViewBundleRootTools)' == 'true' and '$(_RootToolsSupportedRid)' == 'true' and Exists('$(RootToolsDir)')">
84+
<ItemGroup>
85+
<_RootToolsOut Include="$(RootToolsDir)/**/*" />
86+
</ItemGroup>
87+
<Copy SourceFiles="@(_RootToolsOut)"
88+
DestinationFiles="@(_RootToolsOut->'$(OutDir)root-tools/%(RecursiveDir)%(Filename)%(Extension)')"
89+
SkipUnchangedFiles="true" />
90+
</Target>
91+
92+
<Target Name="AtvCopyRootToolsToPublish" BeforeTargets="ComputeFilesToPublish"
93+
Condition="'$(AndroidTreeViewBundleRootTools)' == 'true' and '$(_RootToolsSupportedRid)' == 'true' and Exists('$(RootToolsDir)')">
94+
<ItemGroup>
95+
<_RootToolsPub Include="$(RootToolsDir)/**/*" />
96+
<ResolvedFileToPublish Include="@(_RootToolsPub)">
97+
<RelativePath>root-tools/%(_RootToolsPub.RecursiveDir)%(_RootToolsPub.Filename)%(_RootToolsPub.Extension)</RelativePath>
98+
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
99+
</ResolvedFileToPublish>
100+
</ItemGroup>
101+
</Target>
102+
103+
</Project>

0 commit comments

Comments
 (0)