From ddf7b0a8a57d6bc13ca3392a83a040c4cbca4946 Mon Sep 17 00:00:00 2001 From: SuperKali Date: Sat, 20 Dec 2025 00:40:58 +0100 Subject: [PATCH] Add device disconnect detection, decompression formats, and UI improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 11 +- src-tauri/Cargo.toml | 3 + src-tauri/src/commands/custom_image.rs | 2 +- src-tauri/src/commands/system.rs | 6 + src-tauri/src/decompress.rs | 89 +++++++++++++-- src-tauri/src/download.rs | 4 +- src-tauri/src/flash/macos/authorization.rs | 3 +- src-tauri/src/flash/macos/writer.rs | 15 ++- src-tauri/src/flash/mod.rs | 27 ----- src-tauri/src/main.rs | 1 + src/App.tsx | 5 +- src/components/BoardModal.tsx | 8 +- src/components/FlashProgress/index.tsx | 125 +++++++++++++++++++-- src/components/Header.tsx | 20 ++-- src/components/ManufacturerModal.tsx | 12 +- src/components/shared/MarqueeText.tsx | 13 ++- src/components/shared/MotdTip.tsx | 17 +-- src/components/shared/UpdateModal.tsx | 9 +- src/hooks/useTauri.ts | 4 + src/locales/de.json | 3 +- src/locales/en.json | 3 +- src/locales/es.json | 3 +- src/locales/fr.json | 3 +- src/locales/it.json | 3 +- src/locales/ja.json | 3 +- src/locales/ko.json | 3 +- src/locales/nl.json | 3 +- src/locales/pl.json | 3 +- src/locales/pt.json | 3 +- src/locales/ru.json | 3 +- src/locales/sl.json | 3 +- src/locales/tr.json | 3 +- src/locales/uk.json | 3 +- src/locales/zh.json | 3 +- src/styles/base.css | 2 + src/styles/components.css | 2 + src/types/index.ts | 9 +- 37 files changed, 315 insertions(+), 117 deletions(-) diff --git a/README.md b/README.md index 15677d3..d838852 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@

Release + Downloads License

@@ -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. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 31add42..095fd12 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/commands/custom_image.rs b/src-tauri/src/commands/custom_image.rs index efde416..8f9a097 100644 --- a/src-tauri/src/commands/custom_image.rs +++ b/src-tauri/src/commands/custom_image.rs @@ -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") diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index 4fa339e..489a69c 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -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] diff --git a/src-tauri/src/decompress.rs b/src-tauri/src/decompress.rs index 469cc80..cad45f5 100644 --- a/src-tauri/src/decompress.rs +++ b/src-tauri/src/decompress.rs @@ -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, ) -> 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, +) -> 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, +) -> 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, +) -> 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( + mut decoder: R, + output_path: &Path, + state: &Arc, + 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()); diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 98220aa..ed5dffc 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -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"); } diff --git a/src-tauri/src/flash/macos/authorization.rs b/src-tauri/src/flash/macos/authorization.rs index 1864a69..4e4e55c 100644 --- a/src-tauri/src/flash/macos/authorization.rs +++ b/src-tauri/src/flash/macos/authorization.rs @@ -34,7 +34,8 @@ pub fn request_authorization(device_path: &str) -> Result { 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(), diff --git a/src-tauri/src/flash/macos/writer.rs b/src-tauri/src/flash/macos/writer.rs index b358290..b303ee6 100644 --- a/src-tauri/src/flash/macos/writer.rs +++ b/src-tauri/src/flash/macos/writer.rs @@ -83,12 +83,15 @@ pub fn open_device_with_saved_auth(device_path: &str) -> Result p, + Err(_) => libc::_exit(2), + }; libc::execl( authopen.as_ptr(), diff --git a/src-tauri/src/flash/mod.rs b/src-tauri/src/flash/mod.rs index 3f13ec9..78bda26 100644 --- a/src-tauri/src/flash/mod.rs +++ b/src-tauri/src/flash/mod.rs @@ -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 { - 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())) -} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index e1e0484..d5c0071 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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| { diff --git a/src/App.tsx b/src/App.tsx index 27b1fe3..d3a1d93 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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); diff --git a/src/components/BoardModal.tsx b/src/components/BoardModal.tsx index e2a019f..b4f219c 100644 --- a/src/components/BoardModal.tsx +++ b/src/components/BoardModal.tsx @@ -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(() => { diff --git a/src/components/FlashProgress/index.tsx b/src/components/FlashProgress/index.tsx index 41f8137..baeec8c 100644 --- a/src/components/FlashProgress/index.tsx +++ b/src/components/FlashProgress/index.tsx @@ -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(null); const intervalRef = useRef(null); + const deviceMonitorRef = useRef(null); const maxProgressRef = useRef(0); const hasStartedRef = useRef(false); + const deviceDisconnectedRef = useRef(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(); } } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 70482c4..ef2046e 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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); diff --git a/src/components/ManufacturerModal.tsx b/src/components/ManufacturerModal.tsx index 93658c6..3d70172 100644 --- a/src/components/ManufacturerModal.tsx +++ b/src/components/ManufacturerModal.tsx @@ -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 = (
diff --git a/src/components/shared/MarqueeText.tsx b/src/components/shared/MarqueeText.tsx index 703add9..7c9bc65 100644 --- a/src/components/shared/MarqueeText.tsx +++ b/src/components/shared/MarqueeText.tsx @@ -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); diff --git a/src/components/shared/MotdTip.tsx b/src/components/shared/MotdTip.tsx index eb0f720..519a957 100644 --- a/src/components/shared/MotdTip.tsx +++ b/src/components/shared/MotdTip.tsx @@ -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) { diff --git a/src/components/shared/UpdateModal.tsx b/src/components/shared/UpdateModal.tsx index 6fdaf6e..50a2846 100644 --- a/src/components/shared/UpdateModal.tsx +++ b/src/components/shared/UpdateModal.tsx @@ -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({ downloaded: 0, total: null }); const [error, setError] = useState(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) { diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index 026d4f7..ed3bbdc 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -88,3 +88,7 @@ export async function uploadLogs(): Promise { export async function openUrl(url: string): Promise { return invoke('open_url', { url }); } + +export async function logInfo(module: string, message: string): Promise { + return invoke('log_from_frontend', { module, message }); +} diff --git a/src/locales/de.json b/src/locales/de.json index 2c97f89..194a78e 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -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" diff --git a/src/locales/en.json b/src/locales/en.json index 216da66..95b352c 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -91,7 +91,8 @@ "authFailed": "Authorization failed", "authCancelled": "Authorization cancelled by user", "decompressionFailed": "Decompression failed", - "uploadFailed": "Upload failed" + "uploadFailed": "Upload failed", + "deviceDisconnected": "Device was disconnected" }, "custom": { "customImage": "Custom Image" diff --git a/src/locales/es.json b/src/locales/es.json index 273a3a5..12f294e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -91,7 +91,8 @@ "authFailed": "Error de autorización", "authCancelled": "Autorización cancelada por el usuario", "decompressionFailed": "Error de descompresión", - "uploadFailed": "Error al subir" + "uploadFailed": "Error al subir", + "deviceDisconnected": "El dispositivo fue desconectado" }, "custom": { "customImage": "Imagen personalizada" diff --git a/src/locales/fr.json b/src/locales/fr.json index 251c53e..bc1f8c5 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -91,7 +91,8 @@ "authFailed": "Échec de l'autorisation", "authCancelled": "Autorisation annulée par l'utilisateur", "decompressionFailed": "Échec de la décompression", - "uploadFailed": "Échec du téléversement" + "uploadFailed": "Échec du téléversement", + "deviceDisconnected": "L'appareil a été déconnecté" }, "custom": { "customImage": "Image personnalisée" diff --git a/src/locales/it.json b/src/locales/it.json index b11d81b..3fce4dc 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -91,7 +91,8 @@ "authFailed": "Autorizzazione fallita", "authCancelled": "Autorizzazione annullata dall'utente", "decompressionFailed": "Decompressione fallita", - "uploadFailed": "Caricamento fallito" + "uploadFailed": "Caricamento fallito", + "deviceDisconnected": "Dispositivo disconnesso" }, "custom": { "customImage": "Immagine Personalizzata" diff --git a/src/locales/ja.json b/src/locales/ja.json index 8b62288..0f5669d 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -91,7 +91,8 @@ "authFailed": "認証失敗", "authCancelled": "ユーザーにより認証がキャンセルされました", "decompressionFailed": "解凍失敗", - "uploadFailed": "アップロード失敗" + "uploadFailed": "アップロード失敗", + "deviceDisconnected": "デバイスが切断されました" }, "custom": { "customImage": "カスタムイメージ" diff --git a/src/locales/ko.json b/src/locales/ko.json index 083f87a..3c3090b 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -91,7 +91,8 @@ "authFailed": "인증 실패", "authCancelled": "사용자가 인증을 취소했습니다", "decompressionFailed": "압축 해제 실패", - "uploadFailed": "업로드 실패" + "uploadFailed": "업로드 실패", + "deviceDisconnected": "장치 연결이 해제되었습니다" }, "custom": { "customImage": "사용자 정의 이미지" diff --git a/src/locales/nl.json b/src/locales/nl.json index 3d86231..db5d07f 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -91,7 +91,8 @@ "authFailed": "Autorisatie mislukt", "authCancelled": "Autorisatie geannuleerd door gebruiker", "decompressionFailed": "Uitpakken mislukt", - "uploadFailed": "Upload mislukt" + "uploadFailed": "Upload mislukt", + "deviceDisconnected": "Apparaat is losgekoppeld" }, "custom": { "customImage": "Aangepaste image" diff --git a/src/locales/pl.json b/src/locales/pl.json index f04e4bc..b3dd20b 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -91,7 +91,8 @@ "authFailed": "Autoryzacja nie powiodła się", "authCancelled": "Autoryzacja anulowana przez użytkownika", "decompressionFailed": "Rozpakowywanie nie powiodło się", - "uploadFailed": "Przesyłanie nie powiodło się" + "uploadFailed": "Przesyłanie nie powiodło się", + "deviceDisconnected": "Urządzenie zostało odłączone" }, "custom": { "customImage": "Własny obraz" diff --git a/src/locales/pt.json b/src/locales/pt.json index 74dcb74..a6b4fe7 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -91,7 +91,8 @@ "authFailed": "Falha na autorização", "authCancelled": "Autorização cancelada pelo usuário", "decompressionFailed": "Falha na descompactação", - "uploadFailed": "Falha no envio" + "uploadFailed": "Falha no envio", + "deviceDisconnected": "Dispositivo foi desconectado" }, "custom": { "customImage": "Imagem personalizada" diff --git a/src/locales/ru.json b/src/locales/ru.json index 72f3f32..54af5dc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -91,7 +91,8 @@ "authFailed": "Ошибка авторизации", "authCancelled": "Авторизация отменена пользователем", "decompressionFailed": "Ошибка распаковки", - "uploadFailed": "Ошибка загрузки" + "uploadFailed": "Ошибка загрузки", + "deviceDisconnected": "Устройство было отключено" }, "custom": { "customImage": "Свой образ" diff --git a/src/locales/sl.json b/src/locales/sl.json index b722f94..f1a1c4b 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -91,7 +91,8 @@ "authFailed": "Avtorizacija ni uspela", "authCancelled": "Uporabnik je preklical avtorizacijo", "decompressionFailed": "Razširjanje ni uspelo", - "uploadFailed": "Nalaganje ni uspelo" + "uploadFailed": "Nalaganje ni uspelo", + "deviceDisconnected": "Naprava je bila odklopljena" }, "custom": { "customImage": "Slika po meri" diff --git a/src/locales/tr.json b/src/locales/tr.json index e33c06e..d4b852c 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -91,7 +91,8 @@ "authFailed": "Yetkilendirme başarısız", "authCancelled": "Yetkilendirme kullanıcı tarafından iptal edildi", "decompressionFailed": "Açma başarısız", - "uploadFailed": "Yükleme başarısız" + "uploadFailed": "Yükleme başarısız", + "deviceDisconnected": "Cihaz bağlantısı kesildi" }, "custom": { "customImage": "Özel İmaj" diff --git a/src/locales/uk.json b/src/locales/uk.json index 847173a..728be93 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -91,7 +91,8 @@ "authFailed": "Помилка авторизації", "authCancelled": "Авторизацію скасовано користувачем", "decompressionFailed": "Помилка розпакування", - "uploadFailed": "Помилка завантаження" + "uploadFailed": "Помилка завантаження", + "deviceDisconnected": "Пристрій було від'єднано" }, "custom": { "customImage": "Власний образ" diff --git a/src/locales/zh.json b/src/locales/zh.json index 7cb06cc..4c3075e 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -91,7 +91,8 @@ "authFailed": "授权失败", "authCancelled": "用户取消了授权", "decompressionFailed": "解压失败", - "uploadFailed": "上传失败" + "uploadFailed": "上传失败", + "deviceDisconnected": "设备已断开连接" }, "custom": { "customImage": "自定义镜像" diff --git a/src/styles/base.css b/src/styles/base.css index 4a201c3..c44b6c1 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -6,6 +6,8 @@ box-sizing: border-box; margin: 0; padding: 0; + user-select: none; + -webkit-user-select: none; } html, body, #root { diff --git a/src/styles/components.css b/src/styles/components.css index ca9d95a..daa6bb0 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -917,6 +917,8 @@ cursor: pointer; transition: all 0.2s ease; border: none; + flex: 1; + max-width: 160px; } .update-modal-btn.primary { diff --git a/src/types/index.ts b/src/types/index.ts index a7c46da..ba09014 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -66,9 +66,14 @@ export interface Manufacturer { export type ImageFilterType = 'all' | 'recommended' | 'stable' | 'nightly' | 'apps' | 'barebone'; /** - * Modal type for app navigation + * Selection step in the wizard flow */ -export type ModalType = 'none' | 'manufacturer' | 'board' | 'image' | 'device'; +export type SelectionStep = 'manufacturer' | 'board' | 'image' | 'device'; + +/** + * Modal type for app navigation (includes 'none' for closed state) + */ +export type ModalType = 'none' | SelectionStep; /** * Custom image info from file picker