flasher: universal streaming flash log on every platform

The diagnostic log born on the macOS path is now a shared module used
by all three flash paths: one well-known location
(archr-flasher-flash.log in the temp dir), reset at the start of each
flash, flushed line by line so even a hard crash leaves the full trace,
with version, OS and start time in the header. Windows logs the volume
lock/dismount results, write and verify hashes and the post-write
config script output; Linux captures the helper's full stderr; macOS
keeps its existing narration. Error messages on every platform now end
with the log location, without disturbing the stable err: tokens the
frontend matches on.
This commit is contained in:
Douglas Teles
2026-07-08 13:14:54 -03:00
parent 40f7b709b5
commit 971d91b3f2
5 changed files with 167 additions and 44 deletions
+56
View File
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 ArchR
//
// Streaming diagnostic log for the flash pipeline, shared by every
// platform. Reset at the start of each flash and flushed line by line,
// so crashes and kills still leave a complete trace the user can attach
// to a bug report.
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
/// Single well-known location, independent of the platform flash path.
pub fn log_path() -> PathBuf {
std::env::temp_dir().join("archr-flasher-flash.log")
}
pub struct DiagLog {
file: Option<File>,
}
impl DiagLog {
/// Truncate the previous run's log and write the header.
pub fn new() -> Self {
let file = File::create(log_path()).ok();
let mut log = DiagLog { file };
let epoch = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
log.push_str(&format!(
"archr-flasher {} flash log (os: {}, started: {} unix)\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
epoch
));
log
}
pub fn push_str(&mut self, line: &str) {
if let Some(f) = self.file.as_mut() {
let _ = f.write_all(line.as_bytes());
let _ = f.flush();
}
}
}
/// Append the log location to an error message without disturbing the
/// stable err:* tokens the frontend matches at the start of the string.
pub fn with_log_hint(err: String) -> String {
if err == "cancelled" {
return err;
}
format!("{} (log: {})", err, log_path().display())
}
+35
View File
@@ -16,6 +16,7 @@ use std::time::Duration;
use tauri::{AppHandle, Emitter};
use serde::Serialize;
use crate::flash::{emit_progress, validate_device, check_temp_space, poll_flash_progress, decompress_xz, decompress_gz};
use crate::diaglog::DiagLog;
// ---------------------------------------------------------------------------
// Linux: pkexec + helper script
@@ -28,11 +29,35 @@ pub fn flash_image_privileged(
custom_dtbo_path: &str,
variant: &str,
verify: bool,
) -> Result<(), String> {
let mut diag = DiagLog::new();
let result = flash_core_linux(
app, image_path, device, custom_dtbo_path, variant, verify, &mut diag,
);
match &result {
Ok(()) => diag.push_str("result: success\n"),
Err(e) => diag.push_str(&format!("result: {}\n", e)),
}
result.map_err(crate::diaglog::with_log_hint)
}
#[cfg(target_os = "linux")]
fn flash_core_linux(
app: &AppHandle,
image_path: &str,
device: &str,
custom_dtbo_path: &str,
variant: &str,
verify: bool,
diag: &mut DiagLog,
) -> Result<(), String> {
use std::process::{Command, Stdio};
use std::os::unix::fs::PermissionsExt;
validate_device(device)?;
diag.push_str(&format!(
"device: {}\nimage: {}\nverify: {}\n", device, image_path, verify
));
let is_xz = image_path.ends_with(".xz");
let is_gz = image_path.ends_with(".gz") && !is_xz;
@@ -89,6 +114,7 @@ pub fn flash_image_privileged(
.arg("systemd-inhibit").output()
.map(|o| o.status.success())
.unwrap_or(false);
diag.push_str(&format!("systemd-inhibit available: {}\n", has_inhibit));
let mut cmd = if has_inhibit {
let mut c = Command::new("systemd-inhibit");
@@ -150,6 +176,15 @@ pub fn flash_image_privileged(
let output = child.wait_with_output()
.map_err(|e| format!("Failed to wait for pkexec: {}", e))?;
// The helper script narrates every stage to stderr; keep the whole
// capture in the diagnostic log regardless of the outcome (no env
// or secrets ever go through this channel).
diag.push_str(&format!(
"helper exit: {:?}\n--- helper stderr ---\n{}\n---\n",
output.status,
String::from_utf8_lossy(&output.stderr).trim()
));
// Stop polling thread
stop.store(true, Ordering::Relaxed);
let _ = poll_handle.join();
+3 -39
View File
@@ -21,34 +21,7 @@ use tauri::{AppHandle, Emitter};
use serde::Serialize;
use crate::flash::{emit_progress, validate_device, check_temp_space, poll_flash_progress, decompress_xz, decompress_gz};
use crate::rawwrite_unix::{receive_fd_from_helper, write_image_to_raw_fd, configure_boot_volume, FdHelperError};
/// Streaming diagnostic log: every line lands on disk the moment it is
/// pushed, so crashes and kills still leave a complete trace. Reset at
/// the start of each flash.
#[cfg(target_os = "macos")]
struct DiagLog {
file: Option<File>,
}
#[cfg(target_os = "macos")]
impl DiagLog {
fn new(path: &Path) -> Self {
let file = File::create(path).ok();
let mut log = DiagLog { file };
log.push_str(&format!(
"archr-flasher {} flash log\n",
env!("CARGO_PKG_VERSION")
));
log
}
fn push_str(&mut self, line: &str) {
if let Some(f) = self.file.as_mut() {
let _ = f.write_all(line.as_bytes());
let _ = f.flush();
}
}
}
use crate::diaglog::DiagLog;
#[cfg(target_os = "macos")]
pub fn flash_image_privileged(
@@ -59,22 +32,13 @@ pub fn flash_image_privileged(
variant: &str,
verify: bool,
) -> Result<(), String> {
let log_path = std::env::temp_dir().join("archr-flasher-macos.log");
// Reset per run and stream every stage as it happens, so even a hard
// crash leaves the full story on disk for bug reports.
let mut diag = DiagLog::new(&log_path);
let mut diag = DiagLog::new();
let result = flash_core(app, image_path, device, custom_dtbo_path, variant, verify, &mut diag);
match &result {
Ok(()) => diag.push_str("result: success\n"),
Err(e) => diag.push_str(&format!("result: {}\n", e)),
}
result.map_err(|e| {
if e == "cancelled" {
e
} else {
format!("{} (log: {})", e, log_path.display())
}
})
result.map_err(crate::diaglog::with_log_hint)
}
#[cfg(target_os = "macos")]
+72 -5
View File
@@ -232,7 +232,7 @@ fn ioctl(h: Handle, code: u32) -> bool {
/// WriteFile came back ERROR_ACCESS_DENIED). Dropping the returned handles
/// releases the locks. Best-effort: a volume that can't be locked is still
/// dismounted and its handle kept.
fn lock_and_dismount_volumes(disk: u32) -> Vec<OwnedHandle> {
fn lock_and_dismount_volumes(disk: u32, diag: &mut DiagLog) -> Vec<OwnedHandle> {
let mut held = Vec::new();
// SAFETY: no arguments.
let mask = unsafe { GetLogicalDrives() };
@@ -270,6 +270,7 @@ fn lock_and_dismount_volumes(disk: u32) -> Vec<OwnedHandle> {
}
eprintln!("[flash-win] locking + dismounting volume {}:", letter);
diag.push_str(&format!("volume {}: lock+dismount\n", letter));
let vh = match open_handle(&vol_path, GENERIC_READ | GENERIC_WRITE, 0) {
Ok(h) => h,
Err(_) => continue,
@@ -293,6 +294,9 @@ fn lock_and_dismount_volumes(disk: u32) -> Vec<OwnedHandle> {
ioctl(vh.0, FSCTL_DISMOUNT_VOLUME);
if !locked {
eprintln!("[flash-win] warning: could not lock {}: — write may still be denied", letter);
diag.push_str(&format!("volume {}: LOCK FAILED, write may be denied\n", letter));
} else {
diag.push_str(&format!("volume {}: locked\n", letter));
}
// Remove the drive letter so Explorer stops probing it (mountpoint
@@ -344,6 +348,7 @@ pub fn write_image(
image_path: &str,
progress_path: &str,
verify: bool,
diag: &mut DiagLog,
) -> Result<(), String> {
let disk = extract_disk_number(device)?;
let image_size = std::fs::metadata(image_path)
@@ -354,6 +359,10 @@ pub fn write_image(
eprintln!(" device = {} (disk {})", device, disk);
eprintln!(" image = {} ({} bytes)", image_path, image_size);
eprintln!(" verify = {}", verify);
diag.push_str(&format!(
"native write: device {} (disk {}), image {} ({} bytes), verify {}\n",
device, disk, image_path, image_size, verify
));
// Lock + dismount the disk's volumes and HOLD the locks for the whole
// write+verify. Two things matter here (learned from Rufus, format.c):
@@ -364,25 +373,45 @@ pub fn write_image(
// reassign the volume, which invalidates the lock we are holding and
// re-arms the write protection. The image write lays down the new
// partition table itself, so no pre-clean is needed.
let volume_locks = lock_and_dismount_volumes(disk);
let volume_locks = lock_and_dismount_volumes(disk, diag);
write_progress(progress_path, "STAGE:writing");
let write_hash = write_pass(device, image_path, image_size, progress_path)?;
let write_hash = match write_pass(device, image_path, image_size, progress_path) {
Ok(h) => {
diag.push_str(&format!("write pass done, sha256 {}\n", h));
h
}
Err(e) => {
diag.push_str(&format!("write pass failed: {}\n", e));
return Err(e);
}
};
if verify {
write_progress(progress_path, "STAGE:verifying:0");
let read_hash = verify_pass(device, image_size, progress_path)?;
let read_hash = match verify_pass(device, image_size, progress_path) {
Ok(h) => h,
Err(e) => {
diag.push_str(&format!("verify read failed: {}\n", e));
return Err(e);
}
};
if read_hash != write_hash {
eprintln!(" verify mismatch: expected {} got {}", write_hash, read_hash);
diag.push_str(&format!(
"verify mismatch: wrote {}, read back {}\n", write_hash, read_hash
));
return Err("verify_failed".to_string());
}
eprintln!(" verify ok: {}", read_hash);
diag.push_str("verify ok, hashes match\n");
}
// Release the volume locks so the freshly-written partitions can mount
// for the config step, then ask Windows to re-read the partition table.
drop(volume_locks);
rescan_disk(device);
diag.push_str("volume locks released, disk rescanned\n");
write_progress(progress_path, "STAGE:done");
Ok(())
}
@@ -544,6 +573,8 @@ fn verify_pass(device: &str, image_size: u64, progress_path: &str) -> Result<Str
#[allow(unused_imports)]
use crate::flash::{emit_progress, validate_device, check_temp_space, poll_flash_progress, decompress_xz, decompress_gz};
#[allow(unused_imports)]
use crate::diaglog::DiagLog;
#[allow(unused_imports)]
use std::fs;
#[allow(unused_imports)]
use std::path::PathBuf;
@@ -571,7 +602,36 @@ pub fn flash_image_privileged(
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut diag = DiagLog::new();
let result = flash_core_windows(
app, image_path, device, custom_dtbo_path, variant, verify, &mut diag,
);
match &result {
Ok(()) => diag.push_str("result: success\n"),
Err(e) => diag.push_str(&format!("result: {}\n", e)),
}
result.map_err(crate::diaglog::with_log_hint)
}
#[cfg(target_os = "windows")]
fn flash_core_windows(
app: &AppHandle,
image_path: &str,
device: &str,
custom_dtbo_path: &str,
variant: &str,
verify: bool,
diag: &mut DiagLog,
) -> Result<(), String> {
use std::os::windows::process::CommandExt;
use std::process::{Command, Stdio};
const CREATE_NO_WINDOW: u32 = 0x08000000;
validate_device(device)?;
diag.push_str(&format!(
"device: {}\nimage: {}\nverify: {}\n", device, image_path, verify
));
let is_xz = image_path.ends_with(".xz");
let is_gz = image_path.ends_with(".gz") && !is_xz;
@@ -624,6 +684,7 @@ pub fn flash_image_privileged(
&img_to_flash.to_string_lossy(),
&progress_file.to_string_lossy(),
verify,
diag,
);
stop.store(true, Ordering::Relaxed);
@@ -664,12 +725,18 @@ pub fn flash_image_privileged(
let _ = fs::remove_file(&script_path);
let _ = fs::remove_file(&progress_file);
let cfg_stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
diag.push_str(&format!(
"config script: {:?}\n--- stdout ---\n{}\n--- stderr ---\n{}\n",
output.status, cfg_stdout.trim(), stderr.trim()
));
if output.status.success() {
emit_progress(app, 100.0, "done");
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(format!("Flash failed: {}", stderr.trim()))
}
+1
View File
@@ -1,6 +1,7 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod diaglog;
mod disk;
mod flash;
mod github;