mirror of
https://github.com/armbian/imager.git
synced 2026-01-06 12:31:28 -08:00
Add device disconnect detection, decompression formats, and UI improvements
- Monitor device connection during all flash stages (download, SHA verify, decompress, write, verify) - Show "Device was disconnected" error instead of generic errors when device is removed - Re-request authorization on retry after disconnect - Add GZ, BZ2, ZSTD decompression support alongside XZ - Log update availability at startup via frontend logging - Fix Update Modal button sizing (equal width) - Disable text selection globally in production - Update README: download badge, macOS Privacy & Security instructions - Add deviceDisconnected translation to all 15 languages - Various code quality improvements (unwrap → expect, try/finally cleanup)
This commit is contained in:
11
README.md
11
README.md
@@ -10,6 +10,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/armbian/imager/releases"><img src="https://img.shields.io/github/v/release/armbian/imager?style=for-the-badge&color=orange" alt="Release"></a>
|
||||
<a href="https://github.com/armbian/imager/releases"><img src="https://img.shields.io/github/downloads/armbian/imager/total?style=for-the-badge&color=green" alt="Downloads"></a>
|
||||
<a href="https://github.com/armbian/imager/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-GPLv2-blue?style=for-the-badge" alt="License"></a>
|
||||
</p>
|
||||
|
||||
@@ -42,12 +43,10 @@
|
||||
|
||||
macOS may show a warning because the app is not signed with an Apple Developer certificate. To open it:
|
||||
|
||||
**Option 1:** Right-click the app → **Open** → Click **Open** in the dialog
|
||||
|
||||
**Option 2:** Run in Terminal:
|
||||
```bash
|
||||
xattr -cr "/Applications/Armbian Imager.app"
|
||||
```
|
||||
1. Try to open the app (it will be blocked)
|
||||
2. Go to **System Settings** → **Privacy & Security**
|
||||
3. Scroll down and click **Open Anyway** next to "Armbian Imager was blocked"
|
||||
4. Click **Open** in the confirmation dialog
|
||||
|
||||
This only needs to be done once.
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
|
||||
futures-util = "0.3"
|
||||
liblzma = { version = "0.4", features = ["static"] }
|
||||
flate2 = "1.0"
|
||||
bzip2 = "0.4"
|
||||
zstd = "0.13"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
dirs = "5"
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn select_custom_image(
|
||||
.file()
|
||||
.add_filter(
|
||||
"Disk Images",
|
||||
&["img", "iso", "xz", "gz", "zip", "zst", "bz2", "raw"],
|
||||
&["img", "iso", "raw", "xz", "gz", "bz2", "zst"],
|
||||
)
|
||||
.add_filter("All Files", &["*"])
|
||||
.set_title("Select Disk Image")
|
||||
|
||||
@@ -7,6 +7,12 @@ use sys_locale::get_locale;
|
||||
|
||||
const MODULE: &str = "commands::system";
|
||||
|
||||
/// Log a message from the frontend
|
||||
#[tauri::command]
|
||||
pub fn log_from_frontend(module: String, message: String) {
|
||||
log_info!(&format!("frontend::{}", module), "{}", message);
|
||||
}
|
||||
|
||||
/// Get the system locale (e.g., "en-US", "it-IT", "de-DE")
|
||||
/// Returns the language code for i18n initialization
|
||||
#[tauri::command]
|
||||
|
||||
@@ -9,7 +9,11 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bzip2::read::BzDecoder;
|
||||
use flate2::read::GzDecoder;
|
||||
use liblzma::read::XzDecoder;
|
||||
use zstd::stream::read::Decoder as ZstdDecoder;
|
||||
|
||||
use crate::config;
|
||||
use crate::download::DownloadState;
|
||||
@@ -101,17 +105,65 @@ pub fn decompress_with_system_xz(
|
||||
}
|
||||
|
||||
/// Decompress using Rust xz2 library (slower, single-threaded fallback)
|
||||
pub fn decompress_with_rust_library(
|
||||
pub fn decompress_with_rust_xz(
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
state: &Arc<DownloadState>,
|
||||
) -> Result<(), String> {
|
||||
let temp_file =
|
||||
File::open(input_path).map_err(|e| format!("Failed to open temp file: {}", e))?;
|
||||
let input_file =
|
||||
File::open(input_path).map_err(|e| format!("Failed to open input file: {}", e))?;
|
||||
let buf_reader = BufReader::with_capacity(config::download::DECOMPRESS_BUFFER_SIZE, input_file);
|
||||
let decoder = XzDecoder::new(buf_reader);
|
||||
decompress_with_reader(decoder, output_path, state, "xz")
|
||||
}
|
||||
|
||||
let buf_reader = BufReader::with_capacity(config::download::DECOMPRESS_BUFFER_SIZE, temp_file);
|
||||
let mut decoder = XzDecoder::new(buf_reader);
|
||||
/// Decompress gzip files using flate2
|
||||
pub fn decompress_with_gz(
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
state: &Arc<DownloadState>,
|
||||
) -> Result<(), String> {
|
||||
let input_file =
|
||||
File::open(input_path).map_err(|e| format!("Failed to open input file: {}", e))?;
|
||||
let buf_reader = BufReader::with_capacity(config::download::DECOMPRESS_BUFFER_SIZE, input_file);
|
||||
let decoder = GzDecoder::new(buf_reader);
|
||||
decompress_with_reader(decoder, output_path, state, "gz")
|
||||
}
|
||||
|
||||
/// Decompress bzip2 files
|
||||
pub fn decompress_with_bz2(
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
state: &Arc<DownloadState>,
|
||||
) -> Result<(), String> {
|
||||
let input_file =
|
||||
File::open(input_path).map_err(|e| format!("Failed to open input file: {}", e))?;
|
||||
let buf_reader = BufReader::with_capacity(config::download::DECOMPRESS_BUFFER_SIZE, input_file);
|
||||
let decoder = BzDecoder::new(buf_reader);
|
||||
decompress_with_reader(decoder, output_path, state, "bz2")
|
||||
}
|
||||
|
||||
/// Decompress zstd files
|
||||
pub fn decompress_with_zstd(
|
||||
input_path: &Path,
|
||||
output_path: &Path,
|
||||
state: &Arc<DownloadState>,
|
||||
) -> Result<(), String> {
|
||||
let input_file =
|
||||
File::open(input_path).map_err(|e| format!("Failed to open input file: {}", e))?;
|
||||
let buf_reader = BufReader::with_capacity(config::download::DECOMPRESS_BUFFER_SIZE, input_file);
|
||||
let decoder = ZstdDecoder::new(buf_reader)
|
||||
.map_err(|e| format!("Failed to create zstd decoder: {}", e))?;
|
||||
decompress_with_reader(decoder, output_path, state, "zstd")
|
||||
}
|
||||
|
||||
/// Generic decompression using any Read implementation
|
||||
fn decompress_with_reader<R: Read>(
|
||||
mut decoder: R,
|
||||
output_path: &Path,
|
||||
state: &Arc<DownloadState>,
|
||||
format_name: &str,
|
||||
) -> Result<(), String> {
|
||||
let output_file =
|
||||
File::create(output_path).map_err(|e| format!("Failed to create output file: {}", e))?;
|
||||
|
||||
@@ -128,7 +180,7 @@ pub fn decompress_with_rust_library(
|
||||
|
||||
let bytes_read = decoder
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| format!("Decompression error: {}", e))?;
|
||||
.map_err(|e| format!("{} decompression error: {}", format_name, e))?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
@@ -194,11 +246,11 @@ pub fn decompress_local_file(
|
||||
output_path.display()
|
||||
);
|
||||
|
||||
// Only handle .xz for now (most common for Armbian)
|
||||
if filename.ends_with(".xz") {
|
||||
// Try system xz first, fall back to Rust library
|
||||
// Handle different compression formats
|
||||
let result = if filename.ends_with(".xz") {
|
||||
// Try system xz first (faster, multi-threaded), fall back to Rust library
|
||||
log_info!(MODULE, "Decompressing XZ format");
|
||||
if let Err(e) = decompress_with_system_xz(input_path, &output_path, state) {
|
||||
// Check if it was cancelled
|
||||
if state.is_cancelled.load(Ordering::SeqCst) {
|
||||
return Err("Decompression cancelled".to_string());
|
||||
}
|
||||
@@ -207,14 +259,27 @@ pub fn decompress_local_file(
|
||||
"System xz failed: {}, falling back to Rust library (slower)",
|
||||
e
|
||||
);
|
||||
decompress_with_rust_library(input_path, &output_path, state)?;
|
||||
decompress_with_rust_xz(input_path, &output_path, state)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else if filename.ends_with(".gz") {
|
||||
log_info!(MODULE, "Decompressing GZ format");
|
||||
decompress_with_gz(input_path, &output_path, state)
|
||||
} else if filename.ends_with(".bz2") {
|
||||
log_info!(MODULE, "Decompressing BZ2 format");
|
||||
decompress_with_bz2(input_path, &output_path, state)
|
||||
} else if filename.ends_with(".zst") {
|
||||
log_info!(MODULE, "Decompressing ZSTD format");
|
||||
decompress_with_zstd(input_path, &output_path, state)
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Unsupported compression format for: {}",
|
||||
filename
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
result?;
|
||||
|
||||
state.is_decompressing.store(false, Ordering::SeqCst);
|
||||
log_info!(MODULE, "Decompression complete: {}", output_path.display());
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::config;
|
||||
use crate::decompress::{decompress_with_rust_library, decompress_with_system_xz};
|
||||
use crate::decompress::{decompress_with_rust_xz, decompress_with_system_xz};
|
||||
use crate::{log_error, log_info, log_warn};
|
||||
|
||||
const MODULE: &str = "download";
|
||||
@@ -305,7 +305,7 @@ pub async fn download_image(
|
||||
"System xz failed: {}, falling back to Rust library (slower)",
|
||||
e
|
||||
);
|
||||
decompress_with_rust_library(&temp_path, &output_path, &state)?;
|
||||
decompress_with_rust_xz(&temp_path, &output_path, &state)?;
|
||||
log_info!(MODULE, "Rust fallback decompression complete");
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ pub fn request_authorization(device_path: &str) -> Result<bool, String> {
|
||||
|
||||
unsafe {
|
||||
let right_name = format!("sys.openfile.readwrite.{}", raw_device);
|
||||
let right_name_cstr = std::ffi::CString::new(right_name.clone()).unwrap();
|
||||
let right_name_cstr = std::ffi::CString::new(right_name.clone())
|
||||
.map_err(|_| "Invalid device path: contains null byte".to_string())?;
|
||||
|
||||
let mut item = AuthorizationItem {
|
||||
name: right_name_cstr.as_ptr(),
|
||||
|
||||
@@ -83,12 +83,15 @@ pub fn open_device_with_saved_auth(device_path: &str) -> Result<OpenDeviceResult
|
||||
// Redirect stdin from pipe
|
||||
libc::dup2(stdin_pipe[0], libc::STDIN_FILENO);
|
||||
|
||||
let authopen = std::ffi::CString::new("/usr/libexec/authopen").unwrap();
|
||||
let arg_stdoutpipe = std::ffi::CString::new("-stdoutpipe").unwrap();
|
||||
let arg_extauth = std::ffi::CString::new("-extauth").unwrap();
|
||||
let arg_o = std::ffi::CString::new("-o").unwrap();
|
||||
let arg_mode = std::ffi::CString::new("2").unwrap(); // O_RDWR
|
||||
let path = std::ffi::CString::new(device_path).unwrap();
|
||||
let authopen = std::ffi::CString::new("/usr/libexec/authopen").expect("static string");
|
||||
let arg_stdoutpipe = std::ffi::CString::new("-stdoutpipe").expect("static string");
|
||||
let arg_extauth = std::ffi::CString::new("-extauth").expect("static string");
|
||||
let arg_o = std::ffi::CString::new("-o").expect("static string");
|
||||
let arg_mode = std::ffi::CString::new("2").expect("static string");
|
||||
let path = match std::ffi::CString::new(device_path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => libc::_exit(2),
|
||||
};
|
||||
|
||||
libc::execl(
|
||||
authopen.as_ptr(),
|
||||
|
||||
@@ -14,10 +14,6 @@ mod linux;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(any(target_os = "linux", target_os = "macos"))]
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -118,26 +114,3 @@ pub(crate) fn sync_device(_device_path: &str) {
|
||||
let _ = Command::new("sync").output();
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate SHA256 hash of a file
|
||||
#[allow(dead_code)]
|
||||
pub fn calculate_sha256(path: &PathBuf) -> Result<String, String> {
|
||||
let mut file = File::open(path).map_err(|e| format!("Failed to open file: {}", e))?;
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = vec![0u8; 1024 * 1024];
|
||||
|
||||
loop {
|
||||
let bytes_read = file
|
||||
.read(&mut buffer)
|
||||
.map_err(|e| format!("Failed to read file: {}", e))?;
|
||||
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
hasher.update(&buffer[..bytes_read]);
|
||||
}
|
||||
|
||||
Ok(hex::encode(hasher.finalize()))
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ fn main() {
|
||||
commands::custom_image::decompress_custom_image,
|
||||
commands::system::open_url,
|
||||
commands::system::get_system_locale,
|
||||
commands::system::log_from_frontend,
|
||||
paste::upload::upload_logs,
|
||||
])
|
||||
.setup(|app| {
|
||||
|
||||
@@ -10,12 +10,9 @@ import { FlashProgress } from './components/FlashProgress';
|
||||
import { AppVersion } from './components/shared/AppVersion';
|
||||
import { selectCustomImage } from './hooks/useTauri';
|
||||
import { useDeviceMonitor } from './hooks/useDeviceMonitor';
|
||||
import type { BoardInfo, ImageInfo, BlockDevice, ModalType } from './types';
|
||||
import type { BoardInfo, ImageInfo, BlockDevice, ModalType, SelectionStep } from './types';
|
||||
import './styles/index.css';
|
||||
|
||||
/** Selection step in the wizard flow */
|
||||
type SelectionStep = 'manufacturer' | 'board' | 'image' | 'device';
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation();
|
||||
const [isFlashing, setIsFlashing] = useState(false);
|
||||
|
||||
@@ -30,11 +30,13 @@ export function BoardModal({ isOpen, onClose, onSelect, manufacturer }: BoardMod
|
||||
[isOpen]
|
||||
);
|
||||
|
||||
// Reset state when modal closes or manufacturer changes
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state when manufacturer changes
|
||||
if (!isOpen) {
|
||||
setImagesReady(false);
|
||||
}
|
||||
setSearch('');
|
||||
setImagesReady(false);
|
||||
}, [manufacturer]);
|
||||
}, [isOpen, manufacturer]);
|
||||
|
||||
// Pre-load images for current manufacturer
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { HardDrive, Disc, FileImage } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { BoardInfo, ImageInfo, BlockDevice } from '../../types';
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
requestWriteAuthorization,
|
||||
checkNeedsDecompression,
|
||||
decompressCustomImage,
|
||||
getBlockDevices,
|
||||
} from '../../hooks/useTauri';
|
||||
import { FlashStageIcon, getStageKey } from './FlashStageIcon';
|
||||
import { FlashActions } from './FlashActions';
|
||||
@@ -22,6 +23,8 @@ import { MarqueeText } from '../shared/MarqueeText';
|
||||
import type { FlashStage } from './FlashStageIcon';
|
||||
import fallbackImage from '../../assets/armbian-logo_nofound.png';
|
||||
|
||||
const DEVICE_POLL_INTERVAL = 2000;
|
||||
|
||||
interface FlashProgressProps {
|
||||
board: BoardInfo;
|
||||
image: ImageInfo;
|
||||
@@ -45,8 +48,10 @@ export function FlashProgress({
|
||||
const [imageLoadError, setImageLoadError] = useState(false);
|
||||
const [imagePath, setImagePath] = useState<string | null>(null);
|
||||
const intervalRef = useRef<number | null>(null);
|
||||
const deviceMonitorRef = useRef<number | null>(null);
|
||||
const maxProgressRef = useRef<number>(0);
|
||||
const hasStartedRef = useRef<boolean>(false);
|
||||
const deviceDisconnectedRef = useRef<boolean>(false);
|
||||
|
||||
// Cleanup downloaded image file (skip for custom images)
|
||||
async function cleanupImage(path: string | null) {
|
||||
@@ -60,6 +65,54 @@ export function FlashProgress({
|
||||
}
|
||||
}
|
||||
|
||||
// Handle device disconnection during flashing
|
||||
const handleDeviceDisconnected = useCallback(async () => {
|
||||
deviceDisconnectedRef.current = true;
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (deviceMonitorRef.current) clearInterval(deviceMonitorRef.current);
|
||||
try {
|
||||
await cancelOperation();
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
setError(t('error.deviceDisconnected'));
|
||||
setStage('error');
|
||||
}, [t]);
|
||||
|
||||
// Monitor device connection during active operations
|
||||
useEffect(() => {
|
||||
const activeStages: FlashStage[] = ['downloading', 'verifying_sha', 'decompressing', 'flashing', 'verifying'];
|
||||
if (!activeStages.includes(stage)) {
|
||||
if (deviceMonitorRef.current) {
|
||||
clearInterval(deviceMonitorRef.current);
|
||||
deviceMonitorRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const checkDevice = async () => {
|
||||
try {
|
||||
const devices = await getBlockDevices();
|
||||
const stillConnected = devices.some(d => d.path === device.path);
|
||||
if (!stillConnected) {
|
||||
handleDeviceDisconnected();
|
||||
}
|
||||
} catch {
|
||||
// Ignore polling errors
|
||||
}
|
||||
};
|
||||
|
||||
checkDevice();
|
||||
deviceMonitorRef.current = window.setInterval(checkDevice, DEVICE_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (deviceMonitorRef.current) {
|
||||
clearInterval(deviceMonitorRef.current);
|
||||
deviceMonitorRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [stage, device.path, handleDeviceDisconnected]);
|
||||
|
||||
async function loadBoardImage() {
|
||||
try {
|
||||
const url = await getBoardImageUrl(board.slug);
|
||||
@@ -125,6 +178,20 @@ export function FlashProgress({
|
||||
startFlash(customPath);
|
||||
}
|
||||
} catch (err) {
|
||||
if (deviceDisconnectedRef.current) return;
|
||||
|
||||
// Check if device is still connected before showing decompression error
|
||||
try {
|
||||
const devices = await getBlockDevices();
|
||||
const stillConnected = devices.some(d => d.path === device.path);
|
||||
if (!stillConnected) {
|
||||
handleDeviceDisconnected();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, continue with decompression error
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : t('error.decompressionFailed'));
|
||||
setStage('error');
|
||||
}
|
||||
@@ -158,7 +225,7 @@ export function FlashProgress({
|
||||
}
|
||||
}
|
||||
|
||||
if (prog.error) {
|
||||
if (prog.error && !deviceDisconnectedRef.current) {
|
||||
setError(prog.error);
|
||||
setStage('error');
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
@@ -175,6 +242,20 @@ export function FlashProgress({
|
||||
startFlash(path);
|
||||
} catch (err) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (deviceDisconnectedRef.current) return;
|
||||
|
||||
// Check if device is still connected before showing download error
|
||||
try {
|
||||
const devices = await getBlockDevices();
|
||||
const stillConnected = devices.some(d => d.path === device.path);
|
||||
if (!stillConnected) {
|
||||
handleDeviceDisconnected();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, continue with download error
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : t('error.downloadFailed'));
|
||||
setStage('error');
|
||||
}
|
||||
@@ -198,7 +279,7 @@ export function FlashProgress({
|
||||
maxProgressRef.current = prog.progress_percent;
|
||||
setProgress(prog.progress_percent);
|
||||
}
|
||||
if (prog.error) {
|
||||
if (prog.error && !deviceDisconnectedRef.current) {
|
||||
setError(prog.error);
|
||||
setStage('error');
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
@@ -215,6 +296,20 @@ export function FlashProgress({
|
||||
setProgress(100);
|
||||
} catch (err) {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
if (deviceDisconnectedRef.current) return;
|
||||
|
||||
// Check if device is still connected before showing flash error
|
||||
try {
|
||||
const devices = await getBlockDevices();
|
||||
const stillConnected = devices.some(d => d.path === device.path);
|
||||
if (!stillConnected) {
|
||||
handleDeviceDisconnected();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, assume disconnected on certain errors
|
||||
}
|
||||
|
||||
setError(err instanceof Error ? err.message : t('error.flashFailed'));
|
||||
setStage('error');
|
||||
}
|
||||
@@ -231,14 +326,30 @@ export function FlashProgress({
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
async function handleRetry() {
|
||||
setError(null);
|
||||
deviceDisconnectedRef.current = false;
|
||||
|
||||
// If device was disconnected, need to re-authorize
|
||||
if (imagePath) {
|
||||
startFlash(imagePath);
|
||||
// Re-authorize before flashing
|
||||
setStage('authorizing');
|
||||
try {
|
||||
const authorized = await requestWriteAuthorization(device.path);
|
||||
if (!authorized) {
|
||||
setError(t('error.authCancelled'));
|
||||
setStage('error');
|
||||
return;
|
||||
}
|
||||
startFlash(imagePath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('error.authFailed'));
|
||||
setStage('error');
|
||||
}
|
||||
} else if (image.is_custom && image.custom_path) {
|
||||
startFlash(image.custom_path);
|
||||
handleAuthorization();
|
||||
} else {
|
||||
startDownload();
|
||||
handleAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { Check } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import armbianLogo from '../assets/armbian-logo.png';
|
||||
import type { BoardInfo, ImageInfo, BlockDevice } from '../types';
|
||||
import type { BoardInfo, ImageInfo, BlockDevice, SelectionStep } from '../types';
|
||||
import type { Manufacturer } from './ManufacturerModal';
|
||||
import { UpdateModal } from './shared/UpdateModal';
|
||||
import { MotdTip } from './shared/MotdTip';
|
||||
|
||||
type NavigationStep = 'manufacturer' | 'board' | 'image' | 'device';
|
||||
|
||||
interface HeaderProps {
|
||||
selectedManufacturer?: Manufacturer | null;
|
||||
selectedBoard?: BoardInfo | null;
|
||||
selectedImage?: ImageInfo | null;
|
||||
selectedDevice?: BlockDevice | null;
|
||||
onReset?: () => void;
|
||||
onNavigateToStep?: (step: NavigationStep) => void;
|
||||
onNavigateToStep?: (step: SelectionStep) => void;
|
||||
isFlashing?: boolean;
|
||||
}
|
||||
|
||||
@@ -33,14 +31,14 @@ export function Header({
|
||||
// For custom images, show different steps
|
||||
const steps = isCustomImage
|
||||
? [
|
||||
{ key: 'image' as NavigationStep, label: t('header.stepImage'), completed: !!selectedImage },
|
||||
{ key: 'device' as NavigationStep, label: t('header.stepStorage'), completed: !!selectedDevice },
|
||||
{ key: 'image' as SelectionStep, label: t('header.stepImage'), completed: !!selectedImage },
|
||||
{ key: 'device' as SelectionStep, label: t('header.stepStorage'), completed: !!selectedDevice },
|
||||
]
|
||||
: [
|
||||
{ key: 'manufacturer' as NavigationStep, label: t('header.stepManufacturer'), completed: !!selectedManufacturer },
|
||||
{ key: 'board' as NavigationStep, label: t('header.stepBoard'), completed: !!selectedBoard },
|
||||
{ key: 'image' as NavigationStep, label: t('header.stepOs'), completed: !!selectedImage },
|
||||
{ key: 'device' as NavigationStep, label: t('header.stepStorage'), completed: !!selectedDevice },
|
||||
{ key: 'manufacturer' as SelectionStep, label: t('header.stepManufacturer'), completed: !!selectedManufacturer },
|
||||
{ key: 'board' as SelectionStep, label: t('header.stepBoard'), completed: !!selectedBoard },
|
||||
{ key: 'image' as SelectionStep, label: t('header.stepOs'), completed: !!selectedImage },
|
||||
{ key: 'device' as SelectionStep, label: t('header.stepStorage'), completed: !!selectedDevice },
|
||||
];
|
||||
|
||||
function handleLogoClick() {
|
||||
@@ -49,7 +47,7 @@ export function Header({
|
||||
}
|
||||
}
|
||||
|
||||
function handleStepClick(step: NavigationStep, completed: boolean) {
|
||||
function handleStepClick(step: SelectionStep, completed: boolean) {
|
||||
// Only allow clicking on completed steps, and not during flashing
|
||||
if (!isFlashing && completed && onNavigateToStep) {
|
||||
onNavigateToStep(step);
|
||||
|
||||
@@ -92,8 +92,16 @@ export function ManufacturerModal({ isOpen, onClose, onSelect }: ManufacturerMod
|
||||
|
||||
// Preload all vendor logos
|
||||
const [logosLoaded, setLogosLoaded] = useState(false);
|
||||
|
||||
// Reset logos loaded state when modal closes
|
||||
useEffect(() => {
|
||||
if (!manufacturers.length || logosLoaded) return;
|
||||
if (!isOpen) {
|
||||
setLogosLoaded(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !manufacturers.length || logosLoaded) return;
|
||||
|
||||
const vendorsWithLogos = manufacturers.filter(m => vendorHasLogo(m.id) && m.id !== 'other');
|
||||
if (vendorsWithLogos.length === 0) {
|
||||
@@ -112,7 +120,7 @@ export function ManufacturerModal({ isOpen, onClose, onSelect }: ManufacturerMod
|
||||
};
|
||||
img.src = getVendorLogoUrl(m.id);
|
||||
});
|
||||
}, [manufacturers, logosLoaded]);
|
||||
}, [isOpen, manufacturers, logosLoaded]);
|
||||
|
||||
const searchBarContent = (
|
||||
<div className="modal-search">
|
||||
|
||||
@@ -30,9 +30,16 @@ export function MarqueeText({ text, maxWidth = 180, className = '' }: MarqueeTex
|
||||
text-transform: ${computedStyle.textTransform};
|
||||
`;
|
||||
measureSpan.textContent = text;
|
||||
document.body.appendChild(measureSpan);
|
||||
const singleTextWidth = measureSpan.offsetWidth;
|
||||
document.body.removeChild(measureSpan);
|
||||
|
||||
let singleTextWidth = 0;
|
||||
try {
|
||||
document.body.appendChild(measureSpan);
|
||||
singleTextWidth = measureSpan.offsetWidth;
|
||||
} finally {
|
||||
if (measureSpan.parentNode) {
|
||||
measureSpan.parentNode.removeChild(measureSpan);
|
||||
}
|
||||
}
|
||||
|
||||
const overflow = singleTextWidth > maxWidth;
|
||||
setIsOverflow(overflow);
|
||||
|
||||
@@ -34,20 +34,9 @@ export function MotdTip() {
|
||||
// Filter out expired messages
|
||||
const now = new Date();
|
||||
const validMessages = messages.filter((msg) => {
|
||||
try {
|
||||
// Parse date (format: YYYY-DD-MM or YYYY-MM-DD)
|
||||
const parts = msg.expiration.split('-');
|
||||
if (parts.length === 3) {
|
||||
const year = parseInt(parts[0], 10);
|
||||
const month = parseInt(parts[1], 10) - 1;
|
||||
const day = parseInt(parts[2], 10);
|
||||
const expDate = new Date(year, month, day);
|
||||
return expDate > now;
|
||||
}
|
||||
} catch {
|
||||
return true; // Include if date parsing fails
|
||||
}
|
||||
return true;
|
||||
if (!msg.expiration) return true;
|
||||
const expDate = new Date(msg.expiration);
|
||||
return isNaN(expDate.getTime()) || expDate > now;
|
||||
});
|
||||
|
||||
if (validMessages.length > 0) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Download, RefreshCw, CheckCircle, AlertCircle, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { check, Update } from '@tauri-apps/plugin-updater';
|
||||
import { relaunch } from '@tauri-apps/plugin-process';
|
||||
import { logInfo } from '../../hooks/useTauri';
|
||||
|
||||
type UpdateState = 'idle' | 'checking' | 'available' | 'downloading' | 'ready' | 'error';
|
||||
|
||||
@@ -18,8 +19,12 @@ export function UpdateModal() {
|
||||
const [progress, setProgress] = useState<DownloadProgress>({ downloaded: 0, total: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const hasCheckedRef = useRef(false);
|
||||
|
||||
const checkForUpdate = useCallback(async () => {
|
||||
if (hasCheckedRef.current) return;
|
||||
hasCheckedRef.current = true;
|
||||
|
||||
setState('checking');
|
||||
setError(null);
|
||||
|
||||
@@ -29,7 +34,9 @@ export function UpdateModal() {
|
||||
if (updateResult) {
|
||||
setUpdate(updateResult);
|
||||
setState('available');
|
||||
logInfo('updater', `Update available: ${updateResult.currentVersion} -> ${updateResult.version}`);
|
||||
} else {
|
||||
logInfo('updater', 'No updates available');
|
||||
setState('idle');
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -88,3 +88,7 @@ export async function uploadLogs(): Promise<UploadResult> {
|
||||
export async function openUrl(url: string): Promise<void> {
|
||||
return invoke('open_url', { url });
|
||||
}
|
||||
|
||||
export async function logInfo(module: string, message: string): Promise<void> {
|
||||
return invoke('log_from_frontend', { module, message });
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@
|
||||
"authFailed": "Autorisierung fehlgeschlagen",
|
||||
"authCancelled": "Autorisierung vom Benutzer abgebrochen",
|
||||
"decompressionFailed": "Dekomprimierung fehlgeschlagen",
|
||||
"uploadFailed": "Hochladen fehlgeschlagen"
|
||||
"uploadFailed": "Hochladen fehlgeschlagen",
|
||||
"deviceDisconnected": "Gerät wurde getrennt"
|
||||
},
|
||||
"custom": {
|
||||
"customImage": "Benutzerdefiniertes Image"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user