diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e08326d..b276f74 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -158,9 +158,31 @@ jobs: path: native-publish/* if-no-files-found: error + linux-arm64: + name: Build Linux ARM64 + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Build release binaries + run: cargo build --workspace --release --locked + - name: Test Rust workspace + run: cargo test --workspace --locked + - name: Prepare artifact + run: | + mkdir -p dist + cp target/release/sensitivity dist/sensitivity-linux-aarch64 + cp target/release/sensitivity-gui dist/sensitivity-gui-linux-aarch64 + - uses: actions/upload-artifact@v7 + with: + name: sensitivity-Linux-arm64 + path: dist/* + if-no-files-found: error + msrv: name: Minimum Rust 1.97 - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@1.97.1 @@ -169,7 +191,7 @@ jobs: fuzz-target: name: Fuzz target compiles - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 61bc61b..b852688 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ permissions: jobs: preflight: name: Release preflight - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@1.97.1 @@ -37,8 +37,11 @@ jobs: matrix: include: - name: linux-x86_64 - os: ubuntu-22.04 + os: ubuntu-24.04 archive: sensitivity-linux-x86_64.tar.gz + - name: linux-aarch64 + os: ubuntu-24.04-arm + archive: sensitivity-linux-aarch64.tar.gz - name: windows-x86_64 os: windows-latest archive: sensitivity-windows-x86_64.zip @@ -123,6 +126,57 @@ jobs: rm package/Sensitivity.app/Contents/Info.plist.bak fi tar -C package -czf "${{ matrix.archive }}" . + - name: Package Linux desktop formats + if: matrix.name == 'linux-x86_64' + shell: bash + run: | + mkdir -p deb-root/DEBIAN deb-root/usr/bin deb-root/usr/share/applications deb-root/usr/share/icons/hicolor/512x512/apps + cp target/release/sensitivity deb-root/usr/bin/sensitivity + cp target/release/sensitivity-gui deb-root/usr/bin/sensitivity-gui + cp assets/sensitivity-icon.png deb-root/usr/share/icons/hicolor/512x512/apps/sensitivity.png + cat > deb-root/usr/share/applications/sensitivity.desktop <<'EOF' + [Desktop Entry] + Type=Application + Name=Sensitivity + Comment=Direct USB Xiaomi Recovery tool + Exec=sensitivity-gui + Icon=sensitivity + Terminal=false + Categories=Utility;System; + EOF + cat > deb-root/DEBIAN/control < + Description: Sensitivity Xiaomi Recovery tool + Direct USB recovery validation, download, and flashing for Xiaomi devices. + EOF + dpkg-deb --build --root-owner-group deb-root sensitivity-linux-x86_64.deb + mkdir -p Sensitivity.AppDir/usr/bin Sensitivity.AppDir/usr/share/icons/hicolor/512x512/apps + cp target/release/sensitivity target/release/sensitivity-gui Sensitivity.AppDir/usr/bin/ + cp assets/sensitivity-icon.png Sensitivity.AppDir/usr/share/icons/hicolor/512x512/apps/sensitivity.png + cat > Sensitivity.AppDir/AppRun <<'EOF' + #!/bin/sh + exec "$(dirname "$0")/usr/bin/sensitivity-gui" "$@" + EOF + chmod +x Sensitivity.AppDir/AppRun + cat > Sensitivity.AppDir/sensitivity.desktop <<'EOF' + [Desktop Entry] + Type=Application + Name=Sensitivity + Comment=Direct USB Xiaomi Recovery tool + Exec=sensitivity-gui + Icon=sensitivity + Terminal=false + Categories=Utility;System; + EOF + cp Sensitivity.AppDir/sensitivity.desktop Sensitivity.AppDir/usr/share/applications/ + curl -L --fail --retry 3 -o appimagetool.AppImage https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage + chmod +x appimagetool.AppImage + APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool.AppImage Sensitivity.AppDir sensitivity-linux-x86_64.AppImage - name: Package Windows if: matrix.runtime != '' shell: pwsh @@ -165,6 +219,8 @@ jobs: name: ${{ matrix.name }} path: | ${{ matrix.archive }} + sensitivity-*.AppImage + sensitivity-*.deb sensitivity-*-setup.exe if-no-files-found: error diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 0c38052..30d795d 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -38,7 +38,7 @@ concurrency: jobs: codeql: name: CodeQL (${{ matrix.language }}) - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: security-events: write packages: read @@ -67,7 +67,7 @@ jobs: dependency-review: name: Dependency review if: github.event_name == 'pull_request' - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: read diff --git a/Cargo.lock b/Cargo.lock index 97d860a..63bb110 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,6 +2969,7 @@ version = "1.1.3" dependencies = [ "anyhow", "eframe", + "reqwest", "rfd", "sensitivity", "serde", diff --git a/README.md b/README.md index 0da091f..9824691 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ It speaks the Mi Assistant ADB-like protocol directly over USB, validates offici ## Install -Download a release for Windows, Linux, or macOS from [GitHub Releases](https://github.com/Has-X/Sensitivity/releases). On Windows, choose the native `Sensitivity-Setup-x64.exe` or `Sensitivity-Setup-arm64.exe` for the processor architecture, then open Sensitivity from Start. Portable ZIPs are available too. The Windows app follows the system light or dark mode and accent colour, with Mica material where supported. The app and CLI support 34 languages; see the [localization guide](docs/LOCALIZATION.md) for the maintained list and translation rules. The separate `sensitivity-cli.exe` is for terminals and scripts. On Linux or macOS, open `sensitivity-gui`. Releases include SHA-256 checksums. +Download a release for Windows, Linux, or macOS from [GitHub Releases](https://github.com/Has-X/Sensitivity/releases). On Windows, choose the native `Sensitivity-Setup-x64.exe` or `Sensitivity-Setup-arm64.exe` for the processor architecture, then open Sensitivity from Start. Portable ZIPs are available too. Linux releases include x86_64 tarball, AppImage, and Debian packages, plus an ARM64 tarball. The Windows app follows the system light or dark mode and accent colour, with Mica material where supported. The app and CLI support 34 languages; see the [localization guide](docs/LOCALIZATION.md) for the maintained list and translation rules. The separate `sensitivity-cli.exe` is for terminals and scripts. On Linux or macOS, open `sensitivity-gui`. Releases include SHA-256 checksums. To build or install from source: @@ -45,13 +45,23 @@ In the desktop app, select the detected recovery and ROM, review validation and For the CLI: 1. Boot the phone into stock recovery and choose **Connect with Mi Assistant**. -2. Connect it directly by USB and run the setup check: +2. Connect it directly by USB and run the setup check. On Windows, use the packaged CLI executable: + + ```powershell + .\sensitivity-cli.exe doctor + ``` + + On Linux and macOS, use: ```console sensitivity doctor ``` -3. Read the detected device identity: +3. Read the detected device identity with the same platform-specific command: + + ```powershell + .\sensitivity-cli.exe info + ``` ```console sensitivity info @@ -59,6 +69,10 @@ For the CLI: 4. Flash an official Recovery ROM: + ```powershell + .\sensitivity-cli.exe flash C:\path\to\recovery-rom.zip + ``` + ```console sensitivity flash /path/to/recovery-rom.zip ``` @@ -92,19 +106,22 @@ The maintained documentation is available in the [Sensitivity Wiki](https://gith Translation contributors should use the [English source and translator guide](docs/LOCALIZATION.md). It records the safety context for Windows, installer, portable GUI, and CLI messages. -Use `sensitivity help` or `sensitivity help ` for the complete command reference. +Use `sensitivity help` or `sensitivity help ` for the complete command reference. On Windows, use `sensitivity-cli.exe help` or `sensitivity-cli.exe help `. + +On Windows, the packaged command is `sensitivity-cli.exe` (or `sensitivity-cli` after the installer adds it to `PATH`). On Linux and macOS, the command is `sensitivity`. The examples below use the cross-platform name. ## Common commands ```console sensitivity doctor # diagnose USB and local ADB coexistence sensitivity devices # list matching USB interfaces without claiming them -sensitivity detect # verify the direct-USB protocol handshake +sensitivity detect # verify the recovery protocol handshake sensitivity info # human-readable device information sensitivity info --json # stable output for scripts sensitivity completions bash # generate shell completion definitions sensitivity list-allowed-roms # query packages accepted for this device sensitivity download-latest # download and verify the latest approved ROM +sensitivity flash ROM.zip --validate-only # validate without starting sideload sensitivity flash ROM.zip # validate and flash a local package sensitivity flash-from-latest # download, validate, and flash sensitivity reboot # leave recovery @@ -120,14 +137,14 @@ The supported profiles are `global`, `eea`, `in`, `ru`, `id`, `tr`, `tw`, and `c ## ADB coexistence -Sensitivity uses direct USB and leaves a local Android Debug Bridge server untouched by default. If `adb` already owns the recovery interface, stop it only for this invocation: +Sensitivity leaves a local Android Debug Bridge server untouched by default. When that server already owns a Mi Recovery interface, Sensitivity reuses the matching recovery transport while keeping unrelated Android devices connected. It falls back to direct USB when no compatible recovery is available through ADB. To force direct USB for one invocation: ```console sensitivity --adb-policy stop doctor sensitivity --adb-policy stop flash ROM.zip ``` -Sensitivity never occupies port 5037 and only asks the ADB server to stop when you choose that policy. The Windows app detects likely ownership conflicts, explains the impact, and asks before retrying. +Sensitivity only asks the ADB server to stop when you choose that policy. The Windows app lists Mi Recovery transports without presenting normal Android devices as recovery targets. Existing debugging sessions remain connected when the default policy is used. Users and scripts moving from the older project should read [Migrating from MiAssistantFork](docs/MIGRATING_FROM_MAF.md). diff --git a/apps/windows/Sensitivity.WinUI/MainWindow.xaml.cs b/apps/windows/Sensitivity.WinUI/MainWindow.xaml.cs index 86bd429..9489e9e 100644 --- a/apps/windows/Sensitivity.WinUI/MainWindow.xaml.cs +++ b/apps/windows/Sensitivity.WinUI/MainWindow.xaml.cs @@ -235,6 +235,8 @@ await RunBusyAsync(async cancellationToken => private async void DevicePicker_SelectionChanged(object sender, SelectionChangedEventArgs e) { _hasRecoveryDevice = DevicePicker.SelectedItem is UsbDevice; + StopAdbToggle.IsEnabled = DevicePicker.SelectedItem is not UsbDevice selected + || !string.Equals(selected.Transport, "adb-server", StringComparison.Ordinal); UpdateDeviceActionState(); ClearDeviceInfo(); if (DevicePicker.SelectedItem is UsbDevice) @@ -258,9 +260,9 @@ await RunBusyAsync(async cancellationToken => DeviceInfo info; try { - info = await _backend.GetDeviceInfoAsync(device.Index, StopAdbToggle.IsOn, cancellationToken); + info = await _backend.GetDeviceInfoAsync(device.Index, ShouldStopAdb(device), cancellationToken); } - catch (Exception error) when (!StopAdbToggle.IsOn + catch (Exception error) when (!ShouldStopAdb(device) && AutoResolveAdbToggle.IsOn && SensitivityBackend.IsUsbOwnershipError(error)) { @@ -349,7 +351,7 @@ private async void ListAllowedRomsButton_Click(object sender, RoutedEventArgs e) } await RunBusyAsync(async cancellationToken => { - var result = await _backend.ListAllowedRomsAsync(device.Index, StopAdbToggle.IsOn, cancellationToken); + var result = await _backend.ListAllowedRomsAsync(device.Index, ShouldStopAdb(device), cancellationToken); AllowedRomsText.Text = string.Join(Environment.NewLine, new[] { result.StandardOutput.Trim(), result.StandardError.Trim() }.Where(text => !string.IsNullOrWhiteSpace(text))); if (!result.Succeeded) throw new InvalidOperationException(result.ErrorMessage); }, L("status.fetching_allowed")); @@ -364,7 +366,7 @@ private async void DownloadLatestButton_Click(object sender, RoutedEventArgs e) } await RunBusyAsync(async cancellationToken => { - var result = await _backend.DownloadLatestAsync(device.Index, DownloadDirectoryText.Text, StopAdbToggle.IsOn, cancellationToken); + var result = await _backend.DownloadLatestAsync(device.Index, DownloadDirectoryText.Text, ShouldStopAdb(device), cancellationToken); if (!result.Succeeded) throw new InvalidOperationException(result.ErrorMessage); var romPath = ExtractDownloadedRomPath(result.StandardOutput) ?? FindDownloadedRom(); if (romPath is not null) @@ -458,6 +460,9 @@ private bool TrySelectDownloadedRom() : null; } + private bool ShouldStopAdb(UsbDevice device) + => StopAdbToggle.IsOn && !string.Equals(device.Transport, "adb-server", StringComparison.Ordinal); + private async void FlashLatestButton_Click(object sender, RoutedEventArgs e) { if (DevicePicker.SelectedItem is not UsbDevice device) @@ -470,7 +475,7 @@ private async void FlashLatestButton_Click(object sender, RoutedEventArgs e) SetBusy(true, L("status.downloading_latest")); try { - var result = await _backend.FlashLatestAsync(device.Index, DownloadDirectoryText.Text, StopAdbToggle.IsOn, HandleBackendEventAsync, _operationCancellation.Token); + var result = await _backend.FlashLatestAsync(device.Index, DownloadDirectoryText.Text, ShouldStopAdb(device), HandleBackendEventAsync, _operationCancellation.Token); if (!result.Succeeded) throw new InvalidOperationException(result.ErrorMessage); FlashProgress.Value = 100; ProgressPercentText.Text = "100%"; @@ -523,7 +528,7 @@ private async void StartFlashButton_Click(object sender, RoutedEventArgs e) var result = await _backend.FlashAsync( device.Index, _romPath, - StopAdbToggle.IsOn, + ShouldStopAdb(device), HandleBackendEventAsync, _operationCancellation.Token); if (_operationCancellation.IsCancellationRequested) @@ -604,7 +609,7 @@ private async void RebootButton_Click(object sender, RoutedEventArgs e) } await RunBusyAsync(async cancellationToken => { - var result = await _backend.RebootAsync(device.Index, StopAdbToggle.IsOn, cancellationToken); + var result = await _backend.RebootAsync(device.Index, ShouldStopAdb(device), cancellationToken); if (!result.Succeeded) throw new InvalidOperationException(result.ErrorMessage); ShowStatus(L("status.reboot_requested"), L("status.reboot_detail"), InfoBarSeverity.Success); }, L("status.sending_reboot")); @@ -627,7 +632,7 @@ private async void EraseDataButton_Click(object sender, RoutedEventArgs e) } await RunBusyAsync(async cancellationToken => { - var result = await _backend.FormatDataAsync(device.Index, StopAdbToggle.IsOn, cancellationToken); + var result = await _backend.FormatDataAsync(device.Index, ShouldStopAdb(device), cancellationToken); if (!result.Succeeded) throw new InvalidOperationException(result.ErrorMessage); ShowStatus(L("status.erase_requested"), L("status.erase_detail"), InfoBarSeverity.Success); }, L("status.erasing")); @@ -635,10 +640,12 @@ await RunBusyAsync(async cancellationToken => private async void RunDoctorButton_Click(object sender, RoutedEventArgs e) { - var index = (DevicePicker.SelectedItem as UsbDevice)?.Index ?? 0; + var selectedDevice = DevicePicker.SelectedItem as UsbDevice; + var index = selectedDevice?.Index ?? 0; + var stopAdb = selectedDevice is null ? StopAdbToggle.IsOn : ShouldStopAdb(selectedDevice); await RunBusyAsync(async cancellationToken => { - var result = await _backend.RunDoctorAsync(index, StopAdbToggle.IsOn, cancellationToken); + var result = await _backend.RunDoctorAsync(index, stopAdb, cancellationToken); DiagnosticsText.Text = string.Join( Environment.NewLine, new[] { result.StandardOutput.Trim(), result.StandardError.Trim() } @@ -660,7 +667,7 @@ private async void DetectButton_Click(object sender, RoutedEventArgs e) await RunBusyAsync(async cancellationToken => { - var result = await _backend.DetectAsync(device.Index, StopAdbToggle.IsOn, cancellationToken); + var result = await _backend.DetectAsync(device.Index, ShouldStopAdb(device), cancellationToken); DiagnosticsText.Text = string.Join( Environment.NewLine, new[] { result.StandardOutput.Trim(), result.StandardError.Trim() } diff --git a/apps/windows/Sensitivity.WinUI/Models/BackendModels.cs b/apps/windows/Sensitivity.WinUI/Models/BackendModels.cs index 57055b6..2b7768c 100644 --- a/apps/windows/Sensitivity.WinUI/Models/BackendModels.cs +++ b/apps/windows/Sensitivity.WinUI/Models/BackendModels.cs @@ -20,11 +20,27 @@ public sealed class UsbDevice [JsonPropertyName("product_id")] public int ProductId { get; set; } + [JsonPropertyName("transport")] + public string Transport { get; set; } = "usb"; + + [JsonPropertyName("transport_id")] + public long? TransportId { get; set; } + + [JsonPropertyName("recovery_device")] + public string? RecoveryDevice { get; set; } + + [JsonPropertyName("model")] + public string? Model { get; set; } + [JsonPropertyName("protocol")] public int Protocol { get; set; } [JsonIgnore] - public string DisplayName => $"{LocalizationService.Get("label.recovery_device")} {Index + 1} · {VendorId:x4}:{ProductId:x4} · USB {Bus}/{Address}"; + public string DisplayName => Transport == "adb-server" + ? string.IsNullOrWhiteSpace(RecoveryDevice) && string.IsNullOrWhiteSpace(Model) + ? $"{LocalizationService.Get("label.recovery_device")} {Index + 1} · ADB" + : $"{LocalizationService.Get("label.recovery_device")} {Index + 1} · {(string.IsNullOrWhiteSpace(RecoveryDevice) ? Model : RecoveryDevice)} · ADB" + : $"{LocalizationService.Get("label.recovery_device")} {Index + 1} · {VendorId:x4}:{ProductId:x4} · USB {Bus}/{Address}"; } public sealed class DeviceInfo diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index c1f193f..f96e9ae 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -13,6 +13,7 @@ publish = false anyhow = "1" eframe = { version = "0.36.1", default-features = false, features = ["default_fonts", "glow", "persistence", "wayland", "x11"] } rfd = "0.17.2" +reqwest = { version = "0.13", default-features = false, features = ["blocking", "rustls"] } sensitivity = { version = "1.1.3", path = "../.." } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/gui/src/main.rs b/crates/gui/src/main.rs index c264f59..30a9432 100644 --- a/crates/gui/src/main.rs +++ b/crates/gui/src/main.rs @@ -13,15 +13,19 @@ use std::thread; use eframe::egui; use sensitivity::mi::{DeviceInfo, MiClient}; use sensitivity::usb::{UsbDeviceInfo, UsbTransport}; -use sensitivity::{sideload, util, validate}; +use sensitivity::{download, sideload, util, validate}; const SERVER_URL: &str = "https://update.miui.com/updates/miotaV3.php"; fn main() -> eframe::Result<()> { + let app_icon = + eframe::icon_data::from_png_bytes(include_bytes!("../../../assets/sensitivity-icon.png")) + .expect("embedded Sensitivity icon must be a valid PNG"); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1040.0, 720.0]) - .with_min_inner_size([820.0, 560.0]), + .with_min_inner_size([820.0, 560.0]) + .with_icon(app_icon), ..Default::default() }; eframe::run_native( @@ -37,6 +41,7 @@ enum Message { Error(String), DeviceInfo(DeviceInfo), Roms(String), + Downloaded(PathBuf), Validated { path: PathBuf, token: String, @@ -292,9 +297,11 @@ fn load_catalog(language: Language) -> HashMap { let aliases: HashMap = serde_json::from_str(include_str!("../../../locales/_keys/gui.json")).unwrap_or_default(); for (id, source_key) in aliases { - if let Some(value) = catalog.get(&source_key).cloned() { - catalog.insert(id, value); - } + let value = catalog + .get(&source_key) + .cloned() + .unwrap_or_else(|| source_key.clone()); + catalog.insert(id, value); } catalog } @@ -324,6 +331,7 @@ struct SensitivityApp { cancel: Option>, confirm_flash: bool, confirm_format: bool, + icon_texture: egui::TextureHandle, } impl SensitivityApp { @@ -333,6 +341,19 @@ impl SensitivityApp { .and_then(|storage| storage.get_string("sensitivity.state")) .and_then(|json| serde_json::from_str::(&json).ok()) .unwrap_or_default(); + let icon = eframe::icon_data::from_png_bytes(include_bytes!( + "../../../assets/sensitivity-icon.png" + )) + .expect("embedded Sensitivity icon must be a valid PNG"); + let icon_image = egui::ColorImage::from_rgba_unmultiplied( + [icon.width as usize, icon.height as usize], + &icon.rgba, + ); + let icon_texture = context.egui_ctx.load_texture( + "sensitivity-icon", + icon_image, + egui::TextureOptions::LINEAR, + ); let mut app = Self { devices: Vec::new(), selected_device: 0, @@ -351,6 +372,7 @@ impl SensitivityApp { cancel: None, confirm_flash: false, confirm_format: false, + icon_texture, }; app.status = app.t("status.initial"); app.refresh_devices(); @@ -480,6 +502,44 @@ impl SensitivityApp { } } + fn download_latest(&mut self) { + let Some(info) = self.device_info.clone() else { + self.log(self.t("log.read_info_first_roms")); + return; + }; + let output_dir = std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| home.join("Downloads")) + .filter(|path| path.is_dir()) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + self.start_task(self.t("status.downloading_latest"), move |sender| { + let result = (|| -> anyhow::Result { + let request = validate::build_request_json(&info, None)?; + let response = validate::validate(SERVER_URL, &request)?; + let json = response + .full_json + .ok_or_else(|| anyhow::anyhow!("No full ROM response returned"))?; + let (latest, mirrors) = download::parse_latest_from_json(&json)?; + let url = download::choose_url(&mirrors, &latest.filename) + .ok_or_else(|| anyhow::anyhow!("No HTTPS ROM mirror returned"))?; + let client = reqwest::blocking::Client::builder() + .user_agent("MiTunes_UserAgent_v3.0") + .build()?; + download::download_with_md5(&client, &url, &output_dir, &latest.md5) + })(); + match result { + Ok(path) => { + let _ = sender.send(Message::Downloaded(path)); + let _ = sender.send(Message::Status("status.downloaded".into())); + } + Err(error) => { + let _ = + sender.send(Message::Error(format!("status.operation_failed|{error:#}"))); + } + } + }); + } + fn validate_rom(&mut self) { let Some(info) = self.device_info.clone() else { self.log(self.t("log.read_info_first_validate")); @@ -648,6 +708,11 @@ impl SensitivityApp { }; self.busy = false; } + Message::Downloaded(path) => { + self.rom_path = Some(path.clone()); + self.validated = None; + self.log(self.t("status.downloaded")); + } Message::Validated { path, token, @@ -707,6 +772,10 @@ impl eframe::App for SensitivityApp { egui::Panel::top("header").show(ui, |ui| { ui.horizontal(|ui| { + ui.add( + egui::Image::from_texture(&self.icon_texture) + .fit_to_exact_size(egui::vec2(30.0, 30.0)), + ); ui.heading(self.t("app.title")); ui.separator(); ui.label(self.t("app.subtitle")); @@ -783,6 +852,15 @@ impl eframe::App for SensitivityApp { { self.choose_rom(); } + if ui + .add_enabled( + !self.busy && self.device_info.is_some(), + egui::Button::new(self.t("action.download_latest")), + ) + .clicked() + { + self.download_latest(); + } if let Some(path) = &self.rom_path { ui.small(path.display().to_string()); } diff --git a/docs/MIGRATING_FROM_MAF.md b/docs/MIGRATING_FROM_MAF.md index eb25c4d..3af389e 100644 --- a/docs/MIGRATING_FROM_MAF.md +++ b/docs/MIGRATING_FROM_MAF.md @@ -1,6 +1,6 @@ # Migrating from MiAssistantFork -Sensitivity is the maintained successor to MiAssistantFork (MAF) and now contains its useful GUI, cancellation, packaging, and fuzz-testing ideas without retaining a duplicate protocol implementation. Verify the connected recovery with `sensitivity doctor`, then replace an existing MAF workflow. +Sensitivity is the maintained successor to MiAssistantFork (MAF) and now contains its useful GUI, cancellation, packaging, and fuzz-testing ideas without retaining a duplicate protocol implementation. Verify the connected recovery with `sensitivity doctor`, then replace an existing MAF workflow. On Windows, use `sensitivity-cli.exe` in place of `sensitivity` for every command below. ## Command mapping @@ -14,11 +14,11 @@ Sensitivity is the maintained successor to MiAssistantFork (MAF) and now contain | `miassistant-cli format-data` | `sensitivity format-data` | Requires typing `ERASE`, or `--yes` for intentional automation. | | `miassistant-cli reboot` | `sensitivity reboot` | Same recovery command. | -The duplicate `miassistant` executable has been retired. Update scripts to use the `sensitivity` name. +The duplicate `miassistant` executable has been retired. Update scripts to use `sensitivity` on Linux and macOS, or `sensitivity-cli.exe` on Windows. ## Behavioral differences -- Sensitivity leaves the desktop ADB server alone by default. Use `--adb-policy stop` only when diagnostics show that ADB owns the recovery interface. +- Sensitivity leaves the desktop ADB server alone by default and reuses a matching Mi Recovery transport when ADB already owns it. Use `--adb-policy stop` only to force the direct USB fallback. - Sensitivity requires HTTPS for Xiaomi validation unless the hidden advanced override is explicitly supplied. - Downloads are written to a hidden partial file and only replace the destination after MD5 verification. - Ctrl-C or the GUI Cancel button requests a graceful close after the current USB operation. @@ -28,6 +28,6 @@ The duplicate `miassistant` executable has been retired. Update scripts to use t ## Automation changes -Use `sensitivity info --json` rather than parsing labeled text. Generate completion definitions with `sensitivity completions bash`, `zsh`, `fish`, `elvish`, or `powershell`. +Use `sensitivity info --json` rather than parsing labeled text. On Windows, use `sensitivity-cli.exe info --json` instead. Generate completion definitions with `sensitivity completions bash`, `zsh`, `fish`, `elvish`, or `powershell` (`sensitivity-cli.exe` on Windows). Scripts that can erase data must pass `--yes` explicitly. Scripts should not pass `--adb-policy stop` globally; doing so stops a running ADB server even for devices unrelated to the recovery operation. diff --git a/docs/WINDOWS_ARCHITECTURE.md b/docs/WINDOWS_ARCHITECTURE.md index 45a719a..59187a7 100644 --- a/docs/WINDOWS_ARCHITECTURE.md +++ b/docs/WINDOWS_ARCHITECTURE.md @@ -30,7 +30,7 @@ ends. ## Machine event contract -`sensitivity --machine` writes one JSON object per line. Current events are: +`sensitivity-cli.exe --machine` writes one JSON object per line. Current events are: ```json {"event":"status","message":"Validating ROM with Xiaomi"} @@ -52,11 +52,11 @@ user's local application-data directory. ## ADB coexistence -The safe default is to leave the user's ADB server running. If Windows reports -that the recovery USB interface is busy, the app offers to stop ADB once and -retry; it does not silently disrupt another debugging session. Users with a -persistently conflicting setup can opt into stopping ADB before every direct -USB connection. +The safe default is to leave the user's ADB server running. If it already owns +a Mi Recovery interface, the Rust backend selects that recovery through the +local ADB server and leaves unrelated Android debugging sessions connected. +Direct USB remains the fallback. Stopping ADB is an explicit override because +it disrupts every device attached to that server. ## Build and release diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index d755886..177b0a3 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -1,6 +1,6 @@ # Sensitivity Wiki -Sensitivity is direct USB recovery tooling for Xiaomi devices. The project includes a Rust CLI, a native Fluent 2 WinUI 3 Windows application, and portable Linux and macOS interfaces. The Windows app follows system light or dark mode and accent color, uses Mica where available, and ships as a native x64 or ARM64 package. It is developed and published by [Chromatic](https://chromatic.hu). +Sensitivity is USB recovery tooling for Xiaomi devices. It can reuse an existing local ADB server for Mi Recovery while keeping other Android devices connected, with direct USB as a fallback. The project includes a Rust CLI, a native Fluent 2 WinUI 3 Windows application, and portable Linux and macOS interfaces. The Windows app follows system light or dark mode and accent color, uses Mica where available, and ships as a native x64 or ARM64 package. It is developed and published by [Chromatic](https://chromatic.hu). ## Start here diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index e6797c6..c83e94f 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -10,7 +10,7 @@ Choose `Sensitivity-Setup-x64.exe` for Intel and AMD PCs, or `Sensitivity-Setup- ## Linux and macOS -Extract the platform archive and launch `sensitivity-gui` for the desktop interface or `sensitivity` for the CLI. Linux archives include the optional udev rule installer. +Extract the platform archive and launch `sensitivity-gui` for the desktop interface. Linux x86_64 releases also provide an AppImage and Debian package; ARM64 releases provide the portable archive. On Linux and macOS, use `sensitivity` for the CLI; on Windows, use `sensitivity-cli.exe` (or `sensitivity-cli` when it is on `PATH`). Linux archives include the optional udev rule installer. ## Build from source diff --git a/docs/wiki/Recovery-safety.md b/docs/wiki/Recovery-safety.md index 58d056e..b5c3d52 100644 --- a/docs/wiki/Recovery-safety.md +++ b/docs/wiki/Recovery-safety.md @@ -10,3 +10,19 @@ Before flashing: 4. Keep the USB connection stable and do not interrupt a running transfer. The `doctor`, `devices`, and `info` commands are read-only. ADB is preserved by default. Only use the explicit ADB stop policy when another process owns the recovery interface. + +## Validation diagnostics + +Check a local package without opening sideload, and save a redacted response for troubleshooting: + +```console +sensitivity flash ROM.zip --validate-only --dump-json validation-shape.json +``` + +To inspect Xiaomi's package-discovery response without downloading a ROM: + +```console +sensitivity list-allowed-roms --dump-json discovery-shape.json +``` + +On Windows, use `sensitivity-cli.exe`. The diagnostic keeps JSON field names and value types while replacing every scalar value. It does not contain the validation token, device serial number, server message, or ROM URL. Review any diagnostic file before attaching it to a public issue. diff --git a/locales/_keys/gui.json b/locales/_keys/gui.json index ef9c165..ae934c3 100644 --- a/locales/_keys/gui.json +++ b/locales/_keys/gui.json @@ -10,6 +10,7 @@ "action.list_allowed_roms": "List allowed ROMs", "section.official_rom": "Official Recovery ROM", "action.choose_rom": "Choose ROM ZIP", + "action.download_latest": "Download latest ROM", "action.validate_rom": "Validate ROM", "status.validation_wipe": "Validation requires a data wipe", "status.validation_no_wipe": "Validated; no wipe requested", @@ -46,6 +47,8 @@ "status.rom_query_complete": "ROM query complete.", "status.rom_query_failed": "ROM query failed: {error}", "status.rom_selected": "ROM selected. Validate it before flashing.", + "status.downloading_latest": "Downloading the latest official ROM…", + "status.downloaded": "ROM download completed", "log.read_info_first_validate": "Read device info before validating a ROM.", "log.choose_rom_first": "Choose an official Recovery ROM ZIP first.", "status.hashing_rom": "Hashing and validating ROM...", diff --git a/locales/_keys/windows.json b/locales/_keys/windows.json index 155fded..c2bdc82 100644 --- a/locales/_keys/windows.json +++ b/locales/_keys/windows.json @@ -86,7 +86,7 @@ "connection.ready": "{device} is ready", "connection.details": "Recovery {version} · {region} {romzone}", "status.recovery_ready": "Recovery ready", - "status.handshake_ok": "The direct USB handshake and device queries succeeded.", + "status.handshake_ok": "The recovery handshake and device queries succeeded.", "status.reading_recovery": "Reading recovery information…", "status.ready_to_flash": "Ready to validate and flash", "dialog.choose_rom": "Choose a ROM", diff --git a/locales/ar/windows.json b/locales/ar/windows.json index 0938118..588ba74 100644 --- a/locales/ar/windows.json +++ b/locales/ar/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} جاهز", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery جاهزة", - "The direct USB handshake and device queries succeeded.": "نجحت المصافحة المباشرة USB واستعلامات الجهاز.", + "The recovery handshake and device queries succeeded.": "نجحت مصافحة وضع الاسترداد واستعلامات الجهاز.", "Reading recovery information…": "قراءة معلومات الاسترداد...", "Ready to validate and flash": "جاهز للتحقق من الصحة وفلاش", "Choose a ROM": "اختر ROM", diff --git a/locales/bg/windows.json b/locales/bg/windows.json index 8665627..a974630 100644 --- a/locales/bg/windows.json +++ b/locales/bg/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} е готов", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery готов", - "The direct USB handshake and device queries succeeded.": "Директното ръкостискане USB и заявките за устройство бяха успешни.", + "The recovery handshake and device queries succeeded.": "Проверката на връзката с recovery и заявките към устройството бяха успешни.", "Reading recovery information…": "Четене на информация за възстановяване...", "Ready to validate and flash": "Готов за валидиране и флаш", "Choose a ROM": "Изберете ROM", diff --git a/locales/cs/windows.json b/locales/cs/windows.json index 978e5b0..84201cc 100644 --- a/locales/cs/windows.json +++ b/locales/cs/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} je připraveno", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery připraveno", - "The direct USB handshake and device queries succeeded.": "Přímé USB handshake a dotazy na zařízení byly úspěšné.", + "The recovery handshake and device queries succeeded.": "Navázání spojení s recovery a dotazy na zařízení proběhly úspěšně.", "Reading recovery information…": "Čtení informací o obnovení…", "Ready to validate and flash": "Připraveno k ověření a flashování", "Choose a ROM": "Vyberte ROM", diff --git a/locales/da/windows.json b/locales/da/windows.json index edd167e..66fbf6f 100644 --- a/locales/da/windows.json +++ b/locales/da/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} er klar", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery klar", - "The direct USB handshake and device queries succeeded.": "Det direkte USB-håndtryk og enhedsforespørgsler lykkedes.", + "The recovery handshake and device queries succeeded.": "Forbindelsen til recovery og enhedsforespørgslerne lykkedes.", "Reading recovery information…": "Læser genoprettelsesoplysninger...", "Ready to validate and flash": "Klar til at validere og flashe", "Choose a ROM": "Vælg en ROM", diff --git a/locales/de/windows.json b/locales/de/windows.json index 9cb8e18..031ade5 100644 --- a/locales/de/windows.json +++ b/locales/de/windows.json @@ -77,7 +77,7 @@ "{device} is ready": "{device} ist bereit", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery bereit", - "The direct USB handshake and device queries succeeded.": "Der direkte USB-Handshake und die Geräteabfragen waren erfolgreich.", + "The recovery handshake and device queries succeeded.": "Die Verbindung zur Recovery und die Geräteabfragen waren erfolgreich.", "Reading recovery information…": "Informationen zur Recovery werden gelesen…", "Choose a ROM": "Wählen Sie einen ROM", "No recovery selected": "Keine Recovery ausgewählt", diff --git a/locales/el/windows.json b/locales/el/windows.json index e820632..e6beeef 100644 --- a/locales/el/windows.json +++ b/locales/el/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} είναι έτοιμο", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery έτοιμο", - "The direct USB handshake and device queries succeeded.": "Η άμεση χειραψία USB και τα ερωτήματα συσκευής πέτυχαν.", + "The recovery handshake and device queries succeeded.": "Η σύνδεση με το recovery και τα ερωτήματα συσκευής ολοκληρώθηκαν με επιτυχία.", "Reading recovery information…": "Ανάγνωση πληροφοριών ανάκτησης…", "Ready to validate and flash": "Έτοιμο για επικύρωση και αναβοσβήνει", "Choose a ROM": "Επιλέξτε ROM", diff --git a/locales/en/windows.json b/locales/en/windows.json index c565302..e16a1c9 100644 --- a/locales/en/windows.json +++ b/locales/en/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} is ready", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery ready", - "The direct USB handshake and device queries succeeded.": "The direct USB handshake and device queries succeeded.", + "The recovery handshake and device queries succeeded.": "The recovery handshake and device queries succeeded.", "Reading recovery information…": "Reading recovery information…", "Ready to validate and flash": "Ready to validate and flash", "Choose a ROM": "Choose a ROM", diff --git a/locales/es/windows.json b/locales/es/windows.json index 22a9d7e..c201d8d 100644 --- a/locales/es/windows.json +++ b/locales/es/windows.json @@ -83,7 +83,7 @@ "{device} is ready": "{device} está listo", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery listo", - "The direct USB handshake and device queries succeeded.": "El handshake USB directo y las consultas del dispositivo fueron correctos.", + "The recovery handshake and device queries succeeded.": "La conexión con el recovery y las consultas del dispositivo se completaron correctamente.", "Reading recovery information…": "Leyendo información del recovery…", "Ready to validate and flash": "Listo para validar y flashear", "Select an official Recovery ROM ZIP before continuing.": "Selecciona un ZIP de ROM Recovery oficial antes de continuar.", diff --git a/locales/fi/windows.json b/locales/fi/windows.json index 00da0a5..0f4b969 100644 --- a/locales/fi/windows.json +++ b/locales/fi/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} on valmis", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery valmis", - "The direct USB handshake and device queries succeeded.": "Suora USB-kättely ja laitekyselyt onnistuivat.", + "The recovery handshake and device queries succeeded.": "Recovery-yhteys ja laitekyselyt onnistuivat.", "Reading recovery information…": "Luetaan palautustietoja…", "Ready to validate and flash": "Valmiina tarkistamaan ja vilkkumaan", "Choose a ROM": "Valitse ROM", diff --git a/locales/fr/windows.json b/locales/fr/windows.json index 6144dfb..41b15e5 100644 --- a/locales/fr/windows.json +++ b/locales/fr/windows.json @@ -74,7 +74,7 @@ "{device} is ready": "{device} est prêt", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery prêt", - "The direct USB handshake and device queries succeeded.": "La négociation directe USB et les requêtes sur les appareils ont réussi.", + "The recovery handshake and device queries succeeded.": "La connexion au recovery et les requêtes sur l’appareil ont réussi.", "Reading recovery information…": "Lecture des informations de Recovery…", "Choose a ROM": "Choisissez un ROM", "Select an official Recovery ROM ZIP before continuing.": "Sélectionnez un Recovery ROM officiel avant de continuer.", diff --git a/locales/hi/windows.json b/locales/hi/windows.json index bccdc98..437d27e 100644 --- a/locales/hi/windows.json +++ b/locales/hi/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} तैयार है", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery तैयार", - "The direct USB handshake and device queries succeeded.": "प्रत्यक्ष USB हैंडशेक और डिवाइस क्वेरी सफल रहीं।", + "The recovery handshake and device queries succeeded.": "रिकवरी हैंडशेक और डिवाइस क्वेरी सफल रहीं।", "Reading recovery information…": "पुनर्प्राप्ति जानकारी पढ़ें...", "Ready to validate and flash": "सत्यापित करने और फ़्लैश करने के लिए तैयार", "Choose a ROM": "चुनें ROM", diff --git a/locales/hr/windows.json b/locales/hr/windows.json index b30a08c..27731d2 100644 --- a/locales/hr/windows.json +++ b/locales/hr/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} je spreman", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery spremno", - "The direct USB handshake and device queries succeeded.": "Izravno USB rukovanje i upiti uređaja su uspjeli.", + "The recovery handshake and device queries succeeded.": "Povezivanje s recoveryjem i upiti uređaja bili su uspješni.", "Reading recovery information…": "Čitanje informacija o oporavku...", "Ready to validate and flash": "Spremno za potvrdu i flash", "Choose a ROM": "Odaberite ROM", diff --git a/locales/hu/windows.json b/locales/hu/windows.json index b87aa81..df9d9bf 100644 --- a/locales/hu/windows.json +++ b/locales/hu/windows.json @@ -84,7 +84,7 @@ "{device} is ready": "{device} készen áll", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery készen áll", - "The direct USB handshake and device queries succeeded.": "A közvetlen USB-kézfogás és az eszközlekérdezések sikeresek.", + "The recovery handshake and device queries succeeded.": "A recovery-kapcsolat és az eszközlekérdezések sikeresek.", "Reading recovery information…": "Recovery-információk beolvasása…", "Ready to validate and flash": "Kész az ellenőrzésre és flashelésre", "Select an official Recovery ROM ZIP before continuing.": "A folytatás előtt válassz hivatalos Recovery ROM ZIP-et.", diff --git a/locales/id/windows.json b/locales/id/windows.json index d3545f6..b945bce 100644 --- a/locales/id/windows.json +++ b/locales/id/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} sudah siap", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery siap", - "The direct USB handshake and device queries succeeded.": "Jabat tangan USB langsung dan kueri perangkat berhasil.", + "The recovery handshake and device queries succeeded.": "Koneksi recovery dan kueri perangkat berhasil.", "Reading recovery information…": "Membaca informasi pemulihan…", "Ready to validate and flash": "Siap memvalidasi dan mem-flash", "Choose a ROM": "Pilih ROM", diff --git a/locales/it/windows.json b/locales/it/windows.json index db1a4c4..fcf3251 100644 --- a/locales/it/windows.json +++ b/locales/it/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} è pronto", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery pronto", - "The direct USB handshake and device queries succeeded.": "L'handshake diretto USB e le query sul dispositivo sono riuscite.", + "The recovery handshake and device queries succeeded.": "La connessione alla recovery e le query del dispositivo sono riuscite.", "Reading recovery information…": "Lettura delle informazioni di ripristino…", "Ready to validate and flash": "Pronto per la convalida e l'aggiornamento", "Choose a ROM": "Scegli un ROM", diff --git a/locales/ja/windows.json b/locales/ja/windows.json index 1e2cde4..2b551fb 100644 --- a/locales/ja/windows.json +++ b/locales/ja/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} の準備が完了しました", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery 準備完了", - "The direct USB handshake and device queries succeeded.": "直接の USB ハンドシェイクとデバイス クエリは成功しました。", + "The recovery handshake and device queries succeeded.": "リカバリーとの接続とデバイス情報の取得に成功しました。", "Reading recovery information…": "リカバリー情報の読み取り中...", "Ready to validate and flash": "検証およびフラッシュの準備ができました。", "Choose a ROM": "ROM を選択してください", diff --git a/locales/ko/windows.json b/locales/ko/windows.json index 371eb7d..e96ba21 100644 --- a/locales/ko/windows.json +++ b/locales/ko/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device}이(가) 준비되었습니다", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery 준비됨", - "The direct USB handshake and device queries succeeded.": "직접 USB 핸드셰이크 및 장치 쿼리가 성공했습니다.", + "The recovery handshake and device queries succeeded.": "복구 연결 및 기기 정보 조회에 성공했습니다.", "Reading recovery information…": "복구 정보 읽기…", "Ready to validate and flash": "검증 및 플래시 준비가 완료되었습니다.", "Choose a ROM": "ROM을(를) 선택하세요", diff --git a/locales/nb/windows.json b/locales/nb/windows.json index 0d7c816..e06cf63 100644 --- a/locales/nb/windows.json +++ b/locales/nb/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} er klar", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery klar", - "The direct USB handshake and device queries succeeded.": "Det direkte USB-håndtrykket og enhetsspørringene var vellykket.", + "The recovery handshake and device queries succeeded.": "Tilkoblingen til recovery og enhetsspørringene var vellykket.", "Reading recovery information…": "Leser gjenopprettingsinformasjon...", "Ready to validate and flash": "Klar til å validere og flashe", "Choose a ROM": "Velg en ROM", diff --git a/locales/nl/windows.json b/locales/nl/windows.json index d976d00..79e5b79 100644 --- a/locales/nl/windows.json +++ b/locales/nl/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} is klaar", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery gereed", - "The direct USB handshake and device queries succeeded.": "De directe USB handshake- en apparaatquery's zijn geslaagd.", + "The recovery handshake and device queries succeeded.": "De verbinding met recovery en de apparaatquery's zijn geslaagd.", "Reading recovery information…": "Herstelinformatie lezen...", "Ready to validate and flash": "Klaar om te valideren en te flashen", "Choose a ROM": "Kies een ROM", diff --git a/locales/pl/windows.json b/locales/pl/windows.json index 7b34909..f7d57a5 100644 --- a/locales/pl/windows.json +++ b/locales/pl/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} jest gotowy", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery gotowy", - "The direct USB handshake and device queries succeeded.": "Bezpośrednie uzgadnianie USB i zapytania o urządzenia powiodły się.", + "The recovery handshake and device queries succeeded.": "Połączenie z recovery i zapytania o urządzenie powiodły się.", "Reading recovery information…": "Czytanie informacji o przywracaniu...", "Ready to validate and flash": "Gotowy do sprawdzenia i flashowania", "Choose a ROM": "Wybierz ROM", diff --git a/locales/pt-BR/windows.json b/locales/pt-BR/windows.json index 14ffad2..0acf65d 100644 --- a/locales/pt-BR/windows.json +++ b/locales/pt-BR/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} está pronto", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery pronto", - "The direct USB handshake and device queries succeeded.": "O handshake direto USB e as consultas do dispositivo foram bem-sucedidas.", + "The recovery handshake and device queries succeeded.": "A conexão com o recovery e as consultas do dispositivo foram concluídas.", "Reading recovery information…": "Lendo informações de recuperação...", "Ready to validate and flash": "Pronto para validar e atualizar", "Choose a ROM": "Escolha um ROM", diff --git a/locales/pt-PT/windows.json b/locales/pt-PT/windows.json index 14ffad2..f650c9d 100644 --- a/locales/pt-PT/windows.json +++ b/locales/pt-PT/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} está pronto", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery pronto", - "The direct USB handshake and device queries succeeded.": "O handshake direto USB e as consultas do dispositivo foram bem-sucedidas.", + "The recovery handshake and device queries succeeded.": "A ligação ao recovery e as consultas do dispositivo foram concluídas.", "Reading recovery information…": "Lendo informações de recuperação...", "Ready to validate and flash": "Pronto para validar e atualizar", "Choose a ROM": "Escolha um ROM", diff --git a/locales/ro/windows.json b/locales/ro/windows.json index 2ac163a..cc9e5d1 100644 --- a/locales/ro/windows.json +++ b/locales/ro/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} este gata", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery gata", - "The direct USB handshake and device queries succeeded.": "Strângerea de mână directă USB și interogările dispozitivului au reușit.", + "The recovery handshake and device queries succeeded.": "Conexiunea la recovery și interogările dispozitivului au reușit.", "Reading recovery information…": "Citire informații despre recuperare...", "Ready to validate and flash": "Gata de validare și flash", "Choose a ROM": "Alegeți un ROM", diff --git a/locales/ru/windows.json b/locales/ru/windows.json index afb0f86..1551157 100644 --- a/locales/ru/windows.json +++ b/locales/ru/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} готово", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery готов", - "The direct USB handshake and device queries succeeded.": "Прямое рукопожатие USB и запросы устройств завершились успешно.", + "The recovery handshake and device queries succeeded.": "Подключение к recovery и запросы к устройству выполнены успешно.", "Reading recovery information…": "Чтение информации для восстановления…", "Ready to validate and flash": "Готов к проверке и прошивке", "Choose a ROM": "Выберите ROM", diff --git a/locales/sk/windows.json b/locales/sk/windows.json index f44522b..4b79e08 100644 --- a/locales/sk/windows.json +++ b/locales/sk/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} je pripravený", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery pripravený", - "The direct USB handshake and device queries succeeded.": "Priame handshake USB a dopyty na zariadenie boli úspešné.", + "The recovery handshake and device queries succeeded.": "Pripojenie k recovery a dopyty na zariadenie boli úspešné.", "Reading recovery information…": "Čítanie informácií o obnovení…", "Ready to validate and flash": "Pripravené na overenie a flash", "Choose a ROM": "Vyberte ROM", diff --git a/locales/sl/windows.json b/locales/sl/windows.json index 4f90594..ddd537b 100644 --- a/locales/sl/windows.json +++ b/locales/sl/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} je pripravljen", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery pripravljen", - "The direct USB handshake and device queries succeeded.": "Neposredno rokovanje USB in poizvedbe po napravi so bile uspešne.", + "The recovery handshake and device queries succeeded.": "Povezava z recoveryjem in poizvedbe naprave so uspele.", "Reading recovery information…": "Branje informacij o obnovitvi ...", "Ready to validate and flash": "Pripravljen za potrditev in flash", "Choose a ROM": "Izberite ROM", diff --git a/locales/sr/windows.json b/locales/sr/windows.json index 9240d89..e7a8efe 100644 --- a/locales/sr/windows.json +++ b/locales/sr/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} is ready", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery ready", - "The direct USB handshake and device queries succeeded.": "The direct USB handshake and device queries succeeded.", + "The recovery handshake and device queries succeeded.": "Povezivanje sa recovery okruženjem i upiti uređaja su uspeli.", "Reading recovery information…": "Читање информација о опоравку…", "Ready to validate and flash": "Спреман за потврду и флеш", "Choose a ROM": "Choose a ROM", diff --git a/locales/sv/windows.json b/locales/sv/windows.json index 404d8cf..cebc105 100644 --- a/locales/sv/windows.json +++ b/locales/sv/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} är klar", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery redo", - "The direct USB handshake and device queries succeeded.": "Den direkta USB handskakningen och enhetsfrågorna lyckades.", + "The recovery handshake and device queries succeeded.": "Anslutningen till recovery och enhetsfrågorna lyckades.", "Reading recovery information…": "Läser återställningsinformation...", "Ready to validate and flash": "Redo att validera och flasha", "Choose a ROM": "Välj ett ROM", diff --git a/locales/th/windows.json b/locales/th/windows.json index 384286c..9855585 100644 --- a/locales/th/windows.json +++ b/locales/th/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} พร้อมแล้ว", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery พร้อม", - "The direct USB handshake and device queries succeeded.": "การจับมือ USB โดยตรงและการค้นหาอุปกรณ์สำเร็จ", + "The recovery handshake and device queries succeeded.": "การเชื่อมต่อกับ recovery และการอ่านข้อมูลอุปกรณ์สำเร็จ", "Reading recovery information…": "กำลังอ่านข้อมูลการกู้คืน...", "Ready to validate and flash": "พร้อมที่จะตรวจสอบและแฟลช", "Choose a ROM": "เลือก ROM", diff --git a/locales/tr/windows.json b/locales/tr/windows.json index 498daaf..57de1a1 100644 --- a/locales/tr/windows.json +++ b/locales/tr/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} hazır", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery hazır", - "The direct USB handshake and device queries succeeded.": "Doğrudan USB anlaşması ve cihaz sorguları başarılı oldu.", + "The recovery handshake and device queries succeeded.": "Recovery bağlantısı ve cihaz sorguları başarılı oldu.", "Reading recovery information…": "Kurtarma bilgileri okunuyor…", "Ready to validate and flash": "Doğrulamaya ve flaşa hazır", "Choose a ROM": "Bir ROM seçin", diff --git a/locales/uk/windows.json b/locales/uk/windows.json index 9d021c8..16c2365 100644 --- a/locales/uk/windows.json +++ b/locales/uk/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} готовий", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery готовий", - "The direct USB handshake and device queries succeeded.": "Пряме рукостискання USB і запити пристрою виконано успішно.", + "The recovery handshake and device queries succeeded.": "Підключення до recovery і запити до пристрою виконано успішно.", "Reading recovery information…": "Читання інформації про відновлення…", "Ready to validate and flash": "Готовий до перевірки та прошивки", "Choose a ROM": "Виберіть ROM", diff --git a/locales/vi/windows.json b/locales/vi/windows.json index e7380ee..bd5e63c 100644 --- a/locales/vi/windows.json +++ b/locales/vi/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} đã sẵn sàng", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery sẵn sàng", - "The direct USB handshake and device queries succeeded.": "Bắt tay trực tiếp USB truy vấn thiết bị và bắt tay đã thành công.", + "The recovery handshake and device queries succeeded.": "Kết nối recovery và truy vấn thiết bị đã thành công.", "Reading recovery information…": "Đang đọc thông tin khôi phục…", "Ready to validate and flash": "Sẵn sàng xác thực và flash", "Choose a ROM": "Chọn một ROM", diff --git a/locales/zh-CN/windows.json b/locales/zh-CN/windows.json index 109f4f4..e3bbd39 100644 --- a/locales/zh-CN/windows.json +++ b/locales/zh-CN/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} 已准备就绪", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery 准备就绪", - "The direct USB handshake and device queries succeeded.": "直接 USB 握手和设备查询成功。", + "The recovery handshake and device queries succeeded.": "Recovery 连接和设备信息查询成功。", "Reading recovery information…": "读取恢复信息...", "Ready to validate and flash": "准备好验证并刷新", "Choose a ROM": "选择 ROM", diff --git a/locales/zh-TW/windows.json b/locales/zh-TW/windows.json index 696c07b..948ac10 100644 --- a/locales/zh-TW/windows.json +++ b/locales/zh-TW/windows.json @@ -80,7 +80,7 @@ "{device} is ready": "{device} 已準備就緒", "Recovery {version} · {region} {romzone}": "Recovery {version} · {region} {romzone}", "Recovery ready": "Recovery 準備就緒", - "The direct USB handshake and device queries succeeded.": "直接 USB 握手和設備查詢成功。", + "The recovery handshake and device queries succeeded.": "Recovery 連線和裝置資訊查詢成功。", "Reading recovery information…": "讀取復原資訊...", "Ready to validate and flash": "準備好驗證並刷新", "Choose a ROM": "選擇 ROM", diff --git a/packaging/windows/README-WINDOWS.md b/packaging/windows/README-WINDOWS.md index 2d4ec12..2faa7db 100644 --- a/packaging/windows/README-WINDOWS.md +++ b/packaging/windows/README-WINDOWS.md @@ -4,7 +4,7 @@ Run `Sensitivity-Setup-x64.exe` and open **Sensitivity** from the Start menu, or The installer follows the Windows light or dark setting. The app selects a supported system language or in-app override, including the 34 runtime catalogs listed in the project localization guide. For unattended deployment, use `Sensitivity-Setup-x64.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART`. -The app includes diagnostics and can explain likely driver or ADB ownership problems. The installer adds `sensitivity-cli.exe` to the machine `PATH`; open a new terminal after setup and run `sensitivity-cli` for scripts and detailed troubleshooting. +The app includes diagnostics and can explain likely driver or ADB ownership problems. The installer adds `sensitivity-cli.exe` to the machine `PATH`; open a new terminal after setup and run `sensitivity-cli` (the installed `sensitivity-cli.exe`) for scripts and detailed troubleshooting. ## One-time WinUSB setup diff --git a/src/main.rs b/src/main.rs index 3674ab6..b49e086 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,7 +135,11 @@ enum Commands { json: bool, }, /// Query the server and list allowed ROMs - ListAllowedRoms, + ListAllowedRoms { + /// Write a redacted validation response to PATH for troubleshooting + #[arg(long, value_name = "PATH")] + dump_json: Option, + }, /// Validate and sideload the given Recovery ROM zip Flash { path: PathBuf, @@ -148,6 +152,12 @@ enum Commands { /// Allow/force data wipe (sets sideload-host :1). Useful if using --token without server response. #[arg(long, action = ArgAction::SetTrue)] wipe: bool, + /// Write a redacted validation response to PATH for troubleshooting + #[arg(long, value_name = "PATH")] + dump_json: Option, + /// Validate with Xiaomi and stop before sideloading + #[arg(long, conflicts_with = "token")] + validate_only: bool, }, /// Erase user data, then reboot FormatData { @@ -217,6 +227,21 @@ fn run() -> Result<()> { // Open USB transport let make_client = || -> Result { + if cli.adb_policy == AdbPolicy::Keep && adb_was_running { + if let Ok(recoveries) = util::adb_server::discover_mi_recoveries(Duration::from_secs(3)) + { + if !recoveries.is_empty() { + let recovery = recoveries.get(cli.device_index).ok_or_else(|| { + anyhow::anyhow!( + "Device index {} out of range ({} found through ADB server)", + cli.device_index, + recoveries.len() + ) + })?; + return Ok(MiClient::from_adb_server(recovery.transport_id)); + } + } + } let transport = UsbTransport::open(cli.device_index, cli.debug_usb).context(tr("error.open_usb"))?; MiClient::new(transport).context(tr("error.init_adb")) @@ -229,7 +254,8 @@ fn run() -> Result<()> { return Ok(()); } Commands::Devices { json } => { - let devices = UsbTransport::discover().context(tr("error.discover_usb"))?; + let devices = discover_recovery_interfaces(cli.adb_policy, adb_was_running) + .context(tr("error.discover_usb"))?; if *json { println!("{}", serde_json::to_string_pretty(&devices)?); } else if devices.is_empty() { @@ -283,7 +309,8 @@ fn run() -> Result<()> { )] ) ); - let devices = UsbTransport::discover().context(tr("error.discover_usb"))?; + let devices = discover_recovery_interfaces(cli.adb_policy, adb_was_running) + .context(tr("error.discover_usb"))?; println!( "{}", trf( @@ -353,7 +380,7 @@ fn run() -> Result<()> { Commands::Devices { .. } => unreachable!("devices returns before USB command dispatch"), Commands::Doctor => unreachable!("doctor returns before USB command dispatch"), Commands::Detect => { - println!("{}", tr("status.device_detected")); + println!("{}", tr("status.recovery_ready")); } Commands::Info { json } => { let info = client.read_all_info().context(tr("error.fetch_device"))?; @@ -471,7 +498,7 @@ fn run() -> Result<()> { .context(tr("error.sideload"))?; emit_completed(cli.machine, &tr("status.flash_completed")); } - Commands::ListAllowedRoms => { + Commands::ListAllowedRoms { dump_json } => { let info = effective_device_info( &identity, client.read_all_info().context(tr("error.fetch_device"))?, @@ -480,13 +507,18 @@ fn run() -> Result<()> { validate::build_request_json(&info, None).context(tr("error.build_validation"))?; let resp = validate::validate(&cli.server_url, &req_json) .context(tr("error.validation_http"))?; - validate::print_allowed(&resp); + if let Some(path) = dump_json.as_deref() { + write_redacted_validation_response(&resp, path)?; + } + validate::print_allowed(&resp)?; } Commands::Flash { path, yes, token, wipe, + dump_json, + validate_only, } => { if !path.exists() { bail!( @@ -532,6 +564,9 @@ fn run() -> Result<()> { None => { let r = validate::validate(&cli.server_url, &req_json) .context(tr("error.validation_http"))?; + if let Some(path) = dump_json.as_deref() { + write_redacted_validation_response(&r, path)?; + } if let Some(msg) = r.code_message.as_deref() { println!("{}", trf("status.server_message", &[("{message}", msg)])); } @@ -543,6 +578,10 @@ fn run() -> Result<()> { t } }; + if validate_only { + emit_status(cli.machine, &tr("status.ready_result")); + return Ok(()); + } if let Some(v) = &resp.pkgrom_validate { if v.is_empty() { eprintln!("No allowed ROMs reported by server (Validate array empty). Proceeding may fail."); @@ -601,6 +640,15 @@ fn reset_control_file(path: Option<&Path>) -> Result<()> { Ok(()) } +fn write_redacted_validation_response( + result: &validate::ValidateResult, + path: &Path, +) -> Result<()> { + let diagnostic = validate::redacted_response_json(result)?; + std::fs::write(path, diagnostic) + .with_context(|| format!("Writing redacted validation response to {}", path.display())) +} + fn emit_machine_event(event: serde_json::Value) { println!("{event}"); io::stdout().flush().ok(); @@ -719,6 +767,14 @@ fn install_cancel_handler(cancel_file: Option<&Path>) -> Result> } fn print_usb_device(device: &sensitivity::usb::UsbDeviceInfo) { + if device.transport == "adb-server" { + println!( + " [{}] {} via the running ADB server", + device.index, + device.recovery_device.as_deref().unwrap_or("Mi Recovery") + ); + return; + } println!( " [{}] {:04x}:{:04x} bus {} address {} interface {} endpoints 0x{:02x}/0x{:02x}", device.index, @@ -732,6 +788,38 @@ fn print_usb_device(device: &sensitivity::usb::UsbDeviceInfo) { ); } +fn discover_recovery_interfaces( + adb_policy: AdbPolicy, + adb_server_running: bool, +) -> Result> { + if adb_policy == AdbPolicy::Keep && adb_server_running { + if let Ok(recoveries) = util::adb_server::discover_mi_recoveries(Duration::from_secs(3)) { + if !recoveries.is_empty() { + return Ok(recoveries + .into_iter() + .enumerate() + .map(|(index, recovery)| sensitivity::usb::UsbDeviceInfo { + index, + transport: "adb-server".to_owned(), + transport_id: Some(recovery.transport_id), + bus: 0, + address: 0, + vendor_id: 0, + product_id: 0, + interface: 0, + protocol: 1, + endpoint_in: 0, + endpoint_out: 0, + recovery_device: Some(recovery.recovery_device), + model: recovery.model, + }) + .collect()); + } + } + } + UsbTransport::discover() +} + fn adb_may_own_interface(error: &anyhow::Error) -> bool { let message = format!("{error:#}"); message.contains("Claiming interface") || message.contains("Opening USB device") @@ -854,6 +942,64 @@ mod cli_tests { assert_eq!(cli.approval_file, Some(PathBuf::from("approve.flag"))); } + #[test] + fn flash_redacted_diagnostic_path_parses() { + let cli = Cli::try_parse_from([ + "sensitivity", + "flash", + "rom.zip", + "--dump-json", + "validation-shape.json", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Commands::Flash { + dump_json: Some(path), + .. + } if path.as_path() == Path::new("validation-shape.json") + )); + } + + #[test] + fn list_allowed_roms_redacted_diagnostic_path_parses() { + let cli = Cli::try_parse_from([ + "sensitivity", + "list-allowed-roms", + "--dump-json", + "validation-shape.json", + ]) + .unwrap(); + assert!(matches!( + cli.command, + Commands::ListAllowedRoms { + dump_json: Some(path) + } if path.as_path() == Path::new("validation-shape.json") + )); + } + + #[test] + fn flash_validate_only_parses() { + let cli = + Cli::try_parse_from(["sensitivity", "flash", "rom.zip", "--validate-only"]).unwrap(); + assert!(matches!( + cli.command, + Commands::Flash { + validate_only: true, + .. + } + )); + assert!(Cli::try_parse_from([ + "sensitivity", + "flash", + "rom.zip", + "--validate-only", + "--token", + "private-token", + ]) + .is_err()); + } + #[test] fn supervised_wipe_requires_an_approval_path() { let cancel = AtomicBool::new(true); diff --git a/src/mi/mod.rs b/src/mi/mod.rs index c0dacc3..d9c3ae8 100644 --- a/src/mi/mod.rs +++ b/src/mi/mod.rs @@ -6,6 +6,7 @@ use anyhow::{Context, Result}; use crate::adb::{connect, AdbConnection}; use crate::usb::UsbTransport; +use crate::util::adb_server::{AdbServerSideloadStream, AdbServerTransport}; pub mod profile; #[derive(Debug, Clone, serde::Serialize)] @@ -21,13 +22,34 @@ pub struct DeviceInfo { } pub struct MiClient { - adb: AdbConnection, + backend: MiBackend, +} + +enum MiBackend { + Direct(AdbConnection), + Server(AdbServerTransport), +} + +pub enum OpenedSideload<'a> { + Direct { + stream: crate::adb::AdbStream<'a>, + pending: Option, + }, + Server(AdbServerSideloadStream), } impl MiClient { pub fn new(usb: UsbTransport) -> Result { let adb = connect(usb).context("ADB CONNECT handshake failed")?; - Ok(Self { adb }) + Ok(Self { + backend: MiBackend::Direct(adb), + }) + } + + pub fn from_adb_server(transport_id: u64) -> Self { + Self { + backend: MiBackend::Server(AdbServerTransport::new(transport_id)), + } } pub fn read_all_info(&mut self) -> Result { @@ -52,25 +74,34 @@ impl MiClient { } pub fn simple_query(&mut self, cmd: &str) -> Result { - let text = self - .adb - .query_text(cmd) - .with_context(|| format!("query_text {}", cmd))?; + let text = match &mut self.backend { + MiBackend::Direct(adb) => adb.query_text(cmd), + MiBackend::Server(adb) => adb.query_text(cmd), + } + .with_context(|| format!("query_text {cmd}"))?; Ok(text) } pub fn simple_command(&mut self, cmd: &str) -> Result<()> { - let mut s = self.adb.open_service(cmd)?; - // Reboot and format-data can deliberately drop USB before CLSE. A - // successfully opened service is the recovery's acknowledgement. - let _ = s.read_to_end(); + match &mut self.backend { + MiBackend::Direct(adb) => { + let mut stream = adb.open_service(cmd)?; + // Reboot and format-data can deliberately drop USB before CLSE. A + // successfully opened service is the recovery's acknowledgement. + let _ = stream.read_to_end(); + } + MiBackend::Server(adb) => adb.command(cmd)?, + } Ok(()) } - pub fn open_sideload( - &mut self, - name: &str, - ) -> Result<(crate::adb::AdbStream<'_>, Option)> { - self.adb.open_sideload(name) + pub fn open_sideload(&mut self, name: &str) -> Result> { + match &mut self.backend { + MiBackend::Direct(adb) => { + let (stream, pending) = adb.open_sideload(name)?; + Ok(OpenedSideload::Direct { stream, pending }) + } + MiBackend::Server(adb) => Ok(OpenedSideload::Server(adb.open_sideload(name)?)), + } } } diff --git a/src/sideload.rs b/src/sideload.rs index 926b48d..ffbff3c 100644 --- a/src/sideload.rs +++ b/src/sideload.rs @@ -11,7 +11,8 @@ use anyhow::{bail, Context, Result}; use indicatif::{ProgressBar, ProgressStyle}; use crate::adb::{AdbStream, A_CLSE, A_OKAY, A_WRTE}; -use crate::mi::MiClient; +use crate::mi::{MiClient, OpenedSideload}; +use crate::util::adb_server::AdbServerSideloadStream; fn block_window(total: u64, chunk_size: usize, index: u64) -> Option<(u64, usize)> { let offset = index.checked_mul(chunk_size as u64)?; @@ -22,6 +23,31 @@ fn block_window(total: u64, chunk_size: usize, index: u64) -> Option<(u64, usize Some((offset, length)) } +#[derive(Debug, PartialEq, Eq)] +enum ServerSideloadCommand { + Block(u64), + Done, + Fail, +} + +fn parse_server_sideload_command(command: [u8; 8]) -> Result { + match &command { + b"DONEDONE" => return Ok(ServerSideloadCommand::Done), + b"FAILFAIL" => return Ok(ServerSideloadCommand::Fail), + _ => {} + } + let command_text = + std::str::from_utf8(&command).context("Sideload block request is not ASCII")?; + if !command_text.bytes().all(|byte| byte.is_ascii_digit()) { + bail!("Unexpected sideload command from recovery"); + } + Ok(ServerSideloadCommand::Block( + command_text + .parse::() + .context("Invalid sideload block index")?, + )) +} + fn sideload_host_service( total: u64, chunk_size: usize, @@ -104,16 +130,46 @@ where // The last field is the wipe flag. Some cross-region updates require data wipe. // When server indicates Erase==1, we must send ":1"; otherwise ":0" will make recovery abort. let host_str = sideload_host_service(total, chunk_size, validate_token, allow_wipe)?; - let (mut stream, pending) = client - .open_sideload(&host_str) - .context("Opening sideload-host service")?; - // Give the device more time between requests during sideload - // (some recoveries take >5s before first WRTE) - stream.set_timeout(std::time::Duration::from_secs(30)); - progress(0, total); - let mut reader = BufReader::new(file); + match client + .open_sideload(&host_str) + .context("Opening sideload-host service")? + { + OpenedSideload::Direct { stream, pending } => transfer_direct( + stream, + pending, + &mut reader, + total, + chunk_size, + cancel, + &mut progress, + ), + OpenedSideload::Server(stream) => transfer_through_adb_server( + stream, + &mut reader, + total, + chunk_size, + cancel, + &mut progress, + ), + } +} + +fn transfer_direct( + mut stream: AdbStream<'_>, + pending: Option, + reader: &mut BufReader, + total: u64, + chunk_size: usize, + cancel: &AtomicBool, + progress: &mut F, +) -> Result<()> +where + F: FnMut(u64, u64), +{ + // Some recoveries take more than five seconds before their first request. + stream.set_timeout(std::time::Duration::from_secs(30)); let mut send_block = |index: u64, s: &mut AdbStream<'_>, pkt_arg0: u32, pkt_arg1: u32| -> Result { let Some((offset, to_send)) = block_window(total, chunk_size, index) else { @@ -221,6 +277,53 @@ where Ok(()) } +fn transfer_through_adb_server( + mut stream: AdbServerSideloadStream, + reader: &mut BufReader, + total: u64, + chunk_size: usize, + cancel: &AtomicBool, + progress: &mut F, +) -> Result<()> +where + F: FnMut(u64, u64), +{ + // The ADB server unwraps ADB packets and exposes Android's sideload-host + // protocol: eight decimal block-index bytes, followed by the requested data. + stream.set_timeout(std::time::Duration::from_secs(30)); + let mut bytes_sent = 0; + loop { + if cancel.load(Ordering::Relaxed) { + stream.close(); + bail!("Sideload cancelled by user"); + } + + let index = match parse_server_sideload_command(stream.read_command()?)? { + ServerSideloadCommand::Done => { + stream.close(); + if bytes_sent < total { + bail!("Sideload ended after {bytes_sent} of {total} bytes"); + } + return Ok(()); + } + ServerSideloadCommand::Fail => { + stream.close(); + bail!("Sideload reported failure"); + } + ServerSideloadCommand::Block(index) => index, + }; + let Some((offset, length)) = block_window(total, chunk_size, index) else { + bail!("Recovery requested sideload block {index} outside the ROM package"); + }; + let mut block = vec![0; length]; + reader.seek(SeekFrom::Start(offset))?; + reader.read_exact(&mut block)?; + stream.write_block(&block)?; + bytes_sent = bytes_sent.max(offset + length as u64); + progress(bytes_sent, total); + } +} + #[cfg(test)] mod tests { use super::*; @@ -238,6 +341,23 @@ mod tests { assert_eq!(block_window(10, 64 * 1024, u64::MAX), None); } + #[test] + fn adb_server_sideload_commands_are_strictly_parsed() { + assert_eq!( + parse_server_sideload_command(*b"00000023").unwrap(), + ServerSideloadCommand::Block(23) + ); + assert_eq!( + parse_server_sideload_command(*b"DONEDONE").unwrap(), + ServerSideloadCommand::Done + ); + assert_eq!( + parse_server_sideload_command(*b"FAILFAIL").unwrap(), + ServerSideloadCommand::Fail + ); + assert!(parse_server_sideload_command(*b"0000oops").is_err()); + } + #[test] fn final_host_field_is_only_the_wipe_flag() { assert_eq!( diff --git a/src/usb/mod.rs b/src/usb/mod.rs index e12959c..a12aa78 100644 --- a/src/usb/mod.rs +++ b/src/usb/mod.rs @@ -17,6 +17,8 @@ pub struct UsbTransport { #[derive(Debug, Clone, serde::Serialize)] pub struct UsbDeviceInfo { pub index: usize, + pub transport: String, + pub transport_id: Option, pub bus: u8, pub address: u8, pub vendor_id: u16, @@ -25,6 +27,8 @@ pub struct UsbDeviceInfo { pub protocol: u8, pub endpoint_in: u8, pub endpoint_out: u8, + pub recovery_device: Option, + pub model: Option, } struct UsbCandidate { @@ -72,6 +76,8 @@ fn discover_candidates(context: &rusb::Context) -> Result> { device: device.clone(), info: UsbDeviceInfo { index: 0, + transport: "usb".to_owned(), + transport_id: None, bus: device.bus_number(), address: device.address(), vendor_id: descriptor.as_ref().map_or(0, |value| value.vendor_id()), @@ -80,6 +86,8 @@ fn discover_candidates(context: &rusb::Context) -> Result> { protocol: setting.protocol_code(), endpoint_in, endpoint_out, + recovery_device: None, + model: None, }, }; if setting.protocol_code() == 0x01 { diff --git a/src/util/adb_server.rs b/src/util/adb_server.rs index d63115f..07d1a77 100644 --- a/src/util/adb_server.rs +++ b/src/util/adb_server.rs @@ -2,11 +2,43 @@ // Licensed under the GNU AGPL v3.0. See LICENSE file for details. // Website: https://chromatic.hu -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::io::{Read, Write}; use std::net::{Shutdown, TcpStream}; use std::time::{Duration, Instant}; +const ADB_SERVER_PORT: u16 = 5037; +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(3); + +#[derive(Debug, Clone, serde::Serialize)] +pub struct AdbServerDeviceInfo { + pub index: usize, + pub transport_id: u64, + pub state: String, + pub product: Option, + pub model: Option, + pub device: Option, + pub recovery_device: String, +} + +#[derive(Debug, Clone)] +pub struct AdbServerTransport { + transport_id: u64, + timeout: Duration, +} + +pub struct AdbServerSideloadStream { + stream: TcpStream, +} + +struct ListedDevice { + transport_id: u64, + state: String, + product: Option, + model: Option, + device: Option, +} + fn connect(port: u16, timeout: Duration) -> Result { let addr = format!("127.0.0.1:{}", port); let stream = TcpStream::connect(addr).context("connect adb server 127.0.0.1:5037")?; @@ -23,18 +55,211 @@ fn send_request(stream: &mut TcpStream, req: &str) -> Result<()> { Ok(()) } -fn read_status(stream: &mut TcpStream) -> Result { +fn read_status_text(stream: &mut TcpStream) -> Result { let mut status = [0u8; 4]; stream.read_exact(&mut status)?; Ok(String::from_utf8_lossy(&status).to_string()) } +fn read_hex_length(stream: &mut TcpStream) -> Result { + let mut length = [0u8; 4]; + stream.read_exact(&mut length)?; + usize::from_str_radix(&String::from_utf8_lossy(&length), 16) + .context("invalid length from adb server") +} + +fn read_length_prefixed(stream: &mut TcpStream) -> Result> { + let length = read_hex_length(stream)?; + if length > 1024 * 1024 { + bail!("adb server response is too large: {length} bytes"); + } + let mut payload = vec![0; length]; + stream.read_exact(&mut payload)?; + Ok(payload) +} + +fn expect_okay(stream: &mut TcpStream, request: &str) -> Result<()> { + let sensitive_service = request.starts_with("sideload-host:"); + let request_label = if sensitive_service { + "sideload-host service" + } else { + request + }; + match read_status_text(stream)?.as_str() { + "OKAY" => Ok(()), + "FAIL" => { + if sensitive_service { + bail!("adb server rejected {request_label}"); + } + let message = read_length_prefixed(stream) + .map(|value| String::from_utf8_lossy(&value).into_owned()) + .unwrap_or_else(|_| "unknown adb server failure".to_owned()); + bail!("adb server rejected {request_label}: {message}") + } + status => bail!("unexpected adb server status {status:?} for {request_label}"), + } +} + +fn host_request(request: &str, timeout: Duration) -> Result { + let mut stream = connect(ADB_SERVER_PORT, timeout)?; + send_request(&mut stream, request)?; + expect_okay(&mut stream, request)?; + Ok(stream) +} + +fn read_stream_text(mut stream: TcpStream, context: &str) -> Result { + let mut output = Vec::new(); + match stream.read_to_end(&mut output) { + Ok(_) => {} + // Windows reports a socket timeout as an unclassified OS error on + // some toolchains. Once the recovery returned text, a later timeout + // only means this short service omitted its final close. + Err(error) + if !output.is_empty() + && (matches!( + error.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ) || error.raw_os_error() == Some(10060)) => {} + Err(error) => return Err(error).with_context(|| context.to_owned()), + } + let text = String::from_utf8(output).context("adb server returned non-UTF-8 text")?; + Ok(text.trim_matches(['\0', '\r', '\n']).to_owned()) +} + +fn parse_device_line(line: &str) -> Option { + let mut fields = line.split_ascii_whitespace(); + let _serial = fields.next()?; + let state = fields.next()?.to_owned(); + let mut transport_id = None; + let mut product = None; + let mut model = None; + let mut device = None; + for field in fields { + let Some((key, value)) = field.split_once(':') else { + continue; + }; + match key { + "transport_id" => transport_id = value.parse().ok(), + "product" => product = Some(value.to_owned()), + "model" => model = Some(value.to_owned()), + "device" => device = Some(value.to_owned()), + _ => {} + } + } + Some(ListedDevice { + transport_id: transport_id?, + state, + product, + model, + device, + }) +} + +pub fn discover_mi_recoveries(timeout: Duration) -> Result> { + let mut stream = host_request("host:devices-l", timeout)?; + let payload = read_length_prefixed(&mut stream)?; + let listing = String::from_utf8(payload).context("adb device list is not UTF-8")?; + let mut recoveries = Vec::new(); + for line in listing.lines() { + let Some(device_info) = parse_device_line(line) else { + continue; + }; + if device_info.state != "sideload" && device_info.state != "recovery" { + continue; + } + let transport_id = device_info.transport_id; + let transport = AdbServerTransport::new(transport_id); + let recovery_device = transport + .query_text_with_timeout("getdevice:", timeout) + .with_context(|| format!("probing ADB transport {transport_id} as Mi Recovery"))?; + if recovery_device.is_empty() { + continue; + } + recoveries.push(AdbServerDeviceInfo { + index: recoveries.len(), + transport_id, + state: device_info.state, + product: device_info.product, + model: device_info.model, + device: device_info.device, + recovery_device, + }); + } + Ok(recoveries) +} + +impl AdbServerTransport { + pub fn new(transport_id: u64) -> Self { + Self { + transport_id, + timeout: DEFAULT_TIMEOUT, + } + } + + fn open_service_with_timeout(&self, service: &str, timeout: Duration) -> Result { + let mut stream = + host_request(&format!("host:transport-id:{}", self.transport_id), timeout)?; + send_request(&mut stream, service)?; + expect_okay(&mut stream, service)?; + Ok(stream) + } + + fn query_text_with_timeout(&self, service: &str, timeout: Duration) -> Result { + let stream = self.open_service_with_timeout(service, timeout)?; + read_stream_text(stream, &format!("reading {service} through adb server")) + } + + pub fn query_text(&self, service: &str) -> Result { + self.query_text_with_timeout(service, self.timeout) + } + + pub fn command(&self, service: &str) -> Result<()> { + let stream = self.open_service_with_timeout(service, self.timeout)?; + let _ = read_stream_text(stream, &format!("running {service} through adb server")); + Ok(()) + } + + pub fn open_sideload(&self, service: &str) -> Result { + let stream = self.open_service_with_timeout(service, Duration::from_secs(30))?; + Ok(AdbServerSideloadStream { stream }) + } + + pub fn set_timeout(&mut self, timeout: Duration) { + self.timeout = timeout; + } +} + +impl AdbServerSideloadStream { + pub fn set_timeout(&mut self, timeout: Duration) { + self.stream.set_read_timeout(Some(timeout)).ok(); + self.stream.set_write_timeout(Some(timeout)).ok(); + } + + pub fn read_command(&mut self) -> Result<[u8; 8]> { + let mut command = [0; 8]; + self.stream + .read_exact(&mut command) + .context("reading sideload command through adb server")?; + Ok(command) + } + + pub fn write_block(&mut self, block: &[u8]) -> Result<()> { + self.stream + .write_all(block) + .context("writing sideload block through adb server") + } + + pub fn close(self) { + let _ = self.stream.shutdown(Shutdown::Both); + } +} + pub fn kill_adb_server(timeout: Duration) -> Result<()> { - let mut s = connect(5037, timeout)?; + let mut s = connect(ADB_SERVER_PORT, timeout)?; // Try host:kill send_request(&mut s, "host:kill")?; // Read status; server may close immediately on success. - match read_status(&mut s) { + match read_status_text(&mut s) { Ok(st) if st == "OKAY" => {} Ok(st) if st == "FAIL" => { // Read length and payload for diagnostics @@ -67,11 +292,11 @@ fn wait_until_stopped(timeout: Duration) -> Result<()> { } pub fn is_running(timeout: Duration) -> bool { - match connect(5037, timeout) { + match connect(ADB_SERVER_PORT, timeout) { Ok(mut s) => { // Send a ping request (host:version) to confirm it's an adb server if send_request(&mut s, "host:version").is_ok() { - if let Ok(st) = read_status(&mut s) { + if let Ok(st) = read_status_text(&mut s) { return st == "OKAY" || st == "FAIL"; // both indicate a speaking server } } @@ -80,3 +305,53 @@ pub fn is_running(timeout: Duration) -> bool { Err(_) => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_listing_parser_ignores_the_serial_and_reads_transport_metadata() { + let parsed = parse_device_line( + "private-serial\tsideload product:garnet model:xiaomi_for_arm64 device:garnet transport_id:16", + ) + .unwrap(); + + assert_eq!(parsed.transport_id, 16); + assert_eq!(parsed.state, "sideload"); + assert_eq!(parsed.product.as_deref(), Some("garnet")); + assert_eq!(parsed.model.as_deref(), Some("xiaomi_for_arm64")); + assert_eq!(parsed.device.as_deref(), Some("garnet")); + } + + #[test] + fn device_listing_parser_requires_a_transport_id() { + assert!(parse_device_line("serial\tdevice product:sweet").is_none()); + } + + #[test] + fn sideload_service_errors_never_echo_the_validation_token() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let message = b"secret-token-in-server-error"; + stream.write_all(b"FAIL").unwrap(); + stream + .write_all(format!("{:04x}", message.len()).as_bytes()) + .unwrap(); + stream.write_all(message).unwrap(); + }); + let mut stream = TcpStream::connect(address).unwrap(); + let error = expect_okay( + &mut stream, + "sideload-host:123:65536:secret-token-in-request:0", + ) + .unwrap_err() + .to_string(); + server.join().unwrap(); + + assert!(error.contains("sideload-host service")); + assert!(!error.contains("secret-token")); + } +} diff --git a/src/validate.rs b/src/validate.rs index 5ea0d9a..aa7a06d 100644 --- a/src/validate.rs +++ b/src/validate.rs @@ -7,7 +7,7 @@ use anyhow::{anyhow, bail, Context, Result}; use base64::{engine::general_purpose, Engine as _}; use cbc::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit}; use reqwest::blocking::Client; -use serde::Deserialize; +use serde_json::{Map, Value}; use std::time::Duration; use crate::mi::DeviceInfo; @@ -113,35 +113,114 @@ fn extract_json_braces(text: &str) -> Option { Some(text[start..=end].to_string()) } -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum ValidateField { - Str(String), - Arr(Vec), +fn object_value_ci<'a>(object: &'a Map, key: &str) -> Option<&'a Value> { + object + .iter() + .find_map(|(name, value)| name.eq_ignore_ascii_case(key).then_some(value)) } -#[derive(Debug, Deserialize)] -struct ResponsePkgRom { - #[serde(default, rename = "Validate")] - validate: Option, - #[serde(default, rename = "Erase")] - erase: Option, - #[serde(default, rename = "Token")] - token: Option, +fn find_key_recursive_ci<'a>(value: &'a Value, key: &str) -> Option<&'a Value> { + match value { + Value::Object(object) => object_value_ci(object, key).or_else(|| { + object + .values() + .find_map(|child| find_key_recursive_ci(child, key)) + }), + Value::Array(items) => items + .iter() + .find_map(|child| find_key_recursive_ci(child, key)), + _ => None, + } +} + +fn non_empty_string(value: &Value) -> Option { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn token_from_validate(value: &Value) -> Option { + if let Some(token) = non_empty_string(value) { + return Some(token); + } + let object = value.as_object()?; + object_value_ci(object, "token") + .and_then(non_empty_string) + .or_else(|| object_value_ci(object, "validate").and_then(token_from_validate)) +} + +fn erase_value(value: &Value) -> Option { + value + .as_i64() + .and_then(|number| i32::try_from(number).ok()) + .or_else(|| value.as_str().and_then(|number| number.parse().ok())) + .or_else(|| value.as_bool().map(i32::from)) +} + +fn parse_validation_response(json_text: &str) -> Result { + let root: Value = serde_json::from_str(json_text).context("Parsing JSON in server response")?; + let mut out = ValidateResult { + full_json: Some(json_text.to_owned()), + ..ValidateResult::default() + }; + + if let Some(pkg) = find_key_recursive_ci(&root, "PkgRom") { + if let Some(object) = pkg.as_object() { + if let Some(validate) = object_value_ci(object, "Validate") { + if let Some(items) = validate.as_array() { + out.pkgrom_validate = Some( + items + .iter() + .filter_map(non_empty_string) + .collect::>(), + ); + } else { + out.validate_token = token_from_validate(validate); + } + } + if out.validate_token.is_none() { + out.validate_token = object_value_ci(object, "Token").and_then(non_empty_string); + } + out.pkgrom_erase = object_value_ci(object, "Erase").and_then(erase_value); + } + } + + if let Some(code) = find_key_recursive_ci(&root, "Code") { + out.code_message = code + .as_object() + .and_then(|object| object_value_ci(object, "message")) + .and_then(non_empty_string); + } + + Ok(out) } -#[derive(Debug, Deserialize)] -struct ResponseCode { - #[serde(default)] - message: String, +fn redact_json_value(value: &Value) -> Value { + match value { + Value::Object(object) => Value::Object( + object + .iter() + .map(|(key, value)| (key.clone(), redact_json_value(value))) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(redact_json_value).collect()), + Value::String(_) => Value::String("".to_owned()), + Value::Number(_) => Value::Number(0.into()), + Value::Bool(_) => Value::Bool(false), + Value::Null => Value::Null, + } } -#[derive(Debug, Deserialize)] -struct ResponseRoot { - #[serde(default, rename = "PkgRom")] - pkg_rom: Option, - #[serde(default, rename = "Code")] - code: Option, +pub fn redacted_response_json(result: &ValidateResult) -> Result { + let raw = result + .full_json + .as_deref() + .ok_or_else(|| anyhow!("No validation response JSON is available"))?; + let value: Value = serde_json::from_str(raw).context("Parsing validation response for dump")?; + serde_json::to_string_pretty(&redact_json_value(&value)) + .context("Serializing redacted validation response") } pub fn validate(server_url: &str, json_body: &str) -> Result { @@ -176,61 +255,27 @@ pub fn validate(server_url: &str, json_body: &str) -> Result { let preview = String::from_utf8_lossy(&plain); let json_text = extract_json_braces(&preview) .ok_or_else(|| anyhow!("No JSON object found in plaintext (len {})", plain.len()))?; - let root: ResponseRoot = - serde_json::from_str(&json_text).context("Parsing JSON in server response")?; - let mut out = ValidateResult::default(); - if let Some(pkg) = root.pkg_rom { - if let Some(v) = pkg.validate { - match v { - ValidateField::Arr(list) => out.pkgrom_validate = Some(list), - ValidateField::Str(s) => out.validate_token = Some(s), - } - } - if out.validate_token.is_none() { - if let Some(tok) = pkg.token { - out.validate_token = Some(tok); - } - } - out.pkgrom_erase = pkg.erase; - } - if let Some(code) = root.code { - if !code.message.is_empty() { - out.code_message = Some(code.message); - } - } - out.full_json = Some(json_text.clone()); - if out.pkgrom_validate.is_none() && out.code_message.is_none() { - bail!( - "Validation response missing expected keys (PkgRom.Validate or Code.message); decrypted payload was {} bytes", - plain.len() - ); - } - Ok(out) + parse_validation_response(&json_text) } -pub fn print_allowed(res: &ValidateResult) { +pub fn print_allowed(res: &ValidateResult) -> Result<()> { // Prefer explicit allowed list (PkgRom.Validate) if let Some(list) = &res.pkgrom_validate { if list.is_empty() { - println!("{}", tr("status.no_allowed_roms")); + bail!("{}", tr("status.no_allowed_roms")); } else { println!("{}", tr("status.allowed_roms")); for s in list { println!("- {}", s); } } - return; + return Ok(()); } // Fallback: parse top-level JSON and print entries with name/md5 (as miasst.c does for list-allowed-roms) if let Some(json_str) = &res.full_json { if let Ok(val) = serde_json::from_str::(json_str) { if let Some(obj) = val.as_object() { - // Detect invalid data like C code - if obj.contains_key("Signup") || obj.contains_key("VersionBoot") { - eprintln!("{}: Invalid data", tr("error.prefix")); - return; - } let mut printed = false; for (k, v) in obj { if k == "Icon" { @@ -246,18 +291,13 @@ pub fn print_allowed(res: &ValidateResult) { } } if printed { - return; + return Ok(()); } } } } - // Last resort: print server message if any - if let Some(msg) = &res.code_message { - println!("{}", msg); - } else { - println!("{}", tr("status.no_allowed_roms")); - } + bail!("{}", tr("status.no_allowed_roms")) } #[cfg(test)] @@ -278,4 +318,93 @@ mod tests { let j = extract_json_braces(s).unwrap(); assert_eq!(j, "{ \"a\": 1 }"); } + + #[test] + fn parses_standard_validation_token() { + let result = parse_validation_response( + r#"{"PkgRom":{"Validate":"secret-token","Erase":1},"Code":{"message":"success"}}"#, + ) + .unwrap(); + assert_eq!(result.validate_token.as_deref(), Some("secret-token")); + assert_eq!(result.pkgrom_erase, Some(1)); + assert_eq!(result.code_message.as_deref(), Some("success")); + } + + #[test] + fn parses_case_insensitive_nested_validation_token() { + let result = parse_validation_response( + r#"{"data":{"pkgrom":{"validate":{"token":"nested-token"},"erase":"1"}},"code":{"MESSAGE":"success"}}"#, + ) + .unwrap(); + assert_eq!(result.validate_token.as_deref(), Some("nested-token")); + assert_eq!(result.pkgrom_erase, Some(1)); + assert_eq!(result.code_message.as_deref(), Some("success")); + } + + #[test] + fn preserves_allowed_rom_array() { + let result = + parse_validation_response(r#"{"PkgRom":{"Validate":["one.zip","two.zip"],"Erase":0}}"#) + .unwrap(); + assert_eq!( + result.pkgrom_validate, + Some(vec!["one.zip".to_owned(), "two.zip".to_owned()]) + ); + assert_eq!(result.validate_token, None); + } + + #[test] + fn diagnostic_json_redacts_all_scalar_values() { + let result = parse_validation_response( + r#"{"PkgRom":{"Validate":"secret-token","Erase":7,"Wipe":true},"Code":{"message":"success"}}"#, + ) + .unwrap(); + let diagnostic = redacted_response_json(&result).unwrap(); + assert!(diagnostic.contains("PkgRom")); + assert!(diagnostic.contains("Validate")); + assert!(!diagnostic.contains("secret-token")); + assert!(!diagnostic.contains("success")); + assert!(!diagnostic.contains("string:")); + assert!(!diagnostic.contains("\": 7")); + assert!(!diagnostic.contains("true")); + let redacted: Value = serde_json::from_str(&diagnostic).unwrap(); + assert!(redacted["PkgRom"]["Validate"].is_string()); + assert!(redacted["PkgRom"]["Erase"].is_number()); + assert!(redacted["PkgRom"]["Wipe"].is_boolean()); + } + + #[test] + fn unfamiliar_json_remains_available_for_redacted_diagnostics() { + let result = parse_validation_response(r#"{"Unexpected":{"Value":"private"}}"#).unwrap(); + assert_eq!(result.validate_token, None); + let diagnostic = redacted_response_json(&result).unwrap(); + assert!(diagnostic.contains("Unexpected")); + assert!(diagnostic.contains("Value")); + assert!(!diagnostic.contains("private")); + } + + #[test] + fn response_without_a_package_is_not_reported_as_success() { + let result = parse_validation_response( + r#"{"AuthResult":0,"Code":{"code":0,"message":"success"},"Signup":{},"patchInfo":[]}"#, + ) + .unwrap(); + + assert!(print_allowed(&result).is_err()); + } + + #[test] + fn latest_rom_is_accepted_alongside_account_metadata() { + let result = parse_validation_response( + r#"{ + "Code":{"code":0,"message":"success"}, + "Signup":{"rank":"0"}, + "VersionBoot":"1", + "LatestRom":{"name":"ROM","md5":"0123456789abcdef0123456789abcdef"} + }"#, + ) + .unwrap(); + + assert!(print_allowed(&result).is_ok()); + } }