From 41c0e90f548ce6b8c2cf68b2d12c9905bbf56167 Mon Sep 17 00:00:00 2001 From: SuperKali Date: Mon, 29 Dec 2025 13:17:35 +0100 Subject: [PATCH] feat: detect board from custom image filename and improve decompression Board detection: - Parse Armbian filename pattern (Armbian_VERSION_BOARD_DISTRO_...) to extract board name - Match extracted board name against database to show board image instead of generic icon - Auto-load board data if not cached with race condition protection - Fallback to generic icon for non-Armbian images Decompression improvements: - Decompress custom images to app cache directory (custom-decompress/) - Use timestamp-based unique filenames to avoid conflicts - Cleanup decompressed files after successful flash Performance: - Optimize lock scope to release mutex early after extracting boards - Use compare-and-swap pattern to prevent race conditions --- src-tauri/src/commands/custom_image.rs | 176 +++++++++++++++++++++++++ src-tauri/src/decompress.rs | 25 +++- src-tauri/src/main.rs | 27 ++++ src/App.tsx | 25 +++- src/components/flash/FlashProgress.tsx | 41 +++++- src/hooks/useTauri.ts | 15 +++ 6 files changed, 294 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands/custom_image.rs b/src-tauri/src/commands/custom_image.rs index 6d85bec..c442a30 100644 --- a/src-tauri/src/commands/custom_image.rs +++ b/src-tauri/src/commands/custom_image.rs @@ -6,7 +6,10 @@ use serde::{Deserialize, Serialize}; use std::path::PathBuf; use tauri::State; +use crate::config; use crate::decompress::{decompress_local_file, needs_decompression}; +use crate::images::{extract_images, fetch_all_images, get_unique_boards, BoardInfo}; +use crate::utils::{get_cache_dir, normalize_slug}; use crate::{log_error, log_info}; use super::state::AppState; @@ -130,3 +133,176 @@ pub async fn select_custom_image(window: tauri::Window) -> Result Result<(), String> { + log_info!( + "custom_image", + "Deleting decompressed custom image: {}", + image_path + ); + let path = PathBuf::from(&image_path); + + // Safety check: only delete files in our custom-decompress directory + let custom_decompress_dir = get_cache_dir(config::app::NAME).join("custom-decompress"); + + if !path.starts_with(&custom_decompress_dir) { + log_error!( + "custom_image", + "Attempted to delete file outside custom-decompress cache: {}", + image_path + ); + return Err("Cannot delete files outside custom-decompress directory".to_string()); + } + + if path.exists() { + std::fs::remove_file(&path).map_err(|e| { + log_error!( + "custom_image", + "Failed to delete decompressed image {}: {}", + image_path, + e + ); + format!("Failed to delete decompressed image: {}", e) + })?; + log_info!("custom_image", "Deleted decompressed image: {}", image_path); + } + + // Try to remove empty parent directory (ignore errors) + let _ = std::fs::remove_dir(&custom_decompress_dir); + + Ok(()) +} + +/// Detect board information from custom image filename +/// Parses Armbian naming convention: Armbian_VERSION_BOARD_DISTRO_VENDOR_KERNEL_FLAVOR.img.xz +#[tauri::command] +pub async fn detect_board_from_filename( + filename: String, + state: State<'_, AppState>, +) -> Result, String> { + log_info!( + "custom_image", + "=== Starting board detection from filename: {} ===", + filename + ); + + // 1. Extract filename from path (remove directory) + let path = PathBuf::from(&filename); + let filename_only = path + .file_name() + .and_then(|n| n.to_str()) + .ok_or("Invalid filename")?; + + // 2. Remove extension(s) - handle .img.xz, .img.gz, .img.zst, .img.bz2, .img + let stem = filename_only + .strip_suffix(".xz") + .or_else(|| filename_only.strip_suffix(".gz")) + .or_else(|| filename_only.strip_suffix(".zst")) + .or_else(|| filename_only.strip_suffix(".bz2")) + .or_else(|| filename_only.strip_suffix(".img")) + .unwrap_or(filename_only); + + // 3. Parse Armbian naming pattern: Armbian_VERSION_BOARD_DISTRO_VENDOR_KERNEL_FLAVOR + let parts: Vec<&str> = stem.split('_').collect(); + + // 4. Validate Armbian format (at least 4 parts, starts with "Armbian") + if parts.len() < 4 || !parts[0].eq_ignore_ascii_case("Armbian") { + log_info!( + "custom_image", + "Not an Armbian image or invalid format: {}", + filename_only + ); + return Ok(None); + } + + // 5. Extract board name (index 2) + let board_name = parts[2]; + log_info!( + "custom_image", + "Extracted board name from filename: {}", + board_name + ); + + // 6. Normalize board name to slug format + let normalized_slug = normalize_slug(board_name); + log_info!("custom_image", "Normalized board slug: {}", normalized_slug); + + // 7. Ensure board data is loaded (auto-load if not cached) + // Use compare-and-swap pattern to prevent race conditions + log_info!("custom_image", "Checking if board data is cached..."); + { + let needs_loading = { + let json_guard = state.images_json.lock().await; + json_guard.is_none() + }; + + if needs_loading { + log_info!( + "custom_image", + "Board data not cached, fetching from API..." + ); + let json = fetch_all_images().await.map_err(|e| { + log_error!("custom_image", "Failed to fetch board data: {}", e); + format!("Failed to fetch board data: {}", e) + })?; + + // Cache the fetched data + let mut json_guard = state.images_json.lock().await; + // Double-check: another thread might have loaded it while we were fetching + if json_guard.is_none() { + *json_guard = Some(json); + log_info!("custom_image", "Board data cached successfully"); + } else { + log_info!( + "custom_image", + "Board data was already cached by another thread" + ); + } + } + } + + // 8. Get cached boards data (now guaranteed to be loaded) + // Extract boards in a scoped block to release lock early + let matching_board = { + log_info!("custom_image", "Accessing cached board data..."); + let json_guard = state.images_json.lock().await; + let json = json_guard.as_ref().ok_or("Images not loaded")?; + + log_info!("custom_image", "Loaded images JSON, extracting boards..."); + let images = extract_images(json); + log_info!("custom_image", "Extracted {} images", images.len()); + let boards = get_unique_boards(&images); + log_info!( + "custom_image", + "Found {} unique boards in database", + boards.len() + ); + // Lock released here + + // 9. Find matching board by slug + boards + .iter() + .find(|board| board.slug == normalized_slug) + .cloned() + }; // matching_board is now owned, lock is released + + if let Some(ref board) = matching_board { + log_info!( + "custom_image", + "Detected board: {} (slug: {})", + board.name, + board.slug + ); + } else { + log_info!( + "custom_image", + "Board not found in database: {}", + normalized_slug + ); + } + + log_info!("custom_image", "Board detection completed successfully"); + Ok(matching_board) +} diff --git a/src-tauri/src/decompress.rs b/src-tauri/src/decompress.rs index 9964bf2..e79974c 100644 --- a/src-tauri/src/decompress.rs +++ b/src-tauri/src/decompress.rs @@ -142,18 +142,29 @@ pub fn decompress_local_file( .and_then(|n| n.to_str()) .ok_or("Invalid filename")?; - // Determine output filename (remove compression extension) - let output_filename = filename + // Extract base filename (remove compression extension) + let base_filename = filename .trim_end_matches(".xz") .trim_end_matches(".gz") .trim_end_matches(".bz2") .trim_end_matches(".zst"); - // Output to same directory as input - let output_path = input_path - .parent() - .ok_or("Invalid input path")? - .join(output_filename); + // Generate unique filename with timestamp to handle concurrent operations + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|e| format!("Failed to get timestamp: {}", e))? + .as_millis(); + + // Use base_filename directly (it already has the correct .img extension) + let output_filename = format!("{}-{}", base_filename, timestamp); + + // Output to cache directory instead of user's directory + let custom_cache_dir = crate::utils::get_cache_dir(config::app::NAME).join("custom-decompress"); + + std::fs::create_dir_all(&custom_cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + let output_path = custom_cache_dir.join(&output_filename); // Check if already decompressed if output_path.exists() { diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 1aea827..af74275 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -39,6 +39,30 @@ fn cleanup_download_cache() { } } +/// Clean up orphaned decompressed custom images from previous sessions +fn cleanup_custom_decompress_cache() { + let custom_dir = get_cache_dir(config::app::NAME).join("custom-decompress"); + + if custom_dir.exists() { + if let Ok(entries) = std::fs::read_dir(&custom_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() { + log_info!( + "main", + "Cleaning up orphaned decompressed file: {}", + path.display() + ); + let _ = std::fs::remove_file(&path); + } + } + } + + // Remove empty directory + let _ = std::fs::remove_dir(&custom_dir); + } +} + /// Returns true if running as AppImage (APPIMAGE env var is set by AppImage runtime) #[cfg(target_os = "linux")] fn is_appimage() -> bool { @@ -68,6 +92,7 @@ fn main() { // Clean up any leftover download images from previous sessions cleanup_download_cache(); + cleanup_custom_decompress_cache(); let mut builder = tauri::Builder::default() .plugin(tauri_plugin_shell::init()) @@ -105,6 +130,8 @@ fn main() { commands::custom_image::select_custom_image, commands::custom_image::check_needs_decompression, commands::custom_image::decompress_custom_image, + commands::custom_image::delete_decompressed_custom_image, + commands::custom_image::detect_board_from_filename, commands::system::open_url, commands::system::get_system_locale, commands::system::log_from_frontend, diff --git a/src/App.tsx b/src/App.tsx index 581171b..b9a6cde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,7 +4,7 @@ import { Header, HomePage } from './components/layout'; import { ManufacturerModal, BoardModal, ImageModal, DeviceModal } from './components/modals'; import { FlashProgress } from './components/flash'; import { AppVersion } from './components/shared'; -import { selectCustomImage } from './hooks/useTauri'; +import { selectCustomImage, detectBoardFromFilename, logInfo } from './hooks/useTauri'; import { useDeviceMonitor } from './hooks/useDeviceMonitor'; import type { BoardInfo, ImageInfo, BlockDevice, ModalType, SelectionStep, Manufacturer } from './types'; import './styles/index.css'; @@ -68,6 +68,18 @@ function App() { try { const result = await selectCustomImage(); if (result) { + // Detect board from filename + let detectedBoard: BoardInfo | null = null; + try { + detectedBoard = await detectBoardFromFilename(result.name); + if (detectedBoard) { + logInfo('App', `Detected board from filename: ${detectedBoard.name} (${detectedBoard.slug})`); + } + } catch (err) { + // Ignore detection errors, fall back to generic + console.warn('Failed to detect board from filename:', err); + } + // Create a custom ImageInfo object const customImage: ImageInfo = { armbian_version: 'Custom', @@ -83,9 +95,12 @@ function App() { is_custom: true, custom_path: result.path, }; - // Reset selections and set custom board/image for display purposes + + // Reset selections and set board for display resetSelectionsFrom('manufacturer'); - setSelectedBoard({ + + // Use detected board if found, otherwise use generic custom board + const displayBoard = detectedBoard || { slug: 'custom', name: t('custom.customImage'), vendor: 'custom', @@ -98,7 +113,9 @@ function App() { has_eos_support: false, has_tvb_support: false, has_wip_support: false, - }); + }; + + setSelectedBoard(displayBoard); setSelectedImage(customImage); } } catch (err) { diff --git a/src/components/flash/FlashProgress.tsx b/src/components/flash/FlashProgress.tsx index b5adfc3..41ba358 100644 --- a/src/components/flash/FlashProgress.tsx +++ b/src/components/flash/FlashProgress.tsx @@ -11,6 +11,7 @@ import { cancelOperation, getBoardImageUrl, deleteDownloadedImage, + deleteDecompressedCustomImage, requestWriteAuthorization, checkNeedsDecompression, decompressCustomImage, @@ -51,10 +52,28 @@ export function FlashProgress({ const hasStartedRef = useRef(false); const deviceDisconnectedRef = useRef(false); - // Cleanup downloaded image file (skip for custom images) + // Debug logging for board detection + useEffect(() => { + console.log('[FlashProgress] Board received:', { + slug: board.slug, + name: board.name, + is_custom: image.is_custom, + }); + }, [board.slug, board.name, image.is_custom]); + + // Cleanup downloaded image file or decompressed custom image async function cleanupImage(path: string | null) { - if (image.is_custom) return; - if (path) { + if (!path) return; + + if (image.is_custom) { + // Cleanup decompressed custom images + try { + await deleteDecompressedCustomImage(path); + } catch { + // Ignore cleanup errors + } + } else { + // Cleanup downloaded images try { await deleteDownloadedImage(path); } catch { @@ -289,6 +308,8 @@ export function FlashProgress({ if (intervalRef.current) clearInterval(intervalRef.current); setStage('complete'); setProgress(100); + // Cleanup decompressed file after successful flash + await cleanupImage(path); } catch (err) { if (intervalRef.current) clearInterval(intervalRef.current); if (deviceDisconnectedRef.current) return; @@ -304,6 +325,8 @@ export function FlashProgress({ // If we can't check, assume disconnected on certain errors } + // Cleanup decompressed file before showing error + await cleanupImage(path); setError(err instanceof Error ? err.message : t('error.flashFailed')); setStage('error'); } @@ -365,11 +388,21 @@ export function FlashProgress({
{showHeader && (
- {image.is_custom ? ( + {(() => { + const showGenericIcon = image.is_custom && board.slug === 'custom'; + console.log('[FlashProgress] Image display logic:', { + is_custom: image.is_custom, + board_slug: board.slug, + showGenericIcon, + }); + return showGenericIcon; + })() ? ( + // Generic icon for non-Armbian or undetected custom images
) : ( + // Board image for detected Armbian custom images OR standard images {board.name} { return invoke('delete_downloaded_image', { imagePath }); } +export async function deleteDecompressedCustomImage(imagePath: string): Promise { + return invoke('delete_decompressed_custom_image', { imagePath }); +} + +/** + * Detects board information from custom image filename + * Parses Armbian naming convention to extract board slug and match against database + * + * @param filename - Filename (can include path) + * @returns Promise resolving to BoardInfo if detected, null otherwise + */ +export async function detectBoardFromFilename(filename: string): Promise { + return invoke('detect_board_from_filename', { filename }); +} + // Re-export CustomImageInfo for backward compatibility export type { CustomImageInfo } from '../types';