diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index af496c0..ca69f18 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -884,13 +884,20 @@ pub async fn download_npu_model(app: AppHandle) -> WaResult<()> { .map_err(|e| WaError::new("npu", e.to_string())) } -/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir. -/// -/// ponytail: sourced by copying DLLs from local dirs named in -/// `WA_NPU_RUNTIME_SRC` (';'-separated) — the same act a bundled installer step -/// would perform. Upgrade path: host a versioned runtime bundle and -/// download+unzip it here instead. Until a source is configured this is a clean -/// typed error, never a panic. +/// Hosted OpenVINO runtime bundle (ORT 1.24.1 + OpenVINO 2025.4.1 DLLs, zipped). +/// Overridable at runtime via `WA_NPU_RUNTIME_URL`. Keep the SHA-256 in step with +/// the uploaded bundle (see `dist/whispassist-npu-runtime-win-x64.zip`). +#[cfg(feature = "npu")] +const NPU_RUNTIME_URL: &str = + "https://git.dou.bet/api/packages/iamdoubz/generic/npu-runtime/2025.4.1/whispassist-npu-runtime-win-x64.zip"; +/// SHA-256 of the runtime bundle; empty string disables the integrity check. +#[cfg(feature = "npu")] +const NPU_RUNTIME_SHA256: &str = "c60de07b5b1ddc2fd1e966d8275d9f55ec261efc81355ea814d6dac897adbdc5"; + +/// Stages the ONNX Runtime + OpenVINO DLLs into the app's NPU runtime dir by +/// downloading the hosted bundle and unzipping it (T3.4). `WA_NPU_RUNTIME_SRC` +/// (';'-separated dirs) is honored as a dev/offline override that copies local +/// DLLs instead of downloading. #[cfg(feature = "npu")] async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> { if crate::paths::npu_runtime_ready() { @@ -898,14 +905,24 @@ async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> { } let dir = crate::paths::npu_runtime_dir(); std::fs::create_dir_all(&dir).map_err(|e| WaError::new("npu", e.to_string()))?; - let src = std::env::var_os("WA_NPU_RUNTIME_SRC").ok_or_else(|| { - WaError::new( - "npu", - "NPU runtime source not configured (set WA_NPU_RUNTIME_SRC to the ORT+OpenVINO DLL dir[s])", - ) - })?; + + // Dev/offline override: copy DLLs from local dirs instead of downloading. + if let Some(src) = std::env::var_os("WA_NPU_RUNTIME_SRC") { + return stage_npu_runtime_from_local(app, &dir, &src); + } + + let url = std::env::var("WA_NPU_RUNTIME_URL").unwrap_or_else(|_| NPU_RUNTIME_URL.to_string()); + download_and_extract_runtime(app, &dir, &url).await +} + +#[cfg(feature = "npu")] +fn stage_npu_runtime_from_local( + app: &AppHandle, + dir: &Path, + src: &std::ffi::OsStr, +) -> WaResult<()> { let mut copied = 0u32; - for d in std::env::split_paths(&src) { + for d in std::env::split_paths(src) { let Ok(entries) = std::fs::read_dir(&d) else { continue; }; @@ -933,6 +950,89 @@ async fn stage_npu_runtime(app: &AppHandle) -> WaResult<()> { Ok(()) } +/// Downloads the runtime bundle (streaming progress + SHA-256 check) and unzips +/// its DLLs flat into `dir`. +#[cfg(feature = "npu")] +async fn download_and_extract_runtime(app: &AppHandle, dir: &Path, url: &str) -> WaResult<()> { + use futures_util::StreamExt; + use sha2::{Digest, Sha256}; + + let resp = reqwest::get(url) + .await + .map_err(|e| WaError::new("npu", e.to_string()))?; + if !resp.status().is_success() { + return Err(WaError::new( + "npu", + format!("runtime download failed: HTTP {}", resp.status()), + )); + } + let total = resp.content_length(); + let tmp = dir.join("runtime.zip.part"); + let mut file = std::fs::File::create(&tmp).map_err(|e| WaError::new("npu", e.to_string()))?; + let mut hasher = Sha256::new(); + let mut received = 0u64; + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| WaError::new("npu", e.to_string()))?; + std::io::Write::write_all(&mut file, &chunk) + .map_err(|e| WaError::new("npu", e.to_string()))?; + hasher.update(&chunk); + received += chunk.len() as u64; + let _ = app.emit( + "npu://download", + serde_json::json!({ "stage": "runtime", "received": received, "total": total }), + ); + } + drop(file); + + let digest = format!("{:x}", hasher.finalize()); + if !NPU_RUNTIME_SHA256.is_empty() && digest != NPU_RUNTIME_SHA256 { + let _ = std::fs::remove_file(&tmp); + return Err(WaError::new("npu", "runtime bundle checksum mismatch")); + } + + // Unzip off the async runtime (CPU/IO-bound). + let tmp_for_unzip = tmp.clone(); + let dir_for_unzip = dir.to_path_buf(); + tokio::task::spawn_blocking(move || extract_zip_flat(&tmp_for_unzip, &dir_for_unzip)) + .await + .map_err(|e| WaError::new("npu", e.to_string()))? + .map_err(|e| WaError::new("npu", e))?; + let _ = std::fs::remove_file(&tmp); + + if !crate::paths::npu_runtime_ready() { + return Err(WaError::new( + "npu", + "runtime bundle extracted but onnxruntime.dll is missing", + )); + } + Ok(()) +} + +/// Extract every file entry of a zip into `dir`, flattening paths to just the +/// file name (which also prevents zip-slip path traversal). +#[cfg(feature = "npu")] +fn extract_zip_flat(zip_path: &Path, dir: &Path) -> Result<(), String> { + let file = std::fs::File::open(zip_path).map_err(|e| e.to_string())?; + let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?; + for i in 0..archive.len() { + let mut entry = archive.by_index(i).map_err(|e| e.to_string())?; + if entry.is_dir() { + continue; + } + let Some(name) = Path::new(entry.name()) + .file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + else { + continue; + }; + let mut out = std::fs::File::create(dir.join(name)).map_err(|e| e.to_string())?; + std::io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?; + } + Ok(()) +} + /// Downloads everything the NPU engine needs (ONNX model + OpenVINO runtime) for /// the Settings ▸ Hardware "download NPU package" action (T3.4 step 2). #[tauri::command]