flasher: open the raw disk through authopen on macOS, like rpi-imager

The osascript+dd approach failed at the very start of the write on
current macOS: the raw /dev/rdisk open is refused before a single byte
lands, even running as root under administrator privileges. rpi-imager
never runs a privileged script; it asks /usr/libexec/authopen for the
device and receives an already-authorized file descriptor over an
AF_UNIX socketpair (SCM_RIGHTS), which is the mechanism Apple sanctions
for raw disk access from GUI apps. This port does the same: diskutil
unmounts as the plain user, authopen prompts and hands back the fd, the
image streams from this process in 4MB sector-aligned chunks with the
first and last MB wiped as before, F_FULLFSYNC seals the write, and the
boot partition setup (panel overlay, variant, soysauce extlinux) happens
in plain Rust on the remounted FAT volume with no privileges at all.

The fd handoff, write loop and boot-volume setup live in an OS-generic
unix module so the Linux host compiles and exercises the shipped code;
a behavioural harness with a protocol-faithful fake authopen verified
byte-identical writes, sector padding, signature wipes, monotonic
progress, the cancel path and the boot-volume configuration. Dismissing
the authorization prompt now reports as a clean cancellation, and the
diagnostic log at $TMPDIR/archr-flasher-macos.log records every stage.
This commit is contained in:
Douglas Teles
2026-07-08 12:41:26 -03:00
parent b307db6588
commit 09305feed1
11 changed files with 417 additions and 206 deletions
+1
View File
@@ -63,6 +63,7 @@ dependencies = [
"cc",
"flate2",
"fs2",
"libc",
"md-5",
"reqwest 0.12.28",
"serde",
+1
View File
@@ -34,6 +34,7 @@ sys-locale = "0.3"
sha2 = "0.10"
fs2 = "0.4"
md-5 = "0.10"
libc = "0.2"
tauri-plugin-updater = "2"
tauri-plugin-process = "2"
+159 -199
View File
@@ -1,13 +1,17 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 ArchR
//
// macOS flash path: DiskArbitration-style unmount via diskutil + dd to the
// raw /dev/rdisk, driven under osascript admin privileges. Split out of
// flash.rs so each OS owns its own flash logic.
// macOS flash path: unmount via diskutil, open the raw /dev/rdisk through
// /usr/libexec/authopen (the fd comes back over SCM_RIGHTS) and stream the
// image from this process, exactly like rpi-imager's macfile.cpp. The
// previous osascript+dd approach died at the raw open on current macOS
// before writing a single byte; authopen is the sanctioned way for a GUI
// app to get an authorized raw-device fd. The boot-partition setup happens
// afterwards as the plain user on the remounted FAT volume, no root needed.
#![allow(unused_imports)]
use std::io::{BufReader, Read, Write};
use std::fs::{self, File};
use std::io::{BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
@@ -16,10 +20,8 @@ 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::rawwrite_unix::{receive_fd_from_helper, write_image_to_raw_fd, configure_boot_volume, FdHelperError};
// ---------------------------------------------------------------------------
// macOS: privilege escalation via osascript
// ---------------------------------------------------------------------------
#[cfg(target_os = "macos")]
pub fn flash_image_privileged(
app: &AppHandle,
@@ -29,22 +31,44 @@ pub fn flash_image_privileged(
variant: &str,
verify: bool,
) -> Result<(), String> {
let _ = verify; // TODO: thread through into osascript invocation
use std::process::{Command, Stdio};
use std::os::unix::fs::PermissionsExt;
let _ = verify; // TODO: read back and compare hashes over the same fd
let log_path = std::env::temp_dir().join("archr-flasher-macos.log");
let mut diag = String::new();
let result = flash_core(app, image_path, device, custom_dtbo_path, variant, &mut diag);
let _ = fs::write(&log_path, &diag);
result.map_err(|e| {
if e == "cancelled" {
e
} else {
format!("{} (log: {})", e, log_path.display())
}
})
}
#[cfg(target_os = "macos")]
fn flash_core(
app: &AppHandle,
image_path: &str,
device: &str,
custom_dtbo_path: &str,
variant: &str,
diag: &mut String,
) -> Result<(), String> {
use std::process::Command;
validate_device(device)?;
diag.push_str(&format!("device: {}\nimage: {}\n", device, image_path));
let is_xz = image_path.ends_with(".xz");
let is_gz = image_path.ends_with(".gz") && !is_xz;
let needs_decompress = is_xz || is_gz;
// Check temp space before decompression
if needs_decompress {
check_temp_space(image_path)?;
}
// Step 1: Decompress in user space (with progress)
// Step 1: decompress in user space (with progress)
let img_to_flash = if needs_decompress {
emit_progress(app, 0.0, "decompressing");
let temp_img = std::env::temp_dir().join("archr-flash-temp.img");
@@ -58,218 +82,154 @@ pub fn flash_image_privileged(
PathBuf::from(image_path)
};
// Get decompressed image size for progress tracking
let image_size = fs::metadata(&img_to_flash)
.map(|m| m.len()).unwrap_or(0);
let image_size = fs::metadata(&img_to_flash).map(|m| m.len()).unwrap_or(0);
// Step 2: Unmount disk (macOS auto-mounts SD cards)
// RPi Imager technique: force unmount (kDADiskUnmountOptionForce equivalent)
let _ = Command::new("diskutil")
// Step 2: device size for the last-MB signature wipe (best effort)
let device_size = diskutil_device_bytes(device);
diag.push_str(&format!("device bytes: {:?}\n", device_size));
// Step 3: unmount everything on the disk (macOS auto-mounts SD cards)
let unmount = Command::new("diskutil")
.args(["unmountDisk", "force", device])
.status();
.output();
if let Ok(o) = &unmount {
diag.push_str(&format!(
"unmountDisk: {:?} {}\n", o.status,
String::from_utf8_lossy(&o.stderr)
));
}
// Step 3: Write helper script
let script_path = std::env::temp_dir().join("archr-flash.sh");
fs::write(&script_path, FLASH_SCRIPT_MACOS)
.map_err(|e| format!("Cannot write helper script: {}", e))?;
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
.map_err(|e| format!("Cannot set script permissions: {}", e))?;
// Step 4: authorized raw fd via authopen. The password prompt is
// authopen's own; the user dismissing it surfaces as a non-zero exit.
let rdisk = device.replace("/dev/disk", "/dev/rdisk");
let args = vec![
"-stdoutpipe".to_string(),
"-o".to_string(),
libc::O_RDWR.to_string(),
rdisk.clone(),
];
emit_progress(app, 55.0, "writing");
let fd = match receive_fd_from_helper("/usr/libexec/authopen", &args) {
Ok(fd) => fd,
Err(FdHelperError::Refused(code, err)) => {
diag.push_str(&format!("authopen refused (code {}): {}\n", code, err));
return Err("cancelled".into());
}
Err(FdHelperError::NoFd(err)) => {
diag.push_str(&format!("authopen sent no fd: {}\n", err));
return Err("err:auth_cancelled authopen returned no descriptor".into());
}
Err(FdHelperError::Spawn(err)) => {
diag.push_str(&format!("authopen spawn: {}\n", err));
return Err(format!("Failed to run authopen: {}", err));
}
};
diag.push_str(&format!("authopen ok, writing to {}\n", rdisk));
// Step 4: Set up progress file
// Step 5: stream the image, feeding the existing progress poller
// through the progress file it already understands.
let progress_file = std::env::temp_dir().join("archr-flash-progress");
fs::write(&progress_file, "0").ok();
emit_progress(app, 60.0, "writing");
let stop = Arc::new(AtomicBool::new(false));
let poll_handle = poll_flash_progress(
app.clone(), progress_file.clone(), image_size, stop.clone(),
);
// Step 5: Run via osascript with administrator privileges
// Build shell command with proper escaping for AppleScript context:
// AppleScript do shell script uses double-quoted strings — escape \ and "
let shell_esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"").replace('\'', "'\\''");
let shell_cmd = format!(
"bash '{}' '{}' '{}' '{}' '{}' '{}'",
shell_esc(&script_path.display().to_string()),
shell_esc(&img_to_flash.display().to_string()),
shell_esc(device), shell_esc(custom_dtbo_path), shell_esc(variant),
shell_esc(&progress_file.display().to_string())
);
let applescript = format!(
"do shell script \"{}\" with administrator privileges",
shell_cmd.replace('\\', "\\\\").replace('"', "\\\"")
);
let mut dev = File::from(fd);
let write_result = write_image_to_raw_fd(&mut dev, &img_to_flash, device_size, |done| {
let _ = fs::write(&progress_file, done.to_string());
});
drop(dev); // close the raw fd before diskutil touches the disk again
let child = Command::new("osascript")
.args(["-e", &applescript])
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("osascript error: {}", e))?;
let output = child.wait_with_output()
.map_err(|e| format!("Failed to wait for osascript: {}", e))?;
// Stop polling thread
stop.store(true, Ordering::Relaxed);
let _ = poll_handle.join();
// Cleanup
let _ = fs::remove_file(&script_path);
let _ = fs::remove_file(&progress_file);
if is_xz {
if needs_decompress {
let _ = fs::remove_file(&img_to_flash);
}
// Keep a diagnostic log regardless of outcome; the UI shows a short
// translated message, this file carries the real story for reports.
let log_path = std::env::temp_dir().join("archr-flasher-macos.log");
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
let _ = fs::write(&log_path, format!(
"exit: {:?}\ndevice: {}\nimage: {}\n--- script stderr ---\n{}",
output.status, device, img_to_flash.display(), stderr
));
if !output.status.success() {
if stderr.contains("User canceled") || stderr.contains("-128") {
return Err("cancelled".into());
}
// Stable tokens from the helper script win over the generic text.
for token in ["err:dd_write", "err:mount_boot"] {
if stderr.contains(token) {
return Err(format!("{} (log: {})", token, log_path.display()));
}
}
if stderr.to_lowercase().contains("busy") {
return Err("err:device_busy".into());
}
return Err(format!("Flash failed: {} (log: {})", stderr, log_path.display()));
match &write_result {
Ok(bytes) => diag.push_str(&format!("wrote {} bytes\n", bytes)),
Err(e) => diag.push_str(&format!("write failed: {}\n", e)),
}
write_result?;
// Step 6: remount and configure the boot partition as the plain user.
emit_progress(app, 92.0, "configuring");
let boot_vol = match mount_boot_volume(device, diag) {
Some(v) => v,
None => {
let _ = Command::new("diskutil").args(["eject", device]).status();
return Err(format!(
"err:mount_boot could not mount {}s1 after flashing (image was written)",
device
));
}
};
diag.push_str(&format!("boot volume: {}\n", boot_vol.display()));
let cfg = configure_boot_volume(&boot_vol, Path::new(custom_dtbo_path), variant);
if let Err(e) = &cfg {
diag.push_str(&format!("configure failed: {}\n", e));
let _ = Command::new("diskutil").args(["eject", device]).status();
}
cfg?;
emit_progress(app, 95.0, "syncing");
emit_progress(app, 100.0, "done");
let _ = Command::new("sync").status();
let _ = Command::new("diskutil").args(["eject", device]).status();
emit_progress(app, 100.0, "done");
Ok(())
}
/// Disk size in bytes from `diskutil info`, e.g. "Disk Size: 31.9 GB
/// (31914983424 Bytes)".
#[cfg(target_os = "macos")]
const FLASH_SCRIPT_MACOS: &str = r#"#!/bin/bash
set -e
IMAGE="$1"
DEVICE="$2"
CUSTOM_DTBO="$3"
VARIANT="$4"
PROGRESS_FILE="$5"
# RPi Imager technique: force unmount (kDADiskUnmountOptionForce equivalent)
diskutil unmountDisk force "$DEVICE" 2>/dev/null || true
# Write raw image to device (macOS uses rdisk for raw access = faster)
RDISK=$(echo "$DEVICE" | sed 's|/dev/disk|/dev/rdisk|')
# === ZERO FIRST AND LAST MB BEFORE WRITING ===
# Mirror the rpi-imager approach: destroy any leftover MBR/GPT/FS
# signatures so kernel/diskutil can't fall back to stale partition state
# from a previous flash. Critical for ArchR specifically because
# fs-resize bails on first boot if /storage still has .config from the
# previous SD owner, which kept the same SD bricked across reflashes.
echo "STAGE:wiping_signatures" > "$PROGRESS_FILE"
DEVICE_BYTES=$(diskutil info "$DEVICE" | awk '/Disk Size/ {for(i=1;i<=NF;i++) if($i ~ /^\([0-9]+/) {gsub(/[(),]/, "", $i); print $i; exit}}')
dd if=/dev/zero of="$RDISK" bs=1m count=1 conv=sync 2>/dev/null || true
if [ -n "$DEVICE_BYTES" ] && [ "$DEVICE_BYTES" -gt $((2 * 1024 * 1024)) ] 2>/dev/null; then
LAST_MB_OFFSET=$((DEVICE_BYTES / (1024 * 1024) - 1))
dd if=/dev/zero of="$RDISK" bs=1m count=1 oseek="$LAST_MB_OFFSET" conv=sync 2>/dev/null || true
fi
sync
# dd in background, monitor via SIGINFO. One retry after a fresh force
# unmount: macOS occasionally remounts the card between our unmount and
# the raw open, and the first dd then dies with "Resource busy".
run_dd() {
DD_STDERR=$(mktemp)
dd if="$IMAGE" of="$RDISK" bs=4m 2>"$DD_STDERR" &
DD_PID=$!
while kill -0 $DD_PID 2>/dev/null; do
sleep 1
kill -INFO $DD_PID 2>/dev/null || true
sleep 0.5
# BSD dd prints "N bytes transferred" to stderr
BYTES=$(tail -1 "$DD_STDERR" 2>/dev/null | grep -o '^[0-9]*' || true)
if [ -n "$BYTES" ] && [ "$BYTES" -gt 0 ] 2>/dev/null; then
echo "$BYTES" > "$PROGRESS_FILE"
fi
done
wait $DD_PID
DD_RC=$?
return $DD_RC
fn diskutil_device_bytes(device: &str) -> Option<u64> {
use std::process::Command;
let out = Command::new("diskutil").args(["info", device]).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout).to_string();
for line in text.lines() {
if line.contains("Disk Size:") {
let start = line.find('(')?;
let end = line.find(" Bytes")?;
let num: String = line[start + 1..end]
.chars().filter(|c| c.is_ascii_digit()).collect();
return num.parse().ok();
}
}
None
}
set +e
run_dd
DD_RC=$?
if [ $DD_RC -ne 0 ] && grep -qi "busy" "$DD_STDERR" 2>/dev/null; then
echo "dd hit Resource busy, forcing unmount and retrying once" >&2
diskutil unmountDisk force "$DEVICE" >&2 2>&1 || true
sleep 2
run_dd
DD_RC=$?
fi
if [ $DD_RC -ne 0 ]; then
echo "err:dd_write rc=$DD_RC" >&2
tail -3 "$DD_STDERR" >&2 2>/dev/null
rm -f "$DD_STDERR"
exit 1
fi
rm -f "$DD_STDERR"
set -e
sync
# Re-mount disk so we can access boot partition (retry up to 3 times)
BOOT_VOL=""
for i in 1 2 3 4 5; do
sleep 2
# mountDisk mounts every volume; the explicit s1 mount covers the
# case where diskutil skips the boot FAT on the first pass.
diskutil mountDisk "$DEVICE" >&2 2>&1 || true
diskutil mount "${DEVICE}s1" >&2 2>&1 || true
sleep 1
BOOT_VOL=$(diskutil info "${DEVICE}s1" 2>/dev/null | grep "Mount Point:" | awk -F: '{print $2}' | xargs)
[ -n "$BOOT_VOL" ] && [ -d "$BOOT_VOL" ] && break
BOOT_VOL=""
done
if [ -z "$BOOT_VOL" ] || [ ! -d "$BOOT_VOL" ]; then
echo "err:mount_boot could not mount ${DEVICE}s1 after flashing (image was written)" >&2
diskutil info "${DEVICE}s1" >&2 2>&1 || true
diskutil eject "$DEVICE" 2>/dev/null || true
exit 1
fi
# Copy custom DTBO as mipi-panel.dtbo
if [ ! -f "$CUSTOM_DTBO" ]; then
echo "ERROR: Custom DTBO not found at $CUSTOM_DTBO" >&2
diskutil eject "$DEVICE" 2>/dev/null || true
exit 1
fi
mkdir -p "$BOOT_VOL/overlays"
cp "$CUSTOM_DTBO" "$BOOT_VOL/overlays/mipi-panel.dtbo"
# Write variant file
echo -n "$VARIANT" > "$BOOT_VOL/variant"
# Switch extlinux config for soysauce variant (uses explicit FDT),
# matching the Linux flash path. Without it the board keeps the default
# extlinux.conf and the console never boots.
if [ "$VARIANT" = "soysauce" ] && [ -f "$BOOT_VOL/extlinux/extlinux.conf.soysauce" ]; then
cp "$BOOT_VOL/extlinux/extlinux.conf" "$BOOT_VOL/extlinux/extlinux.conf.bak"
cp "$BOOT_VOL/extlinux/extlinux.conf.soysauce" "$BOOT_VOL/extlinux/extlinux.conf"
fi
sync
# Eject disk safely
diskutil eject "$DEVICE" 2>/dev/null || true
"#;
/// Remount the freshly written disk and resolve the boot (s1) mount point.
/// mountDisk mounts every volume; the explicit s1 mount covers the case
/// where diskutil skips the boot FAT on the first pass.
#[cfg(target_os = "macos")]
fn mount_boot_volume(device: &str, diag: &mut String) -> Option<PathBuf> {
use std::process::Command;
let part = format!("{}s1", device);
for attempt in 1..=5 {
thread::sleep(Duration::from_secs(2));
let _ = Command::new("diskutil").args(["mountDisk", device]).output();
let _ = Command::new("diskutil").args(["mount", &part]).output();
thread::sleep(Duration::from_secs(1));
if let Ok(out) = Command::new("diskutil").args(["info", &part]).output() {
let text = String::from_utf8_lossy(&out.stdout).to_string();
for line in text.lines() {
if line.contains("Mount Point:") {
if let Some(mp) = line.splitn(2, ':').nth(1) {
let mp = mp.trim();
if !mp.is_empty() && Path::new(mp).is_dir() {
return Some(PathBuf::from(mp));
}
}
}
}
if attempt == 5 {
diag.push_str(&format!("diskutil info {}:\n{}\n", part, text));
}
}
}
None
}
+5
View File
@@ -18,6 +18,11 @@ mod flashwrite;
mod flash_linux;
#[cfg(target_os = "macos")]
mod flash_macos;
// Helper-fd handoff (SCM_RIGHTS) + raw streaming writer used by the macOS
// path; kept OS-generic unix so the Linux host build type-checks it too.
#[cfg(unix)]
#[allow(dead_code)]
mod rawwrite_unix;
// Native Windows raw-disk writer (ported from rpi-imager) + its orchestration.
// Replaces the fragile PowerShell Clear-Disk + FileStream write that surfaced
// as "write-protected" errors.
+239
View File
@@ -0,0 +1,239 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 ArchR
//
// Unix building blocks for the macOS raw-write path: spawn a helper
// (/usr/libexec/authopen on macOS) that opens the target device on our
// behalf and hands the open fd back over an AF_UNIX socketpair via
// SCM_RIGHTS, then stream the image onto that fd from this process.
// This mirrors what rpi-imager does in macfile.cpp and is the mechanism
// Apple sanctions for raw disk access from GUI apps; running dd as root
// under osascript stopped being reliable on current macOS.
//
// Everything here is OS-generic unix so the exact shipped code compiles
// and gets exercised on the Linux build host too; the macOS specifics
// (diskutil orchestration, the authopen binary path) live in
// flash_macos.rs. No inner attributes here: the test harness includes
// this file verbatim inside a mod block, where they are not allowed;
// the allow(dead_code) lives on the mod declaration in main.rs.
use std::fs::{self, File};
use std::io::{Read, Seek, SeekFrom, Write};
use std::os::fd::{FromRawFd, OwnedFd, RawFd};
use std::path::Path;
use std::process::{Command, Stdio};
const SECTOR: u64 = 512;
const CHUNK: usize = 4 * 1024 * 1024;
const MB: u64 = 1024 * 1024;
#[derive(Debug)]
pub enum FdHelperError {
/// The helper could not be spawned at all.
Spawn(String),
/// The helper ran but exited non-zero without delivering an fd; for
/// authopen this is the user dismissing the authorization prompt.
Refused(i32, String),
/// The helper exited cleanly yet no fd arrived (protocol breakage).
NoFd(String),
}
/// Spawn `cmd args...` with its stdout connected to one end of an AF_UNIX
/// socketpair and receive an open file descriptor from it via SCM_RIGHTS.
pub fn receive_fd_from_helper(cmd: &str, args: &[String]) -> Result<OwnedFd, FdHelperError> {
let mut sv = [0i32; 2];
let rc = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sv.as_mut_ptr()) };
if rc != 0 {
return Err(FdHelperError::Spawn(format!(
"socketpair: {}", std::io::Error::last_os_error()
)));
}
let (parent_sock, child_sock) = (sv[0], sv[1]);
// Stdio::from_raw_fd takes ownership of child_sock: after spawn the
// parent copy is closed, so recvmsg sees EOF if the helper exits
// without ever sending the fd.
let spawned = unsafe {
Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::from_raw_fd(child_sock))
.stderr(Stdio::piped())
.spawn()
};
let mut child = match spawned {
Ok(c) => c,
Err(e) => {
unsafe { libc::close(parent_sock) };
return Err(FdHelperError::Spawn(format!("{}: {}", cmd, e)));
}
};
// Blocks until the helper sends the fd or closes its end. For
// authopen this covers the whole time the password prompt is up.
let received = recv_fd(parent_sock);
unsafe { libc::close(parent_sock) };
let mut stderr_text = String::new();
if let Some(mut se) = child.stderr.take() {
let _ = se.read_to_string(&mut stderr_text);
}
let status = child.wait();
match received {
Some(fd) => Ok(unsafe { OwnedFd::from_raw_fd(fd) }),
None => {
let code = status.ok().and_then(|s| s.code()).unwrap_or(-1);
if code != 0 {
Err(FdHelperError::Refused(code, stderr_text))
} else {
Err(FdHelperError::NoFd(stderr_text))
}
}
}
}
/// recvmsg until an SCM_RIGHTS control message arrives; returns the fd.
fn recv_fd(sock: RawFd) -> Option<RawFd> {
loop {
let mut data = [0u8; 64];
let mut iov = libc::iovec {
iov_base: data.as_mut_ptr() as *mut libc::c_void,
iov_len: data.len(),
};
// Room for one fd worth of control data, kept comfortably large.
let mut cbuf = [0u8; 128];
let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
msg.msg_iov = &mut iov;
msg.msg_iovlen = 1 as _;
msg.msg_control = cbuf.as_mut_ptr() as *mut libc::c_void;
msg.msg_controllen = cbuf.len() as _;
let n = unsafe { libc::recvmsg(sock, &mut msg, 0) };
if n < 0 {
if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
continue;
}
return None;
}
if n == 0 {
return None; // helper closed its end without sending an fd
}
let mut c = unsafe { libc::CMSG_FIRSTHDR(&msg) };
while !c.is_null() {
let hdr = unsafe { &*c };
if hdr.cmsg_level == libc::SOL_SOCKET && hdr.cmsg_type == libc::SCM_RIGHTS {
let fd = unsafe { *(libc::CMSG_DATA(c) as *const libc::c_int) };
return Some(fd);
}
c = unsafe { libc::CMSG_NXTHDR(&msg, c) };
}
// Plain data with no rights attached (authopen may chat first):
// keep reading until the fd or EOF shows up.
}
}
/// Zero the first and last MB (stale MBR/GPT/FS signatures survive a
/// reflash otherwise), then stream the image in 4MB chunks. Raw character
/// devices reject partial-sector writes, so the final short chunk is
/// padded with zeros up to the sector size; the padded tail lands past
/// the image end where the disk is unused. `progress` receives the count
/// of real image bytes written so far. Returns the image byte count.
pub fn write_image_to_raw_fd(
dev: &mut File,
image: &Path,
device_size: Option<u64>,
mut progress: impl FnMut(u64),
) -> Result<u64, String> {
let zeros = vec![0u8; MB as usize];
dev.seek(SeekFrom::Start(0))
.map_err(|e| format!("err:write_failed seek 0: {}", e))?;
dev.write_all(&zeros)
.map_err(|e| format!("err:write_failed wiping first MB: {}", e))?;
if let Some(size) = device_size {
if size > 2 * MB {
let last_mb = (size / MB - 1) * MB;
dev.seek(SeekFrom::Start(last_mb))
.map_err(|e| format!("err:write_failed seek last MB: {}", e))?;
dev.write_all(&zeros)
.map_err(|e| format!("err:write_failed wiping last MB: {}", e))?;
}
}
dev.seek(SeekFrom::Start(0))
.map_err(|e| format!("err:write_failed seek rewind: {}", e))?;
let mut src = File::open(image)
.map_err(|e| format!("Cannot open image: {}", e))?;
let mut buf = vec![0u8; CHUNK];
let mut done: u64 = 0;
loop {
// Fill the chunk fully so short reads from the fs cache don't
// translate into tiny unaligned device writes.
let mut filled = 0usize;
while filled < CHUNK {
let n = src.read(&mut buf[filled..])
.map_err(|e| format!("Image read at {}: {}", done + filled as u64, e))?;
if n == 0 {
break;
}
filled += n;
}
if filled == 0 {
break;
}
let aligned = ((filled as u64 + SECTOR - 1) & !(SECTOR - 1)) as usize;
buf[filled..aligned].fill(0);
dev.write_all(&buf[..aligned])
.map_err(|e| format!("err:write_failed at byte {}: {}", done, e))?;
done += filled as u64;
progress(done);
if filled < CHUNK {
break;
}
}
full_sync(dev).map_err(|e| format!("err:write_failed flush: {}", e))?;
Ok(done)
}
#[cfg(target_os = "macos")]
fn full_sync(f: &File) -> std::io::Result<()> {
use std::os::fd::AsRawFd;
// F_FULLFSYNC forces the drive itself to flush, plain fsync on macOS
// only pushes to the drive cache.
if unsafe { libc::fcntl(f.as_raw_fd(), libc::F_FULLFSYNC) } == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
fn full_sync(f: &File) -> std::io::Result<()> {
f.sync_all()
}
/// Post-flash boot partition setup, done as the regular user on the
/// mounted FAT volume: install the panel overlay, record the variant and
/// switch the soysauce extlinux config, mirroring the Linux flash path.
pub fn configure_boot_volume(boot: &Path, dtbo: &Path, variant: &str) -> Result<(), String> {
if !dtbo.is_file() {
return Err(format!("Custom DTBO not found at {}", dtbo.display()));
}
let overlays = boot.join("overlays");
fs::create_dir_all(&overlays)
.map_err(|e| format!("Cannot create overlays dir: {}", e))?;
fs::copy(dtbo, overlays.join("mipi-panel.dtbo"))
.map_err(|e| format!("Cannot copy overlay: {}", e))?;
fs::write(boot.join("variant"), variant)
.map_err(|e| format!("Cannot write variant: {}", e))?;
if variant == "soysauce" {
let conf = boot.join("extlinux/extlinux.conf");
let soy = boot.join("extlinux/extlinux.conf.soysauce");
if soy.is_file() {
fs::copy(&conf, boot.join("extlinux/extlinux.conf.bak"))
.map_err(|e| format!("Cannot back up extlinux.conf: {}", e))?;
fs::copy(&soy, &conf)
.map_err(|e| format!("Cannot switch extlinux.conf: {}", e))?;
}
}
Ok(())
}
+2 -1
View File
@@ -92,5 +92,6 @@
"no": "No",
"verify_label": "Verify after writing",
"verify_hint": "Reads the SD card back and compares its SHA-256 against the image. Adds 1-2 minutes but catches bad cards and corrupted writes.",
"error_mount_boot": "The image was written, but the boot partition did not mount to install your panel overlay. Remove and reinsert the card, then use the overlay generator to copy overlays/mipi-panel.dtbo manually."
"error_mount_boot": "The image was written, but the boot partition did not mount to install your panel overlay. Remove and reinsert the card, then use the overlay generator to copy overlays/mipi-panel.dtbo manually.",
"configuring": "Installing panel overlay..."
}
+2 -1
View File
@@ -92,5 +92,6 @@
"no": "No",
"verify_label": "Verificar tras escribir",
"verify_hint": "Lee la SD y compara su SHA-256 con la imagen. Suma 1-2 minutos pero detecta tarjetas defectuosas y escrituras corruptas.",
"error_mount_boot": "La imagen se grabó, pero la partición de arranque no se montó para instalar el overlay del panel. Retire y reinserte la tarjeta y copie overlays/mipi-panel.dtbo manualmente con el generador de overlays."
"error_mount_boot": "La imagen se grabó, pero la partición de arranque no se montó para instalar el overlay del panel. Retire y reinserte la tarjeta y copie overlays/mipi-panel.dtbo manualmente con el generador de overlays.",
"configuring": "Instalando overlay del panel..."
}
+2 -1
View File
@@ -92,5 +92,6 @@
"no": "Não",
"verify_label": "Verificar após gravar",
"verify_hint": "Le o cartão SD e compara o SHA-256 com a imagem. Acrescenta 1-2 minutos mas detecta cartões ruins e gravações corrompidas.",
"error_mount_boot": "A imagem foi gravada, mas a partição de boot não montou para instalar o overlay do painel. Remova e reinsira o cartão e copie overlays/mipi-panel.dtbo manualmente com o gerador de overlays."
"error_mount_boot": "A imagem foi gravada, mas a partição de boot não montou para instalar o overlay do painel. Remova e reinsira o cartão e copie overlays/mipi-panel.dtbo manualmente com o gerador de overlays.",
"configuring": "Instalando overlay do painel..."
}
+2 -1
View File
@@ -92,5 +92,6 @@
"no": "Нет",
"verify_label": "Проверить после записи",
"verify_hint": "Считывает SD-карту и сравнивает её SHA-256 с образом. Добавляет 1-2 минуты, но обнаруживает плохие карты и поврежденные записи.",
"error_mount_boot": "Образ записан, но загрузочный раздел не смонтировался для установки оверлея панели. Извлеките и снова вставьте карту, затем скопируйте overlays/mipi-panel.dtbo вручную через генератор оверлеев."
"error_mount_boot": "Образ записан, но загрузочный раздел не смонтировался для установки оверлея панели. Извлеките и снова вставьте карту, затем скопируйте overlays/mipi-panel.dtbo вручную через генератор оверлеев.",
"configuring": "Установка оверлея панели..."
}
+2 -1
View File
@@ -92,5 +92,6 @@
"no": "否",
"verify_label": "写入后校验",
"verify_hint": "读回 SD 卡并将其 SHA-256 与镜像比较。会增加 1-2 分钟,可发现损坏的 SD 卡和损坏的写入。",
"error_mount_boot": "镜像已写入,但引导分区未能挂载以安装面板 overlay。请拔出并重新插入存储卡,然后使用 overlay 生成器手动复制 overlays/mipi-panel.dtbo。"
"error_mount_boot": "镜像已写入,但引导分区未能挂载以安装面板 overlay。请拔出并重新插入存储卡,然后使用 overlay 生成器手动复制 overlays/mipi-panel.dtbo。",
"configuring": "正在安装面板 overlay..."
}
+2 -2
View File
@@ -709,9 +709,9 @@ function translateError(msg) {
[/err:device_busy/i, 'error_device_busy'],
[/err:media/i, 'error_media'],
[/err:open_device/i, 'error_open_device'],
[/err:dd_write/i, 'error_write_failed'],
[/err:dd_write|err:write_failed/i, 'error_write_failed'],
[/err:mount_boot/i, 'error_mount_boot'],
[/failed to run pkexec|osascript error|failed to run powershell/i, 'error_privilege'],
[/failed to run pkexec|osascript error|failed to run powershell|failed to run authopen/i, 'error_privilege'],
[/not authorized|dismissed/i, 'error_privilege'],
[/decompress error|xzdecoder/i, 'error_decompress'],
[/write error|flush error/i, 'error_write_failed'],