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:
SuperKali
2025-12-29 13:17:35 +01:00
parent 054c41fcec
commit 41c0e90f54
6 changed files with 294 additions and 15 deletions

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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}

View File

@@ -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';