mirror of
https://github.com/armbian/imager.git
synced 2026-01-06 12:31:28 -08:00
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
This commit is contained in:
@@ -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<Option<CustomI
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a decompressed custom image file
|
||||
#[tauri::command]
|
||||
pub async fn delete_decompressed_custom_image(image_path: String) -> 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<Option<BoardInfo>, 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)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
25
src/App.tsx
25
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) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
cancelOperation,
|
||||
getBoardImageUrl,
|
||||
deleteDownloadedImage,
|
||||
deleteDecompressedCustomImage,
|
||||
requestWriteAuthorization,
|
||||
checkNeedsDecompression,
|
||||
decompressCustomImage,
|
||||
@@ -51,10 +52,28 @@ export function FlashProgress({
|
||||
const hasStartedRef = useRef<boolean>(false);
|
||||
const deviceDisconnectedRef = useRef<boolean>(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({
|
||||
<div className={`flash-container ${!showHeader ? 'centered' : ''}`}>
|
||||
{showHeader && (
|
||||
<div className="flash-header">
|
||||
{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
|
||||
<div className="flash-board-image flash-custom-image-icon">
|
||||
<FileImage size={40} />
|
||||
</div>
|
||||
) : (
|
||||
// Board image for detected Armbian custom images OR standard images
|
||||
<img
|
||||
src={imageLoadError ? fallbackImage : (boardImageUrl || fallbackImage)}
|
||||
alt={board.name}
|
||||
|
||||
@@ -61,6 +61,21 @@ export async function deleteDownloadedImage(imagePath: string): Promise<void> {
|
||||
return invoke('delete_downloaded_image', { imagePath });
|
||||
}
|
||||
|
||||
export async function deleteDecompressedCustomImage(imagePath: string): Promise<void> {
|
||||
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<BoardInfo | null> {
|
||||
return invoke('detect_board_from_filename', { filename });
|
||||
}
|
||||
|
||||
// Re-export CustomImageInfo for backward compatibility
|
||||
export type { CustomImageInfo } from '../types';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user