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:
SuperKali
2025-12-20 00:40:58 +01:00
parent 8fc41d1932
commit ddf7b0a8a5
37 changed files with 315 additions and 117 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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