fix: sha256 verification to prevent corrupt partial download loop (#1095)

* 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 <cj@cjpais.com>
This commit is contained in:
Viren Mohindra 2026-03-21 02:19:26 -04:00 committed by GitHub
parent e35f0a714e
commit 58cda3f3b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 386 additions and 62 deletions

1
src-tauri/Cargo.lock generated
View file

@ -2441,6 +2441,7 @@ dependencies = [
"rustfft",
"serde",
"serde_json",
"sha2",
"signal-hook",
"specta",
"specta-typescript",

View file

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

View file

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

View file

@ -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<String>,
pub sha256: Option<String>,
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<HashMap<String, ModelInfo>>,
cancel_flags: &'a Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
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<String> {
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");
}
}

View file

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

View file

@ -14,6 +14,7 @@ type ModelStatus =
| "ready"
| "loading"
| "downloading"
| "verifying"
| "extracting"
| "error"
| "unloaded"
@ -30,6 +31,7 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
currentModel,
downloadProgress,
downloadStats,
verifyingModels,
extractingModels,
selectModel,
} = useModelStore();
@ -152,6 +154,20 @@ const ModelSelector: React.FC<ModelSelectorProps> = ({ 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<ModelSelectorProps> = ({ 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;

View file

@ -4,6 +4,7 @@ type ModelStatus =
| "ready"
| "loading"
| "downloading"
| "verifying"
| "extracting"
| "error"
| "unloaded"
@ -32,6 +33,8 @@ const ModelStatusButton: React.FC<ModelStatusButtonProps> = ({
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":

View file

@ -35,6 +35,7 @@ const getLanguageDisplayText = (
export type ModelCardStatus =
| "downloadable"
| "downloading"
| "verifying"
| "extracting"
| "switching"
| "active"
@ -277,6 +278,16 @@ const ModelCard: React.FC<ModelCardProps> = ({
</div>
</div>
)}
{status === "verifying" && (
<div className="w-full mt-3">
<div className="w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
<div className="h-full bg-logo-primary rounded-full animate-pulse w-full" />
</div>
<p className="text-xs text-text/50 mt-1">
{t("modelSelector.verifyingGeneric")}
</p>
</div>
)}
{status === "extracting" && (
<div className="w-full mt-3">
<div className="w-full h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">

View file

@ -18,6 +18,7 @@ const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
downloadModel,
selectModel,
downloadingModels,
verifyingModels,
extractingModels,
downloadProgress,
downloadStats,
@ -26,15 +27,21 @@ const Onboarding: React.FC<OnboardingProps> = ({ 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<OnboardingProps> = ({ onModelSelected }) => {
selectedModelId,
models,
downloadingModels,
verifyingModels,
extractingModels,
selectModel,
onModelSelected,
@ -57,15 +65,17 @@ const Onboarding: React.FC<OnboardingProps> = ({ 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";
};

View file

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

View file

@ -119,6 +119,8 @@
"custom": "مخصص",
"active": "نشط",
"noModelsAvailable": "لا توجد نماذج متاحة",
"verifying": "جارٍ التحقق من {{modelName}}...",
"verifyingGeneric": "جارٍ التحقق...",
"extracting": "...جاري استخراج {{modelName}}",
"extractingMultiple": "...جاري استخراج {{count}} من النماذج",
"extractingGeneric": "...جاري الاستخراج",

View file

@ -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í...",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -120,6 +120,8 @@
"active": "アクティブ",
"switching": "切替中...",
"noModelsAvailable": "利用可能なモデルがありません",
"verifying": "{{modelName}}を検証中...",
"verifyingGeneric": "検証中...",
"extracting": "{{modelName}}を展開中...",
"extractingMultiple": "{{count}}個のモデルを展開中...",
"extractingGeneric": "展開中...",

View file

@ -120,6 +120,8 @@
"active": "활성",
"switching": "전환 중...",
"noModelsAvailable": "사용 가능한 모델이 없습니다",
"verifying": "{{modelName}} 검증 중...",
"verifyingGeneric": "검증 중...",
"extracting": "{{modelName}} 압축 해제 중...",
"extractingMultiple": "{{count}}개 모델 압축 해제 중...",
"extractingGeneric": "압축 해제 중...",

View file

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

View file

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

View file

@ -120,6 +120,8 @@
"active": "Активный",
"switching": "Переключение...",
"noModelsAvailable": "Нет доступных моделей",
"verifying": "Проверка {{modelName}}...",
"verifyingGeneric": "Проверка...",
"extracting": "Извлечение {{modelName}}...",
"extractingMultiple": "Извлечение моделей {{count}}...",
"extractingGeneric": "Извлечение...",

View file

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

View file

@ -119,6 +119,8 @@
"custom": "Користувацька",
"active": "Активна",
"noModelsAvailable": "Немає доступних моделей",
"verifying": "Перевірка {{modelName}}...",
"verifyingGeneric": "Перевірка...",
"extracting": "Розпакування {{modelName}}...",
"extractingMultiple": "Розпакування {{count}} моделей...",
"extractingGeneric": "Розпакування...",

View file

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

View file

@ -120,6 +120,8 @@
"active": "使用中",
"switching": "切換中...",
"noModelsAvailable": "沒有可用的模型",
"verifying": "正在驗證 {{modelName}}...",
"verifyingGeneric": "正在驗證...",
"extracting": "正在解壓 {{modelName}}...",
"extractingMultiple": "正在解壓 {{count}} 個模型...",
"extractingGeneric": "解壓中...",

View file

@ -120,6 +120,8 @@
"active": "活跃",
"switching": "切换中...",
"noModelsAvailable": "没有可用的模型",
"verifying": "正在验证 {{modelName}}...",
"verifyingGeneric": "正在验证...",
"extracting": "正在解压 {{modelName}}...",
"extractingMultiple": "正在解压 {{count}} 个模型...",
"extractingGeneric": "解压中...",

View file

@ -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<string, true>;
verifyingModels: Record<string, true>;
extractingModels: Record<string, true>;
downloadProgress: Record<string, DownloadProgress>;
downloadStats: Record<string, DownloadStats>;
@ -43,6 +45,7 @@ interface ModelsStore {
deleteModel: (modelId: string) => Promise<boolean>;
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<ModelsStore>()(
models: [],
currentModel: "",
downloadingModels: {},
verifyingModels: {},
extractingModels: {},
downloadProgress: {},
downloadStats: {},
@ -175,22 +179,27 @@ export const useModelStore = create<ModelsStore>()(
}),
);
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<ModelsStore>()(
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<ModelsStore>()(
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<ModelsStore>()(
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<string>("model-verification-started", (event) => {
const modelId = event.payload;
set(
produce((state) => {
state.verifyingModels[modelId] = true;
}),
);
});
listen<string>("model-verification-completed", (event) => {
const modelId = event.payload;
set(
produce((state) => {
delete state.verifyingModels[modelId];
}),
);
});
listen<string>("model-extraction-started", (event) => {
const modelId = event.payload;
set(
@ -360,6 +409,7 @@ export const useModelStore = create<ModelsStore>()(
set(
produce((state) => {
delete state.downloadingModels[modelId];
delete state.verifyingModels[modelId];
delete state.downloadProgress[modelId];
delete state.downloadStats[modelId];
}),