From 58cda3f3b367d6dc10fb32e87af6d0e9452da894 Mon Sep 17 00:00:00 2001 From: Viren Mohindra Date: Sat, 21 Mar 2026 02:19:26 -0400 Subject: [PATCH] fix: sha256 verification to prevent corrupt partial download loop (#1095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add SHA256 verification to prevent corrupt partial download loop Fixes issue #858 — corrupt .partial files caused an infinite loop where a failed/cancelled download would resume from EOF, pass extraction, and loop forever with no way to recover without manual file deletion. Three-part fix: - Add sha2 crate and compute_sha256() helper; verify hash post-download before extraction/rename for all 15 built-in models. Hash mismatch deletes the .partial so the next attempt starts fresh. - Delete .partial on extraction failure (independent of checksums) so corrupt archives can't re-enter the loop via the same path. - Emit model-download-failed event from the command handler so all callers (ModelsSettings, Onboarding) get consistent error feedback via a central store listener rather than per-caller return-value checks. SHA256 hashes hardcoded alongside each model's URL in ModelInfo. Custom models (sha256: None) skip verification silently. * chore: remove doc comment from sha256 field Doc comments on exported specta types propagate into bindings.ts as inline JSDoc — not useful in generated TS. Removing at the source. * test: add SHA256 verification unit tests Extracts inline verify logic into ModelManager::verify_sha256() and covers all four branches: - None expected hash → skipped (custom models) - Matching hash → ok, file kept - Mismatched hash → error returned, partial file deleted - Unreadable file → error returned Ensures corrupt partial downloads are always cleaned up so the next attempt starts fresh. * chore: cargo fmt * fix: clear cancel flag on sha256 failure, spawn_blocking for hash, fallback cleanup - remove cancel_flags entry on sha256 failure to prevent a stale flag from immediately cancelling the next retry attempt (HIGH) - add fallback state cleanup in modelStore when result.status != ok, so spinner clears even if model-download-failed event is missed (HIGH) - run compute_sha256 in spawn_blocking to avoid stalling the async executor while hashing large model files (up to 1.6 GB) (MEDIUM) * emit some more events for the ui to pick up * add translations + format --------- Co-authored-by: CJ Pais --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/commands/models.rs | 14 +- src-tauri/src/managers/model.rs | 281 +++++++++++++++--- src/bindings.ts | 2 +- .../model-selector/ModelSelector.tsx | 17 ++ .../model-selector/ModelStatusButton.tsx | 3 + src/components/onboarding/ModelCard.tsx | 11 + src/components/onboarding/Onboarding.tsx | 16 +- .../settings/models/ModelsSettings.tsx | 4 + src/i18n/locales/ar/translation.json | 2 + src/i18n/locales/cs/translation.json | 2 + src/i18n/locales/de/translation.json | 2 + src/i18n/locales/en/translation.json | 2 + src/i18n/locales/es/translation.json | 2 + src/i18n/locales/fr/translation.json | 2 + src/i18n/locales/it/translation.json | 2 + src/i18n/locales/ja/translation.json | 2 + src/i18n/locales/ko/translation.json | 2 + src/i18n/locales/pl/translation.json | 2 + src/i18n/locales/pt/translation.json | 2 + src/i18n/locales/ru/translation.json | 2 + src/i18n/locales/tr/translation.json | 2 + src/i18n/locales/uk/translation.json | 2 + src/i18n/locales/vi/translation.json | 2 + src/i18n/locales/zh-TW/translation.json | 2 + src/i18n/locales/zh/translation.json | 2 + src/stores/modelStore.ts | 64 +++- 28 files changed, 386 insertions(+), 62 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1274c57..8603374 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2441,6 +2441,7 @@ dependencies = [ "rustfft", "serde", "serde_json", + "sha2", "signal-hook", "specta", "specta-typescript", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7577b77..fe0964e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -68,6 +68,7 @@ chrono = "0.4" rusqlite = { version = "0.37", features = ["bundled"] } tar = "0.4.44" flate2 = "1.0" +sha2 = "0.10" transcribe-rs = { version = "0.3.2", features = ["whisper-cpp", "onnx"] } handy-keys = "0.2.4" ferrous-opencc = "0.2.3" diff --git a/src-tauri/src/commands/models.rs b/src-tauri/src/commands/models.rs index fca4f0a..86d89a8 100644 --- a/src-tauri/src/commands/models.rs +++ b/src-tauri/src/commands/models.rs @@ -24,13 +24,23 @@ pub async fn get_model_info( #[tauri::command] #[specta::specta] pub async fn download_model( + app_handle: AppHandle, model_manager: State<'_, Arc>, model_id: String, ) -> Result<(), String> { - model_manager + let result = model_manager .download_model(&model_id) .await - .map_err(|e| e.to_string()) + .map_err(|e| e.to_string()); + + if let Err(ref error) = result { + let _ = app_handle.emit( + "model-download-failed", + serde_json::json!({ "model_id": &model_id, "error": error }), + ); + } + + result } #[tauri::command] diff --git a/src-tauri/src/managers/model.rs b/src-tauri/src/managers/model.rs index 93d78a0..bce85d3 100644 --- a/src-tauri/src/managers/model.rs +++ b/src-tauri/src/managers/model.rs @@ -4,11 +4,12 @@ use flate2::read::GzDecoder; use futures_util::StreamExt; use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use specta::Type; use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; -use std::io::Write; +use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; @@ -34,6 +35,7 @@ pub struct ModelInfo { pub description: String, pub filename: String, pub url: Option, + pub sha256: Option, pub size_mb: u64, pub is_downloaded: bool, pub is_downloading: bool, @@ -57,6 +59,31 @@ pub struct DownloadProgress { pub percentage: f64, } +/// RAII guard that cleans up download state (`is_downloading` flag and cancel flag) +/// when dropped, unless explicitly disarmed. This ensures consistent cleanup on +/// every error path without requiring manual cleanup at each `?` or `return Err`. +struct DownloadCleanup<'a> { + available_models: &'a Mutex>, + cancel_flags: &'a Arc>>>, + model_id: String, + disarmed: bool, +} + +impl<'a> Drop for DownloadCleanup<'a> { + fn drop(&mut self) { + if self.disarmed { + return; + } + { + let mut models = self.available_models.lock().unwrap(); + if let Some(model) = models.get_mut(self.model_id.as_str()) { + model.is_downloading = false; + } + } + self.cancel_flags.lock().unwrap().remove(&self.model_id); + } +} + pub struct ModelManager { app_handle: AppHandle, models_dir: PathBuf, @@ -103,6 +130,9 @@ impl ModelManager { description: "Fast and fairly accurate.".to_string(), filename: "ggml-small.bin".to_string(), url: Some("https://blob.handy.computer/ggml-small.bin".to_string()), + sha256: Some( + "1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b".to_string(), + ), size_mb: 487, is_downloaded: false, is_downloading: false, @@ -128,6 +158,9 @@ impl ModelManager { description: "Good accuracy, medium speed".to_string(), filename: "whisper-medium-q4_1.bin".to_string(), url: Some("https://blob.handy.computer/whisper-medium-q4_1.bin".to_string()), + sha256: Some( + "79283fc1f9fe12ca3248543fbd54b73292164d8df5a16e095e2bceeaaabddf57".to_string(), + ), size_mb: 492, // Approximate size is_downloaded: false, is_downloading: false, @@ -152,6 +185,9 @@ impl ModelManager { description: "Balanced accuracy and speed.".to_string(), filename: "ggml-large-v3-turbo.bin".to_string(), url: Some("https://blob.handy.computer/ggml-large-v3-turbo.bin".to_string()), + sha256: Some( + "1fc70f774d38eb169993ac391eea357ef47c88757ef72ee5943879b7e8e2bc69".to_string(), + ), size_mb: 1600, // Approximate size is_downloaded: false, is_downloading: false, @@ -176,6 +212,9 @@ impl ModelManager { description: "Good accuracy, but slow.".to_string(), filename: "ggml-large-v3-q5_0.bin".to_string(), url: Some("https://blob.handy.computer/ggml-large-v3-q5_0.bin".to_string()), + sha256: Some( + "d75795ecff3f83b5faa89d1900604ad8c780abd5739fae406de19f23ecd98ad1".to_string(), + ), size_mb: 1100, // Approximate size is_downloaded: false, is_downloading: false, @@ -201,6 +240,9 @@ impl ModelManager { .to_string(), filename: "breeze-asr-q5_k.bin".to_string(), url: Some("https://blob.handy.computer/breeze-asr-q5_k.bin".to_string()), + sha256: Some( + "8efbf0ce8a3f50fe332b7617da787fb81354b358c288b008d3bdef8359df64c6".to_string(), + ), size_mb: 1080, is_downloaded: false, is_downloading: false, @@ -226,6 +268,9 @@ impl ModelManager { description: "English only. The best model for English speakers.".to_string(), filename: "parakeet-tdt-0.6b-v2-int8".to_string(), // Directory name url: Some("https://blob.handy.computer/parakeet-v2-int8.tar.gz".to_string()), + sha256: Some( + "ac9b9429984dd565b25097337a887bb7f0f8ac393573661c651f0e7d31563991".to_string(), + ), size_mb: 473, // Approximate size for int8 quantized model is_downloaded: false, is_downloading: false, @@ -260,6 +305,9 @@ impl ModelManager { description: "Fast and accurate. Supports 25 European languages.".to_string(), filename: "parakeet-tdt-0.6b-v3-int8".to_string(), // Directory name url: Some("https://blob.handy.computer/parakeet-v3-int8.tar.gz".to_string()), + sha256: Some( + "43d37191602727524a7d8c6da0eef11c4ba24320f5b4730f1a2497befc2efa77".to_string(), + ), size_mb: 478, // Approximate size for int8 quantized model is_downloaded: false, is_downloading: false, @@ -284,6 +332,9 @@ impl ModelManager { description: "Very fast, English only. Handles accents well.".to_string(), filename: "moonshine-base".to_string(), url: Some("https://blob.handy.computer/moonshine-base.tar.gz".to_string()), + sha256: Some( + "04bf6ab012cfceebd4ac7cf88c1b31d027bbdd3cd704649b692e2e935236b7e8".to_string(), + ), size_mb: 58, is_downloaded: false, is_downloading: false, @@ -310,6 +361,9 @@ impl ModelManager { url: Some( "https://blob.handy.computer/moonshine-tiny-streaming-en.tar.gz".to_string(), ), + sha256: Some( + "465addcfca9e86117415677dfdc98b21edc53537210333a3ecdb58509a80abaf".to_string(), + ), size_mb: 31, is_downloaded: false, is_downloading: false, @@ -336,6 +390,9 @@ impl ModelManager { url: Some( "https://blob.handy.computer/moonshine-small-streaming-en.tar.gz".to_string(), ), + sha256: Some( + "dbb3e1c1832bd88a4ac712f7449a136cc2c9a18c5fe33a12ed1b7cb1cfe9cdd5".to_string(), + ), size_mb: 100, is_downloaded: false, is_downloading: false, @@ -362,6 +419,9 @@ impl ModelManager { url: Some( "https://blob.handy.computer/moonshine-medium-streaming-en.tar.gz".to_string(), ), + sha256: Some( + "07a66f3bff1c77e75a2f637e5a263928a08baae3c29c4c053fc968a9a9373d13".to_string(), + ), size_mb: 192, is_downloaded: false, is_downloading: false, @@ -394,6 +454,9 @@ impl ModelManager { .to_string(), filename: "sense-voice-int8".to_string(), url: Some("https://blob.handy.computer/sense-voice-int8.tar.gz".to_string()), + sha256: Some( + "171d611fe5d353a50bbb741b6f3ef42559b1565685684e9aa888ef563ba3e8a4".to_string(), + ), size_mb: 160, is_downloaded: false, is_downloading: false, @@ -421,6 +484,9 @@ impl ModelManager { description: "Russian speech recognition. Fast and accurate.".to_string(), filename: "giga-am-v3-int8".to_string(), url: Some("https://blob.handy.computer/giga-am-v3-int8.tar.gz".to_string()), + sha256: Some( + "d872462268430db140b69b72e0fc4b787b194c1dbe51b58de39444d55b6da45b".to_string(), + ), size_mb: 152, is_downloaded: false, is_downloading: false, @@ -452,6 +518,9 @@ impl ModelManager { .to_string(), filename: "canary-180m-flash".to_string(), url: Some("https://blob.handy.computer/canary-180m-flash.tar.gz".to_string()), + sha256: Some( + "6d9cfca6118b296e196eaedc1c8fa9788305a7b0f1feafdb6dc91932ab6e53f7".to_string(), + ), size_mb: 146, is_downloaded: false, is_downloading: false, @@ -486,6 +555,9 @@ impl ModelManager { .to_string(), filename: "canary-1b-v2".to_string(), url: Some("https://blob.handy.computer/canary-1b-v2.tar.gz".to_string()), + sha256: Some( + "02305b2a25f9cf3e7deaffa7f94df00efa44f442cd55c101c2cb9c000f904666".to_string(), + ), size_mb: 692, is_downloaded: false, is_downloading: false, @@ -804,7 +876,8 @@ impl ModelManager { name: display_name, description: "Not officially supported".to_string(), filename, - url: None, // Custom models have no download URL + url: None, // Custom models have no download URL + sha256: None, // Custom models skip verification size_mb, is_downloaded: true, // Already present on disk is_downloading: false, @@ -825,6 +898,56 @@ impl ModelManager { Ok(()) } + /// Verifies the SHA256 of `path` against `expected_sha256` (if provided). + /// On mismatch or read error the partial file is deleted and an error is returned, + /// so the next download attempt always starts from a clean state. + /// When `expected_sha256` is `None` (custom user models) verification is skipped. + fn verify_sha256(path: &Path, expected_sha256: Option<&str>, model_id: &str) -> Result<()> { + let Some(expected) = expected_sha256 else { + return Ok(()); + }; + match Self::compute_sha256(path) { + Ok(actual) if actual == expected => { + info!("SHA256 verified for model {}", model_id); + Ok(()) + } + Ok(actual) => { + warn!( + "SHA256 mismatch for model {}: expected {}, got {}", + model_id, expected, actual + ); + let _ = fs::remove_file(path); + Err(anyhow::anyhow!( + "Download verification failed for model {}: file is corrupt. Please retry.", + model_id + )) + } + Err(e) => { + let _ = fs::remove_file(path); + Err(anyhow::anyhow!( + "Failed to verify download for model {}: {}. Please retry.", + model_id, + e + )) + } + } + } + + /// Computes the SHA256 hex digest of a file, reading in 64KB chunks to handle large models. + fn compute_sha256(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0u8; 65536]; + loop { + let n = file.read(&mut buffer)?; + if n == 0 { + break; + } + hasher.update(&buffer[..n]); + } + Ok(format!("{:x}", hasher.finalize())) + } + pub async fn download_model(&self, model_id: &str) -> Result<()> { let model_info = { let models = self.available_models.lock().unwrap(); @@ -877,6 +1000,15 @@ impl ModelManager { flags.insert(model_id.to_string(), cancel_flag.clone()); } + // Guard ensures is_downloading and cancel_flags are cleaned up on every + // error path. Disarmed only on success (which sets is_downloaded = true). + let mut cleanup = DownloadCleanup { + available_models: &self.available_models, + cancel_flags: &self.cancel_flags, + model_id: model_id.to_string(), + disarmed: false, + }; + // Create HTTP client with range request for resuming let client = reqwest::Client::new(); let mut request = client.get(&url); @@ -909,13 +1041,6 @@ impl ModelManager { if !response.status().is_success() && response.status() != reqwest::StatusCode::PARTIAL_CONTENT { - // Mark as not downloading on error - { - let mut models = self.available_models.lock().unwrap(); - if let Some(model) = models.get_mut(model_id) { - model.is_downloading = false; - } - } return Err(anyhow::anyhow!( "Failed to download model: HTTP {}", response.status() @@ -965,38 +1090,14 @@ impl ModelManager { while let Some(chunk) = stream.next().await { // Check if download was cancelled if cancel_flag.load(Ordering::Relaxed) { - // Close the file before returning drop(file); info!("Download cancelled for: {}", model_id); - - // Update state to mark as not downloading - { - let mut models = self.available_models.lock().unwrap(); - if let Some(model) = models.get_mut(model_id) { - model.is_downloading = false; - } - } - - // Remove cancel flag - { - let mut flags = self.cancel_flags.lock().unwrap(); - flags.remove(model_id); - } - - // Keep partial file for resume functionality + // Keep partial file for resume functionality. + // Guard handles is_downloading + cancel_flags cleanup on drop. return Ok(()); } - let chunk = chunk.map_err(|e| { - // Mark as not downloading on error - { - let mut models = self.available_models.lock().unwrap(); - if let Some(model) = models.get_mut(model_id) { - model.is_downloading = false; - } - } - e - })?; + let chunk = chunk?; file.write_all(&chunk)?; downloaded += chunk.len() as u64; @@ -1044,12 +1145,6 @@ impl ModelManager { if actual_size != total_size { // Download is incomplete/corrupted - delete partial and return error let _ = fs::remove_file(&partial_path); - { - let mut models = self.available_models.lock().unwrap(); - if let Some(model) = models.get_mut(model_id) { - model.is_downloading = false; - } - } return Err(anyhow::anyhow!( "Download incomplete: expected {} bytes, got {} bytes", total_size, @@ -1058,6 +1153,24 @@ impl ModelManager { } } + // Verify SHA256 checksum. Runs in a blocking thread so the async executor is not + // stalled while hashing large model files (up to 1.6 GB). On failure the partial + // is deleted inside verify_sha256 so the next attempt always starts fresh. + let _ = self.app_handle.emit("model-verification-started", model_id); + info!("Verifying SHA256 for model {}...", model_id); + let verify_path = partial_path.clone(); + let verify_expected = model_info.sha256.clone(); + let verify_model_id = model_id.to_string(); + let verify_result = tokio::task::spawn_blocking(move || { + Self::verify_sha256(&verify_path, verify_expected.as_deref(), &verify_model_id) + }) + .await + .map_err(|e| anyhow::anyhow!("SHA256 task panicked: {}", e))?; + verify_result?; + let _ = self + .app_handle + .emit("model-verification-completed", model_id); + // Handle directory-based models (extract tar.gz) vs file-based models if model_info.is_directory { // Track that this model is being extracted @@ -1094,6 +1207,9 @@ impl ModelManager { let error_msg = format!("Failed to extract archive: {}", e); // Clean up failed extraction let _ = fs::remove_dir_all(&temp_extract_dir); + // Delete the corrupt partial file so the next download attempt starts fresh + // instead of resuming from a broken archive (issue #858). + let _ = fs::remove_file(&partial_path); // Remove from extracting set { let mut extracting = self.extracting_models.lock().unwrap(); @@ -1148,7 +1264,9 @@ impl ModelManager { fs::rename(&partial_path, &model_path)?; } - // Update download status + // Disarm the guard — success path does its own cleanup because it + // additionally sets is_downloaded = true. + cleanup.disarmed = true; { let mut models = self.available_models.lock().unwrap(); if let Some(model) = models.get_mut(model_id) { @@ -1157,12 +1275,7 @@ impl ModelManager { model.partial_size = 0; } } - - // Remove cancel flag on successful completion - { - let mut flags = self.cancel_flags.lock().unwrap(); - flags.remove(model_id); - } + self.cancel_flags.lock().unwrap().remove(model_id); // Emit completion event let _ = self.app_handle.emit("model-download-complete", model_id); @@ -1357,6 +1470,7 @@ mod tests { description: "Test".to_string(), filename: "ggml-small.bin".to_string(), url: Some("https://example.com".to_string()), + sha256: None, size_mb: 100, is_downloaded: false, is_downloading: false, @@ -1427,4 +1541,73 @@ mod tests { assert!(result.is_ok()); assert_eq!(models.len(), count_before); } + + // ── SHA256 verification tests ───────────────────────────────────────────── + + /// Helper: write `data` to a temp file and return (TempDir, path). + /// TempDir must be kept alive for the duration of the test. + fn write_temp_file(data: &[u8]) -> (TempDir, std::path::PathBuf) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("model.partial"); + let mut f = File::create(&path).unwrap(); + f.write_all(data).unwrap(); + (dir, path) + } + + #[test] + fn test_verify_sha256_skipped_when_none() { + // Custom models have no expected hash — verification must be a no-op. + let (_dir, path) = write_temp_file(b"anything"); + assert!(ModelManager::verify_sha256(&path, None, "custom").is_ok()); + assert!( + path.exists(), + "file must be untouched when verification is skipped" + ); + } + + #[test] + fn test_verify_sha256_passes_on_correct_hash() { + // Compute the real hash so the test is self-consistent. + let (_dir, path) = write_temp_file(b"hello world"); + let actual = ModelManager::compute_sha256(&path).unwrap(); + assert!( + ModelManager::verify_sha256(&path, Some(&actual), "test_model").is_ok(), + "should pass when hash matches" + ); + assert!( + path.exists(), + "file must be kept on successful verification" + ); + } + + #[test] + fn test_verify_sha256_fails_and_deletes_partial_on_mismatch() { + let (_dir, path) = write_temp_file(b"this is not the real model"); + let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + + let result = ModelManager::verify_sha256(&path, Some(wrong_hash), "bad_model"); + + assert!(result.is_err(), "mismatch must return an error"); + assert!( + result.unwrap_err().to_string().contains("corrupt"), + "error message should mention corruption" + ); + assert!( + !path.exists(), + "partial file must be deleted after hash mismatch" + ); + } + + #[test] + fn test_verify_sha256_fails_and_deletes_partial_when_file_missing() { + // Simulate a partial file that was already removed (e.g. disk full mid-download). + let dir = TempDir::new().unwrap(); + let missing_path = dir.path().join("gone.partial"); + // Don't create the file — it should not exist. + + let result = + ModelManager::verify_sha256(&missing_path, Some("anyexpectedhash"), "missing_model"); + + assert!(result.is_err(), "missing file must return an error"); + } } diff --git a/src/bindings.ts b/src/bindings.ts index 5cde952..b7d607a 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -821,7 +821,7 @@ reset_bindings: string[] } export type KeyboardImplementation = "tauri" | "handy_keys" export type LLMPrompt = { id: string; name: string; prompt: string } export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" -export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean } +export type ModelInfo = { id: string; name: string; description: string; filename: string; url: string | null; sha256: string | null; size_mb: number; is_downloaded: boolean; is_downloading: boolean; partial_size: number; is_directory: boolean; engine_type: EngineType; accuracy_score: number; speed_score: number; supports_translation: boolean; is_recommended: boolean; supported_languages: string[]; supports_language_selection: boolean; is_custom: boolean } export type ModelLoadStatus = { is_loaded: boolean; current_model: string | null } export type ModelUnloadTimeout = "never" | "immediately" | "min_2" | "min_5" | "min_10" | "min_15" | "hour_1" | "sec_15" export type OrtAcceleratorSetting = "auto" | "cpu" | "cuda" | "directml" | "rocm" diff --git a/src/components/model-selector/ModelSelector.tsx b/src/components/model-selector/ModelSelector.tsx index b27dc63..e3b783a 100644 --- a/src/components/model-selector/ModelSelector.tsx +++ b/src/components/model-selector/ModelSelector.tsx @@ -14,6 +14,7 @@ type ModelStatus = | "ready" | "loading" | "downloading" + | "verifying" | "extracting" | "error" | "unloaded" @@ -30,6 +31,7 @@ const ModelSelector: React.FC = ({ onError }) => { currentModel, downloadProgress, downloadStats, + verifyingModels, extractingModels, selectModel, } = useModelStore(); @@ -152,6 +154,20 @@ const ModelSelector: React.FC = ({ onError }) => { }; const getModelDisplayText = (): string => { + const verifyingKeys = Object.keys(verifyingModels); + if (verifyingKeys.length > 0) { + if (verifyingKeys.length === 1) { + const modelId = verifyingKeys[0]; + const model = models.find((m) => m.id === modelId); + const modelName = model + ? getTranslatedModelName(model, t) + : t("modelSelector.verifyingGeneric").replace("...", ""); + return t("modelSelector.verifying", { modelName }); + } else { + return t("modelSelector.verifyingGeneric"); + } + } + const extractingKeys = Object.keys(extractingModels); if (extractingKeys.length > 0) { if (extractingKeys.length === 1) { @@ -220,6 +236,7 @@ const ModelSelector: React.FC = ({ onError }) => { // Derive display status from model status + store state const getDisplayStatus = (): ModelStatus => { + if (Object.keys(verifyingModels).length > 0) return "verifying"; if (Object.keys(extractingModels).length > 0) return "extracting"; if (Object.keys(downloadProgress).length > 0) return "downloading"; return modelStatus; diff --git a/src/components/model-selector/ModelStatusButton.tsx b/src/components/model-selector/ModelStatusButton.tsx index 28e2edf..838da53 100644 --- a/src/components/model-selector/ModelStatusButton.tsx +++ b/src/components/model-selector/ModelStatusButton.tsx @@ -4,6 +4,7 @@ type ModelStatus = | "ready" | "loading" | "downloading" + | "verifying" | "extracting" | "error" | "unloaded" @@ -32,6 +33,8 @@ const ModelStatusButton: React.FC = ({ return "bg-yellow-400 animate-pulse"; case "downloading": return "bg-logo-primary animate-pulse"; + case "verifying": + return "bg-orange-400 animate-pulse"; case "extracting": return "bg-orange-400 animate-pulse"; case "error": diff --git a/src/components/onboarding/ModelCard.tsx b/src/components/onboarding/ModelCard.tsx index 9ae5fdf..2efed38 100644 --- a/src/components/onboarding/ModelCard.tsx +++ b/src/components/onboarding/ModelCard.tsx @@ -35,6 +35,7 @@ const getLanguageDisplayText = ( export type ModelCardStatus = | "downloadable" | "downloading" + | "verifying" | "extracting" | "switching" | "active" @@ -277,6 +278,16 @@ const ModelCard: React.FC = ({ )} + {status === "verifying" && ( +
+
+
+
+

+ {t("modelSelector.verifyingGeneric")} +

+
+ )} {status === "extracting" && (
diff --git a/src/components/onboarding/Onboarding.tsx b/src/components/onboarding/Onboarding.tsx index 34fe373..dfaa1e2 100644 --- a/src/components/onboarding/Onboarding.tsx +++ b/src/components/onboarding/Onboarding.tsx @@ -18,6 +18,7 @@ const Onboarding: React.FC = ({ onModelSelected }) => { downloadModel, selectModel, downloadingModels, + verifyingModels, extractingModels, downloadProgress, downloadStats, @@ -26,15 +27,21 @@ const Onboarding: React.FC = ({ onModelSelected }) => { const isDownloading = selectedModelId !== null; - // Watch for the selected model to finish downloading + extracting + // Watch for the selected model to finish downloading + verifying + extracting useEffect(() => { if (!selectedModelId) return; const model = models.find((m) => m.id === selectedModelId); const stillDownloading = selectedModelId in downloadingModels; + const stillVerifying = selectedModelId in verifyingModels; const stillExtracting = selectedModelId in extractingModels; - if (model?.is_downloaded && !stillDownloading && !stillExtracting) { + if ( + model?.is_downloaded && + !stillDownloading && + !stillVerifying && + !stillExtracting + ) { // Model is ready — select it and transition selectModel(selectedModelId).then((success) => { if (success) { @@ -49,6 +56,7 @@ const Onboarding: React.FC = ({ onModelSelected }) => { selectedModelId, models, downloadingModels, + verifyingModels, extractingModels, selectModel, onModelSelected, @@ -57,15 +65,17 @@ const Onboarding: React.FC = ({ onModelSelected }) => { const handleDownloadModel = async (modelId: string) => { setSelectedModelId(modelId); + // Error toast is handled centrally by the model-download-failed event listener + // in modelStore — no toast here to avoid duplicates. const success = await downloadModel(modelId); if (!success) { - toast.error(t("onboarding.downloadFailed")); setSelectedModelId(null); } }; const getModelStatus = (modelId: string): ModelCardStatus => { if (modelId in extractingModels) return "extracting"; + if (modelId in verifyingModels) return "verifying"; if (modelId in downloadingModels) return "downloading"; return "downloadable"; }; diff --git a/src/components/settings/models/ModelsSettings.tsx b/src/components/settings/models/ModelsSettings.tsx index 30af3b5..3833338 100644 --- a/src/components/settings/models/ModelsSettings.tsx +++ b/src/components/settings/models/ModelsSettings.tsx @@ -27,6 +27,7 @@ export const ModelsSettings: React.FC = () => { downloadingModels, downloadProgress, downloadStats, + verifyingModels, extractingModels, loading, downloadModel, @@ -78,6 +79,9 @@ export const ModelsSettings: React.FC = () => { if (modelId in extractingModels) { return "extracting"; } + if (modelId in verifyingModels) { + return "verifying"; + } if (modelId in downloadingModels) { return "downloading"; } diff --git a/src/i18n/locales/ar/translation.json b/src/i18n/locales/ar/translation.json index 398f19c..604da86 100644 --- a/src/i18n/locales/ar/translation.json +++ b/src/i18n/locales/ar/translation.json @@ -119,6 +119,8 @@ "custom": "مخصص", "active": "نشط", "noModelsAvailable": "لا توجد نماذج متاحة", + "verifying": "جارٍ التحقق من {{modelName}}...", + "verifyingGeneric": "جارٍ التحقق...", "extracting": "...جاري استخراج {{modelName}}", "extractingMultiple": "...جاري استخراج {{count}} من النماذج", "extractingGeneric": "...جاري الاستخراج", diff --git a/src/i18n/locales/cs/translation.json b/src/i18n/locales/cs/translation.json index a1de9c1..d560fc6 100644 --- a/src/i18n/locales/cs/translation.json +++ b/src/i18n/locales/cs/translation.json @@ -119,6 +119,8 @@ "custom": "Vlastní", "active": "Aktivní", "noModelsAvailable": "Nejsou dostupné žádné modely", + "verifying": "Ověřování {{modelName}}...", + "verifyingGeneric": "Ověřování...", "extracting": "Rozbaluji {{modelName}}...", "extractingMultiple": "Rozbaluji {{count}} modelů...", "extractingGeneric": "Rozbalování...", diff --git a/src/i18n/locales/de/translation.json b/src/i18n/locales/de/translation.json index 66efffe..8133fa4 100644 --- a/src/i18n/locales/de/translation.json +++ b/src/i18n/locales/de/translation.json @@ -120,6 +120,8 @@ "active": "Aktiv", "switching": "Wechseln...", "noModelsAvailable": "Keine Modelle verfügbar", + "verifying": "Überprüfung von {{modelName}}...", + "verifyingGeneric": "Überprüfung...", "extracting": "Entpacke {{modelName}}...", "extractingMultiple": "Entpacke {{count}} Modelle...", "extractingGeneric": "Wird entpackt...", diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index b9ef6c5..9a065b9 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -120,6 +120,8 @@ "active": "Active", "switching": "Switching...", "noModelsAvailable": "No models available", + "verifying": "Verifying {{modelName}}...", + "verifyingGeneric": "Verifying...", "extracting": "Extracting {{modelName}}...", "extractingMultiple": "Extracting {{count}} models...", "extractingGeneric": "Extracting...", diff --git a/src/i18n/locales/es/translation.json b/src/i18n/locales/es/translation.json index 5b67f9a..ab6e8e9 100644 --- a/src/i18n/locales/es/translation.json +++ b/src/i18n/locales/es/translation.json @@ -120,6 +120,8 @@ "active": "Activo", "switching": "Cambiando...", "noModelsAvailable": "No hay modelos disponibles", + "verifying": "Verificando {{modelName}}...", + "verifyingGeneric": "Verificando...", "extracting": "Extrayendo {{modelName}}...", "extractingMultiple": "Extrayendo {{count}} modelos...", "extractingGeneric": "Extrayendo...", diff --git a/src/i18n/locales/fr/translation.json b/src/i18n/locales/fr/translation.json index 0f612ae..29f82cc 100644 --- a/src/i18n/locales/fr/translation.json +++ b/src/i18n/locales/fr/translation.json @@ -120,6 +120,8 @@ "active": "Actif", "switching": "Changement...", "noModelsAvailable": "Aucun modèle disponible", + "verifying": "Vérification de {{modelName}}...", + "verifyingGeneric": "Vérification...", "extracting": "Extraction de {{modelName}}...", "extractingMultiple": "Extraction de {{count}} modèles...", "extractingGeneric": "Extraction...", diff --git a/src/i18n/locales/it/translation.json b/src/i18n/locales/it/translation.json index 6d84aee..b3784a0 100644 --- a/src/i18n/locales/it/translation.json +++ b/src/i18n/locales/it/translation.json @@ -120,6 +120,8 @@ "active": "Attivo", "switching": "Cambio...", "noModelsAvailable": "Nessun modello disponibile", + "verifying": "Verifica di {{modelName}}...", + "verifyingGeneric": "Verifica...", "extracting": "Estrazione di {{modelName}}...", "extractingMultiple": "Estrazione di {{count}} modelli...", "extractingGeneric": "Estrazione...", diff --git a/src/i18n/locales/ja/translation.json b/src/i18n/locales/ja/translation.json index 07e15b3..c6d0850 100644 --- a/src/i18n/locales/ja/translation.json +++ b/src/i18n/locales/ja/translation.json @@ -120,6 +120,8 @@ "active": "アクティブ", "switching": "切替中...", "noModelsAvailable": "利用可能なモデルがありません", + "verifying": "{{modelName}}を検証中...", + "verifyingGeneric": "検証中...", "extracting": "{{modelName}}を展開中...", "extractingMultiple": "{{count}}個のモデルを展開中...", "extractingGeneric": "展開中...", diff --git a/src/i18n/locales/ko/translation.json b/src/i18n/locales/ko/translation.json index e72dd0e..66f451b 100644 --- a/src/i18n/locales/ko/translation.json +++ b/src/i18n/locales/ko/translation.json @@ -120,6 +120,8 @@ "active": "활성", "switching": "전환 중...", "noModelsAvailable": "사용 가능한 모델이 없습니다", + "verifying": "{{modelName}} 검증 중...", + "verifyingGeneric": "검증 중...", "extracting": "{{modelName}} 압축 해제 중...", "extractingMultiple": "{{count}}개 모델 압축 해제 중...", "extractingGeneric": "압축 해제 중...", diff --git a/src/i18n/locales/pl/translation.json b/src/i18n/locales/pl/translation.json index 8b3c888..67de524 100644 --- a/src/i18n/locales/pl/translation.json +++ b/src/i18n/locales/pl/translation.json @@ -120,6 +120,8 @@ "active": "Aktywny", "switching": "Przełączanie...", "noModelsAvailable": "Brak dostępnych modeli", + "verifying": "Weryfikacja {{modelName}}...", + "verifyingGeneric": "Weryfikacja...", "extracting": "Rozpakowywanie {{modelName}}...", "extractingMultiple": "Rozpakowywanie {{count}} modeli...", "extractingGeneric": "Rozpakowywanie...", diff --git a/src/i18n/locales/pt/translation.json b/src/i18n/locales/pt/translation.json index bd4e9f7..f77c7f5 100644 --- a/src/i18n/locales/pt/translation.json +++ b/src/i18n/locales/pt/translation.json @@ -119,6 +119,8 @@ "custom": "Personalizado", "active": "Ativo", "noModelsAvailable": "Nenhum modelo disponível", + "verifying": "Verificando {{modelName}}...", + "verifyingGeneric": "Verificando...", "extracting": "Extraindo {{modelName}}...", "extractingMultiple": "Extraindo {{count}} modelos...", "extractingGeneric": "Extraindo...", diff --git a/src/i18n/locales/ru/translation.json b/src/i18n/locales/ru/translation.json index efd531b..941a200 100644 --- a/src/i18n/locales/ru/translation.json +++ b/src/i18n/locales/ru/translation.json @@ -120,6 +120,8 @@ "active": "Активный", "switching": "Переключение...", "noModelsAvailable": "Нет доступных моделей", + "verifying": "Проверка {{modelName}}...", + "verifyingGeneric": "Проверка...", "extracting": "Извлечение {{modelName}}...", "extractingMultiple": "Извлечение моделей {{count}}...", "extractingGeneric": "Извлечение...", diff --git a/src/i18n/locales/tr/translation.json b/src/i18n/locales/tr/translation.json index a03f382..d0918cb 100644 --- a/src/i18n/locales/tr/translation.json +++ b/src/i18n/locales/tr/translation.json @@ -119,6 +119,8 @@ "custom": "Özel", "active": "Aktif", "noModelsAvailable": "Kullanılabilir model yok", + "verifying": "{{modelName}} doğrulanıyor...", + "verifyingGeneric": "Doğrulanıyor...", "extracting": "{{modelName}} çıkarılıyor...", "extractingMultiple": "{{count}} model çıkarılıyor...", "extractingGeneric": "Çıkarılıyor...", diff --git a/src/i18n/locales/uk/translation.json b/src/i18n/locales/uk/translation.json index 0921f79..903cf6a 100644 --- a/src/i18n/locales/uk/translation.json +++ b/src/i18n/locales/uk/translation.json @@ -119,6 +119,8 @@ "custom": "Користувацька", "active": "Активна", "noModelsAvailable": "Немає доступних моделей", + "verifying": "Перевірка {{modelName}}...", + "verifyingGeneric": "Перевірка...", "extracting": "Розпакування {{modelName}}...", "extractingMultiple": "Розпакування {{count}} моделей...", "extractingGeneric": "Розпакування...", diff --git a/src/i18n/locales/vi/translation.json b/src/i18n/locales/vi/translation.json index 9bb9cd0..b16a15c 100644 --- a/src/i18n/locales/vi/translation.json +++ b/src/i18n/locales/vi/translation.json @@ -120,6 +120,8 @@ "active": "Đang hoạt động", "switching": "Đang chuyển...", "noModelsAvailable": "Không có mô hình nào", + "verifying": "Đang xác minh {{modelName}}...", + "verifyingGeneric": "Đang xác minh...", "extracting": "Đang giải nén {{modelName}}...", "extractingMultiple": "Đang giải nén {{count}} mô hình...", "extractingGeneric": "Đang giải nén...", diff --git a/src/i18n/locales/zh-TW/translation.json b/src/i18n/locales/zh-TW/translation.json index 7279e38..5ce9a8e 100644 --- a/src/i18n/locales/zh-TW/translation.json +++ b/src/i18n/locales/zh-TW/translation.json @@ -120,6 +120,8 @@ "active": "使用中", "switching": "切換中...", "noModelsAvailable": "沒有可用的模型", + "verifying": "正在驗證 {{modelName}}...", + "verifyingGeneric": "正在驗證...", "extracting": "正在解壓 {{modelName}}...", "extractingMultiple": "正在解壓 {{count}} 個模型...", "extractingGeneric": "解壓中...", diff --git a/src/i18n/locales/zh/translation.json b/src/i18n/locales/zh/translation.json index 6377565..f517e61 100644 --- a/src/i18n/locales/zh/translation.json +++ b/src/i18n/locales/zh/translation.json @@ -120,6 +120,8 @@ "active": "活跃", "switching": "切换中...", "noModelsAvailable": "没有可用的模型", + "verifying": "正在验证 {{modelName}}...", + "verifyingGeneric": "正在验证...", "extracting": "正在解压 {{modelName}}...", "extractingMultiple": "正在解压 {{count}} 个模型...", "extractingGeneric": "解压中...", diff --git a/src/stores/modelStore.ts b/src/stores/modelStore.ts index 97d6d7a..901173a 100644 --- a/src/stores/modelStore.ts +++ b/src/stores/modelStore.ts @@ -3,6 +3,7 @@ import { subscribeWithSelector } from "zustand/middleware"; import { produce } from "immer"; import { listen } from "@tauri-apps/api/event"; import { commands, type ModelInfo } from "@/bindings"; +import { toast } from "sonner"; interface DownloadProgress { model_id: string; @@ -23,6 +24,7 @@ interface ModelsStore { models: ModelInfo[]; currentModel: string; downloadingModels: Record; + verifyingModels: Record; extractingModels: Record; downloadProgress: Record; downloadStats: Record; @@ -43,6 +45,7 @@ interface ModelsStore { deleteModel: (modelId: string) => Promise; getModelInfo: (modelId: string) => ModelInfo | undefined; isModelDownloading: (modelId: string) => boolean; + isModelVerifying: (modelId: string) => boolean; isModelExtracting: (modelId: string) => boolean; getDownloadProgress: (modelId: string) => DownloadProgress | undefined; @@ -58,6 +61,7 @@ export const useModelStore = create()( models: [], currentModel: "", downloadingModels: {}, + verifyingModels: {}, extractingModels: {}, downloadProgress: {}, downloadStats: {}, @@ -175,22 +179,27 @@ export const useModelStore = create()( }), ); const result = await commands.downloadModel(modelId); - if (result.status === "ok") { - return true; - } else { - set({ error: `Failed to download model: ${result.error}` }); + if (result.status !== "ok") { + // Fallback cleanup in case the model-download-failed event was not received + // (e.g. listener not yet registered). The event handler is a no-op if it + // arrives after this cleanup since deleting missing keys is safe. set( produce((state) => { delete state.downloadingModels[modelId]; + delete state.downloadProgress[modelId]; + delete state.downloadStats[modelId]; }), ); - return false; } - } catch (err) { - set({ error: `Failed to download model: ${err}` }); + return result.status === "ok"; + } catch { + // model-download-failed event won't fire for JS exceptions (e.g. IPC error), + // so clean up state here to avoid a stuck progress spinner. set( produce((state) => { delete state.downloadingModels[modelId]; + delete state.downloadProgress[modelId]; + delete state.downloadStats[modelId]; }), ); return false; @@ -249,6 +258,10 @@ export const useModelStore = create()( return modelId in get().downloadingModels; }, + isModelVerifying: (modelId: string) => { + return modelId in get().verifyingModels; + }, + isModelExtracting: (modelId: string) => { return modelId in get().extractingModels; }, @@ -316,6 +329,7 @@ export const useModelStore = create()( set( produce((state) => { delete state.downloadingModels[modelId]; + delete state.verifyingModels[modelId]; delete state.downloadProgress[modelId]; delete state.downloadStats[modelId]; }), @@ -323,6 +337,41 @@ export const useModelStore = create()( get().loadModels(); }); + listen<{ model_id: string; error: string }>( + "model-download-failed", + (event) => { + const { model_id: modelId, error } = event.payload; + set( + produce((state) => { + delete state.downloadingModels[modelId]; + delete state.verifyingModels[modelId]; + delete state.downloadProgress[modelId]; + delete state.downloadStats[modelId]; + state.error = error; + }), + ); + toast.error(error); + }, + ); + + listen("model-verification-started", (event) => { + const modelId = event.payload; + set( + produce((state) => { + state.verifyingModels[modelId] = true; + }), + ); + }); + + listen("model-verification-completed", (event) => { + const modelId = event.payload; + set( + produce((state) => { + delete state.verifyingModels[modelId]; + }), + ); + }); + listen("model-extraction-started", (event) => { const modelId = event.payload; set( @@ -360,6 +409,7 @@ export const useModelStore = create()( set( produce((state) => { delete state.downloadingModels[modelId]; + delete state.verifyingModels[modelId]; delete state.downloadProgress[modelId]; delete state.downloadStats[modelId]; }),