From 01b3a52ec2fe8060af54d4fea369b76a7cca30a6 Mon Sep 17 00:00:00 2001 From: Mecid Date: Mon, 12 Jan 2026 19:26:21 +0100 Subject: [PATCH 1/3] Feature: Download only --- package-lock.json | 1 - src-tauri/src/commands/operations.rs | 157 ++++++++++ src-tauri/src/download.rs | 127 ++++++++ src-tauri/src/main.rs | 2 + src/App.tsx | 34 ++- src/components/flash/DownloadProgress.tsx | 342 ++++++++++++++++++++++ src/components/flash/index.ts | 1 + src/components/modals/DeviceModal.tsx | 23 +- src/components/modals/Modal.tsx | 4 +- src/hooks/useTauri.ts | 16 + src/locales/en.json | 17 +- src/styles/modal.css | 43 +++ 12 files changed, 757 insertions(+), 10 deletions(-) create mode 100644 src/components/flash/DownloadProgress.tsx diff --git a/package-lock.json b/package-lock.json index a33578f..81d0d22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3319,7 +3319,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.2.tgz", "integrity": "sha512-fhyD2BLrew6qYf4NNtHff1rLXvzR25rq49p+FeqByOazc6TcSi2n8EYulo5C1PbH+1uBW++5S1SG7FcUU6mlDg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, diff --git a/src-tauri/src/commands/operations.rs b/src-tauri/src/commands/operations.rs index 0128892..83929d4 100644 --- a/src-tauri/src/commands/operations.rs +++ b/src-tauri/src/commands/operations.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use tauri::{AppHandle, State}; +use tauri_plugin_dialog::DialogExt; use tauri_plugin_store::StoreExt; use crate::config; @@ -255,3 +256,159 @@ pub async fn delete_downloaded_image(image_path: String, app: AppHandle) -> Resu Ok(()) } + +/// Show save file dialog and return selected path +/// +/// This is separated from download_to_path so the frontend can +/// start progress polling AFTER the dialog closes. +#[tauri::command] +pub async fn select_save_path( + suggested_filename: String, + decompress: bool, + window: tauri::Window, +) -> Result, String> { + log_info!( + "operations", + "Showing save dialog (suggested: {}, decompress: {})", + suggested_filename, + decompress + ); + + // Determine save filename based on decompress option + let save_filename = if decompress { + suggested_filename + .strip_suffix(".xz") + .unwrap_or(&suggested_filename) + .to_string() + } else { + suggested_filename.clone() + }; + + // Set up file filter based on decompress option + let file_filter = if decompress { + vec!["img", "iso", "raw"] + } else { + vec!["xz", "img", "iso", "raw"] + }; + + // Show save file dialog + let save_path = window + .dialog() + .file() + .set_file_name(&save_filename) + .add_filter("Disk Images", &file_filter) + .set_title("Save Image As") + .blocking_save_file(); + + match save_path { + Some(path) => { + let path_buf = path.as_path().ok_or_else(|| { + log_error!("operations", "Invalid save path selected"); + "Invalid save path selected".to_string() + })?; + log_info!( + "operations", + "User selected save path: {}", + path_buf.display() + ); + Ok(Some(path_buf.to_string_lossy().to_string())) + } + None => { + log_info!("operations", "Save dialog cancelled by user"); + Ok(None) + } + } +} + +/// Download an image to a specified path +/// +/// Downloads the image, optionally decompresses, and saves to the given path. +/// Progress can be tracked via get_download_progress. +#[tauri::command] +pub async fn download_to_path( + file_url: String, + file_url_sha: Option, + save_path: String, + decompress: bool, + state: State<'_, AppState>, +) -> Result { + log_info!( + "operations", + "Download to path requested: {} -> {} (decompress: {})", + file_url, + save_path, + decompress + ); + + let save_path = PathBuf::from(&save_path); + let download_dir = get_cache_dir(config::app::NAME).join("images"); + let download_state = state.download_state.clone(); + + if decompress { + // Normal flow: download and decompress + let cached_path = do_download( + &file_url, + file_url_sha.as_deref(), + &download_dir, + download_state, + ) + .await?; + + log_info!( + "operations", + "Download complete, moving to: {}", + save_path.display() + ); + + // Move/copy the file to the user's selected location + std::fs::copy(&cached_path, &save_path).map_err(|e| { + log_error!( + "operations", + "Failed to copy image to {}: {}", + save_path.display(), + e + ); + format!("Failed to save image: {}", e) + })?; + + // Delete the cached copy (ignore errors) + let _ = std::fs::remove_file(&cached_path); + } else { + // Skip decompression: download raw compressed file + let cached_path = crate::download::download_image_raw( + &file_url, + file_url_sha.as_deref(), + &download_dir, + download_state, + ) + .await?; + + log_info!( + "operations", + "Download complete (compressed), moving to: {}", + save_path.display() + ); + + // Move/copy the compressed file to the user's selected location + std::fs::copy(&cached_path, &save_path).map_err(|e| { + log_error!( + "operations", + "Failed to copy image to {}: {}", + save_path.display(), + e + ); + format!("Failed to save image: {}", e) + })?; + + // Delete the cached copy (ignore errors) + let _ = std::fs::remove_file(&cached_path); + } + + log_info!( + "operations", + "Image saved successfully to: {}", + save_path.display() + ); + + Ok(save_path.to_string_lossy().to_string()) +} diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index 9da8b11..f708c64 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -338,3 +338,130 @@ pub async fn download_image( *state.output_path.lock().await = Some(output_path.clone()); Ok(output_path) } + +/// Download an Armbian image without decompression +/// If sha_url is provided, verifies the downloaded file +/// Returns the path to the compressed file (keeps .xz extension) +pub async fn download_image_raw( + url: &str, + sha_url: Option<&str>, + output_dir: &PathBuf, + state: Arc, +) -> Result { + state.reset(); + + let filename = extract_filename(url)?; + let output_path = output_dir.join(filename); + + log_info!(MODULE, "Download (raw/compressed) requested: {}", url); + log_debug!(MODULE, "Output path: {}", output_path.display()); + + // Check if compressed image is already in cache + if let Some(cached_path) = crate::cache::get_cached_image(filename) { + log_info!(MODULE, "Using cached image: {}", cached_path.display()); + *state.output_path.lock().await = Some(cached_path.clone()); + return Ok(cached_path); + } + + // Create output directory if needed + std::fs::create_dir_all(output_dir) + .map_err(|e| format!("Failed to create output directory: {}", e))?; + + let client = Client::builder() + .user_agent(config::app::USER_AGENT) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + // Start download + log_info!(MODULE, "Starting download..."); + let response = client.get(url).send().await.map_err(|e| { + log_error!(MODULE, "Failed to start download: {}", e); + format!("Failed to start download: {}", e) + })?; + + if !response.status().is_success() { + log_error!(MODULE, "Download failed with status: {}", response.status()); + return Err(format!( + "Download failed with status: {}", + response.status() + )); + } + + // Get content length + let total_size = response.content_length().unwrap_or(0); + state.total_bytes.store(total_size, Ordering::SeqCst); + + log_info!( + MODULE, + "Download size: {} bytes ({:.2} MB)", + total_size, + bytes_to_mb(total_size) + ); + + // Create temp file + let temp_path = output_dir.join(format!("{}.downloading", filename)); + let mut temp_file = + File::create(&temp_path).map_err(|e| format!("Failed to create temp file: {}", e))?; + + // Download with progress tracking + let mut stream = response.bytes_stream(); + let mut downloaded: u64 = 0; + let mut tracker = ProgressTracker::new( + "Download", + MODULE, + total_size, + config::logging::DOWNLOAD_LOG_INTERVAL_MB, + ); + + while let Some(chunk) = stream.next().await { + if state.is_cancelled.load(Ordering::SeqCst) { + log_info!(MODULE, "Download cancelled by user"); + drop(temp_file); + let _ = std::fs::remove_file(&temp_path); + return Err("Download cancelled".to_string()); + } + + let chunk = chunk.map_err(|e| format!("Download error: {}", e))?; + temp_file + .write_all(&chunk) + .map_err(|e| format!("Failed to write chunk: {}", e))?; + + downloaded += chunk.len() as u64; + state.downloaded_bytes.store(downloaded, Ordering::SeqCst); + tracker.update(chunk.len() as u64); + } + + drop(temp_file); + tracker.finish(); + + // Verify SHA256 if URL provided + if let Some(sha_url) = sha_url { + state.is_verifying_sha.store(true, Ordering::SeqCst); + log_info!(MODULE, "Verifying SHA256..."); + match verify_sha256(&client, &temp_path, sha_url, &state).await { + Ok(()) => { + log_info!(MODULE, "SHA256 verification successful"); + } + Err(e) => { + log_error!(MODULE, "SHA256 verification failed: {}", e); + state.is_verifying_sha.store(false, Ordering::SeqCst); + let _ = std::fs::remove_file(&temp_path); + if state.is_cancelled.load(Ordering::SeqCst) { + return Err("Download cancelled".to_string()); + } + return Err(format!("SHA256 verification failed: {}", e)); + } + } + state.is_verifying_sha.store(false, Ordering::SeqCst); + } else { + log_warn!(MODULE, "No SHA URL provided, skipping verification"); + } + + // Move temp file to final location (no decompression) + std::fs::rename(&temp_path, &output_path) + .map_err(|e| format!("Failed to move file: {}", e))?; + + log_info!(MODULE, "Image ready (compressed): {}", output_path.display()); + *state.output_path.lock().await = Some(output_path.clone()); + Ok(output_path) +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 7bd155e..94ddfc6 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -159,6 +159,8 @@ fn main() { commands::operations::flash_image, commands::operations::delete_downloaded_image, commands::operations::force_delete_cached_image, + commands::operations::select_save_path, + commands::operations::download_to_path, commands::progress::cancel_operation, commands::progress::get_download_progress, commands::progress::get_flash_progress, diff --git a/src/App.tsx b/src/App.tsx index 8fcc482..764aae2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Header, HomePage } from './components/layout'; import { ManufacturerModal, BoardModal, ImageModal, DeviceModal } from './components/modals'; -import { FlashProgress } from './components/flash'; +import { FlashProgress, DownloadProgress } from './components/flash'; import { SettingsButton } from './components/settings'; import { selectCustomImage, detectBoardFromFilename, logInfo } from './hooks/useTauri'; import { useDeviceMonitor } from './hooks/useDeviceMonitor'; @@ -12,6 +12,8 @@ import './styles/index.css'; function App() { const { t } = useTranslation(); const [isFlashing, setIsFlashing] = useState(false); + const [isDownloading, setIsDownloading] = useState(false); + const [downloadDecompress, setDownloadDecompress] = useState(true); const [activeModal, setActiveModal] = useState('none'); const [selectedManufacturer, setSelectedManufacturer] = useState(null); const [selectedBoard, setSelectedBoard] = useState(null); @@ -125,6 +127,7 @@ function App() { function handleComplete() { setIsFlashing(false); + setIsDownloading(false); resetSelectionsFrom('manufacturer'); // Reset all selections } @@ -133,6 +136,16 @@ function App() { setSelectedDevice(null); // Reset device to allow re-selection } + function handleBackFromDownload() { + setIsDownloading(false); + } + + function handleDownloadOnly(decompress: boolean) { + setActiveModal('none'); + setDownloadDecompress(decompress); + setIsDownloading(true); + } + function handleReset() { resetSelectionsFrom('manufacturer'); } @@ -152,11 +165,11 @@ function App() { selectedDevice={selectedDevice} onReset={handleReset} onNavigateToStep={handleNavigateToStep} - isFlashing={isFlashing} + isFlashing={isFlashing || isDownloading} />
- {!isFlashing ? ( + {!isFlashing && !isDownloading ? ( setActiveModal('device')} onChooseCustomImage={handleCustomImage} /> - ) : ( + ) : isFlashing ? ( selectedBoard && selectedImage && selectedDevice && ( ) + ) : ( + selectedBoard && selectedImage && ( + + ) )}
@@ -206,9 +229,10 @@ function App() { isOpen={activeModal === 'device'} onClose={() => setActiveModal('none')} onSelect={handleDeviceSelect} + onDownloadOnly={selectedImage && !selectedImage.is_custom ? handleDownloadOnly : undefined} /> - {!isFlashing && } + {!isFlashing && !isDownloading && } ); } diff --git a/src/components/flash/DownloadProgress.tsx b/src/components/flash/DownloadProgress.tsx new file mode 100644 index 0000000..a84dcce --- /dev/null +++ b/src/components/flash/DownloadProgress.tsx @@ -0,0 +1,342 @@ +import { useState, useEffect, useRef } from 'react'; +import { Disc, FolderOpen } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { BoardInfo, ImageInfo } from '../../types'; +import { getImageLogo, getOsName } from '../../assets/os-logos'; +import { + selectSavePath, + downloadToPath, + getDownloadProgress, + cancelOperation, + getBoardImageUrl, +} from '../../hooks/useTauri'; +import { ErrorDisplay, MarqueeText } from '../shared'; +import fallbackImage from '../../assets/armbian-logo_nofound.png'; +import { POLLING } from '../../config'; +import { + Download, + Archive, + CheckCircle, + XCircle, + ShieldCheck, + FolderOpen as FolderIcon, +} from 'lucide-react'; + +type DownloadStage = + | 'selecting' + | 'downloading' + | 'verifying_sha' + | 'decompressing' + | 'complete' + | 'error' + | 'cancelled'; + +interface DownloadProgressProps { + board: BoardInfo; + image: ImageInfo; + decompress: boolean; + onComplete: () => void; + onBack: () => void; +} + +function DownloadStageIcon({ stage, size = 48 }: { stage: DownloadStage; size?: number }) { + switch (stage) { + case 'selecting': + return ; + case 'downloading': + return ; + case 'verifying_sha': + return ; + case 'decompressing': + return ; + case 'complete': + return ; + case 'error': + case 'cancelled': + return ; + } +} + +function getDownloadStageKey(stage: DownloadStage): string { + switch (stage) { + case 'selecting': + return 'download.selecting'; + case 'downloading': + return 'download.downloading'; + case 'verifying_sha': + return 'download.verifyingSha'; + case 'decompressing': + return 'download.decompressing'; + case 'complete': + return 'download.complete'; + case 'cancelled': + return 'download.cancelled'; + case 'error': + return 'download.failed'; + } +} + +export function DownloadProgress({ + board, + image, + decompress, + onComplete, + onBack, +}: DownloadProgressProps) { + const { t } = useTranslation(); + const [stage, setStage] = useState('selecting'); + const [progress, setProgress] = useState(0); + const [error, setError] = useState(null); + const [savedPath, setSavedPath] = useState(null); + const [boardImageUrl, setBoardImageUrl] = useState(null); + const [imageLoadError, setImageLoadError] = useState(false); + const intervalRef = useRef(null); + const maxProgressRef = useRef(0); + const hasStartedRef = useRef(false); + const stageRef = useRef('selecting'); + + // Extract filename from URL + function getFilenameFromUrl(url: string): string { + const urlPath = url.split('?')[0]; + const parts = urlPath.split('/'); + return parts[parts.length - 1] || 'image.img'; + } + + async function loadBoardImage() { + try { + const url = await getBoardImageUrl(board.slug); + setBoardImageUrl(url); + } catch { + // Ignore + } + } + + // Helper to update stage and ref together + function updateStage(newStage: DownloadStage) { + stageRef.current = newStage; + setStage(newStage); + } + + // Start polling for download progress + function startPolling() { + intervalRef.current = window.setInterval(async () => { + try { + const prog = await getDownloadProgress(); + + // Use ref to check current stage (avoids stale closure) + if (prog.is_verifying_sha && stageRef.current !== 'verifying_sha') { + updateStage('verifying_sha'); + maxProgressRef.current = 0; + setProgress(0); + } else if (prog.is_decompressing && stageRef.current !== 'decompressing') { + updateStage('decompressing'); + maxProgressRef.current = 0; + setProgress(0); + } + + if (!prog.is_decompressing && !prog.is_verifying_sha) { + const newProgress = prog.progress_percent; + if (newProgress >= maxProgressRef.current) { + maxProgressRef.current = newProgress; + setProgress(newProgress); + } + } + + if (prog.error) { + setError(prog.error); + updateStage('error'); + if (intervalRef.current) clearInterval(intervalRef.current); + } + } catch { + // Ignore polling errors + } + }, POLLING.DOWNLOAD_PROGRESS); + } + + async function startDownload() { + setProgress(0); + setError(null); + maxProgressRef.current = 0; + + try { + // Step 1: Show save dialog and get path (no download yet) + const suggestedFilename = getFilenameFromUrl(image.file_url); + const savePath = await selectSavePath(suggestedFilename, decompress); + + if (!savePath) { + // User cancelled the save dialog + updateStage('cancelled'); + setError(t('download.cancelled')); + return; + } + + // Step 2: Now start polling (download is about to begin) + updateStage('downloading'); + startPolling(); + + // Step 3: Start the actual download + const path = await downloadToPath( + image.file_url, + image.file_url_sha, + savePath, + decompress + ); + + if (intervalRef.current) clearInterval(intervalRef.current); + setSavedPath(path); + updateStage('complete'); + setProgress(100); + } catch (err) { + if (intervalRef.current) clearInterval(intervalRef.current); + + const errorMessage = err instanceof Error ? err.message : String(err); + setError(errorMessage); + updateStage('error'); + } + } + + useEffect(() => { + if (hasStartedRef.current) return; + hasStartedRef.current = true; + + loadBoardImage(); + startDownload(); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleCancel() { + try { + await cancelOperation(); + if (intervalRef.current) clearInterval(intervalRef.current); + onBack(); + } catch { + // Ignore + } + } + + async function handleRetry() { + setError(null); + hasStartedRef.current = false; + startDownload(); + } + + function getImageDisplayText(): string { + return `Armbian ${image.armbian_version} ${image.distro_release}`; + } + + const showHeader = stage !== 'error' && stage !== 'cancelled'; + + return ( +
+ {showHeader && ( +
+ {board.name} setImageLoadError(true)} + /> +
+

{board.name}

+
+
+ {(() => { + const logo = getImageLogo( + image.distro_release, + image.preinstalled_application + ); + return logo ? ( + {getOsName(image.distro_release)} + ) : ( + + ); + })()} + +
+
+ + {t('download.savingToDisk')} +
+
+
+
+ )} + +
+ +

{t(getDownloadStageKey(stage))}

+ + {stage !== 'complete' && + stage !== 'error' && + stage !== 'cancelled' && + stage !== 'selecting' && ( +
+
+
+
+ {stage !== 'decompressing' && stage !== 'verifying_sha' && ( + {progress.toFixed(0)}% + )} +
+ )} + + {stage === 'complete' && savedPath && ( +

+ {t('download.successHint', { path: savedPath })} +

+ )} + + {error && } + +
+ {stage === 'complete' && ( + <> + + + + )} + {(stage === 'error' || stage === 'cancelled') && ( + <> + + {stage === 'error' && ( + + )} + + )} + {stage !== 'complete' && stage !== 'error' && stage !== 'cancelled' && stage !== 'selecting' && ( + + )} +
+
+
+ ); +} diff --git a/src/components/flash/index.ts b/src/components/flash/index.ts index 58c4860..71020a6 100644 --- a/src/components/flash/index.ts +++ b/src/components/flash/index.ts @@ -3,6 +3,7 @@ */ export { FlashProgress } from './FlashProgress'; +export { DownloadProgress } from './DownloadProgress'; export { FlashActions } from './FlashActions'; export { FlashStageIcon, getStageKey } from './FlashStageIcon'; export type { FlashStage } from './FlashStageIcon'; diff --git a/src/components/modals/DeviceModal.tsx b/src/components/modals/DeviceModal.tsx index 8ee20f3..d79edf6 100644 --- a/src/components/modals/DeviceModal.tsx +++ b/src/components/modals/DeviceModal.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { HardDrive, RefreshCw, AlertTriangle, Shield, MemoryStick, Usb } from 'lucide-react'; +import { HardDrive, RefreshCw, AlertTriangle, Shield, MemoryStick, Usb, Download } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Modal } from './Modal'; import { ErrorDisplay, ConfirmationDialog, ListItemSkeleton } from '../shared'; @@ -68,14 +68,16 @@ interface DeviceModalProps { isOpen: boolean; onClose: () => void; onSelect: (device: BlockDevice) => void; + onDownloadOnly?: (decompress: boolean) => void; } -export function DeviceModal({ isOpen, onClose, onSelect }: DeviceModalProps) { +export function DeviceModal({ isOpen, onClose, onSelect, onDownloadOnly }: DeviceModalProps) { const { t } = useTranslation(); const [selectedDevice, setSelectedDevice] = useState(null); const [showConfirm, setShowConfirm] = useState(false); const [showSkeleton, setShowSkeleton] = useState(false); const [showSystemDevices, setShowSystemDevices] = useState(false); + const [decompressImage, setDecompressImage] = useState(true); // Track previous devices for change detection const prevDevicesRef = useRef(null); @@ -171,6 +173,23 @@ export function DeviceModal({ isOpen, onClose, onSelect }: DeviceModalProps) { isOpen={isOpen && !showConfirm} onClose={onClose} title={t('modal.selectDevice')} + footer={onDownloadOnly && ( +
+ {t('modal.insteadSaveToDisk')} + + +
+ )} >
diff --git a/src/components/modals/Modal.tsx b/src/components/modals/Modal.tsx index fa12b1f..03814c4 100644 --- a/src/components/modals/Modal.tsx +++ b/src/components/modals/Modal.tsx @@ -9,9 +9,10 @@ interface ModalProps { searchBar?: ReactNode; showBack?: boolean; onBack?: () => void; + footer?: ReactNode; } -export function Modal({ isOpen, onClose, title, children, searchBar, showBack, onBack }: ModalProps) { +export function Modal({ isOpen, onClose, title, children, searchBar, showBack, onBack, footer }: ModalProps) { const [isExiting, setIsExiting] = useState(false); const isExitingRef = useRef(false); @@ -74,6 +75,7 @@ export function Modal({ isOpen, onClose, title, children, searchBar, showBack, o
{children}
+ {footer}
); diff --git a/src/hooks/useTauri.ts b/src/hooks/useTauri.ts index c8da5e6..b4fc12b 100644 --- a/src/hooks/useTauri.ts +++ b/src/hooks/useTauri.ts @@ -37,6 +37,22 @@ export async function downloadImage(fileUrl: string, fileUrlSha?: string | null) return invoke('download_image', { fileUrl, fileUrlSha }); } +export async function selectSavePath( + suggestedFilename: string, + decompress: boolean = true +): Promise { + return invoke('select_save_path', { suggestedFilename, decompress }); +} + +export async function downloadToPath( + fileUrl: string, + fileUrlSha: string | null, + savePath: string, + decompress: boolean = true +): Promise { + return invoke('download_to_path', { fileUrl, fileUrlSha, savePath, decompress }); +} + export async function getDownloadProgress(): Promise { return invoke('get_download_progress'); } diff --git a/src/locales/en.json b/src/locales/en.json index 8fd30bd..e41bf78 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -55,7 +55,10 @@ "allImages": "All Images", "insertDevice": "Insert an SD card or USB drive", "refreshDevices": "Refresh Devices", - "loading": "Loading..." + "loading": "Loading...", + "insteadSaveToDisk": "Instead save to disk", + "downloadOnly": "Download Only", + "decompressImage": "Decompress image" }, "device": { "system": "System", @@ -95,6 +98,18 @@ "uploadFailed": "Upload failed", "deviceDisconnected": "Device was disconnected" }, + "download": { + "selecting": "Choose save location...", + "downloading": "Downloading image...", + "verifyingSha": "Verifying download integrity...", + "decompressing": "Decompressing image...", + "complete": "Download complete!", + "failed": "Download failed", + "cancelled": "Download cancelled", + "successHint": "Image saved to: {{path}}", + "savingToDisk": "Saving to disk", + "downloadAnother": "Download Another" + }, "custom": { "customImage": "Custom Image" }, diff --git a/src/styles/modal.css b/src/styles/modal.css index f0c1f72..4017eb7 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -296,6 +296,49 @@ gap: 6px; } +/* Modal Footer - Download Only */ +.modal-footer-download { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 16px 20px; + border-top: 1px solid var(--border-light); + background: var(--bg-secondary); +} + +.modal-footer-download-text { + font-size: 13px; + color: var(--text-secondary); +} + +.modal-footer-download .btn { + display: flex; + align-items: center; + gap: 6px; +} + +.modal-footer-checkbox { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; +} + +.modal-footer-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--accent); + cursor: pointer; +} + +.modal-footer-checkbox:hover { + color: var(--text-primary); +} + .modal-filter-bar { display: flex; gap: 8px; From 01519f7b00348ffd3e7e812d9c6cad8df0730bee Mon Sep 17 00:00:00 2001 From: Mecid Date: Mon, 12 Jan 2026 20:17:05 +0100 Subject: [PATCH 2/3] Bugfix: trunk image hash verification with github api --- src-tauri/src/download.rs | 74 +++++++++++++++++---- src-tauri/src/images/mod.rs | 127 +++++++++++++++++++++++++++++++++++- 2 files changed, 186 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/download.rs b/src-tauri/src/download.rs index f708c64..5eea246 100644 --- a/src-tauri/src/download.rs +++ b/src-tauri/src/download.rs @@ -71,8 +71,13 @@ fn extract_filename(url: &str) -> Result<&str, String> { Ok(filename) } -/// Fetch expected SHA256 from URL -async fn fetch_expected_sha(client: &Client, sha_url: &str) -> Result { +/// Check if URL is from GitHub (uses digest from releases API) +fn is_github_url(url: &str) -> bool { + url.contains("github.com") +} + +/// Fetch expected SHA256 from a .sha URL (for dl.armbian.com) +async fn fetch_sha_from_url(client: &Client, sha_url: &str) -> Result { log_debug!(MODULE, "Fetching SHA256 from: {}", sha_url); let response = client @@ -109,6 +114,22 @@ async fn fetch_expected_sha(client: &Client, sha_url: &str) -> Result Result { + log_debug!(MODULE, "Looking up SHA256 digest for: {}", filename); + + // Use the GitHub releases API to get the digest + match crate::images::get_digest_for_file(filename).await { + Some(hash) => { + log_debug!(MODULE, "Found SHA256 digest: {}", hash); + Ok(hash) + } + None => { + Err(format!("No SHA256 digest found for file: {}", filename)) + } + } +} + /// Calculate SHA256 of a file fn calculate_file_sha256(path: &Path, state: &Arc) -> Result { log_debug!(MODULE, "Calculating SHA256 of: {}", path.display()); @@ -156,10 +177,14 @@ fn calculate_file_sha256(path: &Path, state: &Arc) -> Result, state: &Arc, ) -> Result<(), String> { // Check cancellation before fetching @@ -167,7 +192,16 @@ async fn verify_sha256( return Err("SHA256 verification cancelled".to_string()); } - let expected = fetch_expected_sha(client, sha_url).await?; + // Get expected SHA based on source + let expected = if is_github_url(url) { + // GitHub: use releases API digest + fetch_sha_from_github(filename).await? + } else if let Some(sha_url) = sha_url { + // Other sources: download .sha file + fetch_sha_from_url(client, sha_url).await? + } else { + return Err("No SHA verification method available".to_string()); + }; // Check cancellation after fetching if state.is_cancelled.load(Ordering::SeqCst) { @@ -194,7 +228,8 @@ async fn verify_sha256( } /// Download and decompress an Armbian image -/// If sha_url is provided, verifies the downloaded compressed file before decompression +/// For GitHub URLs: verifies using digest from releases API +/// For other URLs: verifies using provided sha_url pub async fn download_image( url: &str, sha_url: Option<&str>, @@ -290,11 +325,16 @@ pub async fn download_image( drop(temp_file); tracker.finish(); - // Verify SHA256 if URL provided - if let Some(sha_url) = sha_url { + // Verify SHA256 based on download source + let can_verify = is_github_url(url) || sha_url.is_some(); + if can_verify { state.is_verifying_sha.store(true, Ordering::SeqCst); - log_info!(MODULE, "Verifying SHA256..."); - match verify_sha256(&client, &temp_path, sha_url, &state).await { + if is_github_url(url) { + log_info!(MODULE, "Verifying SHA256 (from GitHub releases)..."); + } else { + log_info!(MODULE, "Verifying SHA256 (from .sha file)..."); + } + match verify_sha256(&client, &temp_path, filename, url, sha_url, &state).await { Ok(()) => { log_info!(MODULE, "SHA256 verification successful"); } @@ -340,7 +380,8 @@ pub async fn download_image( } /// Download an Armbian image without decompression -/// If sha_url is provided, verifies the downloaded file +/// For GitHub URLs: verifies using digest from releases API +/// For other URLs: verifies using provided sha_url /// Returns the path to the compressed file (keeps .xz extension) pub async fn download_image_raw( url: &str, @@ -434,11 +475,16 @@ pub async fn download_image_raw( drop(temp_file); tracker.finish(); - // Verify SHA256 if URL provided - if let Some(sha_url) = sha_url { + // Verify SHA256 based on download source + let can_verify = is_github_url(url) || sha_url.is_some(); + if can_verify { state.is_verifying_sha.store(true, Ordering::SeqCst); - log_info!(MODULE, "Verifying SHA256..."); - match verify_sha256(&client, &temp_path, sha_url, &state).await { + if is_github_url(url) { + log_info!(MODULE, "Verifying SHA256 (from GitHub releases)..."); + } else { + log_info!(MODULE, "Verifying SHA256 (from .sha file)..."); + } + match verify_sha256(&client, &temp_path, filename, url, sha_url, &state).await { Ok(()) => { log_info!(MODULE, "SHA256 verification successful"); } diff --git a/src-tauri/src/images/mod.rs b/src-tauri/src/images/mod.rs index db3e061..edd1ce2 100644 --- a/src-tauri/src/images/mod.rs +++ b/src-tauri/src/images/mod.rs @@ -11,7 +11,15 @@ pub use models::{BoardInfo, ImageInfo}; // ArmbianImage is used internally by filters module use crate::config; -use crate::{log_error, log_info}; +use crate::{log_debug, log_error, log_info, log_warn}; +use std::collections::HashMap; +use std::sync::RwLock; + +/// GitHub releases API URL for Armbian OS releases +const GITHUB_RELEASES_API: &str = "https://api.github.com/repos/armbian/os/releases/latest"; + +/// Cached SHA256 digests from GitHub releases (filename -> sha256 hash) +static DIGEST_CACHE: RwLock>> = RwLock::new(None); /// Fetch the all-images.json from Armbian pub async fn fetch_all_images() -> Result { @@ -34,3 +42,120 @@ pub async fn fetch_all_images() -> Result { log_info!("images", "Successfully fetched images data"); Ok(json) } + +/// Fetch SHA256 digests from GitHub releases API +/// Returns a map of filename -> sha256 hash +pub async fn fetch_github_digests() -> Result, String> { + // Check cache first + { + let cache = DIGEST_CACHE.read().map_err(|e| format!("Cache lock error: {}", e))?; + if let Some(ref digests) = *cache { + log_debug!("images", "Using cached GitHub digests ({} entries)", digests.len()); + return Ok(digests.clone()); + } + } + + log_info!("images", "Fetching GitHub release digests from {}", GITHUB_RELEASES_API); + + let client = reqwest::Client::builder() + .user_agent(config::app::USER_AGENT) + .build() + .map_err(|e| format!("Failed to create HTTP client: {}", e))?; + + let response = client + .get(GITHUB_RELEASES_API) + .send() + .await + .map_err(|e| { + log_error!("images", "Failed to fetch GitHub releases: {}", e); + format!("Failed to fetch GitHub releases: {}", e) + })?; + + if !response.status().is_success() { + return Err(format!( + "GitHub API request failed with status: {}", + response.status() + )); + } + + let release: serde_json::Value = response.json().await.map_err(|e| { + log_error!("images", "Failed to parse GitHub releases JSON: {}", e); + format!("Failed to parse GitHub releases: {}", e) + })?; + + let mut digests = HashMap::new(); + + // Parse assets array + if let Some(assets) = release.get("assets").and_then(|a| a.as_array()) { + for asset in assets { + // Get the filename from "name" field + let name = match asset.get("name").and_then(|n| n.as_str()) { + Some(n) => n, + None => continue, + }; + + // Get the digest from "digest" field (format: "sha256:...") + if let Some(digest) = asset.get("digest").and_then(|d| d.as_str()) { + // Extract just the hash part (remove "sha256:" prefix) + let hash = if let Some(stripped) = digest.strip_prefix("sha256:") { + stripped.to_lowercase() + } else { + // If no prefix, use the whole string + digest.to_lowercase() + }; + + // Validate it looks like a SHA256 hash (64 hex chars) + if hash.len() == 64 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + digests.insert(name.to_string(), hash); + } else { + log_warn!("images", "Invalid digest format for {}: {}", name, digest); + } + } + } + } + + log_info!("images", "Loaded {} digests from GitHub releases", digests.len()); + + // Cache the result + { + let mut cache = DIGEST_CACHE.write().map_err(|e| format!("Cache lock error: {}", e))?; + *cache = Some(digests.clone()); + } + + Ok(digests) +} + +/// Clear the GitHub digests cache (useful for refresh) +#[allow(dead_code)] +pub fn clear_digest_cache() { + if let Ok(mut cache) = DIGEST_CACHE.write() { + *cache = None; + log_debug!("images", "Cleared GitHub digests cache"); + } +} + +/// Look up SHA256 digest for a filename +/// Fetches from GitHub API if not cached +pub async fn get_digest_for_file(filename: &str) -> Option { + match fetch_github_digests().await { + Ok(digests) => { + // Try exact match first + if let Some(hash) = digests.get(filename) { + return Some(hash.clone()); + } + + // Try without path (just the filename) + let base_filename = filename.rsplit('/').next().unwrap_or(filename); + if let Some(hash) = digests.get(base_filename) { + return Some(hash.clone()); + } + + log_debug!("images", "No digest found for filename: {}", filename); + None + } + Err(e) => { + log_warn!("images", "Failed to fetch digests: {}", e); + None + } + } +} From 62ca34f7296c392e451d7a98c19b74604b0b0d02 Mon Sep 17 00:00:00 2001 From: Mecid Date: Mon, 12 Jan 2026 20:20:58 +0100 Subject: [PATCH 3/3] Locales: For download only feature --- src/locales/de.json | 17 ++++++++++++++++- src/locales/es.json | 17 ++++++++++++++++- src/locales/fr.json | 17 ++++++++++++++++- src/locales/hr.json | 17 ++++++++++++++++- src/locales/it.json | 17 ++++++++++++++++- src/locales/ja.json | 17 ++++++++++++++++- src/locales/ko.json | 17 ++++++++++++++++- src/locales/nl.json | 17 ++++++++++++++++- src/locales/pl.json | 17 ++++++++++++++++- src/locales/pt-BR.json | 17 ++++++++++++++++- src/locales/pt.json | 17 ++++++++++++++++- src/locales/ru.json | 17 ++++++++++++++++- src/locales/sl.json | 17 ++++++++++++++++- src/locales/sv.json | 17 ++++++++++++++++- src/locales/tr.json | 17 ++++++++++++++++- src/locales/uk.json | 17 ++++++++++++++++- src/locales/zh.json | 17 ++++++++++++++++- 17 files changed, 272 insertions(+), 17 deletions(-) diff --git a/src/locales/de.json b/src/locales/de.json index cb8cc24..a0902b5 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -55,7 +55,10 @@ "allImages": "Alle Images", "insertDevice": "SD-Karte oder USB-Stick einstecken", "refreshDevices": "Geräte aktualisieren", - "loading": "Wird geladen..." + "loading": "Wird geladen...", + "insteadSaveToDisk": "Stattdessen auf Festplatte speichern", + "downloadOnly": "Nur herunterladen", + "decompressImage": "Image dekomprimieren" }, "device": { "system": "System", @@ -95,6 +98,18 @@ "uploadFailed": "Hochladen fehlgeschlagen", "deviceDisconnected": "Gerät wurde getrennt" }, + "download": { + "selecting": "Speicherort auswählen...", + "downloading": "Image wird heruntergeladen...", + "verifyingSha": "Download-Integrität wird überprüft...", + "decompressing": "Image wird dekomprimiert...", + "complete": "Download abgeschlossen!", + "failed": "Download fehlgeschlagen", + "cancelled": "Download abgebrochen", + "successHint": "Image gespeichert unter: {{path}}", + "savingToDisk": "Auf Festplatte speichern", + "downloadAnother": "Weiteres herunterladen" + }, "custom": { "customImage": "Benutzerdefiniertes Image" }, diff --git a/src/locales/es.json b/src/locales/es.json index 599dbd3..266c791 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -55,7 +55,10 @@ "allImages": "Todas las imágenes", "insertDevice": "Inserte una tarjeta SD o unidad USB", "refreshDevices": "Actualizar dispositivos", - "loading": "Cargando..." + "loading": "Cargando...", + "insteadSaveToDisk": "Guardar en disco en su lugar", + "downloadOnly": "Solo descargar", + "decompressImage": "Descomprimir imagen" }, "device": { "system": "Sistema", @@ -95,6 +98,18 @@ "uploadFailed": "Error al subir", "deviceDisconnected": "El dispositivo fue desconectado" }, + "download": { + "selecting": "Elegir ubicación de guardado...", + "downloading": "Descargando imagen...", + "verifyingSha": "Verificando integridad de la descarga...", + "decompressing": "Descomprimiendo imagen...", + "complete": "¡Descarga completada!", + "failed": "Descarga fallida", + "cancelled": "Descarga cancelada", + "successHint": "Imagen guardada en: {{path}}", + "savingToDisk": "Guardando en disco", + "downloadAnother": "Descargar otra" + }, "custom": { "customImage": "Imagen personalizada" }, diff --git a/src/locales/fr.json b/src/locales/fr.json index a63e788..fc824dd 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -55,7 +55,10 @@ "allImages": "Toutes les images", "insertDevice": "Insérez une carte SD ou une clé USB", "refreshDevices": "Actualiser les périphériques", - "loading": "Chargement..." + "loading": "Chargement...", + "insteadSaveToDisk": "Enregistrer sur le disque à la place", + "downloadOnly": "Télécharger uniquement", + "decompressImage": "Décompresser l'image" }, "device": { "system": "Système", @@ -95,6 +98,18 @@ "uploadFailed": "Échec du téléversement", "deviceDisconnected": "L'appareil a été déconnecté" }, + "download": { + "selecting": "Choisir l'emplacement de sauvegarde...", + "downloading": "Téléchargement de l'image...", + "verifyingSha": "Vérification de l'intégrité du téléchargement...", + "decompressing": "Décompression de l'image...", + "complete": "Téléchargement terminé !", + "failed": "Échec du téléchargement", + "cancelled": "Téléchargement annulé", + "successHint": "Image enregistrée dans : {{path}}", + "savingToDisk": "Enregistrement sur le disque", + "downloadAnother": "Télécharger une autre" + }, "custom": { "customImage": "Image personnalisée" }, diff --git a/src/locales/hr.json b/src/locales/hr.json index 572cc2e..de91bf6 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -55,7 +55,10 @@ "allImages": "Sve slike", "insertDevice": "Umetnite SD karticu ili USB uređaj", "refreshDevices": "Osvježi uređaje", - "loading": "Učitavanje..." + "loading": "Učitavanje...", + "insteadSaveToDisk": "Umjesto toga spremi na disk", + "downloadOnly": "Samo preuzmi", + "decompressImage": "Raspakuj sliku" }, "device": { "system": "Sustav", @@ -95,6 +98,18 @@ "uploadFailed": "Slanje neuspješno", "deviceDisconnected": "Uređaj je isključen" }, + "download": { + "selecting": "Odaberi mjesto spremanja...", + "downloading": "Preuzimanje slike...", + "verifyingSha": "Provjera integriteta preuzimanja...", + "decompressing": "Raspakiravanje slike...", + "complete": "Preuzimanje završeno!", + "failed": "Preuzimanje neuspješno", + "cancelled": "Preuzimanje otkazano", + "successHint": "Slika spremljena u: {{path}}", + "savingToDisk": "Spremanje na disk", + "downloadAnother": "Preuzmi drugu" + }, "custom": { "customImage": "Prilagođena slika" }, diff --git a/src/locales/it.json b/src/locales/it.json index f080a3c..e216a71 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -55,7 +55,10 @@ "allImages": "Tutte le Immagini", "insertDevice": "Inserisci una scheda SD o una chiavetta USB", "refreshDevices": "Aggiorna Dispositivi", - "loading": "Caricamento..." + "loading": "Caricamento...", + "insteadSaveToDisk": "Salva su disco invece", + "downloadOnly": "Solo download", + "decompressImage": "Decomprimi immagine" }, "device": { "system": "Sistema", @@ -95,6 +98,18 @@ "uploadFailed": "Caricamento fallito", "deviceDisconnected": "Dispositivo disconnesso" }, + "download": { + "selecting": "Scegli posizione di salvataggio...", + "downloading": "Download immagine in corso...", + "verifyingSha": "Verifica integrità download...", + "decompressing": "Decompressione immagine...", + "complete": "Download completato!", + "failed": "Download fallito", + "cancelled": "Download annullato", + "successHint": "Immagine salvata in: {{path}}", + "savingToDisk": "Salvataggio su disco", + "downloadAnother": "Scarica un'altra" + }, "custom": { "customImage": "Immagine Personalizzata" }, diff --git a/src/locales/ja.json b/src/locales/ja.json index 6ab4713..cffd947 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -55,7 +55,10 @@ "allImages": "すべてのイメージ", "insertDevice": "SDカードまたはUSBドライブを挿入してください", "refreshDevices": "デバイスを更新", - "loading": "読み込み中..." + "loading": "読み込み中...", + "insteadSaveToDisk": "代わりにディスクに保存", + "downloadOnly": "ダウンロードのみ", + "decompressImage": "イメージを解凍" }, "device": { "system": "システム", @@ -95,6 +98,18 @@ "uploadFailed": "アップロード失敗", "deviceDisconnected": "デバイスが切断されました" }, + "download": { + "selecting": "保存場所を選択...", + "downloading": "イメージをダウンロード中...", + "verifyingSha": "ダウンロードの整合性を検証中...", + "decompressing": "イメージを解凍中...", + "complete": "ダウンロード完了!", + "failed": "ダウンロード失敗", + "cancelled": "ダウンロードがキャンセルされました", + "successHint": "イメージの保存先: {{path}}", + "savingToDisk": "ディスクに保存中", + "downloadAnother": "別のイメージをダウンロード" + }, "custom": { "customImage": "カスタムイメージ" }, diff --git a/src/locales/ko.json b/src/locales/ko.json index a5dc66d..5472968 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -55,7 +55,10 @@ "allImages": "모든 이미지", "insertDevice": "SD 카드 또는 USB 드라이브를 삽입하세요", "refreshDevices": "장치 새로고침", - "loading": "로드 중..." + "loading": "로드 중...", + "insteadSaveToDisk": "대신 디스크에 저장", + "downloadOnly": "다운로드만", + "decompressImage": "이미지 압축 해제" }, "device": { "system": "시스템", @@ -95,6 +98,18 @@ "uploadFailed": "업로드 실패", "deviceDisconnected": "장치 연결이 해제되었습니다" }, + "download": { + "selecting": "저장 위치 선택...", + "downloading": "이미지 다운로드 중...", + "verifyingSha": "다운로드 무결성 확인 중...", + "decompressing": "이미지 압축 해제 중...", + "complete": "다운로드 완료!", + "failed": "다운로드 실패", + "cancelled": "다운로드 취소됨", + "successHint": "이미지 저장 위치: {{path}}", + "savingToDisk": "디스크에 저장 중", + "downloadAnother": "다른 이미지 다운로드" + }, "custom": { "customImage": "사용자 정의 이미지" }, diff --git a/src/locales/nl.json b/src/locales/nl.json index 2996b33..824361a 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -55,7 +55,10 @@ "allImages": "Alle images", "insertDevice": "Plaats een SD-kaart of USB-stick", "refreshDevices": "Apparaten vernieuwen", - "loading": "Laden..." + "loading": "Laden...", + "insteadSaveToDisk": "In plaats daarvan opslaan op schijf", + "downloadOnly": "Alleen downloaden", + "decompressImage": "Image uitpakken" }, "device": { "system": "Systeem", @@ -95,6 +98,18 @@ "uploadFailed": "Upload mislukt", "deviceDisconnected": "Apparaat is losgekoppeld" }, + "download": { + "selecting": "Kies opslaglocatie...", + "downloading": "Image downloaden...", + "verifyingSha": "Download-integriteit controleren...", + "decompressing": "Image uitpakken...", + "complete": "Download voltooid!", + "failed": "Download mislukt", + "cancelled": "Download geannuleerd", + "successHint": "Image opgeslagen in: {{path}}", + "savingToDisk": "Opslaan op schijf", + "downloadAnother": "Nog een downloaden" + }, "custom": { "customImage": "Aangepaste image" }, diff --git a/src/locales/pl.json b/src/locales/pl.json index 89ef8b5..ad92571 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -55,7 +55,10 @@ "allImages": "Wszystkie obrazy", "insertDevice": "Włóż kartę SD lub pendrive USB", "refreshDevices": "Odśwież urządzenia", - "loading": "Ładowanie..." + "loading": "Ładowanie...", + "insteadSaveToDisk": "Zamiast tego zapisz na dysku", + "downloadOnly": "Tylko pobierz", + "decompressImage": "Rozpakuj obraz" }, "device": { "system": "System", @@ -95,6 +98,18 @@ "uploadFailed": "Przesyłanie nie powiodło się", "deviceDisconnected": "Urządzenie zostało odłączone" }, + "download": { + "selecting": "Wybierz lokalizację zapisu...", + "downloading": "Pobieranie obrazu...", + "verifyingSha": "Weryfikacja integralności pobierania...", + "decompressing": "Rozpakowywanie obrazu...", + "complete": "Pobieranie zakończone!", + "failed": "Pobieranie nie powiodło się", + "cancelled": "Pobieranie anulowane", + "successHint": "Obraz zapisany w: {{path}}", + "savingToDisk": "Zapisywanie na dysku", + "downloadAnother": "Pobierz kolejny" + }, "custom": { "customImage": "Własny obraz" }, diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index 054135a..703602f 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -55,7 +55,10 @@ "allImages": "Todas as imagens", "insertDevice": "Insira um cartão SD ou pendrive USB", "refreshDevices": "Atualizar dispositivos", - "loading": "Carregando..." + "loading": "Carregando...", + "insteadSaveToDisk": "Em vez disso, salvar no disco", + "downloadOnly": "Apenas baixar", + "decompressImage": "Descompactar imagem" }, "device": { "system": "Sistema", @@ -95,6 +98,18 @@ "uploadFailed": "Falha no envio", "deviceDisconnected": "Dispositivo foi desconectado" }, + "download": { + "selecting": "Escolher local para salvar...", + "downloading": "Baixando imagem...", + "verifyingSha": "Verificando integridade do download...", + "decompressing": "Descompactando imagem...", + "complete": "Download concluído!", + "failed": "Download falhou", + "cancelled": "Download cancelado", + "successHint": "Imagem salva em: {{path}}", + "savingToDisk": "Salvando no disco", + "downloadAnother": "Baixar outra" + }, "custom": { "customImage": "Imagem personalizada" }, diff --git a/src/locales/pt.json b/src/locales/pt.json index 2f59d29..5acbb3b 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -55,7 +55,10 @@ "allImages": "Todas as imagens", "insertDevice": "Insira um cartão SD ou pendrive USB", "refreshDevices": "Atualizar dispositivos", - "loading": "A carregar..." + "loading": "A carregar...", + "insteadSaveToDisk": "Em vez disso, guardar no disco", + "downloadOnly": "Apenas transferir", + "decompressImage": "Descompactar imagem" }, "device": { "system": "Sistema", @@ -95,6 +98,18 @@ "uploadFailed": "Falha no envio", "deviceDisconnected": "O dispositivo foi desligado" }, + "download": { + "selecting": "Escolher localização de gravação...", + "downloading": "A transferir imagem...", + "verifyingSha": "A verificar integridade da transferência...", + "decompressing": "A descompactar imagem...", + "complete": "Transferência concluída!", + "failed": "Transferência falhou", + "cancelled": "Transferência cancelada", + "successHint": "Imagem guardada em: {{path}}", + "savingToDisk": "A guardar no disco", + "downloadAnother": "Transferir outra" + }, "custom": { "customImage": "Imagem personalizada" }, diff --git a/src/locales/ru.json b/src/locales/ru.json index d4b126e..c7d0b1d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -55,7 +55,10 @@ "allImages": "Все образы", "insertDevice": "Вставьте SD-карту или USB-накопитель", "refreshDevices": "Обновить устройства", - "loading": "Загрузка..." + "loading": "Загрузка...", + "insteadSaveToDisk": "Сохранить на диск вместо этого", + "downloadOnly": "Только скачать", + "decompressImage": "Распаковать образ" }, "device": { "system": "Системный", @@ -95,6 +98,18 @@ "uploadFailed": "Ошибка загрузки", "deviceDisconnected": "Устройство было отключено" }, + "download": { + "selecting": "Выберите место сохранения...", + "downloading": "Загрузка образа...", + "verifyingSha": "Проверка целостности загрузки...", + "decompressing": "Распаковка образа...", + "complete": "Загрузка завершена!", + "failed": "Ошибка загрузки", + "cancelled": "Загрузка отменена", + "successHint": "Образ сохранён в: {{path}}", + "savingToDisk": "Сохранение на диск", + "downloadAnother": "Скачать другой" + }, "custom": { "customImage": "Свой образ" }, diff --git a/src/locales/sl.json b/src/locales/sl.json index c3ef119..2532c06 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -55,7 +55,10 @@ "allImages": "Vse slike", "insertDevice": "Vstavite SD kartico ali USB pogon", "refreshDevices": "Osveži naprave", - "loading": "Nalaganje..." + "loading": "Nalaganje...", + "insteadSaveToDisk": "Namesto tega shrani na disk", + "downloadOnly": "Samo prenesi", + "decompressImage": "Razširi sliko" }, "device": { "system": "Sistemska", @@ -95,6 +98,18 @@ "uploadFailed": "Nalaganje ni uspelo", "deviceDisconnected": "Naprava je bila odklopljena" }, + "download": { + "selecting": "Izberi lokacijo shranjevanja...", + "downloading": "Prenašanje slike...", + "verifyingSha": "Preverjanje celovitosti prenosa...", + "decompressing": "Razširjanje slike...", + "complete": "Prenos končan!", + "failed": "Prenos ni uspel", + "cancelled": "Prenos preklican", + "successHint": "Slika shranjena v: {{path}}", + "savingToDisk": "Shranjevanje na disk", + "downloadAnother": "Prenesi drugo" + }, "custom": { "customImage": "Slika po meri" }, diff --git a/src/locales/sv.json b/src/locales/sv.json index 2abc925..cd32ed2 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -55,7 +55,10 @@ "minimal": "Minimal", "allImages": "Alla images", "insertDevice": "Sätt i ett SD-kort eller USB-minne", - "refreshDevices": "Uppdatera enheter" + "refreshDevices": "Uppdatera enheter", + "insteadSaveToDisk": "Spara till disk istället", + "downloadOnly": "Endast nedladdning", + "decompressImage": "Packa upp image" }, "device": { "system": "System", @@ -95,6 +98,18 @@ "uploadFailed": "Uppladdning misslyckades", "deviceDisconnected": "Enheten kopplades bort" }, + "download": { + "selecting": "Välj sparplats...", + "downloading": "Laddar ner image...", + "verifyingSha": "Verifierar nedladdningens integritet...", + "decompressing": "Packar upp image...", + "complete": "Nedladdning klar!", + "failed": "Nedladdning misslyckades", + "cancelled": "Nedladdning avbruten", + "successHint": "Image sparad till: {{path}}", + "savingToDisk": "Sparar till disk", + "downloadAnother": "Ladda ner en till" + }, "custom": { "customImage": "Egen image" }, diff --git a/src/locales/tr.json b/src/locales/tr.json index 3bf842d..4e1b76a 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -55,7 +55,10 @@ "allImages": "Tüm İmajlar", "insertDevice": "Bir SD kart veya USB sürücü takın", "refreshDevices": "Cihazları Yenile", - "loading": "Yükleniyor..." + "loading": "Yükleniyor...", + "insteadSaveToDisk": "Bunun yerine diske kaydet", + "downloadOnly": "Sadece indir", + "decompressImage": "İmajı aç" }, "device": { "system": "Sistem", @@ -95,6 +98,18 @@ "uploadFailed": "Yükleme başarısız", "deviceDisconnected": "Cihaz bağlantısı kesildi" }, + "download": { + "selecting": "Kayıt konumu seç...", + "downloading": "İmaj indiriliyor...", + "verifyingSha": "İndirme bütünlüğü doğrulanıyor...", + "decompressing": "İmaj açılıyor...", + "complete": "İndirme tamamlandı!", + "failed": "İndirme başarısız", + "cancelled": "İndirme iptal edildi", + "successHint": "İmaj kaydedildi: {{path}}", + "savingToDisk": "Diske kaydediliyor", + "downloadAnother": "Başka bir tane indir" + }, "custom": { "customImage": "Özel İmaj" }, diff --git a/src/locales/uk.json b/src/locales/uk.json index fe70811..cd804ee 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -55,7 +55,10 @@ "allImages": "Усі образи", "insertDevice": "Вставте SD-картку або USB-накопичувач", "refreshDevices": "Оновити пристрої", - "loading": "Завантаження..." + "loading": "Завантаження...", + "insteadSaveToDisk": "Зберегти на диск замість цього", + "downloadOnly": "Лише завантажити", + "decompressImage": "Розпакувати образ" }, "device": { "system": "Системний", @@ -95,6 +98,18 @@ "uploadFailed": "Помилка завантаження", "deviceDisconnected": "Пристрій було від'єднано" }, + "download": { + "selecting": "Виберіть місце збереження...", + "downloading": "Завантаження образу...", + "verifyingSha": "Перевірка цілісності завантаження...", + "decompressing": "Розпакування образу...", + "complete": "Завантаження завершено!", + "failed": "Помилка завантаження", + "cancelled": "Завантаження скасовано", + "successHint": "Образ збережено в: {{path}}", + "savingToDisk": "Збереження на диск", + "downloadAnother": "Завантажити інший" + }, "custom": { "customImage": "Власний образ" }, diff --git a/src/locales/zh.json b/src/locales/zh.json index f228843..0a2ed12 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -55,7 +55,10 @@ "allImages": "所有镜像", "insertDevice": "请插入SD卡或U盘", "refreshDevices": "刷新设备", - "loading": "加载中..." + "loading": "加载中...", + "insteadSaveToDisk": "改为保存到磁盘", + "downloadOnly": "仅下载", + "decompressImage": "解压镜像" }, "device": { "system": "系统", @@ -95,6 +98,18 @@ "uploadFailed": "上传失败", "deviceDisconnected": "设备已断开连接" }, + "download": { + "selecting": "选择保存位置...", + "downloading": "正在下载镜像...", + "verifyingSha": "正在验证下载完整性...", + "decompressing": "正在解压镜像...", + "complete": "下载完成!", + "failed": "下载失败", + "cancelled": "下载已取消", + "successHint": "镜像已保存到:{{path}}", + "savingToDisk": "正在保存到磁盘", + "downloadAnother": "下载另一个" + }, "custom": { "customImage": "自定义镜像" },